Compare commits

..

4 Commits

Author SHA1 Message Date
NewSoupVi
3bf3ce2da4 Merge branch 'main' into NewSoupVi-patch-2 2024-06-20 07:22:52 +02:00
NewSoupVi
8fae75f577 Update Generate.py 2024-05-24 00:05:01 +02:00
NewSoupVi
8b8a95089c Update Generate.py 2024-05-24 00:04:46 +02:00
NewSoupVi
1e99625f3b Add an option docstring to roll_settings to hopefully prevent the weights fiasco from being repeated 2024-05-24 00:03:25 +02:00
1767 changed files with 85974 additions and 376270 deletions

View File

@@ -1,210 +0,0 @@
.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

2
.gitattributes vendored
View File

@@ -1,2 +0,0 @@
worlds/blasphemous/region_data.py linguist-generated=true
worlds/yachtdice/YachtWeights.py linguist-generated=true

1
.github/labeler.yml vendored
View File

@@ -21,6 +21,7 @@
- '!data/**' - '!data/**'
- '!.run/**' - '!.run/**'
- '!.github/**' - '!.github/**'
- '!worlds_disabled/**'
- '!worlds/**' - '!worlds/**'
- '!WebHost.py' - '!WebHost.py'
- '!WebHostLib/**' - '!WebHostLib/**'

View File

@@ -1,21 +1,8 @@
{ {
"include": [ "include": [
"../BizHawkClient.py", "type_check.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", "../worlds/AutoSNIClient.py",
"type_check.py" "../Patch.py"
], ],
"exclude": [ "exclude": [
@@ -29,7 +16,7 @@
"reportMissingImports": true, "reportMissingImports": true,
"reportMissingTypeStubs": true, "reportMissingTypeStubs": true,
"pythonVersion": "3.11", "pythonVersion": "3.8",
"pythonPlatform": "Windows", "pythonPlatform": "Windows",
"executionEnvironments": [ "executionEnvironments": [

View File

@@ -53,7 +53,7 @@ jobs:
- uses: actions/setup-python@v5 - uses: actions/setup-python@v5
if: env.diff != '' if: env.diff != ''
with: with:
python-version: '3.11' python-version: 3.8
- name: "Install dependencies" - name: "Install dependencies"
if: env.diff != '' if: env.diff != ''
@@ -65,7 +65,7 @@ jobs:
continue-on-error: false continue-on-error: false
if: env.diff != '' && matrix.task == 'flake8' if: env.diff != '' && matrix.task == 'flake8'
run: | run: |
flake8 --count --select=E9,F63,F7,F82 --ignore F824 --show-source --statistics ${{ env.diff }} flake8 --count --select=E9,F63,F7,F82 --show-source --statistics ${{ env.diff }}
- name: "flake8: Lint modified files" - name: "flake8: Lint modified files"
continue-on-error: true continue-on-error: true

View File

@@ -19,30 +19,19 @@ on:
env: env:
ENEMIZER_VERSION: 7.1 ENEMIZER_VERSION: 7.1
# NOTE: since appimage/appimagetool and appimage/type2-runtime does not have tags anymore, APPIMAGETOOL_VERSION: 13
# we check the sha256 and require manual intervention if it was updated.
APPIMAGETOOL_VERSION: continuous
APPIMAGETOOL_X86_64_HASH: '29348a20b80827cd261c28e95172ff828b69d43d4e4e18e3fd069e2c8693c94e'
APPIMAGE_RUNTIME_VERSION: continuous
APPIMAGE_RUNTIME_X86_64_HASH: 'e70ffa9b69b211574d0917adc482dd66f25a0083427b5945783965d55b0b0a8b'
permissions: # permissions required for attestation
id-token: 'write'
attestations: 'write'
jobs: jobs:
# build-release-macos: # LF volunteer # build-release-macos: # LF volunteer
build-win: # RCs and releases may still be built and signed by hand build-win-py38: # RCs will still be built and signed by hand
runs-on: windows-latest runs-on: windows-latest
steps: steps:
# - copy code below to release.yml -
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Install python - name: Install python
uses: actions/setup-python@v5 uses: actions/setup-python@v5
with: with:
python-version: '~3.12.7' python-version: '3.8'
check-latest: true
- name: Download run-time dependencies - name: Download run-time dependencies
run: | run: |
Invoke-WebRequest -Uri https://github.com/Ijwu/Enemizer/releases/download/${Env:ENEMIZER_VERSION}/win-x64.zip -OutFile enemizer.zip Invoke-WebRequest -Uri https://github.com/Ijwu/Enemizer/releases/download/${Env:ENEMIZER_VERSION}/win-x64.zip -OutFile enemizer.zip
@@ -75,18 +64,6 @@ jobs:
$contents = Get-ChildItem -Path setups/*.exe -Force -Recurse $contents = Get-ChildItem -Path setups/*.exe -Force -Recurse
$SETUP_NAME=$contents[0].Name $SETUP_NAME=$contents[0].Name
echo "SETUP_NAME=$SETUP_NAME" >> $Env:GITHUB_ENV echo "SETUP_NAME=$SETUP_NAME" >> $Env:GITHUB_ENV
# - copy code above to release.yml -
- name: Attest Build
if: ${{ github.event_name == 'workflow_dispatch' }}
uses: actions/attest-build-provenance@v2
with:
subject-path: |
build/exe.*/ArchipelagoLauncher.exe
build/exe.*/ArchipelagoLauncherDebug.exe
build/exe.*/ArchipelagoGenerate.exe
build/exe.*/ArchipelagoServer.exe
dist/${{ env.ZIP_NAME }}
setups/${{ env.SETUP_NAME }}
- name: Check build loads expected worlds - name: Check build loads expected worlds
shell: bash shell: bash
run: | run: |
@@ -103,7 +80,7 @@ jobs:
shell: bash shell: bash
run: | run: |
cd build/exe* cd build/exe*
cp Players/Templates/VVVVVV.yaml Players/ cp Players/Templates/Clique.yaml Players/
timeout 30 ./ArchipelagoGenerate timeout 30 ./ArchipelagoGenerate
- name: Store 7z - name: Store 7z
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
@@ -121,8 +98,8 @@ jobs:
if-no-files-found: error if-no-files-found: error
retention-days: 7 # keep for 7 days, should be enough retention-days: 7 # keep for 7 days, should be enough
build-ubuntu2204: build-ubuntu2004:
runs-on: ubuntu-22.04 runs-on: ubuntu-20.04
steps: steps:
# - copy code below to release.yml - # - copy code below to release.yml -
- uses: actions/checkout@v4 - uses: actions/checkout@v4
@@ -134,18 +111,14 @@ jobs:
- name: Get a recent python - name: Get a recent python
uses: actions/setup-python@v5 uses: actions/setup-python@v5
with: with:
python-version: '~3.12.7' python-version: '3.11'
check-latest: true
- name: Install build-time dependencies - name: Install build-time dependencies
run: | run: |
echo "PYTHON=python3.12" >> $GITHUB_ENV echo "PYTHON=python3.11" >> $GITHUB_ENV
wget -nv https://github.com/AppImage/appimagetool/releases/download/$APPIMAGETOOL_VERSION/appimagetool-x86_64.AppImage wget -nv https://github.com/AppImage/AppImageKit/releases/download/$APPIMAGETOOL_VERSION/appimagetool-x86_64.AppImage
echo "$APPIMAGETOOL_X86_64_HASH appimagetool-x86_64.AppImage" | sha256sum -c
wget -nv https://github.com/AppImage/type2-runtime/releases/download/$APPIMAGE_RUNTIME_VERSION/runtime-x86_64
echo "$APPIMAGE_RUNTIME_X86_64_HASH runtime-x86_64" | sha256sum -c
chmod a+rx appimagetool-x86_64.AppImage chmod a+rx appimagetool-x86_64.AppImage
./appimagetool-x86_64.AppImage --appimage-extract ./appimagetool-x86_64.AppImage --appimage-extract
echo -e '#/bin/sh\n./squashfs-root/AppRun --runtime-file runtime-x86_64 "$@"' > appimagetool echo -e '#/bin/sh\n./squashfs-root/AppRun "$@"' > appimagetool
chmod a+rx appimagetool chmod a+rx appimagetool
- name: Download run-time dependencies - name: Download run-time dependencies
run: | run: |
@@ -157,7 +130,7 @@ jobs:
# charset-normalizer was somehow incomplete in the github runner # charset-normalizer was somehow incomplete in the github runner
"${{ env.PYTHON }}" -m venv venv "${{ env.PYTHON }}" -m venv venv
source venv/bin/activate source venv/bin/activate
"${{ env.PYTHON }}" -m pip install --upgrade pip "PyGObject<3.51.0" charset-normalizer "${{ env.PYTHON }}" -m pip install --upgrade pip PyGObject charset-normalizer
python setup.py build_exe --yes bdist_appimage --yes python setup.py build_exe --yes bdist_appimage --yes
echo -e "setup.py build output:\n `ls build`" echo -e "setup.py build output:\n `ls build`"
echo -e "setup.py dist output:\n `ls dist`" echo -e "setup.py dist output:\n `ls dist`"
@@ -167,16 +140,6 @@ jobs:
echo "APPIMAGE_NAME=$APPIMAGE_NAME" >> $GITHUB_ENV echo "APPIMAGE_NAME=$APPIMAGE_NAME" >> $GITHUB_ENV
echo "TAR_NAME=$TAR_NAME" >> $GITHUB_ENV echo "TAR_NAME=$TAR_NAME" >> $GITHUB_ENV
# - copy code above to release.yml - # - copy code above to release.yml -
- name: Attest Build
if: ${{ github.event_name == 'workflow_dispatch' }}
uses: actions/attest-build-provenance@v2
with:
subject-path: |
build/exe.*/ArchipelagoLauncher
build/exe.*/ArchipelagoGenerate
build/exe.*/ArchipelagoServer
dist/${{ env.APPIMAGE_NAME }}*
dist/${{ env.TAR_NAME }}
- name: Build Again - name: Build Again
run: | run: |
source venv/bin/activate source venv/bin/activate
@@ -197,7 +160,7 @@ jobs:
shell: bash shell: bash
run: | run: |
cd build/exe* cd build/exe*
cp Players/Templates/VVVVVV.yaml Players/ cp Players/Templates/Clique.yaml Players/
timeout 30 ./ArchipelagoGenerate timeout 30 ./ArchipelagoGenerate
- name: Store AppImage - name: Store AppImage
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4

View File

@@ -47,7 +47,7 @@ jobs:
# Initializes the CodeQL tools for scanning. # Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL - name: Initialize CodeQL
uses: github/codeql-action/init@v3 uses: github/codeql-action/init@v2
with: with:
languages: ${{ matrix.language }} languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file. # 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). # 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) # If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild - name: Autobuild
uses: github/codeql-action/autobuild@v3 uses: github/codeql-action/autobuild@v2
# Command-line programs to run using the OS shell. # Command-line programs to run using the OS shell.
# 📚 https://git.io/JvXDl # 📚 https://git.io/JvXDl
@@ -72,4 +72,4 @@ jobs:
# make release # make release
- name: Perform CodeQL Analysis - name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v3 uses: github/codeql-action/analyze@v2

View File

@@ -11,7 +11,7 @@ on:
- '**.hh?' - '**.hh?'
- '**.hpp' - '**.hpp'
- '**.hxx' - '**.hxx'
- '**/CMakeLists.txt' - '**.CMakeLists'
- '.github/workflows/ctest.yml' - '.github/workflows/ctest.yml'
pull_request: pull_request:
paths: paths:
@@ -21,7 +21,7 @@ on:
- '**.hh?' - '**.hh?'
- '**.hpp' - '**.hpp'
- '**.hxx' - '**.hxx'
- '**/CMakeLists.txt' - '**.CMakeLists'
- '.github/workflows/ctest.yml' - '.github/workflows/ctest.yml'
jobs: jobs:
@@ -36,9 +36,9 @@ jobs:
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: ilammy/msvc-dev-cmd@0b201ec74fa43914dc39ae48a89fd1d8cb592756 - uses: ilammy/msvc-dev-cmd@v1
if: startsWith(matrix.os,'windows') if: startsWith(matrix.os,'windows')
- uses: Bacondish2023/setup-googletest@49065d1f7a6d21f6134864dd65980fe5dbe06c73 - uses: Bacondish2023/setup-googletest@v1
with: with:
build-type: 'Release' build-type: 'Release'
- name: Build tests - name: Build tests

View File

@@ -6,8 +6,6 @@ on:
permissions: permissions:
contents: read contents: read
pull-requests: write pull-requests: write
env:
GH_REPO: ${{ github.repository }}
jobs: jobs:
labeler: labeler:

View File

@@ -5,21 +5,11 @@ name: Release
on: on:
push: push:
tags: tags:
- 'v?[0-9]+.[0-9]+.[0-9]*' - '*.*.*'
env: env:
ENEMIZER_VERSION: 7.1 ENEMIZER_VERSION: 7.1
# NOTE: since appimage/appimagetool and appimage/type2-runtime does not have tags anymore, APPIMAGETOOL_VERSION: 13
# we check the sha256 and require manual intervention if it was updated.
APPIMAGETOOL_VERSION: continuous
APPIMAGETOOL_X86_64_HASH: '29348a20b80827cd261c28e95172ff828b69d43d4e4e18e3fd069e2c8693c94e'
APPIMAGE_RUNTIME_VERSION: continuous
APPIMAGE_RUNTIME_X86_64_HASH: 'e70ffa9b69b211574d0917adc482dd66f25a0083427b5945783965d55b0b0a8b'
permissions: # permissions required for attestation
id-token: 'write'
attestations: 'write'
contents: 'write' # additionally required for release
jobs: jobs:
create-release: create-release:
@@ -36,79 +26,11 @@ jobs:
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# build-release-windows: # this is done by hand because of signing
# build-release-macos: # LF volunteer # build-release-macos: # LF volunteer
build-release-win: build-release-ubuntu2004:
runs-on: windows-latest runs-on: ubuntu-20.04
if: ${{ true }} # change to false to skip if release is built by hand
needs: create-release
steps:
- name: Set env
shell: bash
run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV
# - code below copied from build.yml -
- uses: actions/checkout@v4
- name: Install python
uses: actions/setup-python@v5
with:
python-version: '~3.12.7'
check-latest: true
- name: Download run-time dependencies
run: |
Invoke-WebRequest -Uri https://github.com/Ijwu/Enemizer/releases/download/${Env:ENEMIZER_VERSION}/win-x64.zip -OutFile enemizer.zip
Expand-Archive -Path enemizer.zip -DestinationPath EnemizerCLI -Force
choco install innosetup --version=6.2.2 --allow-downgrade
- name: Build
run: |
python -m pip install --upgrade pip
python setup.py build_exe --yes
if ( $? -eq $false ) {
Write-Error "setup.py failed!"
exit 1
}
$NAME="$(ls build | Select-String -Pattern 'exe')".Split('.',2)[1]
$ZIP_NAME="Archipelago_$NAME.7z"
echo "$NAME -> $ZIP_NAME"
echo "ZIP_NAME=$ZIP_NAME" >> $Env:GITHUB_ENV
New-Item -Path dist -ItemType Directory -Force
cd build
Rename-Item "exe.$NAME" Archipelago
7z a -mx=9 -mhe=on -ms "../dist/$ZIP_NAME" Archipelago
Rename-Item Archipelago "exe.$NAME" # inno_setup.iss expects the original name
- name: Build Setup
run: |
& "${env:ProgramFiles(x86)}\Inno Setup 6\iscc.exe" inno_setup.iss /DNO_SIGNTOOL
if ( $? -eq $false ) {
Write-Error "Building setup failed!"
exit 1
}
$contents = Get-ChildItem -Path setups/*.exe -Force -Recurse
$SETUP_NAME=$contents[0].Name
echo "SETUP_NAME=$SETUP_NAME" >> $Env:GITHUB_ENV
# - code above copied from build.yml -
- name: Attest Build
uses: actions/attest-build-provenance@v2
with:
subject-path: |
build/exe.*/ArchipelagoLauncher.exe
build/exe.*/ArchipelagoLauncherDebug.exe
build/exe.*/ArchipelagoGenerate.exe
build/exe.*/ArchipelagoServer.exe
setups/*
- name: Add to Release
uses: softprops/action-gh-release@975c1b265e11dd76618af1c374e7981f9a6ff44a
with:
draft: true # see above
prerelease: false
name: Archipelago ${{ env.RELEASE_VERSION }}
files: |
setups/*
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
build-release-ubuntu2204:
runs-on: ubuntu-22.04
needs: create-release
steps: steps:
- name: Set env - name: Set env
run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV
@@ -122,18 +44,14 @@ jobs:
- name: Get a recent python - name: Get a recent python
uses: actions/setup-python@v5 uses: actions/setup-python@v5
with: with:
python-version: '~3.12.7' python-version: '3.11'
check-latest: true
- name: Install build-time dependencies - name: Install build-time dependencies
run: | run: |
echo "PYTHON=python3.12" >> $GITHUB_ENV echo "PYTHON=python3.11" >> $GITHUB_ENV
wget -nv https://github.com/AppImage/appimagetool/releases/download/$APPIMAGETOOL_VERSION/appimagetool-x86_64.AppImage wget -nv https://github.com/AppImage/AppImageKit/releases/download/$APPIMAGETOOL_VERSION/appimagetool-x86_64.AppImage
echo "$APPIMAGETOOL_X86_64_HASH appimagetool-x86_64.AppImage" | sha256sum -c
wget -nv https://github.com/AppImage/type2-runtime/releases/download/$APPIMAGE_RUNTIME_VERSION/runtime-x86_64
echo "$APPIMAGE_RUNTIME_X86_64_HASH runtime-x86_64" | sha256sum -c
chmod a+rx appimagetool-x86_64.AppImage chmod a+rx appimagetool-x86_64.AppImage
./appimagetool-x86_64.AppImage --appimage-extract ./appimagetool-x86_64.AppImage --appimage-extract
echo -e '#/bin/sh\n./squashfs-root/AppRun --runtime-file runtime-x86_64 "$@"' > appimagetool echo -e '#/bin/sh\n./squashfs-root/AppRun "$@"' > appimagetool
chmod a+rx appimagetool chmod a+rx appimagetool
- name: Download run-time dependencies - name: Download run-time dependencies
run: | run: |
@@ -145,7 +63,7 @@ jobs:
# charset-normalizer was somehow incomplete in the github runner # charset-normalizer was somehow incomplete in the github runner
"${{ env.PYTHON }}" -m venv venv "${{ env.PYTHON }}" -m venv venv
source venv/bin/activate source venv/bin/activate
"${{ env.PYTHON }}" -m pip install --upgrade pip "PyGObject<3.51.0" charset-normalizer "${{ env.PYTHON }}" -m pip install --upgrade pip PyGObject charset-normalizer
python setup.py build_exe --yes bdist_appimage --yes python setup.py build_exe --yes bdist_appimage --yes
echo -e "setup.py build output:\n `ls build`" echo -e "setup.py build output:\n `ls build`"
echo -e "setup.py dist output:\n `ls dist`" echo -e "setup.py dist output:\n `ls dist`"
@@ -155,14 +73,6 @@ jobs:
echo "APPIMAGE_NAME=$APPIMAGE_NAME" >> $GITHUB_ENV echo "APPIMAGE_NAME=$APPIMAGE_NAME" >> $GITHUB_ENV
echo "TAR_NAME=$TAR_NAME" >> $GITHUB_ENV echo "TAR_NAME=$TAR_NAME" >> $GITHUB_ENV
# - code above copied from build.yml - # - code above copied from build.yml -
- name: Attest Build
uses: actions/attest-build-provenance@v2
with:
subject-path: |
build/exe.*/ArchipelagoLauncher
build/exe.*/ArchipelagoGenerate
build/exe.*/ArchipelagoServer
dist/*
- name: Add to Release - name: Add to Release
uses: softprops/action-gh-release@975c1b265e11dd76618af1c374e7981f9a6ff44a uses: softprops/action-gh-release@975c1b265e11dd76618af1c374e7981f9a6ff44a
with: with:

View File

@@ -40,10 +40,10 @@ jobs:
run: | run: |
wget https://apt.llvm.org/llvm.sh wget https://apt.llvm.org/llvm.sh
chmod +x ./llvm.sh chmod +x ./llvm.sh
sudo ./llvm.sh 19 sudo ./llvm.sh 17
- name: Install scan-build command - name: Install scan-build command
run: | run: |
sudo apt install clang-tools-19 sudo apt install clang-tools-17
- name: Get a recent python - name: Get a recent python
uses: actions/setup-python@v5 uses: actions/setup-python@v5
with: with:
@@ -56,7 +56,7 @@ jobs:
- name: scan-build - name: scan-build
run: | run: |
source venv/bin/activate source venv/bin/activate
scan-build-19 --status-bugs -o scan-build-reports -disable-checker deadcode.DeadStores python setup.py build -y scan-build-17 --status-bugs -o scan-build-reports -disable-checker deadcode.DeadStores python setup.py build -y
- name: Store report - name: Store report
if: failure() if: failure()
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4

View File

@@ -26,7 +26,7 @@ jobs:
- name: "Install dependencies" - name: "Install dependencies"
run: | run: |
python -m pip install --upgrade pip pyright==1.1.392.post0 python -m pip install --upgrade pip pyright==1.1.358
python ModuleUpdate.py --append "WebHostLib/requirements.txt" --force --yes python ModuleUpdate.py --append "WebHostLib/requirements.txt" --force --yes
- name: "pyright: strict check on specific files" - name: "pyright: strict check on specific files"

View File

@@ -8,24 +8,18 @@ on:
paths: paths:
- '**' - '**'
- '!docs/**' - '!docs/**'
- '!deploy/**'
- '!setup.py' - '!setup.py'
- '!Dockerfile'
- '!*.iss' - '!*.iss'
- '!.gitignore' - '!.gitignore'
- '!.dockerignore'
- '!.github/workflows/**' - '!.github/workflows/**'
- '.github/workflows/unittests.yml' - '.github/workflows/unittests.yml'
pull_request: pull_request:
paths: paths:
- '**' - '**'
- '!docs/**' - '!docs/**'
- '!deploy/**'
- '!setup.py' - '!setup.py'
- '!Dockerfile'
- '!*.iss' - '!*.iss'
- '!.gitignore' - '!.gitignore'
- '!.dockerignore'
- '!.github/workflows/**' - '!.github/workflows/**'
- '.github/workflows/unittests.yml' - '.github/workflows/unittests.yml'
@@ -39,15 +33,16 @@ jobs:
matrix: matrix:
os: [ubuntu-latest] os: [ubuntu-latest]
python: python:
- {version: '3.11.2'} # Change to '3.11' around 2026-06-10 - {version: '3.8'}
- {version: '3.12'} - {version: '3.9'}
- {version: '3.13'} - {version: '3.10'}
- {version: '3.11'}
include: include:
- python: {version: '3.11'} # old compat - python: {version: '3.8'} # win7 compat
os: windows-latest os: windows-latest
- python: {version: '3.13'} # current - python: {version: '3.11'} # current
os: windows-latest os: windows-latest
- python: {version: '3.13'} # current - python: {version: '3.11'} # current
os: macos-latest os: macos-latest
steps: steps:
@@ -75,7 +70,7 @@ jobs:
os: os:
- ubuntu-latest - ubuntu-latest
python: python:
- {version: '3.13'} # current - {version: '3.11'} # current
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
@@ -93,4 +88,4 @@ jobs:
run: | run: |
source venv/bin/activate source venv/bin/activate
export PYTHONPATH=$(pwd) export PYTHONPATH=$(pwd)
timeout 600 python test/hosting/__main__.py python test/hosting/__main__.py

11
.gitignore vendored
View File

@@ -4,13 +4,11 @@
*_Spoiler.txt *_Spoiler.txt
*.bmbp *.bmbp
*.apbp *.apbp
*.apcivvi
*.apl2ac *.apl2ac
*.apm3 *.apm3
*.apmc *.apmc
*.apz5 *.apz5
*.aptloz *.aptloz
*.aptww
*.apemerald *.apemerald
*.pyc *.pyc
*.pyd *.pyd
@@ -56,6 +54,7 @@ success.txt
output/ output/
Output Logs/ Output Logs/
/factorio/ /factorio/
/Minecraft Forge Server/
/WebHostLib/static/generated /WebHostLib/static/generated
/freeze_requirements.txt /freeze_requirements.txt
/Archipelago.zip /Archipelago.zip
@@ -151,7 +150,7 @@ venv/
ENV/ ENV/
env.bak/ env.bak/
venv.bak/ venv.bak/
*.code-workspace .code-workspace
shell.nix shell.nix
# Spyder project settings # Spyder project settings
@@ -183,6 +182,12 @@ _speedups.c
_speedups.cpp _speedups.cpp
_speedups.html _speedups.html
# minecraft server stuff
jdk*/
minecraft*/
minecraft_versions.json
!worlds/minecraft/
# pyenv # pyenv
.python-version .python-version

View File

@@ -1,4 +1,3 @@
import sys
from worlds.ahit.Client import launch from worlds.ahit.Client import launch
import Utils import Utils
import ModuleUpdate import ModuleUpdate
@@ -6,4 +5,4 @@ ModuleUpdate.update()
if __name__ == "__main__": if __name__ == "__main__":
Utils.init_logging("AHITClient", exception_logger="Client") Utils.init_logging("AHITClient", exception_logger="Client")
launch(*sys.argv[1:]) launch()

View File

@@ -11,7 +11,6 @@ from typing import List
import Utils import Utils
from settings import get_settings
from NetUtils import ClientStatus from NetUtils import ClientStatus
from Utils import async_start from Utils import async_start
from CommonClient import CommonContext, server_loop, gui_enabled, ClientCommandProcessor, logger, \ from CommonClient import CommonContext, server_loop, gui_enabled, ClientCommandProcessor, logger, \
@@ -81,8 +80,8 @@ class AdventureContext(CommonContext):
self.local_item_locations = {} self.local_item_locations = {}
self.dragon_speed_info = {} self.dragon_speed_info = {}
options = get_settings().adventure_options options = Utils.get_settings()
self.display_msgs = options.display_msgs self.display_msgs = options["adventure_options"]["display_msgs"]
async def server_auth(self, password_requested: bool = False): async def server_auth(self, password_requested: bool = False):
if password_requested and not self.password: if password_requested and not self.password:
@@ -103,7 +102,7 @@ class AdventureContext(CommonContext):
def on_package(self, cmd: str, args: dict): def on_package(self, cmd: str, args: dict):
if cmd == 'Connected': if cmd == 'Connected':
self.locations_array = None self.locations_array = None
if get_settings().adventure_options.as_dict().get("death_link", False): if Utils.get_settings()["adventure_options"].get("death_link", False):
self.set_deathlink = True self.set_deathlink = True
async_start(self.get_freeincarnates_used()) async_start(self.get_freeincarnates_used())
elif cmd == "RoomInfo": elif cmd == "RoomInfo":
@@ -407,7 +406,6 @@ async def atari_sync_task(ctx: AdventureContext):
except ConnectionRefusedError: except ConnectionRefusedError:
logger.debug("Connection Refused, Trying Again") logger.debug("Connection Refused, Trying Again")
ctx.atari_status = CONNECTION_REFUSED_STATUS ctx.atari_status = CONNECTION_REFUSED_STATUS
await asyncio.sleep(1)
continue continue
except CancelledError: except CancelledError:
pass pass
@@ -417,9 +415,8 @@ async def atari_sync_task(ctx: AdventureContext):
async def run_game(romfile): async def run_game(romfile):
options = get_settings().adventure_options auto_start = Utils.get_settings()["adventure_options"].get("rom_start", True)
auto_start = options.rom_start rom_args = Utils.get_settings()["adventure_options"].get("rom_args")
rom_args = options.rom_args
if auto_start is True: if auto_start is True:
import webbrowser import webbrowser
webbrowser.open(romfile) webbrowser.open(romfile)
@@ -514,7 +511,7 @@ if __name__ == '__main__':
import colorama import colorama
colorama.just_fix_windows_console() colorama.init()
asyncio.run(main()) asyncio.run(main())
colorama.deinit() colorama.deinit()

File diff suppressed because it is too large Load Diff

View File

@@ -1,10 +1,9 @@
from __future__ import annotations from __future__ import annotations
import sys
import ModuleUpdate import ModuleUpdate
ModuleUpdate.update() ModuleUpdate.update()
from worlds._bizhawk.context import launch from worlds._bizhawk.context import launch
if __name__ == "__main__": if __name__ == "__main__":
launch(*sys.argv[1:]) launch()

View File

@@ -21,9 +21,9 @@ import Utils
if __name__ == "__main__": if __name__ == "__main__":
Utils.init_logging("TextClient", exception_logger="Client") Utils.init_logging("TextClient", exception_logger="Client")
from MultiServer import CommandProcessor, mark_raw from MultiServer import CommandProcessor
from NetUtils import (Endpoint, decode, NetworkItem, encode, JSONtoTextParser, ClientStatus, Permission, NetworkSlot, from NetUtils import (Endpoint, decode, NetworkItem, encode, JSONtoTextParser, ClientStatus, Permission, NetworkSlot,
RawJSONtoTextParser, add_json_text, add_json_location, add_json_item, JSONTypes, HintStatus, SlotType) RawJSONtoTextParser, add_json_text, add_json_location, add_json_item, JSONTypes)
from Utils import Version, stream_input, async_start from Utils import Version, stream_input, async_start
from worlds import network_data_package, AutoWorldRegister from worlds import network_data_package, AutoWorldRegister
import os import os
@@ -31,7 +31,6 @@ import ssl
if typing.TYPE_CHECKING: if typing.TYPE_CHECKING:
import kvui import kvui
import argparse
logger = logging.getLogger("Client") logger = logging.getLogger("Client")
@@ -46,21 +45,10 @@ def get_ssl_context():
class ClientCommandProcessor(CommandProcessor): 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): def __init__(self, ctx: CommonContext):
self.ctx = ctx self.ctx = ctx
def output(self, text: str): def output(self, text: str):
"""Helper function to abstract logging to the CommonClient UI"""
logger.info(text) logger.info(text)
def _cmd_exit(self) -> bool: def _cmd_exit(self) -> bool:
@@ -73,7 +61,6 @@ class ClientCommandProcessor(CommandProcessor):
if address: if address:
self.ctx.server_address = None self.ctx.server_address = None
self.ctx.username = None self.ctx.username = None
self.ctx.password = None
elif not self.ctx.server_address: elif not self.ctx.server_address:
self.output("Please specify an address.") self.output("Please specify an address.")
return False return False
@@ -107,9 +94,7 @@ class ClientCommandProcessor(CommandProcessor):
return False return False
count = 0 count = 0
checked_count = 0 checked_count = 0
for location, location_id in AutoWorldRegister.world_types[self.ctx.game].location_name_to_id.items():
lookup = self.ctx.location_names[self.ctx.game]
for location_id, location in lookup.items():
if filter_text and filter_text not in location: if filter_text and filter_text not in location:
continue continue
if location_id < 0: if location_id < 0:
@@ -130,87 +115,43 @@ class ClientCommandProcessor(CommandProcessor):
self.output("No missing location checks found.") self.output("No missing location checks found.")
return True return True
def output_datapackage_part(self, name: typing.Literal["Item Names", "Location Names"]) -> bool: def _cmd_items(self):
"""
Helper to digest a specific section of this game's datapackage.
:param name: Printed to the user as context for the part.
:return: Whether the process was successful.
"""
if not self.ctx.game:
self.output(f"No game set, cannot determine {name}.")
return False
lookup = self.ctx.item_names if name == "Item Names" else self.ctx.location_names
lookup = lookup[self.ctx.game]
self.output(f"{name} for {self.ctx.game}")
for name in lookup.values():
self.output(name)
return True
def _cmd_items(self) -> bool:
"""List all item names for the currently running game.""" """List all item names for the currently running game."""
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: if not self.ctx.game:
self.output(f"No game set, cannot determine existing {name} Groups.") self.output("No game set, cannot determine existing items.")
return False return False
lookup = Utils.persistent_load().get("groups_by_checksum", {}).get(self.ctx.checksums[self.ctx.game], {})\ self.output(f"Item Names for {self.ctx.game}")
.get(self.ctx.game, {}).get(group_key, {}) for item_name in AutoWorldRegister.world_types[self.ctx.game].item_name_to_id:
if lookup is None: self.output(item_name)
self.output("datapackage not yet loaded, try again")
def _cmd_item_groups(self):
"""List all item group names for the currently running game."""
if not self.ctx.game:
self.output("No game set, cannot determine existing item groups.")
return False return False
self.output(f"Item Group Names for {self.ctx.game}")
for group_name in AutoWorldRegister.world_types[self.ctx.game].item_name_groups:
self.output(group_name)
if filter_key: def _cmd_locations(self):
if filter_key not in lookup: """List all location names for the currently running game."""
self.output(f"Unknown {name} Group {filter_key}") if not self.ctx.game:
return False self.output("No game set, cannot determine existing locations.")
return False
self.output(f"Location Names for {self.ctx.game}")
for location_name in AutoWorldRegister.world_types[self.ctx.game].location_name_to_id:
self.output(location_name)
self.output(f"{name}s for {name} Group \"{filter_key}\"") def _cmd_location_groups(self):
for entry in lookup[filter_key]: """List all location group names for the currently running game."""
self.output(entry) if not self.ctx.game:
else: self.output("No game set, cannot determine existing location groups.")
self.output(f"{name} Groups for {self.ctx.game}") return False
for group in lookup: self.output(f"Location Group Names for {self.ctx.game}")
self.output(group) for group_name in AutoWorldRegister.world_types[self.ctx.game].location_name_groups:
return True self.output(group_name)
@mark_raw def _cmd_ready(self):
def _cmd_item_groups(self, key: str = "") -> bool:
"""
List all item group names for the currently running game.
:param key: Which item group to filter to. Will log all groups if empty.
"""
return self.output_group_part("item_name_groups", key, "Item")
@mark_raw
def _cmd_location_groups(self, key: str = "") -> bool:
"""
List all location group names for the currently running game.
:param key: Which item group to filter to. Will log all groups if empty.
"""
return self.output_group_part("location_name_groups", key, "Location")
def _cmd_ready(self) -> bool:
"""Send ready status to server.""" """Send ready status to server."""
self.ctx.ready = not self.ctx.ready self.ctx.ready = not self.ctx.ready
if self.ctx.ready: if self.ctx.ready:
@@ -220,17 +161,15 @@ class ClientCommandProcessor(CommandProcessor):
state = ClientStatus.CLIENT_CONNECTED state = ClientStatus.CLIENT_CONNECTED
self.output("Unreadied.") self.output("Unreadied.")
async_start(self.ctx.send_msgs([{"cmd": "StatusUpdate", "status": state}]), name="send StatusUpdate") async_start(self.ctx.send_msgs([{"cmd": "StatusUpdate", "status": state}]), name="send StatusUpdate")
return True
def default(self, raw: str): def default(self, raw: str):
"""The default message parser to be used when parsing any messages that do not match a command"""
raw = self.ctx.on_user_say(raw) raw = self.ctx.on_user_say(raw)
if raw: if raw:
async_start(self.ctx.send_msgs([{"cmd": "Say", "text": raw}]), name="send Say") async_start(self.ctx.send_msgs([{"cmd": "Say", "text": raw}]), name="send Say")
class CommonContext: class CommonContext:
# The following attributes are used to Connect and should be adjusted as needed in subclasses # Should be adjusted as needed in subclasses
tags: typing.Set[str] = {"AP"} tags: typing.Set[str] = {"AP"}
game: typing.Optional[str] = None game: typing.Optional[str] = None
items_handling: typing.Optional[int] = None items_handling: typing.Optional[int] = None
@@ -243,12 +182,25 @@ class CommonContext:
self.lookup_type: typing.Literal["item", "location"] = lookup_type self.lookup_type: typing.Literal["item", "location"] = lookup_type
self._unknown_item: typing.Callable[[int], str] = lambda key: f"Unknown {lookup_type} (ID: {key})" self._unknown_item: typing.Callable[[int], str] = lambda key: f"Unknown {lookup_type} (ID: {key})"
self._archipelago_lookup: typing.Dict[int, str] = {} self._archipelago_lookup: typing.Dict[int, str] = {}
self._flat_store: typing.Dict[int, str] = Utils.KeyedDefaultDict(self._unknown_item)
self._game_store: typing.Dict[str, typing.ChainMap[int, str]] = collections.defaultdict( self._game_store: typing.Dict[str, typing.ChainMap[int, str]] = collections.defaultdict(
lambda: collections.ChainMap(self._archipelago_lookup, Utils.KeyedDefaultDict(self._unknown_item))) lambda: collections.ChainMap(self._archipelago_lookup, Utils.KeyedDefaultDict(self._unknown_item)))
self.warned: bool = False
# noinspection PyTypeChecker # noinspection PyTypeChecker
def __getitem__(self, key: str) -> typing.Mapping[int, str]: def __getitem__(self, key: str) -> typing.Mapping[int, str]:
assert isinstance(key, str), f"ctx.{self.lookup_type}_names used with an id, use the lookup_in_ helpers instead" # TODO: In a future version (0.6.0?) this should be simplified by removing implicit id lookups support.
if isinstance(key, int):
if not self.warned:
# Use warnings instead of logger to avoid deprecation message from appearing on user side.
self.warned = True
warnings.warn(f"Implicit name lookup by id only is deprecated and only supported to maintain "
f"backwards compatibility for now. If multiple games share the same id for a "
f"{self.lookup_type}, name could be incorrect. Please use "
f"`{self.lookup_type}_names.lookup_in_game()` or "
f"`{self.lookup_type}_names.lookup_in_slot()` instead.")
return self._flat_store[key] # type: ignore
return self._game_store[key] return self._game_store[key]
def __len__(self) -> int: def __len__(self) -> int:
@@ -258,7 +210,7 @@ class CommonContext:
return iter(self._game_store) return iter(self._game_store)
def __repr__(self) -> str: def __repr__(self) -> str:
return repr(self._game_store) return self._game_store.__repr__()
def lookup_in_game(self, code: int, game_name: typing.Optional[str] = None) -> str: def lookup_in_game(self, code: int, game_name: typing.Optional[str] = None) -> str:
"""Returns the name for an item/location id in the context of a specific game or own game if `game` is """Returns the name for an item/location id in the context of a specific game or own game if `game` is
@@ -288,6 +240,7 @@ class CommonContext:
id_to_name_lookup_table = Utils.KeyedDefaultDict(self._unknown_item) id_to_name_lookup_table = Utils.KeyedDefaultDict(self._unknown_item)
id_to_name_lookup_table.update({code: name for name, code in name_to_id_lookup_table.items()}) id_to_name_lookup_table.update({code: name for name, code in name_to_id_lookup_table.items()})
self._game_store[game] = collections.ChainMap(self._archipelago_lookup, id_to_name_lookup_table) self._game_store[game] = collections.ChainMap(self._archipelago_lookup, id_to_name_lookup_table)
self._flat_store.update(id_to_name_lookup_table) # Only needed for legacy lookup method.
if game == "Archipelago": if game == "Archipelago":
# Keep track of the Archipelago data package separately so if it gets updated in a custom datapackage, # Keep track of the Archipelago data package separately so if it gets updated in a custom datapackage,
# it updates in all chain maps automatically. # it updates in all chain maps automatically.
@@ -298,7 +251,7 @@ class CommonContext:
starting_reconnect_delay: int = 5 starting_reconnect_delay: int = 5
current_reconnect_delay: int = starting_reconnect_delay current_reconnect_delay: int = starting_reconnect_delay
command_processor: typing.Type[CommandProcessor] = ClientCommandProcessor command_processor: typing.Type[CommandProcessor] = ClientCommandProcessor
ui: typing.Optional["kvui.GameManager"] = None ui = None
ui_task: typing.Optional["asyncio.Task[None]"] = None ui_task: typing.Optional["asyncio.Task[None]"] = None
input_task: typing.Optional["asyncio.Task[None]"] = None input_task: typing.Optional["asyncio.Task[None]"] = None
keep_alive_task: typing.Optional["asyncio.Task[None]"] = None keep_alive_task: typing.Optional["asyncio.Task[None]"] = None
@@ -314,71 +267,38 @@ class CommonContext:
last_death_link: float = time.time() # last send/received death link on AP layer last_death_link: float = time.time() # last send/received death link on AP layer
# remaining type info # remaining type info
slot_info: dict[int, NetworkSlot] slot_info: typing.Dict[int, NetworkSlot]
"""Slot Info from the server for the current connection""" server_address: typing.Optional[str]
server_address: str | None password: typing.Optional[str]
"""Autoconnect address provided by the ctx constructor""" hint_cost: typing.Optional[int]
password: str | None hint_points: typing.Optional[int]
"""Password used for Connecting, expected by server_auth""" player_names: typing.Dict[int, str]
hint_cost: int | None
"""Current Hint Cost per Hint from the server"""
hint_points: int | None
"""Current avaliable Hint Points from the server"""
player_names: dict[int, str]
"""Current lookup of slot number to player display name from server (includes aliases)"""
finished_game: bool finished_game: bool
"""
Bool to signal that status should be updated to Goal after reconnecting
to be used to ensure that a StatusUpdate packet does not get lost when disconnected
"""
ready: bool ready: bool
"""Bool to keep track of state for the /ready command""" team: typing.Optional[int]
team: int | None slot: typing.Optional[int]
"""Team number of currently connected slot""" auth: typing.Optional[str]
slot: int | None seed_name: typing.Optional[str]
"""Slot number of currently connected slot"""
auth: str | None
"""Name used in Connect packet"""
seed_name: str | None
"""Seed name that will be validated on opening a socket if present"""
# locations # locations
locations_checked: set[int] locations_checked: typing.Set[int] # local state
""" locations_scouted: typing.Set[int]
Local container of location ids checked to signal that LocationChecks should be resent after reconnecting items_received: typing.List[NetworkItem]
to be used to ensure that a LocationChecks packet does not get lost when disconnected missing_locations: typing.Set[int] # server state
""" checked_locations: typing.Set[int] # server state
locations_scouted: set[int] server_locations: typing.Set[int] # all locations the server knows of, missing_location | checked_locations
""" locations_info: typing.Dict[int, NetworkItem]
Local container of location ids scouted to signal that LocationScouts should be resent after reconnecting
to be used to ensure that a LocationScouts packet does not get lost when disconnected
"""
items_received: list[NetworkItem]
"""List of NetworkItems recieved from the server"""
missing_locations: set[int]
"""Container of Locations that are unchecked per server state"""
checked_locations: set[int]
"""Container of Locations that are checked per server state"""
server_locations: set[int]
"""Container of Locations that exist per server state; a combination between missing and checked locations"""
locations_info: dict[int, NetworkItem]
"""Dict of location id: NetworkItem info from LocationScouts request"""
# data storage # data storage
stored_data: dict[str, typing.Any] stored_data: typing.Dict[str, typing.Any]
""" stored_data_notification_keys: typing.Set[str]
Data Storage values by key that were retrieved from the server
any keys subscribed to with SetNotify will be kept up to date
"""
stored_data_notification_keys: set[str]
"""Current container of watched Data Storage keys, managed by ctx.set_notify"""
# internals # internals
# current message box through kvui
_messagebox: typing.Optional["kvui.MessageBox"] = None _messagebox: typing.Optional["kvui.MessageBox"] = None
"""Current message box through kvui""" # message box reporting a loss of connection
_messagebox_connection_loss: typing.Optional["kvui.MessageBox"] = None _messagebox_connection_loss: typing.Optional["kvui.MessageBox"] = None
"""Message box reporting a loss of connection"""
def __init__(self, server_address: typing.Optional[str] = None, password: typing.Optional[str] = None) -> None: def __init__(self, server_address: typing.Optional[str] = None, password: typing.Optional[str] = None) -> None:
# server state # server state
@@ -422,12 +342,9 @@ class CommonContext:
self.item_names = self.NameLookupDict(self, "item") self.item_names = self.NameLookupDict(self, "item")
self.location_names = self.NameLookupDict(self, "location") self.location_names = self.NameLookupDict(self, "location")
self.checksums = {}
self.jsontotextparser = JSONtoTextParser(self) self.jsontotextparser = JSONtoTextParser(self)
self.rawjsontotextparser = RawJSONtoTextParser(self) self.rawjsontotextparser = RawJSONtoTextParser(self)
if self.game:
self.checksums[self.game] = network_data_package["games"][self.game]["checksum"]
self.update_data_package(network_data_package) self.update_data_package(network_data_package)
# execution # execution
@@ -480,8 +397,6 @@ class CommonContext:
await self.server.socket.close() await self.server.socket.close()
if self.server_task is not None: if self.server_task is not None:
await self.server_task await self.server_task
if self.ui:
self.ui.update_hints()
async def send_msgs(self, msgs: typing.List[typing.Any]) -> None: async def send_msgs(self, msgs: typing.List[typing.Any]) -> None:
""" `msgs` JSON serializable """ """ `msgs` JSON serializable """
@@ -513,10 +428,7 @@ class CommonContext:
self.auth = await self.console_input() self.auth = await self.console_input()
async def send_connect(self, **kwargs: typing.Any) -> None: 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 = { payload = {
'cmd': 'Connect', 'cmd': 'Connect',
'password': self.password, 'name': self.auth, 'version': Utils.version_tuple, 'password': self.password, 'name': self.auth, 'version': Utils.version_tuple,
@@ -526,14 +438,6 @@ class CommonContext:
if kwargs: if kwargs:
payload.update(kwargs) payload.update(kwargs)
await self.send_msgs([payload]) 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: async def console_input(self) -> str:
if self.ui: if self.ui:
@@ -554,7 +458,6 @@ class CommonContext:
return False return False
def slot_concerns_self(self, slot) -> bool: 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: if slot == self.slot:
return True return True
if slot in self.slot_info: if slot in self.slot_info:
@@ -562,7 +465,6 @@ class CommonContext:
return False return False
def is_echoed_chat(self, print_json_packet: dict) -> bool: 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" \ return print_json_packet.get("type", "") == "Chat" \
and print_json_packet.get("team", None) == self.team \ and print_json_packet.get("team", None) == self.team \
and print_json_packet.get("slot", None) == self.slot and print_json_packet.get("slot", None) == self.slot
@@ -594,14 +496,13 @@ class CommonContext:
"""Gets called before sending a Say to the server from the user. """Gets called before sending a Say to the server from the user.
Returned text is sent, or sending is aborted if None is returned.""" Returned text is sent, or sending is aborted if None is returned."""
return text return text
def on_ui_command(self, text: str) -> None: def on_ui_command(self, text: str) -> None:
"""Gets called by kivy when the user executes a command starting with `/` or `!`. """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.""" The command processor is still called; this is just intended for command echoing."""
self.ui.print_json([{"text": text, "type": "color", "color": "orange"}]) self.ui.print_json([{"text": text, "type": "color", "color": "orange"}])
def update_permissions(self, permissions: typing.Dict[str, int]): 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(): for permission_name, permission_flag in permissions.items():
try: try:
flag = Permission(permission_flag) flag = Permission(permission_flag)
@@ -613,7 +514,6 @@ class CommonContext:
async def shutdown(self): async def shutdown(self):
self.server_address = "" self.server_address = ""
self.username = None self.username = None
self.password = None
self.cancel_autoreconnect() self.cancel_autoreconnect()
if self.server and not self.server.socket.closed: if self.server and not self.server.socket.closed:
await self.server.socket.close() await self.server.socket.close()
@@ -628,16 +528,10 @@ class CommonContext:
await self.ui_task await self.ui_task
if self.input_task: if self.input_task:
self.input_task.cancel() 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 # DataPackage
async def prepare_data_package(self, relevant_games: typing.Set[str], async def prepare_data_package(self, relevant_games: typing.Set[str],
remote_date_package_versions: typing.Dict[str, int],
remote_data_package_checksums: typing.Dict[str, str]): remote_data_package_checksums: typing.Dict[str, str]):
"""Validate that all data is present for the current multiworld. """Validate that all data is present for the current multiworld.
Download, assimilate and cache missing data from the server.""" Download, assimilate and cache missing data from the server."""
@@ -646,36 +540,36 @@ class CommonContext:
needed_updates: typing.Set[str] = set() needed_updates: typing.Set[str] = set()
for game in relevant_games: for game in relevant_games:
if game not in remote_data_package_checksums: if game not in remote_date_package_versions and game not in remote_data_package_checksums:
continue continue
remote_version: int = remote_date_package_versions.get(game, 0)
remote_checksum: typing.Optional[str] = remote_data_package_checksums.get(game) remote_checksum: typing.Optional[str] = remote_data_package_checksums.get(game)
if not remote_checksum: # custom data package and no checksum for this game if remote_version == 0 and not remote_checksum: # custom data package and no checksum for this game
needed_updates.add(game) needed_updates.add(game)
continue continue
cached_checksum: typing.Optional[str] = self.checksums.get(game) local_version: int = network_data_package["games"].get(game, {}).get("version", 0)
# no action required if cached version is new enough local_checksum: typing.Optional[str] = network_data_package["games"].get(game, {}).get("checksum")
if remote_checksum != cached_checksum: # no action required if local version is new enough
local_checksum: typing.Optional[str] = network_data_package["games"].get(game, {}).get("checksum") if (not remote_checksum and (remote_version > local_version or remote_version == 0)) \
if remote_checksum == local_checksum: or remote_checksum != local_checksum:
self.update_game(network_data_package["games"][game], game) 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)
else: else:
cached_game = Utils.load_data_package_for_checksum(game, remote_checksum) self.update_game(cached_game, game)
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: if needed_updates:
await self.send_msgs([{"cmd": "GetDataPackage", "games": [game_name]} for game_name in needed_updates]) await self.send_msgs([{"cmd": "GetDataPackage", "games": [game_name]} for game_name in needed_updates])
def update_game(self, game_package: dict, game: str): def update_game(self, game_package: dict, game: str):
self.item_names.update_game(game, game_package["item_name_to_id"]) self.item_names.update_game(game, game_package["item_name_to_id"])
self.location_names.update_game(game, game_package["location_name_to_id"]) self.location_names.update_game(game, game_package["location_name_to_id"])
self.checksums[game] = game_package.get("checksum")
def update_data_package(self, data_package: dict): def update_data_package(self, data_package: dict):
for game, game_data in data_package["games"].items(): for game, game_data in data_package["games"].items():
@@ -683,28 +577,13 @@ class CommonContext:
def consume_network_data_package(self, data_package: dict): def consume_network_data_package(self, data_package: dict):
self.update_data_package(data_package) self.update_data_package(data_package)
current_cache = Utils.persistent_load().get("datapackage", {}).get("games", {})
current_cache.update(data_package["games"])
Utils.persistent_store("datapackage", "games", current_cache)
logger.info(f"Got new ID/Name DataPackage for {', '.join(data_package['games'])}") logger.info(f"Got new ID/Name DataPackage for {', '.join(data_package['games'])}")
for game, game_data in data_package["games"].items(): for game, game_data in data_package["games"].items():
Utils.store_data_package_for_checksum(game, game_data) Utils.store_data_package_for_checksum(game, game_data)
def consume_network_item_groups(self):
data = {"item_name_groups": self.stored_data[f"_read_item_name_groups_{self.game}"]}
current_cache = Utils.persistent_load().get("groups_by_checksum", {}).get(self.checksums[self.game], {})
if self.game in current_cache:
current_cache[self.game].update(data)
else:
current_cache[self.game] = data
Utils.persistent_store("groups_by_checksum", self.checksums[self.game], current_cache)
def consume_network_location_groups(self):
data = {"location_name_groups": self.stored_data[f"_read_location_name_groups_{self.game}"]}
current_cache = Utils.persistent_load().get("groups_by_checksum", {}).get(self.checksums[self.game], {})
if self.game in current_cache:
current_cache[self.game].update(data)
else:
current_cache[self.game] = data
Utils.persistent_store("groups_by_checksum", self.checksums[self.game], current_cache)
# data storage # data storage
def set_notify(self, *keys: str) -> None: def set_notify(self, *keys: str) -> None:
@@ -732,7 +611,6 @@ class CommonContext:
logger.info(f"DeathLink: Received from {data['source']}") logger.info(f"DeathLink: Received from {data['source']}")
async def send_death(self, death_text: str = ""): 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: if self.server and self.server.socket:
logger.info("DeathLink: Sending death to your friends...") logger.info("DeathLink: Sending death to your friends...")
self.last_death_link = time.time() self.last_death_link = time.time()
@@ -746,7 +624,6 @@ class CommonContext:
}]) }])
async def update_death_link(self, death_link: bool): 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() old_tags = self.tags.copy()
if death_link: if death_link:
self.tags.add("DeathLink") self.tags.add("DeathLink")
@@ -756,7 +633,7 @@ class CommonContext:
await self.send_msgs([{"cmd": "ConnectUpdate", "tags": self.tags}]) await self.send_msgs([{"cmd": "ConnectUpdate", "tags": self.tags}])
def gui_error(self, title: str, text: typing.Union[Exception, str]) -> typing.Optional["kvui.MessageBox"]: def gui_error(self, title: str, text: typing.Union[Exception, str]) -> typing.Optional["kvui.MessageBox"]:
"""Displays an error messagebox in the loaded Kivy UI. Override if using a different UI framework""" """Displays an error messagebox"""
if not self.ui: if not self.ui:
return None return None
title = title or "Error" title = title or "Error"
@@ -783,36 +660,21 @@ class CommonContext:
logger.exception(msg, exc_info=exc_info, extra={'compact_gui': True}) logger.exception(msg, exc_info=exc_info, extra={'compact_gui': True})
self._messagebox_connection_loss = self.gui_error(msg, exc_info[1]) self._messagebox_connection_loss = self.gui_error(msg, exc_info[1])
def make_gui(self) -> "type[kvui.GameManager]": def run_gui(self):
""" """Import kivy UI system and start running it as self.ui_task."""
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 from kvui import GameManager
class TextManager(GameManager): class TextManager(GameManager):
logging_pairs = [
("Client", "Archipelago")
]
base_title = "Archipelago Text Client" base_title = "Archipelago Text Client"
return TextManager self.ui = TextManager(self)
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") self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI")
def run_cli(self): def run_cli(self):
if sys.stdin: 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 # steam overlay breaks when starting console_loop
if 'gameoverlayrenderer' in os.environ.get('LD_PRELOAD', ''): if 'gameoverlayrenderer' in os.environ.get('LD_PRELOAD', ''):
logger.info("Skipping terminal input, due to conflicting Steam Overlay detected. Please use GUI only.") logger.info("Skipping terminal input, due to conflicting Steam Overlay detected. Please use GUI only.")
@@ -963,8 +825,9 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
logger.info(' %s (Player %d)' % (network_player.alias, network_player.slot)) logger.info(' %s (Player %d)' % (network_player.alias, network_player.slot))
# update data package # update data package
data_package_versions = args.get("datapackage_versions", {})
data_package_checksums = args.get("datapackage_checksums", {}) data_package_checksums = args.get("datapackage_checksums", {})
await ctx.prepare_data_package(set(args["games"]), data_package_checksums) await ctx.prepare_data_package(set(args["games"]), data_package_versions, data_package_checksums)
await ctx.server_auth(args['password']) await ctx.server_auth(args['password'])
@@ -980,7 +843,6 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
ctx.disconnected_intentionally = True ctx.disconnected_intentionally = True
ctx.event_invalid_game() ctx.event_invalid_game()
elif 'IncompatibleVersion' in errors: elif 'IncompatibleVersion' in errors:
ctx.disconnected_intentionally = True
raise Exception('Server reported your client version as incompatible. ' raise Exception('Server reported your client version as incompatible. '
'This probably means you have to update.') 'This probably means you have to update.')
elif 'InvalidItemsHandling' in errors: elif 'InvalidItemsHandling' in errors:
@@ -1000,17 +862,10 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
ctx.team = args["team"] ctx.team = args["team"]
ctx.slot = args["slot"] ctx.slot = args["slot"]
# int keys get lost in JSON transfer # int keys get lost in JSON transfer
ctx.slot_info = {0: NetworkSlot("Archipelago", "Archipelago", SlotType.player)} ctx.slot_info = {int(pid): data for pid, data in args["slot_info"].items()}
ctx.slot_info.update({int(pid): data for pid, data in args["slot_info"].items()})
ctx.hint_points = args.get("hint_points", 0) ctx.hint_points = args.get("hint_points", 0)
ctx.consume_players_package(args["players"]) ctx.consume_players_package(args["players"])
ctx.stored_data_notification_keys.add(f"_read_hints_{ctx.team}_{ctx.slot}") ctx.stored_data_notification_keys.add(f"_read_hints_{ctx.team}_{ctx.slot}")
if ctx.game:
game = ctx.game
else:
game = ctx.slot_info[ctx.slot][1]
ctx.stored_data_notification_keys.add(f"_read_item_name_groups_{game}")
ctx.stored_data_notification_keys.add(f"_read_location_name_groups_{game}")
msgs = [] msgs = []
if ctx.locations_checked: if ctx.locations_checked:
msgs.append({"cmd": "LocationChecks", msgs.append({"cmd": "LocationChecks",
@@ -1091,19 +946,11 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
ctx.stored_data.update(args["keys"]) ctx.stored_data.update(args["keys"])
if ctx.ui and f"_read_hints_{ctx.team}_{ctx.slot}" in args["keys"]: if ctx.ui and f"_read_hints_{ctx.team}_{ctx.slot}" in args["keys"]:
ctx.ui.update_hints() ctx.ui.update_hints()
if f"_read_item_name_groups_{ctx.game}" in args["keys"]:
ctx.consume_network_item_groups()
if f"_read_location_name_groups_{ctx.game}" in args["keys"]:
ctx.consume_network_location_groups()
elif cmd == "SetReply": elif cmd == "SetReply":
ctx.stored_data[args["key"]] = args["value"] ctx.stored_data[args["key"]] = args["value"]
if ctx.ui and f"_read_hints_{ctx.team}_{ctx.slot}" == args["key"]: if ctx.ui and f"_read_hints_{ctx.team}_{ctx.slot}" == args["key"]:
ctx.ui.update_hints() ctx.ui.update_hints()
elif f"_read_item_name_groups_{ctx.game}" == args["key"]:
ctx.consume_network_item_groups()
elif f"_read_location_name_groups_{ctx.game}" == args["key"]:
ctx.consume_network_location_groups()
elif args["key"].startswith("EnergyLink"): elif args["key"].startswith("EnergyLink"):
ctx.current_energy_link_value = args["value"] ctx.current_energy_link_value = args["value"]
if ctx.ui: if ctx.ui:
@@ -1135,7 +982,6 @@ async def console_loop(ctx: CommonContext):
def get_base_parser(description: typing.Optional[str] = None): def get_base_parser(description: typing.Optional[str] = None):
"""Base argument parser to be reused for components subclassing off of CommonClient"""
import argparse import argparse
parser = argparse.ArgumentParser(description=description) parser = argparse.ArgumentParser(description=description)
parser.add_argument('--connect', default=None, help='Address of the multiworld host.') parser.add_argument('--connect', default=None, help='Address of the multiworld host.')
@@ -1145,33 +991,7 @@ def get_base_parser(description: typing.Optional[str] = None):
return parser return parser
def handle_url_arg(args: "argparse.Namespace", def run_as_textclient():
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): class TextContext(CommonContext):
# Text Mode to use !hint and such with games that have no text entry # Text Mode to use !hint and such with games that have no text entry
tags = CommonContext.tags | {"TextOnly"} tags = CommonContext.tags | {"TextOnly"}
@@ -1183,7 +1003,7 @@ def run_as_textclient(*args):
if password_requested and not self.password: if password_requested and not self.password:
await super(TextContext, self).server_auth(password_requested) await super(TextContext, self).server_auth(password_requested)
await self.get_username() await self.get_username()
await self.send_connect(game="") await self.send_connect()
def on_package(self, cmd: str, args: dict): def on_package(self, cmd: str, args: dict):
if cmd == "Connected": if cmd == "Connected":
@@ -1210,12 +1030,17 @@ def run_as_textclient(*args):
parser = get_base_parser(description="Gameless Archipelago Client, for text interfacing.") 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('--name', default=None, help="Slot Name to connect as.")
parser.add_argument("url", nargs="?", help="Archipelago connection url") parser.add_argument("url", nargs="?", help="Archipelago connection url")
args = parser.parse_args(args) args = parser.parse_args()
args = handle_url_arg(args, parser=parser) 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)
# use colorama to display colored text highlighting on windows colorama.init()
colorama.just_fix_windows_console()
asyncio.run(main(args)) asyncio.run(main(args))
colorama.deinit() colorama.deinit()
@@ -1223,4 +1048,4 @@ def run_as_textclient(*args):
if __name__ == '__main__': if __name__ == '__main__':
logging.getLogger().setLevel(logging.INFO) # force log-level to work around log level resetting to WARNING 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 run_as_textclient()

View File

@@ -1,100 +0,0 @@
# hadolint global ignore=SC1090,SC1091
# Source
FROM scratch AS release
WORKDIR /release
ADD https://github.com/Ijwu/Enemizer/releases/latest/download/ubuntu.16.04-x64.zip Enemizer.zip
# Enemizer
FROM alpine:3.21 AS enemizer
ARG TARGETARCH
WORKDIR /release
COPY --from=release /release/Enemizer.zip .
# No release for arm architecture. Skip.
RUN if [ "$TARGETARCH" = "amd64" ]; then \
apk add unzip=6.0-r15 --no-cache && \
unzip -u Enemizer.zip -d EnemizerCLI && \
chmod -R 777 EnemizerCLI; \
else touch EnemizerCLI; fi
# Cython builder stage
FROM python:3.12 AS cython-builder
WORKDIR /build
# Copy and install requirements first (better caching)
COPY requirements.txt WebHostLib/requirements.txt
RUN pip install --no-cache-dir -r \
WebHostLib/requirements.txt \
"setuptools>=75,<81"
COPY _speedups.pyx .
COPY intset.h .
RUN cythonize -b -i _speedups.pyx
# Archipelago
FROM python:3.12-slim-bookworm AS archipelago
ARG TARGETARCH
ENV VIRTUAL_ENV=/opt/venv
ENV PYTHONUNBUFFERED=1
WORKDIR /app
# Install requirements
# hadolint ignore=DL3008
RUN apt-get update && \
apt-get install -y --no-install-recommends \
git \
gcc=4:12.2.0-3 \
libc6-dev \
libtk8.6=8.6.13-2 \
g++=4:12.2.0-3 \
curl && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*
# Create and activate venv
RUN python -m venv $VIRTUAL_ENV; \
. $VIRTUAL_ENV/bin/activate
# Copy and install requirements first (better caching)
COPY WebHostLib/requirements.txt WebHostLib/requirements.txt
RUN pip install --no-cache-dir -r \
WebHostLib/requirements.txt \
gunicorn==23.0.0
COPY . .
COPY --from=cython-builder /build/*.so ./
# Run ModuleUpdate
RUN python ModuleUpdate.py -y
# Purge unneeded packages
RUN apt-get purge -y \
git \
gcc \
libc6-dev \
g++ && \
apt-get autoremove -y
# Copy necessary components
COPY --from=enemizer /release/EnemizerCLI /tmp/EnemizerCLI
# No release for arm architecture. Skip.
RUN if [ "$TARGETARCH" = "amd64" ]; then \
cp -r /tmp/EnemizerCLI EnemizerCLI; \
fi; \
rm -rf /tmp/EnemizerCLI
# Define health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
CMD curl -f http://localhost:${PORT:-80} || exit 1
# Ensure no runtime ModuleUpdate.
ENV SKIP_REQUIREMENTS_UPDATE=true
ENTRYPOINT [ "python", "WebHost.py" ]

267
FF1Client.py Normal file
View File

@@ -0,0 +1,267 @@
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()

12
FactorioClient.py Normal file
View File

@@ -0,0 +1,12 @@
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()

577
Fill.py
View File

@@ -4,7 +4,7 @@ import logging
import typing import typing
from collections import Counter, deque from collections import Counter, deque
from BaseClasses import CollectionState, Item, Location, LocationProgressType, MultiWorld, PlandoItemBlock from BaseClasses import CollectionState, Item, Location, LocationProgressType, MultiWorld
from Options import Accessibility from Options import Accessibility
from worlds.AutoWorld import call_all from worlds.AutoWorld import call_all
@@ -12,12 +12,7 @@ from worlds.generic.Rules import add_item_rule
class FillError(RuntimeError): class FillError(RuntimeError):
def __init__(self, *args: typing.Union[str, typing.Any], **kwargs) -> None: pass
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: def _log_fill_progress(name: str, placed: int, total_items: int) -> None:
@@ -29,15 +24,14 @@ def sweep_from_pool(base_state: CollectionState, itempool: typing.Sequence[Item]
new_state = base_state.copy() new_state = base_state.copy()
for item in itempool: for item in itempool:
new_state.collect(item, True) new_state.collect(item, True)
new_state.sweep_for_advancements(locations=locations) new_state.sweep_for_events(locations=locations)
return new_state return new_state
def fill_restrictive(multiworld: 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, 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, swap: bool = True, on_place: typing.Optional[typing.Callable[[Location], None]] = None,
allow_partial: bool = False, allow_excluded: bool = False, one_item_per_player: bool = True, allow_partial: bool = False, allow_excluded: bool = False, name: str = "Unknown") -> None:
name: str = "Unknown") -> None:
""" """
:param multiworld: Multiworld to be filled. :param multiworld: Multiworld to be filled.
:param base_state: State assumed before fill. :param base_state: State assumed before fill.
@@ -64,24 +58,14 @@ def fill_restrictive(multiworld: MultiWorld, base_state: CollectionState, locati
placed = 0 placed = 0
while any(reachable_items.values()) and locations: while any(reachable_items.values()) and locations:
if one_item_per_player: # grab one item per player
# grab one item per player items_to_place = [items.pop()
items_to_place = [items.pop() for items in reachable_items.values() if items]
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 item in items_to_place:
# The items added into `reachable_items` are placed starting from the end of each deque in for p, pool_item in enumerate(item_pool):
# `reachable_items`, so the items being placed are more likely to be found towards the end of `item_pool`.
for p, pool_item in enumerate(reversed(item_pool), start=1):
if pool_item is item: if pool_item is item:
del item_pool[-p] item_pool.pop(p)
break break
maximum_exploration_state = sweep_from_pool( maximum_exploration_state = sweep_from_pool(
base_state, item_pool + unplaced_items, multiworld.get_filled_locations(item.player) base_state, item_pool + unplaced_items, multiworld.get_filled_locations(item.player)
if single_player_placement else None) if single_player_placement else None)
@@ -100,7 +84,7 @@ def fill_restrictive(multiworld: MultiWorld, base_state: CollectionState, locati
# if minimal accessibility, only check whether location is reachable if game not beatable # if minimal accessibility, only check whether location is reachable if game not beatable
if multiworld.worlds[item_to_place.player].options.accessibility == Accessibility.option_minimal: if multiworld.worlds[item_to_place.player].options.accessibility == Accessibility.option_minimal:
perform_access_check = not multiworld.has_beaten_game(maximum_exploration_state, perform_access_check = not multiworld.has_beaten_game(maximum_exploration_state,
item_to_place.player) \ item_to_place.player) \
if single_player_placement else not has_beaten_game if single_player_placement else not has_beaten_game
else: else:
perform_access_check = True perform_access_check = True
@@ -116,13 +100,6 @@ def fill_restrictive(multiworld: MultiWorld, base_state: CollectionState, locati
else: else:
# we filled all reachable spots. # we filled all reachable spots.
if swap: if swap:
# Keep a cache of previous safe swap states that might be usable to sweep from to produce the next
# swap state, instead of sweeping from `base_state` each time.
previous_safe_swap_state_cache: typing.Deque[CollectionState] = deque()
# Almost never are more than 2 states needed. The rare cases that do are usually highly restrictive
# single_player_placement=True pre-fills which can go through more than 10 states in some seeds.
max_swap_base_state_cache_length = 3
# try swapping this item with previously placed items in a safe way then in an unsafe way # try swapping this item with previously placed items in a safe way then in an unsafe way
swap_attempts = ((i, location, unsafe) swap_attempts = ((i, location, unsafe)
for unsafe in (False, True) for unsafe in (False, True)
@@ -137,50 +114,40 @@ def fill_restrictive(multiworld: MultiWorld, base_state: CollectionState, locati
location.item = None location.item = None
placed_item.location = None placed_item.location = None
swap_state = sweep_from_pool(base_state, [placed_item, *item_pool] if unsafe else item_pool,
for previous_safe_swap_state in previous_safe_swap_state_cache: multiworld.get_filled_locations(item.player)
# If a state has already checked the location of the swap, then it cannot be used. if single_player_placement else None)
if location not in previous_safe_swap_state.advancements:
# Previous swap states will have collected all items in `item_pool`, so the new
# `swap_state` can skip having to collect them again.
# Previous swap states will also have already checked many locations, making the sweep
# faster.
swap_state = sweep_from_pool(previous_safe_swap_state, (placed_item,) if unsafe else (),
multiworld.get_filled_locations(item.player)
if single_player_placement else None)
break
else:
# No previous swap_state was usable as a base state to sweep from, so create a new one.
swap_state = sweep_from_pool(base_state, [placed_item, *item_pool] if unsafe else item_pool,
multiworld.get_filled_locations(item.player)
if single_player_placement else None)
# Unsafe states should not be added to the cache because they have collected `placed_item`.
if not unsafe:
if len(previous_safe_swap_state_cache) >= max_swap_base_state_cache_length:
# Remove the oldest cached state.
previous_safe_swap_state_cache.pop()
# Add the new state to the start of the cache.
previous_safe_swap_state_cache.appendleft(swap_state)
# unsafe means swap_state assumes we can somehow collect placed_item before item_to_place # unsafe means swap_state assumes we can somehow collect placed_item before item_to_place
# by continuing to swap, which is not guaranteed. This is unsafe because there is no mechanic # by continuing to swap, which is not guaranteed. This is unsafe because there is no mechanic
# to clean that up later, so there is a chance generation fails. # to clean that up later, so there is a chance generation fails.
if (not single_player_placement or location.player == item_to_place.player) \ if (not single_player_placement or location.player == item_to_place.player) \
and location.can_fill(swap_state, item_to_place, perform_access_check): and location.can_fill(swap_state, item_to_place, perform_access_check):
# Add this item to the existing placement, and
# add the old item to the back of the queue
spot_to_fill = placements.pop(i)
swap_count += 1 # Verify placing this item won't reduce available locations, which would be a useless swap.
swapped_items[placed_item.player, placed_item.name, unsafe] = swap_count prev_state = swap_state.copy()
prev_loc_count = len(
multiworld.get_reachable_locations(prev_state))
reachable_items[placed_item.player].appendleft( swap_state.collect(item_to_place, True)
placed_item) new_loc_count = len(
item_pool.append(placed_item) multiworld.get_reachable_locations(swap_state))
# cleanup at the end to hopefully get better errors if new_loc_count >= prev_loc_count:
cleanup_required = True # Add this item to the existing placement, and
# add the old item to the back of the queue
spot_to_fill = placements.pop(i)
break 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
# Item can't be placed here, restore original item # Item can't be placed here, restore original item
location.item = placed_item location.item = placed_item
@@ -245,7 +212,7 @@ def fill_restrictive(multiworld: MultiWorld, base_state: CollectionState, locati
f"Unfilled locations:\n" f"Unfilled locations:\n"
f"{', '.join(str(location) for location in locations)}\n" f"{', '.join(str(location) for location in locations)}\n"
f"Already placed {len(placements)}:\n" f"Already placed {len(placements)}:\n"
f"{', '.join(str(place) for place in placements)}", multiworld=multiworld) f"{', '.join(str(place) for place in placements)}")
item_pool.extend(unplaced_items) item_pool.extend(unplaced_items)
@@ -254,30 +221,18 @@ def remaining_fill(multiworld: MultiWorld,
locations: typing.List[Location], locations: typing.List[Location],
itempool: typing.List[Item], itempool: typing.List[Item],
name: str = "Remaining", name: str = "Remaining",
move_unplaceable_to_start_inventory: bool = False, move_unplaceable_to_start_inventory: bool = False) -> None:
check_location_can_fill: bool = False) -> None:
unplaced_items: typing.List[Item] = [] unplaced_items: typing.List[Item] = []
placements: typing.List[Location] = [] placements: typing.List[Location] = []
swapped_items: typing.Counter[typing.Tuple[int, str]] = Counter() swapped_items: typing.Counter[typing.Tuple[int, str]] = Counter()
total = min(len(itempool), len(locations)) total = min(len(itempool), len(locations))
placed = 0 placed = 0
# Optimisation: Decide whether to do full location.can_fill check (respect excluded), or only check the item rule
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: while locations and itempool:
item_to_place = itempool.pop() item_to_place = itempool.pop()
spot_to_fill: typing.Optional[Location] = None spot_to_fill: typing.Optional[Location] = None
for i, location in enumerate(locations): for i, location in enumerate(locations):
if location_can_fill_item(location, item_to_place): if location.item_rule(item_to_place):
# popping by index is faster than removing by content, # popping by index is faster than removing by content,
spot_to_fill = locations.pop(i) spot_to_fill = locations.pop(i)
# skipping a scan for the element # skipping a scan for the element
@@ -298,7 +253,7 @@ def remaining_fill(multiworld: MultiWorld,
location.item = None location.item = None
placed_item.location = None placed_item.location = None
if location_can_fill_item(location, item_to_place): if location.item_rule(item_to_place):
# Add this item to the existing placement, and # Add this item to the existing placement, and
# add the old item to the back of the queue # add the old item to the back of the queue
spot_to_fill = placements.pop(i) spot_to_fill = placements.pop(i)
@@ -344,7 +299,7 @@ def remaining_fill(multiworld: MultiWorld,
f"Unfilled locations:\n" f"Unfilled locations:\n"
f"{', '.join(str(location) for location in locations)}\n" f"{', '.join(str(location) for location in locations)}\n"
f"Already placed {len(placements)}:\n" f"Already placed {len(placements)}:\n"
f"{', '.join(str(place) for place in placements)}", multiworld=multiworld) f"{', '.join(str(place) for place in placements)}")
itempool.extend(unplaced_items) itempool.extend(unplaced_items)
@@ -358,26 +313,19 @@ def fast_fill(multiworld: MultiWorld,
return item_pool[placing:], fill_locations[placing:] return item_pool[placing:], fill_locations[placing:]
def accessibility_corrections(multiworld: MultiWorld, def accessibility_corrections(multiworld: MultiWorld, state: CollectionState, locations, pool=[]):
state: CollectionState,
locations: list[Location],
pool: list[Item] | None = None) -> None:
if pool is None:
pool = []
maximum_exploration_state = sweep_from_pool(state, pool) maximum_exploration_state = sweep_from_pool(state, pool)
minimal_players = {player for player in multiworld.player_ids if minimal_players = {player for player in multiworld.player_ids if multiworld.worlds[player].options.accessibility == "minimal"}
multiworld.worlds[player].options.accessibility == "minimal"} unreachable_locations = [location for location in multiworld.get_locations() if location.player in minimal_players and
unreachable_locations = [location for location in multiworld.get_locations() if
location.player in minimal_players and
not location.can_reach(maximum_exploration_state)] not location.can_reach(maximum_exploration_state)]
for location in unreachable_locations: for location in unreachable_locations:
if (location.item is not None and location.item.advancement and location.address is not None and not if (location.item is not None and location.item.advancement and location.address is not None and not
location.locked and location.item.player not in minimal_players): location.locked and location.item.player not in minimal_players):
pool.append(location.item) pool.append(location.item)
state.remove(location.item)
location.item = None location.item = None
if location in state.advancements: if location in state.events:
state.advancements.remove(location) state.events.remove(location)
state.remove(location.item)
locations.append(location) locations.append(location)
if pool and locations: if pool and locations:
locations.sort(key=lambda loc: loc.progress_type != LocationProgressType.PRIORITY) locations.sort(key=lambda loc: loc.progress_type != LocationProgressType.PRIORITY)
@@ -389,7 +337,7 @@ def inaccessible_location_rules(multiworld: MultiWorld, state: CollectionState,
unreachable_locations = [location for location in locations if not location.can_reach(maximum_exploration_state)] unreachable_locations = [location for location in locations if not location.can_reach(maximum_exploration_state)]
if unreachable_locations: if unreachable_locations:
def forbid_important_item_rule(item: Item): def forbid_important_item_rule(item: Item):
return not ((item.classification & 0b0011) and multiworld.worlds[item.player].options.accessibility != "minimal") return not ((item.classification & 0b0011) and multiworld.worlds[item.player].options.accessibility != 'minimal')
for location in unreachable_locations: for location in unreachable_locations:
add_item_rule(location, forbid_important_item_rule) add_item_rule(location, forbid_important_item_rule)
@@ -410,7 +358,7 @@ def distribute_early_items(multiworld: MultiWorld,
early_priority_locations: typing.List[Location] = [] early_priority_locations: typing.List[Location] = []
loc_indexes_to_remove: typing.Set[int] = set() loc_indexes_to_remove: typing.Set[int] = set()
base_state = multiworld.state.copy() base_state = multiworld.state.copy()
base_state.sweep_for_advancements(locations=(loc for loc in multiworld.get_filled_locations() if loc.address is None)) base_state.sweep_for_events(locations=(loc for loc in multiworld.get_filled_locations() if loc.address is None))
for i, loc in enumerate(fill_locations): for i, loc in enumerate(fill_locations):
if loc.can_reach(base_state): if loc.can_reach(base_state):
if loc.progress_type == LocationProgressType.PRIORITY: if loc.progress_type == LocationProgressType.PRIORITY:
@@ -483,12 +431,6 @@ def distribute_early_items(multiworld: MultiWorld,
def distribute_items_restrictive(multiworld: MultiWorld, def distribute_items_restrictive(multiworld: MultiWorld,
panic_method: typing.Literal["swap", "raise", "start_inventory"] = "swap") -> None: panic_method: typing.Literal["swap", "raise", "start_inventory"] = "swap") -> None:
assert all(item.location is None for item in multiworld.itempool), (
"At the start of distribute_items_restrictive, "
"there are items in the multiworld itempool that are already placed on locations:\n"
f"{[(item.location, item) for item in multiworld.itempool if item.location is not None]}"
)
fill_locations = sorted(multiworld.get_unfilled_locations()) fill_locations = sorted(multiworld.get_unfilled_locations())
multiworld.random.shuffle(fill_locations) multiworld.random.shuffle(fill_locations)
# get items to distribute # get items to distribute
@@ -528,68 +470,28 @@ def distribute_items_restrictive(multiworld: MultiWorld,
nonlocal lock_later nonlocal lock_later
lock_later.append(location) lock_later.append(location)
single_player = multiworld.players == 1 and not multiworld.groups
if prioritylocations: if prioritylocations:
regular_progression = []
deprioritized_progression = []
for item in progitempool:
if item.deprioritized:
deprioritized_progression.append(item)
else:
regular_progression.append(item)
# "priority fill" # "priority fill"
# try without deprioritized items in the mix at all. This means they need to be collected into state first. fill_restrictive(multiworld, multiworld.state, prioritylocations, progitempool,
priority_fill_state = sweep_from_pool(multiworld.state, deprioritized_progression) single_player_placement=multiworld.players == 1, swap=False, on_place=mark_for_locking,
fill_restrictive(multiworld, priority_fill_state, prioritylocations, regular_progression, name="Priority")
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) accessibility_corrections(multiworld, multiworld.state, prioritylocations, progitempool)
defaultlocations = prioritylocations + defaultlocations defaultlocations = prioritylocations + defaultlocations
if progitempool: if progitempool:
# "advancement/progression fill" # "advancement/progression fill"
maximum_exploration_state = sweep_from_pool(multiworld.state)
if panic_method == "swap": if panic_method == "swap":
fill_restrictive(multiworld, maximum_exploration_state, defaultlocations, progitempool, swap=True, fill_restrictive(multiworld, multiworld.state, defaultlocations, progitempool,
name="Progression", single_player_placement=single_player) swap=True,
name="Progression", single_player_placement=multiworld.players == 1)
elif panic_method == "raise": elif panic_method == "raise":
fill_restrictive(multiworld, maximum_exploration_state, defaultlocations, progitempool, swap=False, fill_restrictive(multiworld, multiworld.state, defaultlocations, progitempool,
name="Progression", single_player_placement=single_player) swap=False,
name="Progression", single_player_placement=multiworld.players == 1)
elif panic_method == "start_inventory": elif panic_method == "start_inventory":
fill_restrictive(multiworld, maximum_exploration_state, defaultlocations, progitempool, swap=False, fill_restrictive(multiworld, multiworld.state, defaultlocations, progitempool,
allow_partial=True, name="Progression", single_player_placement=single_player) swap=False, allow_partial=True,
name="Progression", single_player_placement=multiworld.players == 1)
if progitempool: if progitempool:
for item in progitempool: for item in progitempool:
logging.debug(f"Moved {item} to start_inventory to prevent fill failure.") logging.debug(f"Moved {item} to start_inventory to prevent fill failure.")
@@ -604,9 +506,7 @@ def distribute_items_restrictive(multiworld: MultiWorld,
if progitempool: if progitempool:
raise FillError( raise FillError(
f"Not enough locations for progression items. " f"Not enough locations for progression items. "
f"There are {len(progitempool)} more progression items than there are available locations.\n" f"There are {len(progitempool)} more progression items than there are available locations."
f"Unfilled locations:\n{multiworld.get_unfilled_locations()}.",
multiworld=multiworld,
) )
accessibility_corrections(multiworld, multiworld.state, defaultlocations) accessibility_corrections(multiworld, multiworld.state, defaultlocations)
@@ -623,8 +523,7 @@ def distribute_items_restrictive(multiworld: MultiWorld,
if excludedlocations: if excludedlocations:
raise FillError( raise FillError(
f"Not enough filler items for excluded locations. " f"Not enough filler items for excluded locations. "
f"There are {len(excludedlocations)} more excluded locations than excludable items.", f"There are {len(excludedlocations)} more excluded locations than filler or trap items."
multiworld=multiworld,
) )
restitempool = filleritempool + usefulitempool restitempool = filleritempool + usefulitempool
@@ -644,26 +543,6 @@ def distribute_items_restrictive(multiworld: MultiWorld,
print_data = {"items": items_counter, "locations": locations_counter} 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(multiworld: MultiWorld) -> None: def flood_items(multiworld: MultiWorld) -> None:
# get items to distribute # get items to distribute
@@ -672,7 +551,7 @@ def flood_items(multiworld: MultiWorld) -> None:
progress_done = False progress_done = False
# sweep once to pick up preplaced items # sweep once to pick up preplaced items
multiworld.state.sweep_for_advancements() multiworld.state.sweep_for_events()
# fill multiworld from top of itempool while we can # fill multiworld from top of itempool while we can
while not progress_done: while not progress_done:
@@ -710,7 +589,7 @@ def flood_items(multiworld: MultiWorld) -> None:
if candidate_item_to_place is not None: if candidate_item_to_place is not None:
item_to_place = candidate_item_to_place item_to_place = candidate_item_to_place
else: else:
raise FillError('No more progress items left to place.', multiworld=multiworld) raise FillError('No more progress items left to place.')
# find item to replace with progress item # find item to replace with progress item
location_list = multiworld.get_reachable_locations() location_list = multiworld.get_reachable_locations()
@@ -739,9 +618,9 @@ def balance_multiworld_progression(multiworld: MultiWorld) -> None:
if multiworld.worlds[player].options.progression_balancing > 0 if multiworld.worlds[player].options.progression_balancing > 0
} }
if not balanceable_players: if not balanceable_players:
logging.info("Skipping multiworld progression balancing.") logging.info('Skipping multiworld progression balancing.')
else: else:
logging.info(f"Balancing multiworld progression for {len(balanceable_players)} Players.") logging.info(f'Balancing multiworld progression for {len(balanceable_players)} Players.')
logging.debug(balanceable_players) logging.debug(balanceable_players)
state: CollectionState = CollectionState(multiworld) state: CollectionState = CollectionState(multiworld)
checked_locations: typing.Set[Location] = set() checked_locations: typing.Set[Location] = set()
@@ -767,6 +646,7 @@ def balance_multiworld_progression(multiworld: MultiWorld) -> None:
def get_sphere_locations(sphere_state: CollectionState, def get_sphere_locations(sphere_state: CollectionState,
locations: typing.Set[Location]) -> typing.Set[Location]: 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)} return {loc for loc in locations if sphere_state.can_reach(loc)}
def item_percentage(player: int, num: int) -> float: def item_percentage(player: int, num: int) -> float:
@@ -839,7 +719,7 @@ def balance_multiworld_progression(multiworld: MultiWorld) -> None:
if player in threshold_percentages): if player in threshold_percentages):
break break
elif not balancing_sphere: elif not balancing_sphere:
raise RuntimeError("Not all required items reachable. Something went terribly wrong here.") raise RuntimeError('Not all required items reachable. Something went terribly wrong here.')
# Gather a set of locations which we can swap items into # Gather a set of locations which we can swap items into
unlocked_locations: typing.Dict[int, typing.Set[Location]] = collections.defaultdict(set) unlocked_locations: typing.Dict[int, typing.Set[Location]] = collections.defaultdict(set)
for l in unchecked_locations: for l in unchecked_locations:
@@ -855,12 +735,12 @@ def balance_multiworld_progression(multiworld: MultiWorld) -> None:
testing = items_to_test.pop() testing = items_to_test.pop()
reducing_state = state.copy() reducing_state = state.copy()
for location in itertools.chain(( for location in itertools.chain((
l for l in items_to_replace l for l in items_to_replace
if l.item.player == player if l.item.player == player
), items_to_test): ), items_to_test):
reducing_state.collect(location.item, True, location) reducing_state.collect(location.item, True, location)
reducing_state.sweep_for_advancements(locations=locations_to_test) reducing_state.sweep_for_events(locations=locations_to_test)
if multiworld.has_beaten_game(balancing_state): if multiworld.has_beaten_game(balancing_state):
if not multiworld.has_beaten_game(reducing_state): if not multiworld.has_beaten_game(reducing_state):
@@ -929,30 +809,52 @@ def swap_location_item(location_1: Location, location_2: Location, check_locked:
location_2.item.location = location_2 location_2.item.location = location_2
def parse_planned_blocks(multiworld: MultiWorld) -> dict[int, list[PlandoItemBlock]]: def distribute_planned(multiworld: MultiWorld) -> None:
def warn(warning: str, force: bool | str) -> None: def warn(warning: str, force: typing.Union[bool, str]) -> None:
if isinstance(force, bool): if force in [True, 'fail', 'failure', 'none', False, 'warn', 'warning']:
logging.warning(f"{warning}") logging.warning(f'{warning}')
else: else:
logging.debug(f"{warning}") logging.debug(f'{warning}')
def failed(warning: str, force: bool | str) -> None: def failed(warning: str, force: typing.Union[bool, str]) -> None:
if force is True: if force in [True, 'fail', 'failure']:
raise Exception(warning) raise Exception(warning)
else: else:
warn(warning, force) warn(warning, force)
swept_state = multiworld.state.copy()
swept_state.sweep_for_events()
reachable = frozenset(multiworld.get_reachable_locations(swept_state))
early_locations: typing.Dict[int, typing.List[str]] = collections.defaultdict(list)
non_early_locations: typing.Dict[int, typing.List[str]] = collections.defaultdict(list)
for loc in multiworld.get_unfilled_locations():
if loc in reachable:
early_locations[loc.player].append(loc.name)
else: # not reachable with swept state
non_early_locations[loc.player].append(loc.name)
world_name_lookup = multiworld.world_name_lookup world_name_lookup = multiworld.world_name_lookup
plando_blocks: dict[int, list[PlandoItemBlock]] = dict() block_value = typing.Union[typing.List[str], typing.Dict[str, typing.Any], str]
player_ids: set[int] = set(multiworld.player_ids) plando_blocks: typing.List[typing.Dict[str, typing.Any]] = []
player_ids = set(multiworld.player_ids)
for player in player_ids: for player in player_ids:
plando_blocks[player] = [] for block in multiworld.plando_items[player]:
for block in multiworld.worlds[player].options.plando_items: block['player'] = player
new_block: PlandoItemBlock = PlandoItemBlock(player, block.from_pool, block.force) if 'force' not in block:
target_world = block.world block['force'] = 'silent'
if 'from_pool' not in block:
block['from_pool'] = True
elif not isinstance(block['from_pool'], bool):
from_pool_type = type(block['from_pool'])
raise Exception(f'Plando "from_pool" has to be boolean, not {from_pool_type} for player {player}.')
if 'world' not in block:
target_world = False
else:
target_world = block['world']
if target_world is False or multiworld.players == 1: # target own world if target_world is False or multiworld.players == 1: # target own world
worlds: set[int] = {player} worlds: typing.Set[int] = {player}
elif target_world is True: # target any worlds besides own elif target_world is True: # target any worlds besides own
worlds = set(multiworld.player_ids) - {player} worlds = set(multiworld.player_ids) - {player}
elif target_world is None: # target all worlds elif target_world is None: # target all worlds
@@ -961,201 +863,156 @@ def parse_planned_blocks(multiworld: MultiWorld) -> dict[int, list[PlandoItemBlo
worlds = set() worlds = set()
for listed_world in target_world: for listed_world in target_world:
if listed_world not in world_name_lookup: if listed_world not in world_name_lookup:
failed(f"Cannot place item to {listed_world}'s world as that world does not exist.", failed(f"Cannot place item to {target_world}'s world as that world does not exist.",
block.force) block['force'])
continue continue
worlds.add(world_name_lookup[listed_world]) worlds.add(world_name_lookup[listed_world])
elif type(target_world) == int: # target world by slot number elif type(target_world) == int: # target world by slot number
if target_world not in range(1, multiworld.players + 1): if target_world not in range(1, multiworld.players + 1):
failed( failed(
f"Cannot place item in world {target_world} as it is not in range of (1, {multiworld.players})", f"Cannot place item in world {target_world} as it is not in range of (1, {multiworld.players})",
block.force) block['force'])
continue continue
worlds = {target_world} worlds = {target_world}
else: # target world by slot name else: # target world by slot name
if target_world not in world_name_lookup: if target_world not in world_name_lookup:
failed(f"Cannot place item to {target_world}'s world as that world does not exist.", failed(f"Cannot place item to {target_world}'s world as that world does not exist.",
block.force) block['force'])
continue continue
worlds = {world_name_lookup[target_world]} worlds = {world_name_lookup[target_world]}
new_block.worlds = worlds block['world'] = worlds
items: list[str] | dict[str, typing.Any] = block.items 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
if isinstance(items, dict): if isinstance(items, dict):
item_list: list[str] = [] item_list: typing.List[str] = []
for key, value in items.items(): for key, value in items.items():
if value is True: if value is True:
value = multiworld.itempool.count(multiworld.worlds[player].create_item(key)) value = multiworld.itempool.count(multiworld.worlds[player].create_item(key))
item_list += [key] * value item_list += [key] * value
items = item_list items = item_list
new_block.items = items if isinstance(items, str):
items = [items]
block['items'] = items
locations: list[str] = block.locations 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']
if isinstance(locations, str): if isinstance(locations, str):
locations = [locations] locations = [locations]
resolved_locations: list[Location] = [] if isinstance(locations, dict):
for target_player in worlds: location_list = []
locations_from_groups: list[str] = [] for key, value in locations.items():
world_locations = multiworld.get_unfilled_locations(target_player) location_list += [key] * value
for group in multiworld.worlds[target_player].location_name_groups: locations = location_list
if group in locations:
locations_from_groups.extend(multiworld.worlds[target_player].location_name_groups[group])
resolved_locations.extend(location for location in world_locations
if location.name in [*locations, *locations_from_groups])
new_block.locations = sorted(dict.fromkeys(locations))
new_block.resolved_locations = sorted(set(resolved_locations))
count = block.count
if not count:
count = (min(len(new_block.items), len(new_block.resolved_locations))
if new_block.resolved_locations else len(new_block.items))
if isinstance(count, int):
count = {"min": count, "max": count}
if "min" not in count:
count["min"] = 0
if "max" not in count:
count["max"] = (min(len(new_block.items), len(new_block.resolved_locations))
if new_block.resolved_locations else len(new_block.items))
new_block.count = count
plando_blocks[player].append(new_block)
return plando_blocks
def resolve_early_locations_for_planned(multiworld: MultiWorld):
def warn(warning: str, force: bool | str) -> None:
if isinstance(force, bool):
logging.warning(f"{warning}")
else:
logging.debug(f"{warning}")
def failed(warning: str, force: bool | str) -> None:
if force is True:
raise Exception(warning)
else:
warn(warning, force)
swept_state = multiworld.state.copy()
swept_state.sweep_for_advancements()
reachable = frozenset(multiworld.get_reachable_locations(swept_state))
early_locations: dict[int, list[Location]] = collections.defaultdict(list)
non_early_locations: dict[int, list[Location]] = collections.defaultdict(list)
for loc in multiworld.get_unfilled_locations():
if loc in reachable:
early_locations[loc.player].append(loc)
else: # not reachable with swept state
non_early_locations[loc.player].append(loc)
for player in multiworld.plando_item_blocks:
removed = []
for block in multiworld.plando_item_blocks[player]:
locations = block.locations
resolved_locations = block.resolved_locations
worlds = block.worlds
if "early_locations" in locations: if "early_locations" in locations:
locations.remove("early_locations")
for target_player in worlds: for target_player in worlds:
resolved_locations += early_locations[target_player] locations += early_locations[target_player]
if "non_early_locations" in locations: if "non_early_locations" in locations:
locations.remove("non_early_locations")
for target_player in worlds: for target_player in worlds:
resolved_locations += non_early_locations[target_player] locations += non_early_locations[target_player]
if block.count["max"] > len(block.items): block['locations'] = list(dict.fromkeys(locations))
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"]: if not block['count']:
removed.append(block) 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'] = multiworld.random.randint(block['count']['min'], block['count']['max'])
for block in removed: if block['count']['target'] > 0:
multiworld.plando_item_blocks[player].remove(block) plando_blocks.append(block)
def distribute_planned_blocks(multiworld: MultiWorld, plando_blocks: list[PlandoItemBlock]):
def warn(warning: str, force: bool | str) -> None:
if isinstance(force, bool):
logging.warning(f"{warning}")
else:
logging.debug(f"{warning}")
def failed(warning: str, force: bool | str) -> None:
if force is True:
raise Exception(warning)
else:
warn(warning, force)
# shuffle, but then sort blocks by number of locations minus number of items, # shuffle, but then sort blocks by number of locations minus number of items,
# so less-flexible blocks get priority # so less-flexible blocks get priority
multiworld.random.shuffle(plando_blocks) multiworld.random.shuffle(plando_blocks)
plando_blocks.sort(key=lambda block: (len(block.resolved_locations) - block.count["target"] plando_blocks.sort(key=lambda block: (len(block['locations']) - block['count']['target']
if len(block.resolved_locations) > 0 if len(block['locations']) > 0
else len(multiworld.get_unfilled_locations(block.player)) - else len(multiworld.get_unfilled_locations(player)) - block['count']['target']))
block.count["target"]))
for placement in plando_blocks: for placement in plando_blocks:
player = placement.player player = placement['player']
try: try:
worlds = placement.worlds worlds = placement['world']
locations = placement.resolved_locations locations = placement['locations']
items = placement.items items = placement['items']
maxcount = placement.count["target"] maxcount = placement['count']['target']
from_pool = placement.from_pool from_pool = placement['from_pool']
item_candidates = [] candidates = list(multiworld.get_unfilled_locations_for_players(locations, sorted(worlds)))
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:
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) multiworld.random.shuffle(candidates)
allstate = multiworld.get_all_state(False) multiworld.random.shuffle(items)
mincount = placement.count["min"] count = 0
allowed_margin = len(item_candidates) - mincount err: typing.List[str] = []
fill_restrictive(multiworld, allstate, candidates, item_candidates, lock=True, successful_pairs: typing.List[typing.Tuple[Item, Location]] = []
allow_partial=True, name="Plando Main Fill") for item_name in items:
item = multiworld.worlds[player].create_item(item_name)
for location in reversed(candidates):
if (location.address is None) == (item.code is None): # either both None or both not None
if not location.item:
if location.item_rule(item):
if location.can_fill(multiworld.state, item, False):
successful_pairs.append((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}.")
else:
err.append(f"Cannot place {item_name} into already filled location {location}.")
else:
err.append(f"Mismatch between {item_name} and {location}, only one is an event.")
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 {multiworld.player_name[player]}, error(s): {' '.join(err)}",
placement['force'])
for (item, location) in successful_pairs:
multiworld.push_item(location, item, collect=False)
location.locked = True
logging.debug(f"Plando placed {item} at {location}")
if from_pool:
try:
multiworld.itempool.remove(item)
except ValueError:
warn(
f"Could not remove {item} from pool for {multiworld.player_name[player]} as it's already missing from it.",
placement['force'])
if len(item_candidates) > allowed_margin:
failed(f"Could not place {len(item_candidates)} "
f"of {mincount + allowed_margin} item(s) "
f"for {multiworld.player_name[player]}, "
f"remaining items: {item_candidates}",
placement.force)
if from_pool:
multiworld.itempool.extend([item for item in item_candidates if item.code is not None])
except Exception as e: except Exception as e:
raise Exception( raise Exception(
f"Error running plando for player {player} ({multiworld.player_name[player]})") from e f"Error running plando for player {player} ({multiworld.player_name[player]})") from e

View File

@@ -10,8 +10,8 @@ import sys
import urllib.parse import urllib.parse
import urllib.request import urllib.request
from collections import Counter from collections import Counter
from typing import Any, Dict, Tuple, Union
from itertools import chain from itertools import chain
from typing import Any
import ModuleUpdate import ModuleUpdate
@@ -42,34 +42,22 @@ def mystery_argparse():
help="Path to output folder. Absolute or relative to cwd.") # absolute or relative to cwd 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('--race', action='store_true', default=defaults.race)
parser.add_argument('--meta_file_path', default=defaults.meta_file_path) parser.add_argument('--meta_file_path', default=defaults.meta_file_path)
parser.add_argument('--log_level', default=defaults.loglevel, help='Sets log level') parser.add_argument('--log_level', default='info', help='Sets log level')
parser.add_argument('--log_time', help="Add timestamps to STDOUT", parser.add_argument('--yaml_output', default=0, type=lambda value: max(int(value), 0),
default=defaults.logtime, action='store_true') help='Output rolled mystery results to yaml up to specified number (made for async multiworld)')
parser.add_argument("--csv_output", action="store_true", parser.add_argument('--plando', default=defaults.plando_options,
help="Output rolled player options to csv (made for async multiworld).") help='List of options that can be set manually. Can be combined, for example "bosses, items"')
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", parser.add_argument("--skip_prog_balancing", action="store_true",
help="Skip progression balancing step during generation.") help="Skip progression balancing step during generation.")
parser.add_argument("--skip_output", action="store_true", parser.add_argument("--skip_output", action="store_true",
help="Skips generation assertion and output stages and skips multidata and spoiler output. " help="Skips generation assertion and output stages and skips multidata and spoiler output. "
"Intended for debugging and testing purposes.") "Intended for debugging and testing purposes.")
parser.add_argument("--spoiler_only", action="store_true",
help="Skips generation assertion and multidata, outputting only a spoiler log. "
"Intended for debugging and testing purposes.")
args = parser.parse_args() args = parser.parse_args()
if args.skip_output and args.spoiler_only:
parser.error("Cannot mix --skip_output and --spoiler_only")
elif args.spoiler == 0 and args.spoiler_only:
parser.error("Cannot use --spoiler_only when --spoiler=0. Use --skip_output or set --spoiler to a different value")
if not os.path.isabs(args.weights_file_path): if not os.path.isabs(args.weights_file_path):
args.weights_file_path = os.path.join(args.player_files_path, args.weights_file_path) args.weights_file_path = os.path.join(args.player_files_path, args.weights_file_path)
if not os.path.isabs(args.meta_file_path): if not os.path.isabs(args.meta_file_path):
args.meta_file_path = os.path.join(args.player_files_path, args.meta_file_path) args.meta_file_path = os.path.join(args.player_files_path, args.meta_file_path)
args.plando: PlandoOptions = PlandoOptions.from_option_string(args.plando) args.plando: PlandoOptions = PlandoOptions.from_option_string(args.plando)
return args return args
@@ -77,7 +65,7 @@ def get_seed_name(random_source) -> str:
return f"{random_source.randint(0, pow(10, seeddigits) - 1)}".zfill(seeddigits) return f"{random_source.randint(0, pow(10, seeddigits) - 1)}".zfill(seeddigits)
def main(args=None) -> tuple[argparse.Namespace, int]: def main(args=None) -> Tuple[argparse.Namespace, int]:
# __name__ == "__main__" check so unittests that already imported worlds don't trip this. # __name__ == "__main__" check so unittests that already imported worlds don't trip this.
if __name__ == "__main__" and "worlds" in sys.modules: if __name__ == "__main__" and "worlds" in sys.modules:
raise Exception("Worlds system should not be loaded before logging init.") raise Exception("Worlds system should not be loaded before logging init.")
@@ -87,7 +75,7 @@ def main(args=None) -> tuple[argparse.Namespace, int]:
seed = get_seed(args.seed) seed = get_seed(args.seed)
Utils.init_logging(f"Generate_{seed}", loglevel=args.log_level, add_timestamp=args.log_time) Utils.init_logging(f"Generate_{seed}", loglevel=args.log_level)
random.seed(seed) random.seed(seed)
seed_name = get_seed_name(random) seed_name = get_seed_name(random)
@@ -95,7 +83,7 @@ def main(args=None) -> tuple[argparse.Namespace, int]:
logging.info("Race mode enabled. Using non-deterministic random source.") logging.info("Race mode enabled. Using non-deterministic random source.")
random.seed() # reset to time-based random source random.seed() # reset to time-based random source
weights_cache: dict[str, tuple[Any, ...]] = {} weights_cache: Dict[str, Tuple[Any, ...]] = {}
if args.weights_file_path and os.path.exists(args.weights_file_path): if args.weights_file_path and os.path.exists(args.weights_file_path):
try: try:
weights_cache[args.weights_file_path] = read_weights_yamls(args.weights_file_path) weights_cache[args.weights_file_path] = read_weights_yamls(args.weights_file_path)
@@ -118,24 +106,15 @@ def main(args=None) -> tuple[argparse.Namespace, int]:
raise Exception("Cannot mix --sameoptions with --meta") raise Exception("Cannot mix --sameoptions with --meta")
else: else:
meta_weights = None meta_weights = None
player_id = 1 player_id = 1
player_files = {} player_files = {}
for file in os.scandir(args.player_files_path): for file in os.scandir(args.player_files_path):
fname = file.name fname = file.name
if file.is_file() and not fname.startswith(".") and not fname.lower().endswith(".ini") and \ if file.is_file() and not fname.startswith(".") and \
os.path.join(args.player_files_path, fname) not in {args.meta_file_path, args.weights_file_path}: 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) path = os.path.join(args.player_files_path, fname)
try: try:
weights_for_file = [] weights_cache[fname] = read_weights_yamls(path)
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: except Exception as e:
raise ValueError(f"File {fname} is invalid. Please fix your yaml.") from e raise ValueError(f"File {fname} is invalid. Please fix your yaml.") from e
@@ -166,12 +145,18 @@ def main(args=None) -> tuple[argparse.Namespace, int]:
f"A mix is also permitted.") f"A mix is also permitted.")
from worlds.AutoWorld import AutoWorldRegister from worlds.AutoWorld import AutoWorldRegister
args.outputname = seed_name from worlds.alttp.EntranceRandomizer import parse_arguments
args.sprite = dict.fromkeys(range(1, args.multi+1), None) erargs = parse_arguments(['--multi', str(args.multi)])
args.sprite_pool = dict.fromkeys(range(1, args.multi+1), None) erargs.seed = seed
args.name = {} erargs.plando_options = args.plando
erargs.spoiler = args.spoiler
erargs.race = args.race
erargs.outputname = seed_name
erargs.outputpath = args.outputpath
erargs.skip_prog_balancing = args.skip_prog_balancing
erargs.skip_output = args.skip_output
settings_cache: dict[str, tuple[argparse.Namespace, ...]] = \ settings_cache: Dict[str, Tuple[argparse.Namespace, ...]] = \
{fname: (tuple(roll_settings(yaml, args.plando) for yaml in yamls) if args.sameoptions else None) {fname: (tuple(roll_settings(yaml, args.plando) for yaml in yamls) if args.sameoptions else None)
for fname, yamls in weights_cache.items()} for fname, yamls in weights_cache.items()}
@@ -196,34 +181,30 @@ def main(args=None) -> tuple[argparse.Namespace, int]:
for player in range(1, args.multi + 1): for player in range(1, args.multi + 1):
player_path_cache[player] = player_files.get(player, args.weights_file_path) player_path_cache[player] = player_files.get(player, args.weights_file_path)
name_counter = Counter() name_counter = Counter()
args.player_options = {} erargs.player_options = {}
player = 1 player = 1
while player <= args.multi: while player <= args.multi:
path = player_path_cache[player] path = player_path_cache[player]
if path: if path:
try: try:
settings: tuple[argparse.Namespace, ...] = settings_cache[path] if settings_cache[path] else \ settings: Tuple[argparse.Namespace, ...] = settings_cache[path] if settings_cache[path] else \
tuple(roll_settings(yaml, args.plando) for yaml in weights_cache[path]) tuple(roll_settings(yaml, args.plando) for yaml in weights_cache[path])
for settingsObject in settings: for settingsObject in settings:
for k, v in vars(settingsObject).items(): for k, v in vars(settingsObject).items():
if v is not None: if v is not None:
try: try:
getattr(args, k)[player] = v getattr(erargs, k)[player] = v
except AttributeError: except AttributeError:
setattr(args, k, {player: v}) setattr(erargs, k, {player: v})
except Exception as e: except Exception as e:
raise Exception(f"Error setting {k} to {v} for player {player}") from e raise Exception(f"Error setting {k} to {v} for player {player}") from e
# name was not specified if path == args.weights_file_path: # if name came from the weights file, just use base player name
if player not in args.name: erargs.name[player] = f"Player{player}"
if path == args.weights_file_path: elif not erargs.name[player]: # if name was not specified, generate it from filename
# weights file, so we need to make the name unique erargs.name[player] = os.path.splitext(os.path.split(path)[-1])[0]
args.name[player] = f"Player{player}" erargs.name[player] = handle_name(erargs.name[player], player, name_counter)
else:
# use the filename
args.name[player] = os.path.splitext(os.path.split(path)[-1])[0]
args.name[player] = handle_name(args.name[player], player, name_counter)
player += 1 player += 1
except Exception as e: except Exception as e:
@@ -231,13 +212,35 @@ def main(args=None) -> tuple[argparse.Namespace, int]:
else: else:
raise RuntimeError(f'No weights specified for player {player}') raise RuntimeError(f'No weights specified for player {player}')
if len(set(name.lower() for name in args.name.values())) != len(args.name): 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 args.name.values())}") raise Exception(f"Names have to be unique. Names: {Counter(name.lower() for name in erargs.name.values())}")
return args, seed 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)
return erargs, seed
def read_weights_yamls(path) -> tuple[Any, ...]: def read_weights_yamls(path) -> Tuple[Any, ...]:
try: try:
if urllib.parse.urlparse(path).scheme in ('https', 'file'): if urllib.parse.urlparse(path).scheme in ('https', 'file'):
yaml = str(urllib.request.urlopen(path).read(), "utf-8-sig") yaml = str(urllib.request.urlopen(path).read(), "utf-8-sig")
@@ -247,20 +250,7 @@ def read_weights_yamls(path) -> tuple[Any, ...]:
except Exception as e: except Exception as e:
raise Exception(f"Failed to read weights ({path})") from e raise Exception(f"Failed to read weights ({path})") from e
from yaml.error import MarkedYAMLError return tuple(parse_yamls(yaml))
try:
return tuple(parse_yamls(yaml))
except MarkedYAMLError as ex:
if ex.problem_mark:
lines = yaml.splitlines()
if ex.context_mark:
relevant_lines = "\n".join(lines[ex.context_mark.line:ex.problem_mark.line+1])
else:
relevant_lines = lines[ex.problem_mark.line]
error_line = " " * ex.problem_mark.column + "^"
raise Exception(f"{ex.context} {ex.problem} on line {ex.problem_mark.line}:"
f"\n{relevant_lines}\n{error_line}")
raise ex
def interpret_on_off(value) -> bool: def interpret_on_off(value) -> bool:
@@ -300,35 +290,33 @@ def get_choice(option, root, value=None) -> Any:
raise RuntimeError(f"All options specified in \"{option}\" are weighted as zero.") raise RuntimeError(f"All options specified in \"{option}\" are weighted as zero.")
class SafeFormatter(string.Formatter): class SafeDict(dict):
def get_value(self, key, args, kwargs): def __missing__(self, key):
if isinstance(key, int): return '{' + key + '}'
if key < len(args):
return args[key]
else:
return "{" + str(key) + "}"
else:
return kwargs.get(key, "{" + key + "}")
def handle_name(name: str, player: int, name_counter: Counter): def handle_name(name: str, player: int, name_counter: Counter):
name_counter[name.lower()] += 1 name_counter[name.lower()] += 1
number = name_counter[name.lower()] number = name_counter[name.lower()]
new_name = "%".join([x.replace("%number%", "{number}").replace("%player%", "{player}") for x in name.split("%%")]) new_name = "%".join([x.replace("%number%", "{number}").replace("%player%", "{player}") for x in name.split("%%")])
new_name = string.Formatter().vformat(new_name, (), SafeDict(number=number,
new_name = SafeFormatter().vformat(new_name, (), {"number": number, NUMBER=(number if number > 1 else ''),
"NUMBER": (number if number > 1 else ''), player=player,
"player": player, PLAYER=(player if player > 1 else '')))
"PLAYER": (player if player > 1 else '')})
# Run .strip twice for edge case where after the initial .slice new_name has a leading whitespace. # Run .strip twice for edge case where after the initial .slice new_name has a leading whitespace.
# Could cause issues for some clients that cannot handle the additional whitespace. # Could cause issues for some clients that cannot handle the additional whitespace.
new_name = new_name.strip()[:16].strip() new_name = new_name.strip()[:16].strip()
if new_name == "Archipelago": if new_name == "Archipelago":
raise Exception(f"You cannot name yourself \"{new_name}\"") raise Exception(f"You cannot name yourself \"{new_name}\"")
return new_name return new_name
def roll_percentage(percentage: Union[int, float]) -> bool:
"""Roll a percentage chance.
percentage is expected to be in range [0, 100]"""
return random.random() < (float(percentage) / 100)
def update_weights(weights: dict, new_weights: dict, update_type: str, name: str) -> dict: def update_weights(weights: dict, new_weights: dict, update_type: str, name: str) -> dict:
logging.debug(f'Applying {new_weights}') logging.debug(f'Applying {new_weights}')
cleaned_weights = {} cleaned_weights = {}
@@ -373,7 +361,7 @@ def update_weights(weights: dict, new_weights: dict, update_type: str, name: str
return weights return weights
def roll_meta_option(option_key, game: str, category_dict: dict) -> Any: def roll_meta_option(option_key, game: str, category_dict: Dict) -> Any:
from worlds import AutoWorldRegister from worlds import AutoWorldRegister
if not game: if not game:
@@ -394,7 +382,7 @@ def roll_linked_options(weights: dict) -> dict:
if "name" not in option_set: if "name" not in option_set:
raise ValueError("One of your linked options does not have a name.") raise ValueError("One of your linked options does not have a name.")
try: try:
if Options.roll_percentage(option_set["percentage"]): if roll_percentage(option_set["percentage"]):
logging.debug(f"Linked option {option_set['name']} triggered.") logging.debug(f"Linked option {option_set['name']} triggered.")
new_options = option_set["options"] new_options = option_set["options"]
for category_name, category_options in new_options.items(): for category_name, category_options in new_options.items():
@@ -427,7 +415,7 @@ def roll_triggers(weights: dict, triggers: list, valid_keys: set) -> dict:
trigger_result = get_choice("option_result", option_set) trigger_result = get_choice("option_result", option_set)
result = get_choice(key, currently_targeted_weights) result = get_choice(key, currently_targeted_weights)
currently_targeted_weights[key] = result currently_targeted_weights[key] = result
if result == trigger_result and Options.roll_percentage(get_choice("percentage", option_set, 100)): if result == trigger_result and roll_percentage(get_choice("percentage", option_set, 100)):
for category_name, category_options in option_set["options"].items(): for category_name, category_options in option_set["options"].items():
currently_targeted_weights = weights currently_targeted_weights = weights
if category_name: if category_name:
@@ -471,7 +459,7 @@ def roll_settings(weights: dict, plando_options: PlandoOptions = PlandoOptions.b
if "linked_options" in weights: if "linked_options" in weights:
weights = roll_linked_options(weights) weights = roll_linked_options(weights)
valid_keys = {"triggers"} valid_keys = set()
if "triggers" in weights: if "triggers" in weights:
weights = roll_triggers(weights, weights["triggers"], valid_keys) weights = roll_triggers(weights, weights["triggers"], valid_keys)
@@ -493,10 +481,6 @@ def roll_settings(weights: dict, plando_options: PlandoOptions = PlandoOptions.b
raise Exception(f"Option {option_key} has to be in a game's section, not on its own.") raise Exception(f"Option {option_key} has to be in a game's section, not on its own.")
ret.game = get_choice("game", weights) 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: if ret.game not in AutoWorldRegister.world_types:
from worlds import failed_world_loads from worlds import failed_world_loads
picks = Utils.get_fuzzy_results(ret.game, list(AutoWorldRegister.world_types) + failed_world_loads, limit=1)[0] picks = Utils.get_fuzzy_results(ret.game, list(AutoWorldRegister.world_types) + failed_world_loads, limit=1)[0]
@@ -530,18 +514,14 @@ def roll_settings(weights: dict, plando_options: PlandoOptions = PlandoOptions.b
for option_key, option in world_type.options_dataclass.type_hints.items(): for option_key, option in world_type.options_dataclass.type_hints.items():
handle_option(ret, game_weights, option_key, option, plando_options) handle_option(ret, game_weights, option_key, option, plando_options)
valid_keys.add(option_key) valid_keys.add(option_key)
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: for option_key in game_weights:
if option_key in valid_keys: if option_key in {"triggers", *valid_keys}:
continue continue
logging.warning(f"{option_key} is not a valid option name for {ret.game} and is not present in triggers " 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}.") if PlandoOptions.items in plando_options:
ret.plando_items = game_weights.get("plando_items", [])
if ret.game == "A Link to the Past":
roll_alttp_settings(ret, game_weights)
return ret return ret

View File

@@ -1,9 +0,0 @@
if __name__ == '__main__':
import ModuleUpdate
ModuleUpdate.update()
import Utils
Utils.init_logging("KH1Client", exception_logger="Client")
from worlds.kh1.Client import launch
launch()

View File

@@ -1,7 +1,7 @@
MIT License MIT License
Copyright (c) 2017 LLCoolDave Copyright (c) 2017 LLCoolDave
Copyright (c) 2025 Berserker66 Copyright (c) 2022 Berserker66
Copyright (c) 2022 CaitSith2 Copyright (c) 2022 CaitSith2
Copyright (c) 2021 LegendaryLinux Copyright (c) 2021 LegendaryLinux

View File

@@ -1,58 +1,51 @@
""" """
Archipelago Launcher Archipelago launcher for bundled app.
* If run with a patch file as argument, launch corresponding client with the patch file as an argument. * if run with APBP as argument, launch corresponding client.
* If run with component name as argument, run it passing argv[2:] as arguments. * if run with executable as argument, run it passing argv[2:] as arguments
* If run without arguments or unknown arguments, open launcher GUI. * if run without arguments, open launcher GUI
Additional components can be added to worlds.LauncherComponents.components. Scroll down to components= to add components to the launcher as well as setup.py
""" """
import argparse import argparse
import itertools
import logging import logging
import multiprocessing import multiprocessing
import os
import shlex import shlex
import subprocess import subprocess
import sys import sys
import urllib.parse
import webbrowser import webbrowser
from collections.abc import Callable, Sequence
from os.path import isfile from os.path import isfile
from shutil import which from shutil import which
from typing import Any from typing import Callable, Sequence, Union, Optional
import Utils
import settings
from worlds.LauncherComponents import Component, components, Type, SuffixIdentifier, icon_paths
if __name__ == "__main__": if __name__ == "__main__":
import ModuleUpdate import ModuleUpdate
ModuleUpdate.update() ModuleUpdate.update()
import settings from Utils import is_frozen, user_path, local_path, init_logging, open_filename, messagebox, \
import Utils is_windows, is_macos, is_linux
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(): def open_host_yaml():
s = settings.get_settings() file = settings.get_settings().filename
file = s.filename
s.save()
assert file, "host.yaml missing" assert file, "host.yaml missing"
if is_linux: if is_linux:
exe = which('sensible-editor') or which('gedit') or \ exe = which('sensible-editor') or which('gedit') or \
which('xdg-open') or which('gnome-open') or which('kde-open') which('xdg-open') or which('gnome-open') or which('kde-open')
subprocess.Popen([exe, file])
elif is_macos: elif is_macos:
exe = which("open") exe = which("open")
subprocess.Popen([exe, file])
else: else:
webbrowser.open(file) webbrowser.open(file)
return
env = os.environ
if "LD_LIBRARY_PATH" in env:
env = env.copy()
del env["LD_LIBRARY_PATH"] # exe is a system binary, so reset LD_LIBRARY_PATH
subprocess.Popen([exe, file], env=env)
def open_patch(): def open_patch():
suffixes = [] suffixes = []
@@ -90,20 +83,12 @@ def browse_files():
def open_folder(folder_path): def open_folder(folder_path):
if is_linux: if is_linux:
exe = which('xdg-open') or which('gnome-open') or which('kde-open') exe = which('xdg-open') or which('gnome-open') or which('kde-open')
subprocess.Popen([exe, folder_path])
elif is_macos: elif is_macos:
exe = which("open") exe = which("open")
subprocess.Popen([exe, folder_path])
else: else:
webbrowser.open(folder_path) webbrowser.open(folder_path)
return
if exe:
env = os.environ
if "LD_LIBRARY_PATH" in env:
env = env.copy()
del env["LD_LIBRARY_PATH"] # exe is a system binary, so reset LD_LIBRARY_PATH
subprocess.Popen([exe, folder_path], env=env)
else:
logging.warning(f"No file browser available to open {folder_path}")
def update_settings(): def update_settings():
@@ -113,51 +98,16 @@ def update_settings():
components.extend([ components.extend([
# Functions # Functions
Component("Open host.yaml", func=open_host_yaml, Component("Open host.yaml", func=open_host_yaml),
description="Open the host.yaml file to change settings for generation, games, and more."), Component("Open Patch", func=open_patch),
Component("Open Patch", func=open_patch, Component("Generate Template Options", func=generate_yamls),
description="Open a patch file, downloaded from the room page or provided by the host."), Component("Discord Server", icon="discord", func=lambda: webbrowser.open("https://discord.gg/8Z65BR2")),
Component("Generate Template Options", func=generate_yamls, Component("Unrated/18+ Discord Server", icon="discord", func=lambda: webbrowser.open("https://discord.gg/fqvNCCRsu4")),
description="Generate template YAMLs for currently installed games."), Component("Browse Files", func=browse_files),
Component("Archipelago Website", func=lambda: webbrowser.open("https://archipelago.gg/"),
description="Open archipelago.gg in your browser."),
Component("Discord Server", icon="discord", func=lambda: webbrowser.open("https://discord.gg/8Z65BR2"),
description="Join the Discord server to play public multiworlds, report issues, or just chat!"),
Component("Unrated/18+ Discord Server", icon="discord",
func=lambda: webbrowser.open("https://discord.gg/fqvNCCRsu4"),
description="Find unrated and 18+ games in the After Dark Discord server."),
Component("Browse Files", func=browse_files,
description="Open the Archipelago installation folder in your file browser."),
]) ])
def handle_uri(path: str) -> tuple[list[Component], Component]: def identify(path: Union[None, str]):
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: if path is None:
return None, None return None, None
for component in components: for component in components:
@@ -168,7 +118,7 @@ def identify(path: None | str) -> tuple[None | str, None | Component]:
return None, None return None, None
def get_exe(component: str | Component) -> Sequence[str] | None: def get_exe(component: Union[str, Component]) -> Optional[Sequence[str]]:
if isinstance(component, str): if isinstance(component, str):
name = component name = component
component = None component = None
@@ -196,8 +146,7 @@ def get_exe(component: str | Component) -> Sequence[str] | None:
def launch(exe, in_terminal=False): def launch(exe, in_terminal=False):
if in_terminal: if in_terminal:
if is_windows: if is_windows:
# intentionally using a window title with a space so it gets quoted and treated as a title subprocess.Popen(['start', *exe], shell=True)
subprocess.Popen(["start", "Running Archipelago", *exe], shell=True)
return return
elif is_linux: elif is_linux:
terminal = which('x-terminal-emulator') or which('gnome-terminal') or which('xterm') terminal = which('x-terminal-emulator') or which('gnome-terminal') or which('xterm')
@@ -211,189 +160,101 @@ def launch(exe, in_terminal=False):
subprocess.Popen(exe) subprocess.Popen(exe)
def create_shortcut(button: Any, component: Component) -> None: refresh_components: Optional[Callable[[], None]] = None
from pyshortcuts import make_shortcut
script = sys.argv[0]
wkdir = Utils.local_path()
script = f"{script} \"{component.display_name}\""
make_shortcut(script, name=f"Archipelago {component.display_name}", icon=local_path("data", "icon.ico"),
startmenu=False, terminal=False, working_dir=wkdir)
button.menu.dismiss()
refresh_components: Callable[[], None] | None = None def run_gui():
from kvui import App, ContainerLayout, GridLayout, Button, Label, ScrollBox, Widget
def run_gui(launch_components: list[Component], args: Any) -> None:
from kvui import (ThemedApp, MDFloatLayout, MDGridLayout, ScrollBox)
from kivy.properties import ObjectProperty
from kivy.core.window import Window from kivy.core.window import Window
from kivy.metrics import dp from kivy.uix.image import AsyncImage
from kivymd.uix.button import MDIconButton, MDButton from kivy.uix.relativelayout import RelativeLayout
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
from kivy.lang.builder import Builder class Launcher(App):
class LauncherCard(MDCard):
component: Component | None
image: str
context_button: MDIconButton = ObjectProperty(None)
def __init__(self, *args, component: Component | None = None, image_path: str = "", **kwargs):
self.component = component
self.image = image_path
super().__init__(args, kwargs)
class Launcher(ThemedApp):
base_title: str = "Archipelago Launcher" base_title: str = "Archipelago Launcher"
top_screen: MDFloatLayout = ObjectProperty(None) container: ContainerLayout
navigation: MDGridLayout = ObjectProperty(None) grid: GridLayout
grid: MDGridLayout = ObjectProperty(None) _tool_layout: Optional[ScrollBox] = None
button_layout: ScrollBox = ObjectProperty(None) _client_layout: Optional[ScrollBox] = None
search_box: MDTextField = ObjectProperty(None)
cards: list[LauncherCard]
current_filter: Sequence[str | Type] | None
def __init__(self, ctx=None, components=None, args=None): def __init__(self, ctx=None):
self.title = self.base_title + " " + Utils.__version__ self.title = self.base_title
self.ctx = ctx self.ctx = ctx
self.icon = r"data/icon.png" self.icon = r"data/icon.png"
self.favorites = []
self.launch_components = components
self.launch_args = args
self.cards = []
self.current_filter = (Type.CLIENT, Type.TOOL, Type.ADJUSTER, Type.MISC)
persistent = Utils.persistent_load()
if "launcher" in persistent:
if "favorites" in persistent["launcher"]:
self.favorites.extend(persistent["launcher"]["favorites"])
if "filter" in persistent["launcher"]:
if persistent["launcher"]["filter"]:
filters = []
for filter in persistent["launcher"]["filter"].split(", "):
if filter == "favorites":
filters.append(filter)
else:
filters.append(Type[filter])
self.current_filter = filters
super().__init__() super().__init__()
def set_favorite(self, caller): def _refresh_components(self) -> None:
if caller.component.display_name in self.favorites:
self.favorites.remove(caller.component.display_name)
caller.icon = "star-outline"
else:
self.favorites.append(caller.component.display_name)
caller.icon = "star"
def build_card(self, component: Component) -> LauncherCard: def build_button(component: Component) -> Widget:
"""
Builds a card widget for a given component.
:param component: The component associated with the button.
:return: The created Card Widget.
""" """
button_card = LauncherCard(component=component, Builds a button widget for a given component.
image_path=icon_paths[component.icon])
def open_menu(caller): Args:
caller.menu.open() component (Component): The component associated with the button.
menu_items = [ Returns:
{ None. The button is added to the parent grid layout.
"text": "Add shortcut on desktop",
"leading_icon": "laptop",
"on_release": lambda: create_shortcut(button_card.context_button, component)
}
]
button_card.context_button.menu = MDDropdownMenu(caller=button_card.context_button, items=menu_items)
button_card.context_button.bind(on_release=open_menu)
return button_card """
button = Button(text=component.display_name, size_hint_y=None, height=40)
def _refresh_components(self, type_filter: Sequence[str | Type] | None = None) -> None: button.component = component
if not type_filter: button.bind(on_release=self.component_action)
type_filter = [Type.CLIENT, Type.ADJUSTER, Type.TOOL, Type.MISC] if component.icon != "icon":
favorites = "favorites" in type_filter image = AsyncImage(source=icon_paths[component.icon],
size=(38, 38), size_hint=(None, 1), pos=(5, 0))
box_layout = RelativeLayout(size_hint_y=None, height=40)
box_layout.add_widget(button)
box_layout.add_widget(image)
return box_layout
return button
# clear before repopulating # clear before repopulating
assert self.button_layout, "must call `build` first" assert self._tool_layout and self._client_layout, "must call `build` first"
tool_children = reversed(self.button_layout.layout.children) tool_children = reversed(self._tool_layout.layout.children)
for child in tool_children: for child in tool_children:
self.button_layout.layout.remove_widget(child) self._tool_layout.layout.remove_widget(child)
client_children = reversed(self._client_layout.layout.children)
for child in client_children:
self._client_layout.layout.remove_widget(child)
cards = [card for card in self.cards if card.component.type in type_filter _tools = {c.display_name: c for c in components if c.type == Type.TOOL}
or favorites and card.component.display_name in self.favorites] _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}
self.current_filter = type_filter for (tool, client) in itertools.zip_longest(itertools.chain(
_tools.items(), _miscs.items(), _adjusters.items()
for card in cards: ), _clients.items()):
self.button_layout.layout.add_widget(card) # column 1
if tool:
top = self.button_layout.children[0].y + self.button_layout.children[0].height \ self._tool_layout.layout.add_widget(build_button(tool[1]))
- self.button_layout.height # column 2
scroll_percent = self.button_layout.convert_distance_to_scroll(0, top) if client:
self.button_layout.scroll_y = max(0, min(1, scroll_percent[1])) self._client_layout.layout.add_widget(build_button(client[1]))
def filter_clients_by_type(self, caller: MDButton):
self._refresh_components(caller.type)
self.search_box.text = ""
def filter_clients_by_name(self, caller: MDTextField, name: str) -> None:
if len(name) == 0:
self._refresh_components(self.current_filter)
return
sub_matches = [
card for card in self.cards
if name.lower() in card.component.display_name.lower() and card.component.type != Type.HIDDEN
]
self.button_layout.layout.clear_widgets()
for card in sub_matches:
self.button_layout.layout.add_widget(card)
def build(self): def build(self):
self.top_screen = Builder.load_file(Utils.local_path("data/launcher.kv")) self.container = ContainerLayout()
self.grid = self.top_screen.ids.grid self.grid = GridLayout(cols=2)
self.navigation = self.top_screen.ids.navigation self.container.add_widget(self.grid)
self.button_layout = self.top_screen.ids.button_layout self.grid.add_widget(Label(text="General", size_hint_y=None, height=40))
self.search_box = self.top_screen.ids.search_box self.grid.add_widget(Label(text="Clients", size_hint_y=None, height=40))
self.set_colors() self._tool_layout = ScrollBox()
self.top_screen.md_bg_color = self.theme_cls.backgroundColor self._tool_layout.layout.orientation = "vertical"
self.grid.add_widget(self._tool_layout)
self._client_layout = ScrollBox()
self._client_layout.layout.orientation = "vertical"
self.grid.add_widget(self._client_layout)
self._refresh_components()
global refresh_components global refresh_components
refresh_components = self._refresh_components refresh_components = self._refresh_components
Window.bind(on_drop_file=self._on_drop_file) Window.bind(on_drop_file=self._on_drop_file)
Window.bind(on_keyboard=self._on_keyboard)
for component in components: return self.container
self.cards.append(self.build_card(component))
self._refresh_components(self.current_filter)
# Uncomment to re-enable the Kivy console/live editor
# Ctrl-E to enable it, make sure numlock/capslock is disabled
# from kivy.modules.console import create_console
# create_console(Window, self.top_screen)
return self.top_screen
def on_start(self):
if self.launch_components:
build_uri_popup(self.launch_components, self.launch_args)
self.launch_components = None
self.launch_args = None
@staticmethod @staticmethod
def component_action(button): def component_action(button):
MDSnackbar(MDSnackbarText(text="Opening in a new window..."), y=dp(24), pos_hint={"center_x": 0.5},
size_hint_x=0.5).open()
if button.component.func: if button.component.func:
button.component.func() button.component.func()
else: else:
@@ -407,28 +268,13 @@ def run_gui(launch_components: list[Component], args: Any) -> None:
else: else:
logging.warning(f"unable to identify component for {filename}") logging.warning(f"unable to identify component for {filename}")
def _on_keyboard(self, window: Window, key: int, scancode: int, codepoint: str, modifier: list[str]):
# Activate search as soon as we start typing, no matter if we are focused on the search box or not.
# Focus first, then capture the first character we type, otherwise it gets swallowed and lost.
# Limit text input to ASCII non-control characters (space bar to tilde).
if not self.search_box.focus:
self.search_box.focus = True
if key in range(32, 126):
self.search_box.text += codepoint
def _stop(self, *largs): def _stop(self, *largs):
# ran into what appears to be https://groups.google.com/g/kivy-users/c/saWDLoYCSZ4 with PyCharm. # ran into what appears to be https://groups.google.com/g/kivy-users/c/saWDLoYCSZ4 with PyCharm.
# Closing the window explicitly cleans it up. # Closing the window explicitly cleans it up.
self.root_window.close() self.root_window.close()
super()._stop(*largs) super()._stop(*largs)
def on_stop(self): Launcher().run()
Utils.persistent_store("launcher", "favorites", self.favorites)
Utils.persistent_store("launcher", "filter", ", ".join(filter.name if isinstance(filter, Type) else filter
for filter in self.current_filter))
super().on_stop()
Launcher(components=launch_components, args=args).run()
# avoiding Launcher reference leak # avoiding Launcher reference leak
# and don't try to do something with widgets after window closed # and don't try to do something with widgets after window closed
@@ -447,61 +293,46 @@ def run_component(component: Component, *args):
logging.warning(f"Component {component} does not appear to be executable.") logging.warning(f"Component {component} does not appear to be executable.")
def main(args: argparse.Namespace | dict | None = None): def main(args: Optional[Union[argparse.Namespace, dict]] = None):
if isinstance(args, argparse.Namespace): if isinstance(args, argparse.Namespace):
args = {k: v for k, v in args._get_kwargs()} args = {k: v for k, v in args._get_kwargs()}
elif not args: elif not args:
args = {} args = {}
path = args.get("Patch|Game|Component|url", None) if args.get("Patch|Game|Component", None) is not None:
if path is not None: file, component = identify(args["Patch|Game|Component"])
if path.startswith("archipelago://"): if file:
args["args"] = (path, *args.get("args", ())) args['file'] = file
# add the url arg to the passthrough args if component:
components, text_client_component = handle_uri(path) args['component'] = component
if not components: if not component:
args["component"] = text_client_component logging.warning(f"Could not identify Component responsible for {args['Patch|Game|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"]: if args["update_settings"]:
update_settings() update_settings()
if "file" in args: if 'file' in args:
run_component(args["component"], args["file"], *args["args"]) run_component(args["component"], args["file"], *args["args"])
elif "component" in args: elif 'component' in args:
run_component(args["component"], *args["args"]) run_component(args["component"], *args["args"])
elif not args["update_settings"]: elif not args["update_settings"]:
run_gui(args.get("launch_components", None), args.get("args", ())) run_gui()
if __name__ == '__main__': if __name__ == '__main__':
init_logging('Launcher') init_logging('Launcher')
multiprocessing.freeze_support() Utils.freeze_support()
multiprocessing.set_start_method("spawn") # if launched process uses kivy, fork won't work multiprocessing.set_start_method("spawn") # if launched process uses kivy, fork won't work
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(description='Archipelago Launcher')
description='Archipelago Launcher',
usage="[-h] [--update_settings] [Patch|Game|Component] [-- component args here]"
)
run_group = parser.add_argument_group("Run") run_group = parser.add_argument_group("Run")
run_group.add_argument("--update_settings", action="store_true", run_group.add_argument("--update_settings", action="store_true",
help="Update host.yaml and exit.") help="Update host.yaml and exit.")
run_group.add_argument("Patch|Game|Component|url", type=str, nargs="?", run_group.add_argument("Patch|Game|Component", type=str, nargs="?",
help="Pass either a patch file, a generated game, the component name to run, or a url to " help="Pass either a patch file, a generated game or the name of a component to run.")
"connect with.")
run_group.add_argument("args", nargs="*", run_group.add_argument("args", nargs="*",
help="Arguments to pass to component.") help="Arguments to pass to component.")
main(parser.parse_args()) main(parser.parse_args())
from worlds.LauncherComponents import processes from worlds.LauncherComponents import processes
for process in processes: for process in processes:
# we await all child processes to close before we tear down the process host # we await all child processes to close before we tear down the process host
# this makes it feel like each one is its own program, as the Launcher is closed now # this makes it feel like each one is its own program, as the Launcher is closed now

View File

@@ -26,14 +26,12 @@ import typing
from CommonClient import (CommonContext, get_base_parser, gui_enabled, logger, from CommonClient import (CommonContext, get_base_parser, gui_enabled, logger,
server_loop) server_loop)
from NetUtils import ClientStatus from NetUtils import ClientStatus
from worlds.ladx import LinksAwakeningWorld
from worlds.ladx.Common import BASE_ID as LABaseID from worlds.ladx.Common import BASE_ID as LABaseID
from worlds.ladx.GpsTracker import GpsTracker from worlds.ladx.GpsTracker import GpsTracker
from worlds.ladx.TrackerConsts import storage_key
from worlds.ladx.ItemTracker import ItemTracker from worlds.ladx.ItemTracker import ItemTracker
from worlds.ladx.LADXR.checkMetadata import checkMetadataTable from worlds.ladx.LADXR.checkMetadata import checkMetadataTable
from worlds.ladx.Locations import get_locations_to_id, meta_to_name from worlds.ladx.Locations import get_locations_to_id, meta_to_name
from worlds.ladx.Tracker import LocationTracker, MagpieBridge, Check from worlds.ladx.Tracker import LocationTracker, MagpieBridge
class GameboyException(Exception): class GameboyException(Exception):
@@ -52,6 +50,22 @@ class BadRetroArchResponse(GameboyException):
pass pass
def magpie_logo():
from kivy.uix.image import CoreImage
binary_data = """
iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAIAAACQkWg2AAAAAXN
SR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA
7DAcdvqGQAAADGSURBVDhPhVLBEcIwDHOYhjHCBuXHj2OTbAL8+
MEGZIxOQ1CinOOk0Op0bmo7tlXXeR9FJMYDLOD9mwcLjQK7+hSZ
wgcWMZJOAGeGKtChNHFL0j+FZD3jSCuo0w7l03wDrWdg00C4/aW
eDEYNenuzPOfPspBnxf0kssE80vN0L8361j10P03DK4x6FHabuV
ear8fHme+b17rwSjbAXeUMLb+EVTV2QHm46MWQanmnydA98KsVS
XkV+qFpGQXrLhT/fqraQeQLuplpNH5g+WkAAAAASUVORK5CYII="""
binary_data = base64.b64decode(binary_data)
data = io.BytesIO(binary_data)
return CoreImage(data, ext="png").texture
class LAClientConstants: class LAClientConstants:
# Connector version # Connector version
VERSION = 0x01 VERSION = 0x01
@@ -86,23 +100,19 @@ class LAClientConstants:
WRamCheckSize = 0x4 WRamCheckSize = 0x4
WRamSafetyValue = bytearray([0]*WRamCheckSize) WRamSafetyValue = bytearray([0]*WRamCheckSize)
wRamStart = 0xC000
hRamStart = 0xFF80
hRamSize = 0x80
MinGameplayValue = 0x06 MinGameplayValue = 0x06
MaxGameplayValue = 0x1A MaxGameplayValue = 0x1A
VictoryGameplayAndSub = 0x0102 VictoryGameplayAndSub = 0x0102
class RAGameboy(): class RAGameboy():
cache = [] cache = []
cache_start = 0
cache_size = 0
last_cache_read = None last_cache_read = None
socket = None socket = None
def __init__(self, address, port) -> None: def __init__(self, address, port) -> None:
self.cache_start = LAClientConstants.wRamStart
self.cache_size = LAClientConstants.hRamStart + LAClientConstants.hRamSize - LAClientConstants.wRamStart
self.address = address self.address = address
self.port = port self.port = port
self.socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) self.socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
@@ -121,14 +131,9 @@ class RAGameboy():
async def get_retroarch_status(self): async def get_retroarch_status(self):
return await self.send_command("GET_STATUS") return await self.send_command("GET_STATUS")
def set_checks_range(self, checks_start, checks_size): def set_cache_limits(self, cache_start, cache_size):
self.checks_start = checks_start self.cache_start = cache_start
self.checks_size = checks_size self.cache_size = cache_size
def set_location_range(self, location_start, location_size, critical_addresses):
self.location_start = location_start
self.location_size = location_size
self.critical_location_addresses = critical_addresses
def send(self, b): def send(self, b):
if type(b) is str: if type(b) is str:
@@ -183,57 +188,21 @@ class RAGameboy():
if not await self.check_safe_gameplay(): if not await self.check_safe_gameplay():
return return
attempts = 0 cache = []
while True: remaining_size = self.cache_size
# RA doesn't let us do an atomic read of a large enough block of RAM while remaining_size:
# Some bytes can't change in between reading location_block and hram_block block = await self.async_read_memory(self.cache_start + len(cache), remaining_size)
location_block = await self.read_memory_block(self.location_start, self.location_size) remaining_size -= len(block)
hram_block = await self.read_memory_block(LAClientConstants.hRamStart, LAClientConstants.hRamSize) cache += block
verification_block = await self.read_memory_block(self.location_start, self.location_size)
valid = True
for address in self.critical_location_addresses:
if location_block[address - self.location_start] != verification_block[address - self.location_start]:
valid = False
if valid:
break
attempts += 1
# Shouldn't really happen, but keep it from choking
if attempts > 5:
return
checks_block = await self.read_memory_block(self.checks_start, self.checks_size)
if not await self.check_safe_gameplay(): if not await self.check_safe_gameplay():
return return
self.cache = bytearray(self.cache_size) self.cache = cache
start = self.checks_start - self.cache_start
self.cache[start:start + len(checks_block)] = checks_block
start = self.location_start - self.cache_start
self.cache[start:start + len(location_block)] = location_block
start = LAClientConstants.hRamStart - self.cache_start
self.cache[start:start + len(hram_block)] = hram_block
self.last_cache_read = time.time() self.last_cache_read = time.time()
async def read_memory_block(self, address: int, size: int):
block = bytearray()
remaining_size = size
while remaining_size:
chunk = await self.async_read_memory(address + len(block), remaining_size)
remaining_size -= len(chunk)
block += chunk
return block
async def read_memory_cache(self, addresses): 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(): if not self.last_cache_read or self.last_cache_read + 0.1 < time.time():
await self.update_cache() await self.update_cache()
if not self.cache: if not self.cache:
@@ -266,7 +235,7 @@ class RAGameboy():
def check_command_response(self, command: str, response: bytes): def check_command_response(self, command: str, response: bytes):
if command == "VERSION": if command == "VERSION":
ok = re.match(r"\d+\.\d+\.\d+", response.decode('ascii')) is not None ok = re.match("\d+\.\d+\.\d+", response.decode('ascii')) is not None
else: else:
ok = response.startswith(command.encode()) ok = response.startswith(command.encode())
if not ok: if not ok:
@@ -390,12 +359,11 @@ class LinksAwakeningClient():
auth = binascii.hexlify(await self.gameboy.async_read_memory(0x0134, 12)).decode() auth = binascii.hexlify(await self.gameboy.async_read_memory(0x0134, 12)).decode()
self.auth = auth self.auth = auth
async def wait_and_init_tracker(self, magpie: MagpieBridge): async def wait_and_init_tracker(self):
await self.wait_for_game_ready() await self.wait_for_game_ready()
self.tracker = LocationTracker(self.gameboy) self.tracker = LocationTracker(self.gameboy)
self.item_tracker = ItemTracker(self.gameboy) self.item_tracker = ItemTracker(self.gameboy)
self.gps_tracker = GpsTracker(self.gameboy) self.gps_tracker = GpsTracker(self.gameboy)
magpie.gps_tracker = self.gps_tracker
async def recved_item_from_ap(self, item_id, from_player, next_index): 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 # Don't allow getting an item until you've got your first check
@@ -437,11 +405,9 @@ class LinksAwakeningClient():
return (await self.gameboy.read_memory_cache([LAClientConstants.wGameplayType]))[LAClientConstants.wGameplayType] == 1 return (await self.gameboy.read_memory_cache([LAClientConstants.wGameplayType]))[LAClientConstants.wGameplayType] == 1
async def main_tick(self, item_get_cb, win_cb, deathlink_cb): async def main_tick(self, item_get_cb, win_cb, deathlink_cb):
await self.gameboy.update_cache()
await self.tracker.readChecks(item_get_cb) await self.tracker.readChecks(item_get_cb)
await self.item_tracker.readItems() await self.item_tracker.readItems()
await self.gps_tracker.read_location() await self.gps_tracker.read_location()
await self.gps_tracker.read_entrances()
current_health = (await self.gameboy.read_memory_cache([LAClientConstants.wLinkHealth]))[LAClientConstants.wLinkHealth] current_health = (await self.gameboy.read_memory_cache([LAClientConstants.wLinkHealth]))[LAClientConstants.wLinkHealth]
if self.deathlink_debounce and current_health != 0: if self.deathlink_debounce and current_health != 0:
@@ -491,7 +457,7 @@ class LinksAwakeningContext(CommonContext):
la_task = None la_task = None
client = None client = None
# TODO: does this need to re-read on reset? # TODO: does this need to re-read on reset?
found_checks = set() found_checks = []
last_resend = time.time() last_resend = time.time()
magpie_enabled = False magpie_enabled = False
@@ -499,14 +465,8 @@ class LinksAwakeningContext(CommonContext):
magpie_task = None magpie_task = None
won = False won = False
@property
def slot_storage_key(self):
return f"{self.slot_info[self.slot].name}_{storage_key}"
def __init__(self, server_address: typing.Optional[str], password: typing.Optional[str], magpie: typing.Optional[bool]) -> None: def __init__(self, server_address: typing.Optional[str], password: typing.Optional[str], magpie: typing.Optional[bool]) -> None:
self.client = LinksAwakeningClient() self.client = LinksAwakeningClient()
self.slot_data = {}
if magpie: if magpie:
self.magpie_enabled = True self.magpie_enabled = True
self.magpie = MagpieBridge() self.magpie = MagpieBridge()
@@ -514,9 +474,9 @@ class LinksAwakeningContext(CommonContext):
def run_gui(self) -> None: def run_gui(self) -> None:
import webbrowser import webbrowser
from kvui import GameManager import kvui
from kivy.metrics import dp from kvui import Button, GameManager
from kivymd.uix.button import MDButton, MDButtonText from kivy.uix.image import Image
class LADXManager(GameManager): class LADXManager(GameManager):
logging_pairs = [ logging_pairs = [
@@ -529,27 +489,23 @@ class LinksAwakeningContext(CommonContext):
b = super().build() b = super().build()
if self.ctx.magpie_enabled: if self.ctx.magpie_enabled:
button = MDButton(MDButtonText(text="Open Tracker"), style="filled", size=(dp(100), dp(70)), radius=5, button = Button(text="", size=(30, 30), size_hint_x=None,
size_hint_x=None, size_hint_y=None, pos_hint={"center_y": 0.55}, on_press=lambda _: webbrowser.open('https://magpietracker.us/?enable_autotracker=1'))
on_press=lambda _: webbrowser.open('https://magpietracker.us/?enable_autotracker=1')) image = Image(size=(16, 16), texture=magpie_logo())
button.height = self.server_connect_bar.height button.add_widget(image)
self.connect_layout.add_widget(button)
def set_center(_, center):
image.center = center
button.bind(center=set_center)
self.connect_layout.add_widget(button)
return b return b
self.ui = LADXManager(self) self.ui = LADXManager(self)
self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI") self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI")
async def send_new_entrances(self, entrances: typing.Dict[str, str]): async def send_checks(self):
# Store the entrances we find on the server for future sessions message = [{"cmd": 'LocationChecks', "locations": self.found_checks}]
message = [{
"cmd": "Set",
"key": self.slot_storage_key,
"default": {},
"want_reply": False,
"operations": [{"operation": "update", "value": entrances}],
}]
await self.send_msgs(message) await self.send_msgs(message)
had_invalid_slot_data = None had_invalid_slot_data = None
@@ -579,19 +535,13 @@ class LinksAwakeningContext(CommonContext):
await self.send_msgs(message) await self.send_msgs(message)
self.won = True self.won = True
async def request_found_entrances(self):
await self.send_msgs([{"cmd": "Get", "keys": [self.slot_storage_key]}])
# Ask for updates so that players can co-op entrances in a seed
await self.send_msgs([{"cmd": "SetNotify", "keys": [self.slot_storage_key]}])
async def on_deathlink(self, data: typing.Dict[str, typing.Any]) -> None: async def on_deathlink(self, data: typing.Dict[str, typing.Any]) -> None:
if self.ENABLE_DEATHLINK: if self.ENABLE_DEATHLINK:
self.client.pending_deathlink = True self.client.pending_deathlink = True
def new_checks(self, item_ids, ladxr_ids): def new_checks(self, item_ids, ladxr_ids):
self.found_checks.update(item_ids) self.found_checks += item_ids
create_task_log_exception(self.check_locations(self.found_checks)) create_task_log_exception(self.send_checks())
if self.magpie_enabled: if self.magpie_enabled:
create_task_log_exception(self.magpie.send_new_checks(ladxr_ids)) create_task_log_exception(self.magpie.send_new_checks(ladxr_ids))
@@ -608,51 +558,21 @@ class LinksAwakeningContext(CommonContext):
while self.client.auth == None: while self.client.auth == None:
await asyncio.sleep(0.1) await asyncio.sleep(0.1)
# Just return if we're closing
if self.exit_event.is_set():
return
self.auth = self.client.auth self.auth = self.client.auth
await self.send_connect() await self.send_connect()
def on_package(self, cmd: str, args: dict): def on_package(self, cmd: str, args: dict):
if cmd == "Connected": if cmd == "Connected":
self.game = self.slot_info[self.slot].game self.game = self.slot_info[self.slot].game
self.slot_data = args.get("slot_data", {})
# This is sent to magpie over local websocket to make its own connection
self.slot_data.update({
"server_address": self.server_address,
"slot_name": self.player_names[self.slot],
"password": self.password,
})
# We can process linked items on already-checked checks now that we have slot_data
if self.client.tracker:
checked_checks = set(self.client.tracker.all_checks) - set(self.client.tracker.remaining_checks)
self.add_linked_items(checked_checks)
# TODO - use watcher_event # TODO - use watcher_event
if cmd == "ReceivedItems": if cmd == "ReceivedItems":
for index, item in enumerate(args["items"], start=args["index"]): for index, item in enumerate(args["items"], start=args["index"]):
self.client.recvd_checks[index] = item self.client.recvd_checks[index] = item
if cmd == "Retrieved" and self.magpie_enabled and self.slot_storage_key in args["keys"]:
self.client.gps_tracker.receive_found_entrances(args["keys"][self.slot_storage_key])
if cmd == "SetReply" and self.magpie_enabled and args["key"] == self.slot_storage_key:
self.client.gps_tracker.receive_found_entrances(args["value"])
async def sync(self): async def sync(self):
sync_msg = [{'cmd': 'Sync'}] sync_msg = [{'cmd': 'Sync'}]
await self.send_msgs(sync_msg) await self.send_msgs(sync_msg)
def add_linked_items(self, checks: typing.List[Check]):
for check in checks:
if check.value and check.linkedItem:
linkedItem = check.linkedItem
if 'condition' not in linkedItem or (self.slot_data and linkedItem['condition'](self.slot_data)):
self.client.item_tracker.setExtraItem(check.linkedItem['item'], check.linkedItem['qty'])
item_id_lookup = get_locations_to_id() item_id_lookup = get_locations_to_id()
async def run_game_loop(self): async def run_game_loop(self):
@@ -661,8 +581,6 @@ class LinksAwakeningContext(CommonContext):
checkMetadataTable[check.id])] for check in ladxr_checks] checkMetadataTable[check.id])] for check in ladxr_checks]
self.new_checks(checks, [check.id for check in ladxr_checks]) self.new_checks(checks, [check.id for check in ladxr_checks])
self.add_linked_items(ladxr_checks)
async def victory(): async def victory():
await self.send_victory() await self.send_victory()
@@ -696,38 +614,20 @@ class LinksAwakeningContext(CommonContext):
if not self.client.recvd_checks: if not self.client.recvd_checks:
await self.sync() await self.sync()
await self.client.wait_and_init_tracker(self.magpie) await self.client.wait_and_init_tracker()
min_tick_duration = 0.1
last_tick = time.time()
while True: while True:
await self.client.main_tick(on_item_get, victory, deathlink) await self.client.main_tick(on_item_get, victory, deathlink)
await asyncio.sleep(0.1)
now = time.time() now = time.time()
tick_duration = now - last_tick
sleep_duration = max(min_tick_duration - tick_duration, 0)
await asyncio.sleep(sleep_duration)
last_tick = now
if self.last_resend + 5.0 < now: if self.last_resend + 5.0 < now:
self.last_resend = now self.last_resend = now
await self.check_locations(self.found_checks) await self.send_checks()
if self.magpie_enabled: if self.magpie_enabled:
try: try:
self.magpie.set_checks(self.client.tracker.all_checks) self.magpie.set_checks(self.client.tracker.all_checks)
await self.magpie.set_item_tracker(self.client.item_tracker) await self.magpie.set_item_tracker(self.client.item_tracker)
if self.slot_data and "slot_data" in self.magpie.features and not self.magpie.has_sent_slot_data: await self.magpie.send_gps(self.client.gps_tracker)
self.magpie.slot_data = self.slot_data
await self.magpie.send_slot_data()
if self.client.gps_tracker.needs_found_entrances:
await self.request_found_entrances()
self.client.gps_tracker.needs_found_entrances = False
new_entrances = await self.magpie.send_gps(self.client.gps_tracker)
if new_entrances:
await self.send_new_entrances(new_entrances)
except Exception: except Exception:
# Don't let magpie errors take out the client # Don't let magpie errors take out the client
pass pass
@@ -738,8 +638,8 @@ class LinksAwakeningContext(CommonContext):
await asyncio.sleep(1.0) await asyncio.sleep(1.0)
def run_game(romfile: str) -> None: def run_game(romfile: str) -> None:
auto_start = LinksAwakeningWorld.settings.rom_start auto_start = typing.cast(typing.Union[bool, str],
Utils.get_options()["ladx_options"].get("rom_start", True))
if auto_start is True: if auto_start is True:
import webbrowser import webbrowser
webbrowser.open(romfile) webbrowser.open(romfile)
@@ -796,6 +696,6 @@ async def main():
await ctx.shutdown() await ctx.shutdown()
if __name__ == '__main__': if __name__ == '__main__':
colorama.just_fix_windows_console() colorama.init()
asyncio.run(main()) asyncio.run(main())
colorama.deinit() colorama.deinit()

View File

@@ -14,7 +14,7 @@ import tkinter as tk
from argparse import Namespace from argparse import Namespace
from concurrent.futures import as_completed, ThreadPoolExecutor from concurrent.futures import as_completed, ThreadPoolExecutor
from glob import glob from glob import glob
from tkinter import Tk, Frame, Label, StringVar, Entry, filedialog, messagebox, Button, Radiobutton, LEFT, X, BOTH, TOP, LabelFrame, \ from tkinter import Tk, Frame, Label, StringVar, Entry, filedialog, messagebox, Button, Radiobutton, LEFT, X, TOP, LabelFrame, \
IntVar, Checkbutton, E, W, OptionMenu, Toplevel, BOTTOM, RIGHT, font as font, PhotoImage IntVar, Checkbutton, E, W, OptionMenu, Toplevel, BOTTOM, RIGHT, font as font, PhotoImage
from tkinter.constants import DISABLED, NORMAL from tkinter.constants import DISABLED, NORMAL
from urllib.parse import urlparse from urllib.parse import urlparse
@@ -29,19 +29,13 @@ from Utils import output_path, local_path, user_path, open_file, get_cert_none_s
GAME_ALTTP = "A Link to the Past" GAME_ALTTP = "A Link to the Past"
WINDOW_MIN_HEIGHT = 525
WINDOW_MIN_WIDTH = 425
class AdjusterWorld(object): class AdjusterWorld(object):
class AdjusterSubWorld(object):
def __init__(self, random):
self.random = random
def __init__(self, sprite_pool): def __init__(self, sprite_pool):
import random import random
self.sprite_pool = {1: sprite_pool} self.sprite_pool = {1: sprite_pool}
self.worlds = {1: self.AdjusterSubWorld(random)} self.per_slot_randoms = {1: random}
class ArgumentDefaultsHelpFormatter(argparse.RawTextHelpFormatter): class ArgumentDefaultsHelpFormatter(argparse.RawTextHelpFormatter):
@@ -49,7 +43,6 @@ class ArgumentDefaultsHelpFormatter(argparse.RawTextHelpFormatter):
def _get_help_string(self, action): def _get_help_string(self, action):
return textwrap.dedent(action.help) return textwrap.dedent(action.help)
# See argparse.BooleanOptionalAction # See argparse.BooleanOptionalAction
class BooleanOptionalActionWithDisable(argparse.Action): class BooleanOptionalActionWithDisable(argparse.Action):
def __init__(self, def __init__(self,
@@ -249,17 +242,16 @@ def adjustGUI():
from argparse import Namespace from argparse import Namespace
from Utils import __version__ as MWVersion from Utils import __version__ as MWVersion
adjustWindow = Tk() adjustWindow = Tk()
adjustWindow.minsize(WINDOW_MIN_WIDTH, WINDOW_MIN_HEIGHT)
adjustWindow.wm_title("Archipelago %s LttP Adjuster" % MWVersion) adjustWindow.wm_title("Archipelago %s LttP Adjuster" % MWVersion)
set_icon(adjustWindow) set_icon(adjustWindow)
rom_options_frame, rom_vars, set_sprite = get_rom_options_frame(adjustWindow) rom_options_frame, rom_vars, set_sprite = get_rom_options_frame(adjustWindow)
bottomFrame2 = Frame(adjustWindow, padx=8, pady=2) bottomFrame2 = Frame(adjustWindow)
romFrame, romVar = get_rom_frame(adjustWindow) romFrame, romVar = get_rom_frame(adjustWindow)
romDialogFrame = Frame(adjustWindow, padx=8, pady=2) romDialogFrame = Frame(adjustWindow)
baseRomLabel2 = Label(romDialogFrame, text='Rom to adjust') baseRomLabel2 = Label(romDialogFrame, text='Rom to adjust')
romVar2 = StringVar() romVar2 = StringVar()
romEntry2 = Entry(romDialogFrame, textvariable=romVar2) romEntry2 = Entry(romDialogFrame, textvariable=romVar2)
@@ -269,9 +261,9 @@ def adjustGUI():
romVar2.set(rom) romVar2.set(rom)
romSelectButton2 = Button(romDialogFrame, text='Select Rom', command=RomSelect2) romSelectButton2 = Button(romDialogFrame, text='Select Rom', command=RomSelect2)
romDialogFrame.pack(side=TOP, expand=False, fill=X) romDialogFrame.pack(side=TOP, expand=True, fill=X)
baseRomLabel2.pack(side=LEFT, expand=False, fill=X, padx=(0, 8)) baseRomLabel2.pack(side=LEFT)
romEntry2.pack(side=LEFT, expand=True, fill=BOTH, pady=1) romEntry2.pack(side=LEFT, expand=True, fill=X)
romSelectButton2.pack(side=LEFT) romSelectButton2.pack(side=LEFT)
def adjustRom(): def adjustRom():
@@ -339,11 +331,12 @@ def adjustGUI():
messagebox.showinfo(title="Success", message="Settings saved to persistent storage") messagebox.showinfo(title="Success", message="Settings saved to persistent storage")
adjustButton = Button(bottomFrame2, text='Adjust Rom', command=adjustRom) adjustButton = Button(bottomFrame2, text='Adjust Rom', command=adjustRom)
rom_options_frame.pack(side=TOP, padx=8, pady=8, fill=BOTH, expand=True) rom_options_frame.pack(side=TOP)
adjustButton.pack(side=LEFT, padx=(5,5)) adjustButton.pack(side=LEFT, padx=(5,5))
saveButton = Button(bottomFrame2, text='Save Settings', command=saveGUISettings) saveButton = Button(bottomFrame2, text='Save Settings', command=saveGUISettings)
saveButton.pack(side=LEFT, padx=(5,5)) saveButton.pack(side=LEFT, padx=(5,5))
bottomFrame2.pack(side=TOP, pady=(5,5)) bottomFrame2.pack(side=TOP, pady=(5,5))
tkinter_center_window(adjustWindow) tkinter_center_window(adjustWindow)
@@ -365,10 +358,10 @@ def run_sprite_update():
logging.info("Done updating sprites") logging.info("Done updating sprites")
def update_sprites(task, on_finish=None, repository_url: str = "https://alttpr.com/sprites"): def update_sprites(task, on_finish=None):
resultmessage = "" resultmessage = ""
successful = True successful = True
sprite_dir = user_path("data", "sprites", "alttp", "remote") sprite_dir = user_path("data", "sprites", "alttpr")
os.makedirs(sprite_dir, exist_ok=True) os.makedirs(sprite_dir, exist_ok=True)
ctx = get_cert_none_ssl_context() ctx = get_cert_none_ssl_context()
@@ -378,11 +371,11 @@ def update_sprites(task, on_finish=None, repository_url: str = "https://alttpr.c
on_finish(successful, resultmessage) on_finish(successful, resultmessage)
try: try:
task.update_status("Downloading remote sprites list") task.update_status("Downloading alttpr sprites list")
with urlopen(repository_url, context=ctx) as response: with urlopen('https://alttpr.com/sprites', context=ctx) as response:
sprites_arr = json.loads(response.read().decode("utf-8")) sprites_arr = json.loads(response.read().decode("utf-8"))
except Exception as e: except Exception as e:
resultmessage = "Error getting list of remote sprites. Sprites not updated.\n\n%s: %s" % (type(e).__name__, e) resultmessage = "Error getting list of alttpr sprites. Sprites not updated.\n\n%s: %s" % (type(e).__name__, e)
successful = False successful = False
task.queue_event(finished) task.queue_event(finished)
return return
@@ -390,13 +383,13 @@ def update_sprites(task, on_finish=None, repository_url: str = "https://alttpr.c
try: try:
task.update_status("Determining needed sprites") task.update_status("Determining needed sprites")
current_sprites = [os.path.basename(file) for file in glob(sprite_dir + '/*')] current_sprites = [os.path.basename(file) for file in glob(sprite_dir + '/*')]
remote_sprites = [(sprite['file'], os.path.basename(urlparse(sprite['file']).path)) alttpr_sprites = [(sprite['file'], os.path.basename(urlparse(sprite['file']).path))
for sprite in sprites_arr if sprite["author"] != "Nintendo"] for sprite in sprites_arr if sprite["author"] != "Nintendo"]
needed_sprites = [(sprite_url, filename) for (sprite_url, filename) in remote_sprites if needed_sprites = [(sprite_url, filename) for (sprite_url, filename) in alttpr_sprites if
filename not in current_sprites] filename not in current_sprites]
remote_filenames = [filename for (_, filename) in remote_sprites] alttpr_filenames = [filename for (_, filename) in alttpr_sprites]
obsolete_sprites = [sprite for sprite in current_sprites if sprite not in remote_filenames] obsolete_sprites = [sprite for sprite in current_sprites if sprite not in alttpr_filenames]
except Exception as e: except Exception as e:
resultmessage = "Error Determining which sprites to update. Sprites not updated.\n\n%s: %s" % ( resultmessage = "Error Determining which sprites to update. Sprites not updated.\n\n%s: %s" % (
type(e).__name__, e) type(e).__name__, e)
@@ -448,7 +441,7 @@ def update_sprites(task, on_finish=None, repository_url: str = "https://alttpr.c
successful = False successful = False
if successful: if successful:
resultmessage = "Remote sprites updated successfully" resultmessage = "alttpr sprites updated successfully"
task.queue_event(finished) task.queue_event(finished)
@@ -583,7 +576,7 @@ class AttachTooltip(object):
def get_rom_frame(parent=None): def get_rom_frame(parent=None):
adjuster_settings = get_adjuster_settings(GAME_ALTTP) adjuster_settings = get_adjuster_settings(GAME_ALTTP)
romFrame = Frame(parent, padx=8, pady=8) romFrame = Frame(parent)
baseRomLabel = Label(romFrame, text='LttP Base Rom: ') baseRomLabel = Label(romFrame, text='LttP Base Rom: ')
romVar = StringVar(value=adjuster_settings.baserom) romVar = StringVar(value=adjuster_settings.baserom)
romEntry = Entry(romFrame, textvariable=romVar) romEntry = Entry(romFrame, textvariable=romVar)
@@ -603,19 +596,20 @@ def get_rom_frame(parent=None):
romSelectButton = Button(romFrame, text='Select Rom', command=RomSelect) romSelectButton = Button(romFrame, text='Select Rom', command=RomSelect)
baseRomLabel.pack(side=LEFT) baseRomLabel.pack(side=LEFT)
romEntry.pack(side=LEFT, expand=True, fill=BOTH, pady=1) romEntry.pack(side=LEFT, expand=True, fill=X)
romSelectButton.pack(side=LEFT) romSelectButton.pack(side=LEFT)
romFrame.pack(side=TOP, fill=X) romFrame.pack(side=TOP, expand=True, fill=X)
return romFrame, romVar return romFrame, romVar
def get_rom_options_frame(parent=None): def get_rom_options_frame(parent=None):
adjuster_settings = get_adjuster_settings(GAME_ALTTP) adjuster_settings = get_adjuster_settings(GAME_ALTTP)
romOptionsFrame = LabelFrame(parent, text="Rom options", padx=8, pady=8) romOptionsFrame = LabelFrame(parent, text="Rom options")
romOptionsFrame.columnconfigure(0, weight=1)
romOptionsFrame.columnconfigure(1, weight=1)
for i in range(5): for i in range(5):
romOptionsFrame.rowconfigure(i, weight=0, pad=4) romOptionsFrame.rowconfigure(i, weight=1)
vars = Namespace() vars = Namespace()
vars.MusicVar = IntVar() vars.MusicVar = IntVar()
@@ -666,7 +660,7 @@ def get_rom_options_frame(parent=None):
spriteSelectButton = Button(spriteDialogFrame, text='...', command=SpriteSelect) spriteSelectButton = Button(spriteDialogFrame, text='...', command=SpriteSelect)
baseSpriteLabel.pack(side=LEFT) baseSpriteLabel.pack(side=LEFT)
spriteEntry.pack(side=LEFT, expand=True, fill=X) spriteEntry.pack(side=LEFT)
spriteSelectButton.pack(side=LEFT) spriteSelectButton.pack(side=LEFT)
oofDialogFrame = Frame(romOptionsFrame) oofDialogFrame = Frame(romOptionsFrame)
@@ -869,7 +863,7 @@ class SpriteSelector():
def open_custom_sprite_dir(_evt): def open_custom_sprite_dir(_evt):
open_file(self.custom_sprite_dir) open_file(self.custom_sprite_dir)
remote_frametitle = Label(self.window, text='Remote Sprites') alttpr_frametitle = Label(self.window, text='ALTTPR Sprites')
custom_frametitle = Frame(self.window) custom_frametitle = Frame(self.window)
title_text = Label(custom_frametitle, text="Custom Sprites") title_text = Label(custom_frametitle, text="Custom Sprites")
@@ -878,8 +872,8 @@ class SpriteSelector():
title_link.pack(side=LEFT) title_link.pack(side=LEFT)
title_link.bind("<Button-1>", open_custom_sprite_dir) title_link.bind("<Button-1>", open_custom_sprite_dir)
self.icon_section(remote_frametitle, self.remote_sprite_dir, self.icon_section(alttpr_frametitle, self.alttpr_sprite_dir,
'Remote sprites not found. Click "Update remote sprites" to download them.') 'ALTTPR sprites not found. Click "Update alttpr sprites" to download them.')
self.icon_section(custom_frametitle, self.custom_sprite_dir, self.icon_section(custom_frametitle, self.custom_sprite_dir,
'Put sprites in the custom sprites folder (see open link above) to have them appear here.') 'Put sprites in the custom sprites folder (see open link above) to have them appear here.')
if not randomOnEvent: if not randomOnEvent:
@@ -892,18 +886,11 @@ class SpriteSelector():
button = Button(frame, text="Browse for file...", command=self.browse_for_sprite) button = Button(frame, text="Browse for file...", command=self.browse_for_sprite)
button.pack(side=RIGHT, padx=(5, 0)) button.pack(side=RIGHT, padx=(5, 0))
button = Button(frame, text="Update remote sprites", command=self.update_remote_sprites) button = Button(frame, text="Update alttpr sprites", command=self.update_alttpr_sprites)
button.pack(side=RIGHT, padx=(5, 0)) button.pack(side=RIGHT, padx=(5, 0))
repository_label = Label(frame, text='Sprite Repository:')
self.repository_url = StringVar(frame, "https://alttpr.com/sprites")
repository_entry = Entry(frame, textvariable=self.repository_url)
repository_entry.pack(side=RIGHT, expand=True, fill=BOTH, pady=1)
repository_label.pack(side=RIGHT, expand=False, padx=(0, 5))
button = Button(frame, text="Do not adjust sprite",command=self.use_default_sprite) button = Button(frame, text="Do not adjust sprite",command=self.use_default_sprite)
button.pack(side=LEFT, padx=(0, 5)) button.pack(side=LEFT,padx=(0,5))
button = Button(frame, text="Default Link sprite", command=self.use_default_link_sprite) button = Button(frame, text="Default Link sprite", command=self.use_default_link_sprite)
button.pack(side=LEFT, padx=(0, 5)) button.pack(side=LEFT, padx=(0, 5))
@@ -1063,7 +1050,7 @@ class SpriteSelector():
for i, button in enumerate(frame.buttons): for i, button in enumerate(frame.buttons):
button.grid(row=i // self.spritesPerRow, column=i % self.spritesPerRow) button.grid(row=i // self.spritesPerRow, column=i % self.spritesPerRow)
def update_remote_sprites(self): def update_alttpr_sprites(self):
# need to wrap in try catch. We don't want errors getting the json or downloading the files to break us. # need to wrap in try catch. We don't want errors getting the json or downloading the files to break us.
self.window.destroy() self.window.destroy()
self.parent.update() self.parent.update()
@@ -1076,8 +1063,7 @@ class SpriteSelector():
messagebox.showerror("Sprite Updater", resultmessage) messagebox.showerror("Sprite Updater", resultmessage)
SpriteSelector(self.parent, self.callback, self.adjuster) SpriteSelector(self.parent, self.callback, self.adjuster)
BackgroundTaskProgress(self.parent, update_sprites, "Updating Sprites", BackgroundTaskProgress(self.parent, update_sprites, "Updating Sprites", on_finish)
on_finish, self.repository_url.get())
def browse_for_sprite(self): def browse_for_sprite(self):
sprite = filedialog.askopenfilename( sprite = filedialog.askopenfilename(
@@ -1167,13 +1153,12 @@ class SpriteSelector():
os.makedirs(self.custom_sprite_dir) os.makedirs(self.custom_sprite_dir)
@property @property
def remote_sprite_dir(self): def alttpr_sprite_dir(self):
return user_path("data", "sprites", "alttp", "remote") return user_path("data", "sprites", "alttpr")
@property @property
def custom_sprite_dir(self): def custom_sprite_dir(self):
return user_path("data", "sprites", "alttp", "custom") return user_path("data", "sprites", "custom")
def get_image_for_sprite(sprite, gif_only: bool = False): def get_image_for_sprite(sprite, gif_only: bool = False):
if not sprite.valid: if not sprite.valid:

View File

@@ -286,14 +286,16 @@ async def gba_sync_task(ctx: MMBN3Context):
except ConnectionRefusedError: except ConnectionRefusedError:
logger.debug("Connection Refused, Trying Again") logger.debug("Connection Refused, Trying Again")
ctx.gba_status = CONNECTION_REFUSED_STATUS ctx.gba_status = CONNECTION_REFUSED_STATUS
await asyncio.sleep(1)
continue continue
async def run_game(romfile): async def run_game(romfile):
from worlds.mmbn3 import MMBN3World options = Utils.get_options().get("mmbn3_options", None)
auto_start = MMBN3World.settings.rom_start if options is None:
if auto_start is True: auto_start = True
else:
auto_start = options.get("rom_start", True)
if auto_start:
import webbrowser import webbrowser
webbrowser.open(romfile) webbrowser.open(romfile)
elif os.path.isfile(auto_start): elif os.path.isfile(auto_start):
@@ -368,7 +370,7 @@ if __name__ == "__main__":
import colorama import colorama
colorama.just_fix_windows_console() colorama.init()
asyncio.run(main()) asyncio.run(main())
colorama.deinit() colorama.deinit()

293
Main.py
View File

@@ -1,21 +1,19 @@
import collections import collections
from collections.abc import Mapping
import concurrent.futures import concurrent.futures
import logging import logging
import os import os
import pickle
import tempfile import tempfile
import time import time
from typing import Any
import zipfile import zipfile
import zlib import zlib
from typing import Dict, List, Optional, Set, Tuple, Union
import worlds import worlds
from BaseClasses import CollectionState, Item, Location, LocationProgressType, MultiWorld from BaseClasses import CollectionState, Item, Location, LocationProgressType, MultiWorld, Region
from Fill import FillError, balance_multiworld_progression, distribute_items_restrictive, flood_items, \ from Fill import balance_multiworld_progression, distribute_items_restrictive, distribute_planned, flood_items
parse_planned_blocks, distribute_planned_blocks, resolve_early_locations_for_planned
from NetUtils import convert_to_base_types
from Options import StartInventoryPool from Options import StartInventoryPool
from Utils import __version__, output_path, restricted_dumps, version_tuple from Utils import __version__, output_path, version_tuple, get_settings
from settings import get_settings from settings import get_settings
from worlds import AutoWorld from worlds import AutoWorld
from worlds.generic.Rules import exclusion_rules, locality_rules from worlds.generic.Rules import exclusion_rules, locality_rules
@@ -23,7 +21,7 @@ from worlds.generic.Rules import exclusion_rules, locality_rules
__all__ = ["main"] __all__ = ["main"]
def main(args, seed=None, baked_server_options: dict[str, object] | None = None): def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = None):
if not baked_server_options: if not baked_server_options:
baked_server_options = get_settings().server_options.as_dict() baked_server_options = get_settings().server_options.as_dict()
assert isinstance(baked_server_options, dict) assert isinstance(baked_server_options, dict)
@@ -37,16 +35,16 @@ def main(args, seed=None, baked_server_options: dict[str, object] | None = None)
logger = logging.getLogger() logger = logging.getLogger()
multiworld.set_seed(seed, args.race, str(args.outputname) if args.outputname else None) multiworld.set_seed(seed, args.race, str(args.outputname) if args.outputname else None)
multiworld.plando_options = args.plando multiworld.plando_options = args.plando_options
multiworld.plando_items = args.plando_items.copy()
multiworld.plando_texts = args.plando_texts.copy()
multiworld.plando_connections = args.plando_connections.copy()
multiworld.game = args.game.copy() multiworld.game = args.game.copy()
multiworld.player_name = args.name.copy() multiworld.player_name = args.name.copy()
multiworld.sprite = args.sprite.copy() multiworld.sprite = args.sprite.copy()
multiworld.sprite_pool = args.sprite_pool.copy() multiworld.sprite_pool = args.sprite_pool.copy()
multiworld.set_options(args) multiworld.set_options(args)
if args.csv_output:
from Options import dump_player_options
dump_player_options(multiworld)
multiworld.set_item_links() multiworld.set_item_links()
multiworld.state = CollectionState(multiworld) multiworld.state = CollectionState(multiworld)
logger.info('Archipelago Version %s - Seed: %s\n', __version__, multiworld.seed) logger.info('Archipelago Version %s - Seed: %s\n', __version__, multiworld.seed)
@@ -54,18 +52,32 @@ def main(args, seed=None, baked_server_options: dict[str, object] | None = None)
logger.info(f"Found {len(AutoWorld.AutoWorldRegister.world_types)} World Types:") logger.info(f"Found {len(AutoWorld.AutoWorldRegister.world_types)} World Types:")
longest_name = max(len(text) for text in AutoWorld.AutoWorldRegister.world_types) longest_name = max(len(text) for text in AutoWorld.AutoWorldRegister.world_types)
max_item = 0
max_location = 0
for cls in AutoWorld.AutoWorldRegister.world_types.values():
if cls.item_id_to_name:
max_item = max(max_item, max(cls.item_id_to_name))
max_location = max(max_location, max(cls.location_id_to_name))
item_digits = len(str(max_item))
location_digits = len(str(max_location))
item_count = len(str(max(len(cls.item_names) for cls in AutoWorld.AutoWorldRegister.world_types.values()))) item_count = len(str(max(len(cls.item_names) for cls in AutoWorld.AutoWorldRegister.world_types.values())))
location_count = len(str(max(len(cls.location_names) for cls in AutoWorld.AutoWorldRegister.world_types.values()))) location_count = len(str(max(len(cls.location_names) for cls in AutoWorld.AutoWorldRegister.world_types.values())))
del max_item, max_location
for name, cls in AutoWorld.AutoWorldRegister.world_types.items(): for name, cls in AutoWorld.AutoWorldRegister.world_types.items():
if not cls.hidden and len(cls.item_names) > 0: if not cls.hidden and len(cls.item_names) > 0:
logger.info(f" {name:{longest_name}}: Items: {len(cls.item_names):{item_count}} | " logger.info(f" {name:{longest_name}}: {len(cls.item_names):{item_count}} "
f"Locations: {len(cls.location_names):{location_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}})")
del item_count, location_count del item_digits, location_digits, item_count, location_count
# This assertion method should not be necessary to run if we are not outputting any multidata. # This assertion method should not be necessary to run if we are not outputting any multidata.
if not args.skip_output and not args.spoiler_only: if not args.skip_output:
AutoWorld.call_stage(multiworld, "assert_generate") AutoWorld.call_stage(multiworld, "assert_generate")
AutoWorld.call_all(multiworld, "generate_early") AutoWorld.call_all(multiworld, "generate_early")
@@ -88,21 +100,12 @@ def main(args, seed=None, baked_server_options: dict[str, object] | None = None)
multiworld.early_items[player][item_name] = max(0, early-count) multiworld.early_items[player][item_name] = max(0, early-count)
remaining_count = count-early remaining_count = count-early
if remaining_count > 0: if remaining_count > 0:
local_early = multiworld.local_early_items[player].get(item_name, 0) local_early = multiworld.early_local_items[player].get(item_name, 0)
if local_early: if local_early:
multiworld.early_items[player][item_name] = max(0, local_early - remaining_count) multiworld.early_items[player][item_name] = max(0, local_early - remaining_count)
del local_early del local_early
del early del early
# items can't be both local and non-local, prefer local
multiworld.worlds[player].options.non_local_items.value -= multiworld.worlds[player].options.local_items.value
multiworld.worlds[player].options.non_local_items.value -= set(multiworld.local_early_items[player])
# Clear non-applicable local and non-local items.
if multiworld.players == 1:
multiworld.worlds[1].options.non_local_items.value = set()
multiworld.worlds[1].options.local_items.value = set()
logger.info('Creating MultiWorld.') logger.info('Creating MultiWorld.')
AutoWorld.call_all(multiworld, "create_regions") AutoWorld.call_all(multiworld, "create_regions")
@@ -110,79 +113,155 @@ def main(args, seed=None, baked_server_options: dict[str, object] | None = None)
AutoWorld.call_all(multiworld, "create_items") AutoWorld.call_all(multiworld, "create_items")
logger.info('Calculating Access Rules.') logger.info('Calculating Access Rules.')
for player in multiworld.player_ids:
# items can't be both local and non-local, prefer local
multiworld.worlds[player].options.non_local_items.value -= multiworld.worlds[player].options.local_items.value
multiworld.worlds[player].options.non_local_items.value -= set(multiworld.local_early_items[player])
AutoWorld.call_all(multiworld, "set_rules") AutoWorld.call_all(multiworld, "set_rules")
for player in multiworld.player_ids: for player in multiworld.player_ids:
exclusion_rules(multiworld, player, multiworld.worlds[player].options.exclude_locations.value) 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 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: for location_name in multiworld.worlds[player].options.priority_locations.value:
try: try:
location = multiworld.get_location(location_name, player) location = multiworld.get_location(location_name, player)
except KeyError: except KeyError as e: # failed to find the given location. Check if it's a legitimate location
continue if location_name not in multiworld.worlds[player].location_name_to_id:
raise Exception(f"Unable to prioritize location {location_name} in player {player}'s world.") from e
if location.progress_type != LocationProgressType.EXCLUDED:
location.progress_type = LocationProgressType.PRIORITY
else: else:
logger.warning(f"Unable to prioritize location \"{location_name}\" in player {player}'s world because the world excluded it.") location.progress_type = LocationProgressType.PRIORITY
world_excluded_locations.add(location_name)
multiworld.worlds[player].options.priority_locations.value -= world_excluded_locations
# Set local and non-local item rules. # Set local and non-local item rules.
# This function is called so late because worlds might otherwise overwrite item_rules which are how locality works
if multiworld.players > 1: if multiworld.players > 1:
locality_rules(multiworld) locality_rules(multiworld)
else:
multiworld.plando_item_blocks = parse_planned_blocks(multiworld) multiworld.worlds[1].options.non_local_items.value = set()
multiworld.worlds[1].options.local_items.value = set()
AutoWorld.call_all(multiworld, "connect_entrances")
AutoWorld.call_all(multiworld, "generate_basic") AutoWorld.call_all(multiworld, "generate_basic")
# remove starting inventory from pool items. # remove starting inventory from pool items.
# Because some worlds don't actually create items during create_items this has to be as late as possible. # Because some worlds don't actually create items during create_items this has to be as late as possible.
fallback_inventory = StartInventoryPool({}) if any(getattr(multiworld.worlds[player].options, "start_inventory_from_pool", None) for player in multiworld.player_ids):
depletion_pool: dict[int, dict[str, int]] = { new_items: List[Item] = []
player: getattr(multiworld.worlds[player].options, "start_inventory_from_pool", fallback_inventory).value.copy() depletion_pool: Dict[int, Dict[str, int]] = {
for player in multiworld.player_ids player: getattr(multiworld.worlds[player].options,
} "start_inventory_from_pool",
target_per_player = { StartInventoryPool({})).value.copy()
player: sum(target_items.values()) for player, target_items in depletion_pool.items() if target_items for player in multiworld.player_ids
} }
for player, items in depletion_pool.items():
if target_per_player: player_world: AutoWorld.World = multiworld.worlds[player]
new_itempool: list[Item] = [] for count in items.values():
for _ in range(count):
# Make new itempool with start_inventory_from_pool items removed new_items.append(player_world.create_filler())
for item in multiworld.itempool: target: int = sum(sum(items.values()) for items in depletion_pool.values())
for i, item in enumerate(multiworld.itempool):
if depletion_pool[item.player].get(item.name, 0): if depletion_pool[item.player].get(item.name, 0):
target -= 1
depletion_pool[item.player][item.name] -= 1 depletion_pool[item.player][item.name] -= 1
# quick abort if we have found all items
if not target:
new_items.extend(multiworld.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"{multiworld.get_player_name(player)}"
f" is trying to remove items from their pool that don't exist: {remaining_items}")
assert len(multiworld.itempool) == len(new_items), "Item Pool amounts should not change."
multiworld.itempool[:] = new_items
# temporary home for item links, should be moved out of Main
for group_id, group in multiworld.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 multiworld.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, multiworld, "ItemLink")
multiworld.regions.append(region)
locations = region.locations
for item in multiworld.itempool:
count = common_item_count.get(item.player, {}).get(item.name, 0)
if count:
loc = Location(group_id, f"Item Link: {item.name} -> {multiworld.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: else:
new_itempool.append(item) new_itempool.append(item)
# Create filler in place of the removed items, warn if any items couldn't be found in the multiworld itempool itemcount = len(multiworld.itempool)
for player, target in target_per_player.items(): multiworld.itempool = new_itempool
unfound_items = {item: count for item, count in depletion_pool[player].items() if count}
if unfound_items: while itemcount > len(multiworld.itempool):
player_name = multiworld.get_player_name(player) items_to_add = []
logger.warning(f"{player_name} tried to remove items from their pool that don't exist: {unfound_items}") 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(multiworld, "create_item", item_player,
group["replacement_items"][player]))
else:
items_to_add.append(AutoWorld.call_single(multiworld, "create_filler", item_player))
multiworld.random.shuffle(items_to_add)
multiworld.itempool.extend(items_to_add[:itemcount - len(multiworld.itempool)])
needed_items = target_per_player[player] - sum(unfound_items.values()) if any(multiworld.item_links.values()):
new_itempool += [multiworld.worlds[player].create_filler() for _ in range(needed_items)]
assert len(multiworld.itempool) == len(new_itempool), "Item Pool amounts should not change."
multiworld.itempool[:] = new_itempool
multiworld.link_items()
if any(world.options.item_links for world in multiworld.worlds.values()):
multiworld._all_state = None multiworld._all_state = None
logger.info("Running Item Plando.") logger.info("Running Item Plando.")
resolve_early_locations_for_planned(multiworld)
distribute_planned_blocks(multiworld, [x for player in multiworld.plando_item_blocks distribute_planned(multiworld)
for x in multiworld.plando_item_blocks[player]])
logger.info('Running Pre Main Fill.') logger.info('Running Pre Main Fill.')
@@ -212,15 +291,6 @@ def main(args, seed=None, baked_server_options: dict[str, object] | None = None)
logger.info(f'Beginning output...') logger.info(f'Beginning output...')
outfilebase = 'AP_' + multiworld.seed_name outfilebase = 'AP_' + multiworld.seed_name
if args.spoiler_only:
if args.spoiler > 1:
logger.info('Calculating playthrough.')
multiworld.spoiler.create_playthrough(create_paths=args.spoiler > 2)
multiworld.spoiler.to_file(output_path('%s_Spoiler.txt' % outfilebase))
logger.info('Done. Skipped multidata modification. Total time: %s', time.perf_counter() - start)
return multiworld
output = tempfile.TemporaryDirectory() output = tempfile.TemporaryDirectory()
with output as temp_dir: with output as temp_dir:
output_players = [player for player in multiworld.player_ids if AutoWorld.World.generate_output.__code__ output_players = [player for player in multiworld.player_ids if AutoWorld.World.generate_output.__code__
@@ -235,19 +305,16 @@ def main(args, seed=None, baked_server_options: dict[str, object] | None = None)
pool.submit(AutoWorld.call_single, multiworld, "generate_output", player, temp_dir)) pool.submit(AutoWorld.call_single, multiworld, "generate_output", player, temp_dir))
# collect ER hint info # collect ER hint info
er_hint_data: dict[int, dict[int, str]] = {} er_hint_data: Dict[int, Dict[int, str]] = {}
AutoWorld.call_all(multiworld, 'extend_hint_information', er_hint_data) AutoWorld.call_all(multiworld, 'extend_hint_information', er_hint_data)
def write_multidata(): def write_multidata():
import NetUtils import NetUtils
from NetUtils import HintStatus slot_data = {}
slot_data: dict[int, Mapping[str, Any]] = {} client_versions = {}
client_versions: dict[int, tuple[int, int, int]] = {} games = {}
games: dict[int, str] = {} minimum_versions = {"server": AutoWorld.World.required_server_version, "clients": client_versions}
minimum_versions: NetUtils.MinimumVersions = { slot_info = {}
"server": AutoWorld.World.required_server_version, "clients": client_versions
}
slot_info: dict[int, NetUtils.NetworkSlot] = {}
names = [[name for player, name in sorted(multiworld.player_name.items())]] names = [[name for player, name in sorted(multiworld.player_name.items())]]
for slot in multiworld.player_ids: for slot in multiworld.player_ids:
player_world: AutoWorld.World = multiworld.worlds[slot] player_world: AutoWorld.World = multiworld.worlds[slot]
@@ -262,17 +329,15 @@ def main(args, seed=None, baked_server_options: dict[str, object] | None = None)
group_members=sorted(group["players"])) group_members=sorted(group["players"]))
precollected_items = {player: [item.code for item in world_precollected if type(item.code) == int] precollected_items = {player: [item.code for item in world_precollected if type(item.code) == int]
for player, world_precollected in multiworld.precollected_items.items()} for player, world_precollected in multiworld.precollected_items.items()}
precollected_hints: dict[int, set[NetUtils.Hint]] = { precollected_hints = {player: set() for player in range(1, multiworld.players + 1 + len(multiworld.groups))}
player: set() for player in range(1, multiworld.players + 1 + len(multiworld.groups))
}
for slot in multiworld.player_ids: for slot in multiworld.player_ids:
slot_data[slot] = multiworld.worlds[slot].fill_slot_data() slot_data[slot] = multiworld.worlds[slot].fill_slot_data()
def precollect_hint(location: Location, auto_status: HintStatus): def precollect_hint(location):
entrance = er_hint_data.get(location.player, {}).get(location.address, "") entrance = er_hint_data.get(location.player, {}).get(location.address, "")
hint = NetUtils.Hint(location.item.player, location.player, location.address, hint = NetUtils.Hint(location.item.player, location.player, location.address,
location.item.code, False, entrance, location.item.flags, auto_status) location.item.code, False, entrance, location.item.flags)
precollected_hints[location.player].add(hint) precollected_hints[location.player].add(hint)
if location.item.player not in multiworld.groups: if location.item.player not in multiworld.groups:
precollected_hints[location.item.player].add(hint) precollected_hints[location.item.player].add(hint)
@@ -280,48 +345,45 @@ def main(args, seed=None, baked_server_options: dict[str, object] | None = None)
for player in multiworld.groups[location.item.player]["players"]: for player in multiworld.groups[location.item.player]["players"]:
precollected_hints[player].add(hint) precollected_hints[player].add(hint)
locations_data: dict[int, dict[int, tuple[int, int, int]]] = {player: {} for player in multiworld.player_ids} locations_data: Dict[int, Dict[int, Tuple[int, int, int]]] = {player: {} for player in multiworld.player_ids}
for location in multiworld.get_filled_locations(): for location in multiworld.get_filled_locations():
if type(location.address) == int: if type(location.address) == int:
assert location.item.code is not None, "item code None should be event, " \ assert location.item.code is not None, "item code None should be event, " \
"location.address should then also be None. Location: " \ "location.address should then also be None. Location: " \
f" {location}, Item: {location.item}" f" {location}"
assert location.address not in locations_data[location.player], ( assert location.address not in locations_data[location.player], (
f"Locations with duplicate address. {location} and " f"Locations with duplicate address. {location} and "
f"{locations_data[location.player][location.address]}") f"{locations_data[location.player][location.address]}")
locations_data[location.player][location.address] = \ locations_data[location.player][location.address] = \
location.item.code, location.item.player, location.item.flags location.item.code, location.item.player, location.item.flags
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 location.name in multiworld.worlds[location.player].options.start_location_hints:
if not location.item.trap: # Unspecified status for location hints, except traps precollect_hint(location)
auto_status = HintStatus.HINT_UNSPECIFIED
precollect_hint(location, auto_status)
elif location.item.name in multiworld.worlds[location.item.player].options.start_hints: elif location.item.name in multiworld.worlds[location.item.player].options.start_hints:
precollect_hint(location, auto_status) precollect_hint(location)
elif any([location.item.name in multiworld.worlds[player].options.start_hints elif any([location.item.name in multiworld.worlds[player].options.start_hints
for player in multiworld.groups.get(location.item.player, {}).get("players", [])]): for player in multiworld.groups.get(location.item.player, {}).get("players", [])]):
precollect_hint(location, auto_status) precollect_hint(location)
# embedded data package # embedded data package
data_package = { data_package = {
game_world.game: worlds.network_data_package["games"][game_world.game] game_world.game: worlds.network_data_package["games"][game_world.game]
for game_world in multiworld.worlds.values() for game_world in multiworld.worlds.values()
} }
data_package["Archipelago"] = worlds.network_data_package["games"]["Archipelago"]
checks_in_area: dict[int, dict[str, int | list[int]]] = {} checks_in_area: Dict[int, Dict[str, Union[int, List[int]]]] = {}
# get spheres -> filter address==None -> skip empty # get spheres -> filter address==None -> skip empty
spheres: list[dict[int, set[int]]] = [] spheres: List[Dict[int, Set[int]]] = []
for sphere in multiworld.get_sendable_spheres(): for sphere in multiworld.get_spheres():
current_sphere: dict[int, set[int]] = collections.defaultdict(set) current_sphere: Dict[int, Set[int]] = collections.defaultdict(set)
for sphere_location in sphere: for sphere_location in sphere:
current_sphere[sphere_location.player].add(sphere_location.address) if type(sphere_location.address) is int:
current_sphere[sphere_location.player].add(sphere_location.address)
if current_sphere: if current_sphere:
spheres.append(dict(current_sphere)) spheres.append(dict(current_sphere))
multidata: NetUtils.MultiData | bytes = { multidata = {
"slot_data": slot_data, "slot_data": slot_data,
"slot_info": slot_info, "slot_info": slot_info,
"connect_names": {name: (0, player) for player, name in multiworld.player_name.items()}, "connect_names": {name: (0, player) for player, name in multiworld.player_name.items()},
@@ -331,21 +393,16 @@ def main(args, seed=None, baked_server_options: dict[str, object] | None = None)
"er_hint_data": er_hint_data, "er_hint_data": er_hint_data,
"precollected_items": precollected_items, "precollected_items": precollected_items,
"precollected_hints": precollected_hints, "precollected_hints": precollected_hints,
"version": (version_tuple.major, version_tuple.minor, version_tuple.build), "version": tuple(version_tuple),
"tags": ["AP"], "tags": ["AP"],
"minimum_versions": minimum_versions, "minimum_versions": minimum_versions,
"seed_name": multiworld.seed_name, "seed_name": multiworld.seed_name,
"spheres": spheres, "spheres": spheres,
"datapackage": data_package, "datapackage": data_package,
"race_mode": int(multiworld.is_race),
} }
# TODO: change to `"version": version_tuple` after getting better serialization
AutoWorld.call_all(multiworld, "modify_multidata", multidata) AutoWorld.call_all(multiworld, "modify_multidata", multidata)
for key in ("slot_data", "er_hint_data"): multidata = zlib.compress(pickle.dumps(multidata), 9)
multidata[key] = convert_to_base_types(multidata[key])
multidata = zlib.compress(restricted_dumps(multidata), 9)
with open(os.path.join(temp_dir, f'{outfilebase}.archipelago'), 'wb') as f: with open(os.path.join(temp_dir, f'{outfilebase}.archipelago'), 'wb') as f:
f.write(bytes([3])) # version of format f.write(bytes([3])) # version of format
@@ -354,7 +411,7 @@ def main(args, seed=None, baked_server_options: dict[str, object] | None = None)
output_file_futures.append(pool.submit(write_multidata)) output_file_futures.append(pool.submit(write_multidata))
if not check_accessibility_task.result(): if not check_accessibility_task.result():
if not multiworld.can_beat_game(): if not multiworld.can_beat_game():
raise FillError("Game appears as unbeatable. Aborting.", multiworld=multiworld) raise Exception("Game appears as unbeatable. Aborting.")
else: else:
logger.warning("Location Accessibility requirements not fulfilled.") logger.warning("Location Accessibility requirements not fulfilled.")

344
MinecraftClient.py Normal file
View File

@@ -0,0 +1,344 @@
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()

View File

@@ -5,22 +5,11 @@ import multiprocessing
import warnings import warnings
if sys.platform in ("win32", "darwin") and sys.version_info < (3, 11, 9): if sys.version_info < (3, 8, 6):
# Official micro version updates. This should match the number in docs/running from source.md. raise RuntimeError("Incompatible Python Version. 3.8.7+ is supported.")
raise RuntimeError(f"Incompatible Python Version found: {sys.version_info}. Official 3.11.9+ is supported.")
elif sys.platform in ("win32", "darwin") and sys.version_info < (3, 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) # don't run update if environment is frozen/compiled or if not the parent process (skip in subprocess)
_skip_update = bool( _skip_update = bool(getattr(sys, "frozen", False) or multiprocessing.parent_process())
getattr(sys, "frozen", False) or
multiprocessing.parent_process() or
os.environ.get("SKIP_REQUIREMENTS_UPDATE", "").lower() in ("1", "true", "yes")
)
update_ran = _skip_update update_ran = _skip_update
@@ -74,11 +63,11 @@ def update_command():
def install_pkg_resources(yes=False): def install_pkg_resources(yes=False):
try: try:
import pkg_resources # noqa: F401 import pkg_resources # noqa: F401
except (AttributeError, ImportError): except ImportError:
check_pip() check_pip()
if not yes: if not yes:
confirm("pkg_resources not found, press enter to install it") confirm("pkg_resources not found, press enter to install it")
subprocess.call([sys.executable, "-m", "pip", "install", "--upgrade", "setuptools>=75,<81"]) subprocess.call([sys.executable, "-m", "pip", "install", "--upgrade", "setuptools"])
def update(yes: bool = False, force: bool = False) -> None: def update(yes: bool = False, force: bool = False) -> None:
@@ -86,13 +75,13 @@ def update(yes: bool = False, force: bool = False) -> None:
if not update_ran: if not update_ran:
update_ran = True update_ran = True
install_pkg_resources(yes=yes)
import pkg_resources
if force: if force:
update_command() update_command()
return return
install_pkg_resources(yes=yes)
import pkg_resources
prev = "" # if a line ends in \ we store here and merge later prev = "" # if a line ends in \ we store here and merge later
for req_file in requirements_files: for req_file in requirements_files:
path = os.path.join(os.path.dirname(sys.argv[0]), req_file) path = os.path.join(os.path.dirname(sys.argv[0]), req_file)

View File

@@ -15,7 +15,6 @@ import math
import operator import operator
import pickle import pickle
import random import random
import shlex
import threading import threading
import time import time
import typing import typing
@@ -28,11 +27,9 @@ ModuleUpdate.update()
if typing.TYPE_CHECKING: if typing.TYPE_CHECKING:
import ssl import ssl
from NetUtils import ServerConnection
import colorama
import websockets import websockets
from websockets.extensions.permessage_deflate import PerMessageDeflate import colorama
try: try:
# ponyorm is a requirement for webhost, not default server, so may not be importable # ponyorm is a requirement for webhost, not default server, so may not be importable
from pony.orm.dbapiprovider import OperationalError from pony.orm.dbapiprovider import OperationalError
@@ -43,12 +40,10 @@ import NetUtils
import Utils import Utils
from Utils import version_tuple, restricted_loads, Version, async_start, get_intended_text from Utils import version_tuple, restricted_loads, Version, async_start, get_intended_text
from NetUtils import Endpoint, ClientStatus, NetworkItem, decode, encode, NetworkPlayer, Permission, NetworkSlot, \ from NetUtils import Endpoint, ClientStatus, NetworkItem, decode, encode, NetworkPlayer, Permission, NetworkSlot, \
SlotType, LocationStore, MultiData, Hint, HintStatus SlotType, LocationStore
from BaseClasses import ItemClassification
min_client_version = Version(0, 1, 6)
min_client_version = Version(0, 5, 0) colorama.init()
colorama.just_fix_windows_console()
def remove_from_list(container, value): def remove_from_list(container, value):
@@ -67,28 +62,9 @@ def pop_from_container(container, value):
return container return container
def update_container_unique(container, entries): def update_dict(dictionary, entries):
if isinstance(container, list): dictionary.update(entries)
existing_container_as_set = set(container) return dictionary
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 # functions callable on storable data on the server by clients
@@ -114,7 +90,7 @@ modify_functions = {
# lists/dicts: # lists/dicts:
"remove": remove_from_list, "remove": remove_from_list,
"pop": pop_from_container, "pop": pop_from_container,
"update": update_container_unique, "update": update_dict,
} }
@@ -126,14 +102,13 @@ def get_saving_second(seed_name: str, interval: int = 60) -> int:
class Client(Endpoint): class Client(Endpoint):
version = Version(0, 0, 0) version = Version(0, 0, 0)
tags: typing.List[str] tags: typing.List[str] = []
remote_items: bool remote_items: bool
remote_start_inventory: bool remote_start_inventory: bool
no_items: bool no_items: bool
no_locations: bool no_locations: bool
no_text: bool
def __init__(self, socket: "ServerConnection", ctx: Context) -> None: def __init__(self, socket: websockets.WebSocketServerProtocol, ctx: Context):
super().__init__(socket) super().__init__(socket)
self.auth = False self.auth = False
self.team = None self.team = None
@@ -183,7 +158,6 @@ class Context:
"compatibility": int} "compatibility": int}
# team -> slot id -> list of clients authenticated to slot. # team -> slot id -> list of clients authenticated to slot.
clients: typing.Dict[int, typing.Dict[int, typing.List[Client]]] 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]]] locations: LocationStore # typing.Dict[int, typing.Dict[int, typing.Tuple[int, int, int]]]
location_checks: typing.Dict[typing.Tuple[int, int], typing.Set[int]] location_checks: typing.Dict[typing.Tuple[int, int], typing.Set[int]]
hints_used: typing.Dict[typing.Tuple[int, int], int] hints_used: typing.Dict[typing.Tuple[int, int], int]
@@ -195,9 +169,11 @@ class Context:
slot_info: typing.Dict[int, NetworkSlot] slot_info: typing.Dict[int, NetworkSlot]
generator_version = Version(0, 0, 0) generator_version = Version(0, 0, 0)
checksums: typing.Dict[str, str] checksums: typing.Dict[str, str]
item_names: typing.Dict[str, typing.Dict[int, str]] item_names: typing.Dict[str, typing.Dict[int, str]] = (
collections.defaultdict(lambda: Utils.KeyedDefaultDict(lambda code: f'Unknown item (ID:{code})')))
item_name_groups: typing.Dict[str, typing.Dict[str, typing.Set[str]]] item_name_groups: typing.Dict[str, typing.Dict[str, typing.Set[str]]]
location_names: typing.Dict[str, typing.Dict[int, str]] location_names: typing.Dict[str, typing.Dict[int, str]] = (
collections.defaultdict(lambda: Utils.KeyedDefaultDict(lambda code: f'Unknown location (ID:{code})')))
location_name_groups: typing.Dict[str, typing.Dict[str, typing.Set[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_item_and_group_names: typing.Dict[str, typing.Set[str]]
all_location_and_group_names: typing.Dict[str, typing.Set[str]] all_location_and_group_names: typing.Dict[str, typing.Set[str]]
@@ -206,6 +182,7 @@ class Context:
""" each sphere is { player: { location_id, ... } } """ """ each sphere is { player: { location_id, ... } } """
logger: logging.Logger logger: logging.Logger
def __init__(self, host: str, port: int, server_password: str, password: str, location_check_points: int, 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", 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, remaining_mode: str = "disabled", auto_shutdown: typing.SupportsFloat = 0, compatibility: int = 2,
@@ -238,7 +215,7 @@ class Context:
self.hint_cost = hint_cost self.hint_cost = hint_cost
self.location_check_points = location_check_points self.location_check_points = location_check_points
self.hints_used = collections.defaultdict(int) self.hints_used = collections.defaultdict(int)
self.hints: typing.Dict[team_slot, typing.Set[Hint]] = collections.defaultdict(set) self.hints: typing.Dict[team_slot, typing.Set[NetUtils.Hint]] = collections.defaultdict(set)
self.release_mode: str = release_mode self.release_mode: str = release_mode
self.remaining_mode: str = remaining_mode self.remaining_mode: str = remaining_mode
self.collect_mode: str = collect_mode self.collect_mode: str = collect_mode
@@ -276,10 +253,6 @@ class Context:
self.location_name_groups = {} self.location_name_groups = {}
self.all_item_and_group_names = {} self.all_item_and_group_names = {}
self.all_location_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.non_hintable_names = collections.defaultdict(frozenset)
self._load_game_data() self._load_game_data()
@@ -373,28 +346,18 @@ class Context:
return True return True
def broadcast_all(self, msgs: typing.List[dict]): def broadcast_all(self, msgs: typing.List[dict]):
msg_is_text = all(msg["cmd"] == "PrintJSON" for msg in msgs) msgs = self.dumper(msgs)
data = self.dumper(msgs) endpoints = (endpoint for endpoint in self.endpoints if endpoint.auth)
endpoints = ( async_start(self.broadcast_send_encoded_msgs(endpoints, msgs))
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 = {}): def broadcast_text_all(self, text: str, additional_arguments: dict = {}):
self.logger.info("Notice (all): %s" % text) self.logger.info("Notice (all): %s" % text)
self.broadcast_all([{**{"cmd": "PrintJSON", "data": [{ "text": text }]}, **additional_arguments}]) self.broadcast_all([{**{"cmd": "PrintJSON", "data": [{ "text": text }]}, **additional_arguments}])
def broadcast_team(self, team: int, msgs: typing.List[dict]): def broadcast_team(self, team: int, msgs: typing.List[dict]):
msg_is_text = all(msg["cmd"] == "PrintJSON" for msg in msgs) msgs = self.dumper(msgs)
data = self.dumper(msgs) endpoints = (endpoint for endpoint in itertools.chain.from_iterable(self.clients[team].values()))
endpoints = ( async_start(self.broadcast_send_encoded_msgs(endpoints, msgs))
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]): def broadcast(self, endpoints: typing.Iterable[Client], msgs: typing.List[dict]):
msgs = self.dumper(msgs) msgs = self.dumper(msgs)
@@ -408,13 +371,13 @@ class Context:
await on_client_disconnected(self, endpoint) await on_client_disconnected(self, endpoint)
def notify_client(self, client: Client, text: str, additional_arguments: dict = {}): def notify_client(self, client: Client, text: str, additional_arguments: dict = {}):
if not client.auth or client.no_text: if not client.auth:
return return
self.logger.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}])) 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 = {}): def notify_client_multiple(self, client: Client, texts: typing.List[str], additional_arguments: dict = {}):
if not client.auth or client.no_text: if not client.auth:
return return
async_start(self.send_msgs(client, async_start(self.send_msgs(client,
[{"cmd": "PrintJSON", "data": [{ "text": text }], **additional_arguments} [{"cmd": "PrintJSON", "data": [{ "text": text }], **additional_arguments}
@@ -445,12 +408,10 @@ class Context:
raise Utils.VersionException("Incompatible multidata.") raise Utils.VersionException("Incompatible multidata.")
return restricted_loads(zlib.decompress(data[1:])) return restricted_loads(zlib.decompress(data[1:]))
def _load(self, decoded_obj: MultiData, game_data_packages: typing.Dict[str, typing.Any], def _load(self, decoded_obj: dict, game_data_packages: typing.Dict[str, typing.Any],
use_embedded_server_options: bool): use_embedded_server_options: bool):
self.read_data = {} 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"] mdata_ver = decoded_obj["minimum_versions"]["server"]
if mdata_ver > version_tuple: 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},"
@@ -458,16 +419,12 @@ class Context:
self.generator_version = Version(*decoded_obj["version"]) self.generator_version = Version(*decoded_obj["version"])
clients_ver = decoded_obj["minimum_versions"].get("clients", {}) clients_ver = decoded_obj["minimum_versions"].get("clients", {})
self.minimum_client_versions = {} self.minimum_client_versions = {}
if self.generator_version < Version(0, 6, 2):
min_version = Version(0, 1, 6)
else:
min_version = min_client_version
for player, version in clients_ver.items(): for player, version in clients_ver.items():
self.minimum_client_versions[player] = max(Version(*version), min_version) self.minimum_client_versions[player] = max(Version(*version), min_client_version)
self.slot_info = decoded_obj["slot_info"] self.slot_info = decoded_obj["slot_info"]
self.games = {slot: slot_info.game for slot, slot_info in self.slot_info.items()} self.games = {slot: slot_info.game for slot, slot_info in self.slot_info.items()}
self.groups = {slot: set(slot_info.group_members) for slot, slot_info in self.slot_info.items() self.groups = {slot: slot_info.group_members for slot, slot_info in self.slot_info.items()
if slot_info.type == SlotType.group} if slot_info.type == SlotType.group}
self.clients = {0: {}} self.clients = {0: {}}
@@ -546,7 +503,6 @@ class Context:
def _save(self, exit_save: bool = False) -> bool: def _save(self, exit_save: bool = False) -> bool:
try: try:
# Does not use Utils.restricted_dumps because we'd rather make a save than not make one
encoded_save = pickle.dumps(self.get_save()) encoded_save = pickle.dumps(self.get_save())
with open(self.save_filename, "wb") as f: with open(self.save_filename, "wb") as f:
f.write(zlib.compress(encoded_save)) f.write(zlib.compress(encoded_save))
@@ -595,9 +551,6 @@ class Context:
self.logger.info(f"Saving failed. Retry in {self.auto_save_interval} seconds.") self.logger.info(f"Saving failed. Retry in {self.auto_save_interval} seconds.")
else: else:
self.save_dirty = False 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 = threading.Thread(target=save_regularly, daemon=True)
self.auto_saver_thread.start() self.auto_saver_thread.start()
@@ -681,29 +634,13 @@ class Context:
return max(1, int(self.hint_cost * 0.01 * len(self.locations[slot]))) return max(1, int(self.hint_cost * 0.01 * len(self.locations[slot])))
return 0 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: for hint_team, hint_slot in self.hints:
if team != hint_team and team is not None: if (team is None or team == hint_team) and (slot is None or slot == hint_slot):
continue # Check specified team only, all if team is None self.hints[hint_team, hint_slot] = {
if slot != hint_slot and slot is not None: hint.re_check(self, hint_team) for hint in
continue # Check specified slot only, all if slot is None self.hints[hint_team, hint_slot]
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): def get_rechecked_hints(self, team: int, slot: int):
self.recheck_hints(team, slot) self.recheck_hints(team, slot)
@@ -752,8 +689,8 @@ class Context:
else: else:
return self.player_names[team, slot] return self.player_names[team, slot]
def notify_hints(self, team: int, hints: typing.List[Hint], only_new: bool = False, def notify_hints(self, team: int, hints: typing.List[NetUtils.Hint], only_new: bool = False,
persist_even_if_found: bool = False, recipients: typing.Sequence[int] = None): recipients: typing.Sequence[int] = None):
"""Send and remember hints.""" """Send and remember hints."""
if only_new: if only_new:
hints = [hint for hint in hints if hint not in self.hints[team, hint.finding_player]] hints = [hint for hint in hints if hint not in self.hints[team, hint.finding_player]]
@@ -767,10 +704,8 @@ class Context:
concerns[player].append(data) concerns[player].append(data)
if not hint.local and data not in concerns[hint.finding_player]: if not hint.local and data not in concerns[hint.finding_player]:
concerns[hint.finding_player].append(data) concerns[hint.finding_player].append(data)
# remember hints in all cases
# For !hint use cases, only hints that were not already found at the time of creation should be remembered if not hint.found:
# For LocationScouts use-cases, all hints should be remembered
if not hint.found or persist_even_if_found:
# since hints are bidirectional, finding player and receiving player, # since hints are bidirectional, finding player and receiving player,
# we can check once if hint already exists # we can check once if hint already exists
if hint not in self.hints[team, hint.finding_player]: if hint not in self.hints[team, hint.finding_player]:
@@ -785,24 +720,13 @@ class Context:
self.on_new_hint(team, slot) self.on_new_hint(team, slot)
for slot, hint_data in concerns.items(): for slot, hint_data in concerns.items():
if recipients is None or slot in recipients: if recipients is None or slot in recipients:
clients = filter(lambda c: not c.no_text, self.clients[team].get(slot, [])) clients = self.clients[team].get(slot)
if not clients: if not clients:
continue continue
client_hints = [datum[1] for datum in sorted(hint_data, key=lambda x: x[0].finding_player != slot)] client_hints = [datum[1] for datum in sorted(hint_data, key=lambda x: x[0].finding_player != slot)]
for client in clients: for client in clients:
async_start(self.send_msgs(client, client_hints)) 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" # "events"
def on_goal_achieved(self, client: Client): def on_goal_achieved(self, client: Client):
@@ -844,7 +768,7 @@ def update_aliases(ctx: Context, team: int):
async_start(ctx.send_encoded_msgs(client, cmd)) async_start(ctx.send_encoded_msgs(client, cmd))
async def server(websocket: "ServerConnection", path: str = "/", ctx: Context = None) -> None: async def server(websocket, path: str = "/", ctx: Context = None):
client = Client(websocket, ctx) client = Client(websocket, ctx)
ctx.endpoints.append(client) ctx.endpoints.append(client)
@@ -935,10 +859,6 @@ async def on_client_joined(ctx: Context, client: Client):
"If your client supports it, " "If your client supports it, "
"you may have additional local commands you can list with /help.", "you may have additional local commands you can list with /help.",
{"type": "Tutorial"}) {"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) ctx.client_connection_timers[client.team, client.slot] = datetime.datetime.now(datetime.timezone.utc)
@@ -1005,13 +925,9 @@ 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]) 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])})" 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 "" tag_text = f" {tagged} of which are tagged {tag}" if connected and tag else ""
status_text = ( goal_text = " and has finished." if ctx.client_game_state[team, slot] == ClientStatus.CLIENT_GOAL else "."
" 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'}" \ text += f"\n{ctx.get_aliased_name(team, slot)} has {connected} connection{'' if connected == 1 else 's'}" \
f"{tag_text}{status_text} {completion_text}" f"{tag_text}{goal_text} {completion_text}"
return text return text
@@ -1075,7 +991,7 @@ def collect_player(ctx: Context, team: int, slot: int, is_group: bool = False):
collect_player(ctx, team, group, True) collect_player(ctx, team, group, True)
def get_remaining(ctx: Context, team: int, slot: int) -> typing.List[typing.Tuple[int, int]]: def get_remaining(ctx: Context, team: int, slot: int) -> typing.List[int]:
return ctx.locations.get_remaining(ctx.location_checks, team, slot) return ctx.locations.get_remaining(ctx.location_checks, team, slot)
@@ -1089,37 +1005,21 @@ 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], def register_location_checks(ctx: Context, team: int, slot: int, locations: typing.Iterable[int],
count_activity: bool = True): count_activity: bool = True):
slot_locations = ctx.locations[slot]
new_locations = set(locations) - ctx.location_checks[team, slot] new_locations = set(locations) - ctx.location_checks[team, slot]
new_locations.intersection_update(slot_locations) # ignore location IDs unknown to this multidata new_locations.intersection_update(ctx.locations[slot]) # ignore location IDs unknown to this multidata
if new_locations: if new_locations:
if count_activity: if count_activity:
ctx.client_activity_timers[team, slot] = datetime.datetime.now(datetime.timezone.utc) ctx.client_activity_timers[team, slot] = datetime.datetime.now(datetime.timezone.utc)
sortable: list[tuple[int, int, int, int]] = []
for location in new_locations: for location in new_locations:
# extract all fields to avoid runtime overhead in LocationStore item_id, target_player, flags = ctx.locations[slot][location]
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) new_item = NetworkItem(item_id, location, slot, flags)
send_items_to(ctx, team, target_player, new_item) send_items_to(ctx, team, target_player, new_item)
ctx.logger.info('(Team #%d) %s sent %s to %s (%s)' % ( 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], 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])) ctx.player_names[(team, target_player)], ctx.location_names[ctx.slot_info[slot].game][location]))
if len(info_texts) >= 140: info_text = json_format_send_event(new_item, target_player)
# split into chunks that are close to compression window of 64K but not too big on the wire ctx.broadcast_team(team, [info_text])
# (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 ctx.location_checks[team, slot] |= new_locations
send_new_items(ctx) send_new_items(ctx)
@@ -1128,15 +1028,14 @@ def register_location_checks(ctx: Context, team: int, slot: int, locations: typi
"hint_points": get_slot_points(ctx, team, slot), "hint_points": get_slot_points(ctx, team, slot),
"checked_locations": new_locations, # send back new checks only "checked_locations": new_locations, # send back new checks only
}]) }])
updated_slots: typing.Set[tuple[int, int]] = set() old_hints = ctx.hints[team, slot].copy()
ctx.recheck_hints(team, slot, updated_slots) ctx.recheck_hints(team, slot)
for hint_team, hint_slot in updated_slots: if old_hints != ctx.hints[team, slot]:
ctx.on_changed_hints(hint_team, hint_slot) ctx.on_changed_hints(team, slot)
ctx.save() ctx.save()
def collect_hints(ctx: Context, team: int, slot: int, item: typing.Union[int, str], auto_status: HintStatus) \ def collect_hints(ctx: Context, team: int, slot: int, item: typing.Union[int, str]) -> typing.List[NetUtils.Hint]:
-> typing.List[Hint]:
hints = [] hints = []
slots: typing.Set[int] = {slot} slots: typing.Set[int] = {slot}
for group_id, group in ctx.groups.items(): for group_id, group in ctx.groups.items():
@@ -1146,58 +1045,31 @@ 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] 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 \ for finding_player, location_id, item_id, receiving_player, item_flags \
in ctx.locations.find_item(slots, seeked_item_id): in ctx.locations.find_item(slots, seeked_item_id):
prev_hint = ctx.get_hint(team, finding_player, location_id) found = location_id in ctx.location_checks[team, finding_player]
if prev_hint: entrance = ctx.er_hint_data.get(finding_player, {}).get(location_id, "")
hints.append(prev_hint) hints.append(NetUtils.Hint(receiving_player, finding_player, location_id, item_id, found, entrance,
else: item_flags))
found = location_id in ctx.location_checks[team, finding_player]
entrance = ctx.er_hint_data.get(finding_player, {}).get(location_id, "")
new_status = auto_status
if found:
new_status = HintStatus.HINT_FOUND
elif item_flags & ItemClassification.trap:
new_status = HintStatus.HINT_AVOID
hints.append(Hint(receiving_player, finding_player, location_id, item_id, found, entrance,
item_flags, new_status))
return hints return hints
def collect_hint_location_name(ctx: Context, team: int, slot: int, location: str, auto_status: HintStatus) \ def collect_hint_location_name(ctx: Context, team: int, slot: int, location: str) -> typing.List[NetUtils.Hint]:
-> typing.List[Hint]:
seeked_location: int = ctx.location_names_for_game(ctx.games[slot])[location] seeked_location: int = ctx.location_names_for_game(ctx.games[slot])[location]
return collect_hint_location_id(ctx, team, slot, seeked_location, auto_status) return collect_hint_location_id(ctx, team, slot, seeked_location)
def collect_hint_location_id(ctx: Context, team: int, slot: int, seeked_location: int, auto_status: HintStatus) \ def collect_hint_location_id(ctx: Context, team: int, slot: int, seeked_location: int) -> typing.List[NetUtils.Hint]:
-> typing.List[Hint]:
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)) result = ctx.locations[slot].get(seeked_location, (None, None, None))
if any(result): if any(result):
item_id, receiving_player, item_flags = result item_id, receiving_player, item_flags = result
found = seeked_location in ctx.location_checks[team, slot] found = seeked_location in ctx.location_checks[team, slot]
entrance = ctx.er_hint_data.get(slot, {}).get(seeked_location, "") entrance = ctx.er_hint_data.get(slot, {}).get(seeked_location, "")
new_status = auto_status return [NetUtils.Hint(receiving_player, slot, seeked_location, item_id, found, entrance, item_flags)]
if found:
new_status = HintStatus.HINT_FOUND
elif item_flags & ItemClassification.trap:
new_status = HintStatus.HINT_AVOID
return [Hint(receiving_player, slot, seeked_location, item_id, found, entrance, item_flags,
new_status)]
return [] return []
status_names: typing.Dict[HintStatus, str] = { def format_hint(ctx: Context, team: int, hint: NetUtils.Hint) -> 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 " \ text = f"[Hint]: {ctx.player_names[team, hint.receiving_player]}'s " \
f"{ctx.item_names[ctx.slot_info[hint.receiving_player].game][hint.item]} is " \ 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"at {ctx.location_names[ctx.slot_info[hint.finding_player].game][hint.location]} " \
@@ -1205,8 +1077,7 @@ def format_hint(ctx: Context, team: int, hint: Hint) -> str:
if hint.entrance: if hint.entrance:
text += f" at {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): def json_format_send_event(net_item: NetworkItem, receiving_player: int):
@@ -1261,10 +1132,7 @@ class CommandProcessor(metaclass=CommandMeta):
if not raw: if not raw:
return return
try: try:
try: command = raw.split()
command = shlex.split(raw, comments=False)
except ValueError: # most likely: "ValueError: No closing quotation"
command = raw.split()
basecommand = command[0] basecommand = command[0]
if basecommand[0] == self.marker: if basecommand[0] == self.marker:
method = self.commands.get(basecommand[1:].lower(), None) method = self.commands.get(basecommand[1:].lower(), None)
@@ -1335,10 +1203,6 @@ class CommonCommandProcessor(CommandProcessor):
timer = int(seconds, 10) timer = int(seconds, 10)
except ValueError: except ValueError:
timer = 10 timer = 10
else:
if timer > 60 * 60:
raise ValueError(f"{timer} is invalid. Maximum is 1 hour.")
async_start(countdown(self.ctx, timer)) async_start(countdown(self.ctx, timer))
return True return True
@@ -1486,10 +1350,10 @@ class ClientMessageProcessor(CommonCommandProcessor):
def _cmd_remaining(self) -> bool: def _cmd_remaining(self) -> bool:
"""List remaining items in your game, but not their location or recipient""" """List remaining items in your game, but not their location or recipient"""
if self.ctx.remaining_mode == "enabled": if self.ctx.remaining_mode == "enabled":
rest_locations = get_remaining(self.ctx, self.client.team, self.client.slot) remaining_item_ids = get_remaining(self.ctx, self.client.team, self.client.slot)
if rest_locations: if remaining_item_ids:
self.output("Remaining items: " + ", ".join(self.ctx.item_names[self.ctx.games[slot]][item_id] self.output("Remaining items: " + ", ".join(self.ctx.item_names[self.client.slot.game][item_id]
for slot, item_id in rest_locations)) for item_id in remaining_item_ids))
else: else:
self.output("No remaining items found.") self.output("No remaining items found.")
return True return True
@@ -1499,10 +1363,10 @@ class ClientMessageProcessor(CommonCommandProcessor):
return False return False
else: # is goal else: # is goal
if self.ctx.client_game_state[self.client.team, self.client.slot] == ClientStatus.CLIENT_GOAL: if self.ctx.client_game_state[self.client.team, self.client.slot] == ClientStatus.CLIENT_GOAL:
rest_locations = get_remaining(self.ctx, self.client.team, self.client.slot) remaining_item_ids = get_remaining(self.ctx, self.client.team, self.client.slot)
if rest_locations: if remaining_item_ids:
self.output("Remaining items: " + ", ".join(self.ctx.item_names[self.ctx.games[slot]][item_id] self.output("Remaining items: " + ", ".join(self.ctx.item_names[self.client.slot.game][item_id]
for slot, item_id in rest_locations)) for item_id in remaining_item_ids))
else: else:
self.output("No remaining items found.") self.output("No remaining items found.")
return True return True
@@ -1610,7 +1474,7 @@ class ClientMessageProcessor(CommonCommandProcessor):
def get_hints(self, input_text: str, for_location: bool = False) -> bool: def get_hints(self, input_text: str, for_location: bool = False) -> bool:
points_available = get_client_points(self.ctx, self.client) points_available = get_client_points(self.ctx, self.client)
cost = self.ctx.get_hint_cost(self.client.slot) cost = self.ctx.get_hint_cost(self.client.slot)
auto_status = HintStatus.HINT_UNSPECIFIED if for_location else HintStatus.HINT_PRIORITY
if not input_text: if not input_text:
hints = {hint.re_check(self.ctx, self.client.team) for hint in 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]}
@@ -1636,9 +1500,9 @@ class ClientMessageProcessor(CommonCommandProcessor):
self.output(f"Sorry, \"{hint_name}\" is marked as non-hintable.") self.output(f"Sorry, \"{hint_name}\" is marked as non-hintable.")
hints = [] hints = []
elif not for_location: elif not for_location:
hints = collect_hints(self.ctx, self.client.team, self.client.slot, hint_id, auto_status) hints = collect_hints(self.ctx, self.client.team, self.client.slot, hint_id)
else: else:
hints = collect_hint_location_id(self.ctx, self.client.team, self.client.slot, hint_id, auto_status) hints = collect_hint_location_id(self.ctx, self.client.team, self.client.slot, hint_id)
else: else:
game = self.ctx.games[self.client.slot] game = self.ctx.games[self.client.slot]
@@ -1658,16 +1522,16 @@ class ClientMessageProcessor(CommonCommandProcessor):
hints = [] hints = []
for item_name in self.ctx.item_name_groups[game][hint_name]: for item_name in self.ctx.item_name_groups[game][hint_name]:
if item_name in self.ctx.item_names_for_game(game): # ensure item has an ID if item_name in self.ctx.item_names_for_game(game): # ensure item has an ID
hints.extend(collect_hints(self.ctx, self.client.team, self.client.slot, item_name, auto_status)) hints.extend(collect_hints(self.ctx, self.client.team, self.client.slot, item_name))
elif not for_location and hint_name in self.ctx.item_names_for_game(game): # item name elif not for_location and hint_name in self.ctx.item_names_for_game(game): # item name
hints = collect_hints(self.ctx, self.client.team, self.client.slot, hint_name, auto_status) hints = collect_hints(self.ctx, self.client.team, self.client.slot, hint_name)
elif hint_name in self.ctx.location_name_groups[game]: # location group name elif hint_name in self.ctx.location_name_groups[game]: # location group name
hints = [] hints = []
for loc_name in self.ctx.location_name_groups[game][hint_name]: for loc_name in self.ctx.location_name_groups[game][hint_name]:
if loc_name in self.ctx.location_names_for_game(game): if loc_name in self.ctx.location_names_for_game(game):
hints.extend(collect_hint_location_name(self.ctx, self.client.team, self.client.slot, loc_name, auto_status)) hints.extend(collect_hint_location_name(self.ctx, self.client.team, self.client.slot, loc_name))
else: # location name else: # location name
hints = collect_hint_location_name(self.ctx, self.client.team, self.client.slot, hint_name, auto_status) hints = collect_hint_location_name(self.ctx, self.client.team, self.client.slot, hint_name)
else: else:
self.output(response) self.output(response)
@@ -1832,9 +1696,7 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
ctx.clients[team][slot].append(client) ctx.clients[team][slot].append(client)
client.version = args['version'] client.version = args['version']
client.tags = args['tags'] client.tags = args['tags']
client.no_locations = bool(client.tags & _non_game_messages.keys()) client.no_locations = 'TextOnly' in client.tags or 'Tracker' in client.tags
# 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 = { connected_packet = {
"cmd": "Connected", "cmd": "Connected",
"team": client.team, "slot": client.slot, "team": client.team, "slot": client.slot,
@@ -1906,10 +1768,7 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
old_tags = client.tags old_tags = client.tags
client.tags = args["tags"] client.tags = args["tags"]
if set(old_tags) != set(client.tags): if set(old_tags) != set(client.tags):
client.no_locations = bool(client.tags & _non_game_messages.keys()) client.no_locations = 'TextOnly' in client.tags or 'Tracker' in client.tags
client.no_text = "NoText" in client.tags or (
"PopTracker" in client.tags and client.version < (0, 5, 1)
)
ctx.broadcast_text_all( ctx.broadcast_text_all(
f"{ctx.get_aliased_name(client.team, client.slot)} (Team #{client.team + 1}) has changed tags " f"{ctx.get_aliased_name(client.team, client.slot)} (Team #{client.team + 1}) has changed tags "
f"from {old_tags} to {client.tags}.", f"from {old_tags} to {client.tags}.",
@@ -1938,124 +1797,21 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
for location in args["locations"]: for location in args["locations"]:
if type(location) is not int: if type(location) is not int:
await ctx.send_msgs(client, await ctx.send_msgs(client,
[{'cmd': 'InvalidPacket', "type": "arguments", [{'cmd': 'InvalidPacket', "type": "arguments", "text": 'LocationScouts',
"text": 'Locations has to be a list of integers',
"original_cmd": cmd}]) "original_cmd": cmd}])
return return
target_item, target_player, flags = ctx.locations[client.slot][location] target_item, target_player, flags = ctx.locations[client.slot][location]
if create_as_hint: if create_as_hint:
hints.extend(collect_hint_location_id(ctx, client.team, client.slot, location, hints.extend(collect_hint_location_id(ctx, client.team, client.slot, location))
HintStatus.HINT_UNSPECIFIED))
locs.append(NetworkItem(target_item, location, target_player, flags)) locs.append(NetworkItem(target_item, location, target_player, flags))
ctx.notify_hints(client.team, hints, only_new=create_as_hint == 2, persist_even_if_found=True) ctx.notify_hints(client.team, hints, only_new=create_as_hint == 2)
if locs and create_as_hint: if locs and create_as_hint:
ctx.save() ctx.save()
await ctx.send_msgs(client, [{'cmd': 'LocationInfo', 'locations': locs}]) await ctx.send_msgs(client, [{'cmd': 'LocationInfo', 'locations': locs}])
elif cmd == 'CreateHints':
location_player = args.get("player", client.slot)
locations = args["locations"]
status = args.get("status", HintStatus.HINT_UNSPECIFIED)
if not locations:
await ctx.send_msgs(client, [{"cmd": "InvalidPacket", "type": "arguments",
"text": "CreateHints: No locations specified.", "original_cmd": cmd}])
return
try:
status = HintStatus(status)
except ValueError as err:
await ctx.send_msgs(client,
[{"cmd": "InvalidPacket", "type": "arguments",
"text": f"Unknown Status: {err}",
"original_cmd": cmd}])
return
hints = []
for location in locations:
if location_player != client.slot and location not in ctx.locations[location_player]:
error_text = (
"CreateHints: One or more of the locations do not exist for the specified off-world player. "
"Please refrain from hinting other slot's locations that you don't know contain your items."
)
await ctx.send_msgs(client, [{"cmd": "InvalidPacket", "type": "arguments",
"text": error_text, "original_cmd": cmd}])
return
target_item, item_player, flags = ctx.locations[location_player][location]
if client.slot not in ctx.slot_set(item_player):
if status != HintStatus.HINT_UNSPECIFIED:
error_text = 'CreateHints: Must use "unspecified"/None status for items from other players.'
await ctx.send_msgs(client, [{"cmd": "InvalidPacket", "type": "arguments",
"text": error_text, "original_cmd": cmd}])
return
if client.slot != location_player:
error_text = "CreateHints: Can only create hints for own items or own locations."
await ctx.send_msgs(client, [{"cmd": "InvalidPacket", "type": "arguments",
"text": error_text, "original_cmd": cmd}])
return
hints += collect_hint_location_id(ctx, client.team, location_player, location, status)
# As of writing this code, only_new=True does not update status for existing hints
ctx.notify_hints(client.team, hints, only_new=True, persist_even_if_found=True)
ctx.save()
elif cmd == 'UpdateHint':
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': elif cmd == 'StatusUpdate':
if client.no_locations and args["status"] == ClientStatus.CLIENT_GOAL: update_client_status(ctx, client, args["status"])
await ctx.send_msgs(client, [{'cmd': 'InvalidPacket', "type": "cmd",
"text": "Trackers can't register Goal Complete",
"original_cmd": cmd}])
else:
update_client_status(ctx, client, args["status"])
elif cmd == 'Say': elif cmd == 'Say':
if "text" not in args or type(args["text"]) is not str or not args["text"].isprintable(): if "text" not in args or type(args["text"]) is not str or not args["text"].isprintable():
@@ -2101,13 +1857,12 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
args["cmd"] = "SetReply" args["cmd"] = "SetReply"
value = ctx.stored_data.get(args["key"], args.get("default", 0)) value = ctx.stored_data.get(args["key"], args.get("default", 0))
args["original_value"] = copy.copy(value) args["original_value"] = copy.copy(value)
args["slot"] = client.slot
for operation in args["operations"]: for operation in args["operations"]:
func = modify_functions[operation["operation"]] func = modify_functions[operation["operation"]]
value = func(value, operation["value"]) value = func(value, operation["value"])
ctx.stored_data[args["key"]] = args["value"] = value ctx.stored_data[args["key"]] = args["value"] = value
targets = set(ctx.stored_data_notification_clients[args["key"]]) targets = set(ctx.stored_data_notification_clients[args["key"]])
if args.get("want_reply", False): if args.get("want_reply", True):
targets.add(client) targets.add(client)
if targets: if targets:
ctx.broadcast(targets, [args]) ctx.broadcast(targets, [args])
@@ -2176,10 +1931,8 @@ class ServerCommandProcessor(CommonCommandProcessor):
def _cmd_exit(self) -> bool: def _cmd_exit(self) -> bool:
"""Shutdown the server""" """Shutdown the server"""
try: self.ctx.server.ws_server.close()
self.ctx.server.ws_server.close() self.ctx.exit_event.set()
finally:
self.ctx.exit_event.set()
return True return True
@mark_raw @mark_raw
@@ -2286,8 +2039,6 @@ class ServerCommandProcessor(CommonCommandProcessor):
item_name, usable, response = get_intended_text(item_name, names) item_name, usable, response = get_intended_text(item_name, names)
if usable: if usable:
amount: int = int(amount) 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))] new_items = [NetworkItem(names[item_name], -1, 0) for _ in range(int(amount))]
send_items_to(self.ctx, team, slot, *new_items) send_items_to(self.ctx, team, slot, *new_items)
@@ -2359,9 +2110,9 @@ class ServerCommandProcessor(CommonCommandProcessor):
hints = [] hints = []
for item_name_from_group in self.ctx.item_name_groups[game][item]: for item_name_from_group in self.ctx.item_name_groups[game][item]:
if item_name_from_group in self.ctx.item_names_for_game(game): # ensure item has an ID if item_name_from_group in self.ctx.item_names_for_game(game): # ensure item has an ID
hints.extend(collect_hints(self.ctx, team, slot, item_name_from_group, HintStatus.HINT_PRIORITY)) hints.extend(collect_hints(self.ctx, team, slot, item_name_from_group))
else: # item name or id else: # item name or id
hints = collect_hints(self.ctx, team, slot, item, HintStatus.HINT_PRIORITY) hints = collect_hints(self.ctx, team, slot, item)
if hints: if hints:
self.ctx.notify_hints(team, hints) self.ctx.notify_hints(team, hints)
@@ -2395,17 +2146,14 @@ class ServerCommandProcessor(CommonCommandProcessor):
if usable: if usable:
if isinstance(location, int): if isinstance(location, int):
hints = collect_hint_location_id(self.ctx, team, slot, location, hints = collect_hint_location_id(self.ctx, team, slot, location)
HintStatus.HINT_UNSPECIFIED)
elif game in self.ctx.location_name_groups and location in self.ctx.location_name_groups[game]: elif game in self.ctx.location_name_groups and location in self.ctx.location_name_groups[game]:
hints = [] hints = []
for loc_name_from_group in self.ctx.location_name_groups[game][location]: 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): 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, hints.extend(collect_hint_location_name(self.ctx, team, slot, loc_name_from_group))
HintStatus.HINT_UNSPECIFIED))
else: else:
hints = collect_hint_location_name(self.ctx, team, slot, location, hints = collect_hint_location_name(self.ctx, team, slot, location)
HintStatus.HINT_UNSPECIFIED)
if hints: if hints:
self.ctx.notify_hints(team, hints) self.ctx.notify_hints(team, hints)
else: else:
@@ -2482,10 +2230,8 @@ async def console(ctx: Context):
def parse_args() -> argparse.Namespace: def parse_args() -> argparse.Namespace:
from settings import get_settings
parser = argparse.ArgumentParser() parser = argparse.ArgumentParser()
defaults = get_settings().server_options.as_dict() defaults = Utils.get_settings()["server_options"].as_dict()
parser.add_argument('multidata', nargs="?", default=defaults["multidata"]) parser.add_argument('multidata', nargs="?", default=defaults["multidata"])
parser.add_argument('--host', default=defaults["host"]) parser.add_argument('--host', default=defaults["host"])
parser.add_argument('--port', default=defaults["port"], type=int) parser.add_argument('--port', default=defaults["port"], type=int)
@@ -2497,8 +2243,6 @@ def parse_args() -> argparse.Namespace:
parser.add_argument('--cert_key', help="Path to SSL Certificate Key file") parser.add_argument('--cert_key', help="Path to SSL Certificate Key file")
parser.add_argument('--loglevel', default=defaults["loglevel"], parser.add_argument('--loglevel', default=defaults["loglevel"],
choices=['debug', 'info', 'warning', 'error', 'critical']) 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('--location_check_points', default=defaults["location_check_points"], type=int)
parser.add_argument('--hint_cost', default=defaults["hint_cost"], 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') parser.add_argument('--disable_item_cheat', default=defaults["disable_item_cheat"], action='store_true')
@@ -2579,9 +2323,7 @@ def load_server_cert(path: str, cert_key: typing.Optional[str]) -> "ssl.SSLConte
async def main(args: argparse.Namespace): async def main(args: argparse.Namespace):
Utils.init_logging(name="Server", Utils.init_logging("Server", loglevel=args.loglevel.lower())
loglevel=args.loglevel.lower(),
add_timestamp=args.logtime)
ctx = Context(args.host, args.port, args.server_password, args.password, args.location_check_points, 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.hint_cost, not args.disable_item_cheat, args.release_mode, args.collect_mode,

View File

@@ -1,25 +1,15 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Mapping, Sequence
import typing import typing
import enum import enum
import warnings import warnings
from json import JSONEncoder, JSONDecoder from json import JSONEncoder, JSONDecoder
if typing.TYPE_CHECKING: import websockets
from websockets import WebSocketServerProtocol as ServerConnection
from Utils import ByValue, Version 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): class JSONMessagePart(typing.TypedDict, total=False):
text: str text: str
# optional # optional
@@ -29,8 +19,6 @@ class JSONMessagePart(typing.TypedDict, total=False):
player: int player: int
# if type == item indicates item flags # if type == item indicates item flags
flags: int flags: int
# if type == hint_status
hint_status: HintStatus
class ClientStatus(ByValue, enum.IntEnum): class ClientStatus(ByValue, enum.IntEnum):
@@ -84,14 +72,13 @@ class NetworkSlot(typing.NamedTuple):
name: str name: str
game: str game: str
type: SlotType type: SlotType
group_members: Sequence[int] = () # only populated if type == group group_members: typing.Union[typing.List[int], typing.Tuple] = () # only populated if type == group
class NetworkItem(typing.NamedTuple): class NetworkItem(typing.NamedTuple):
item: int item: int
location: int location: int
player: int player: int
""" Sending player, except in LocationInfo (from LocationScouts), where it is the receiving player. """
flags: int = 0 flags: int = 0
@@ -107,27 +94,6 @@ def _scan_for_TypedTuples(obj: typing.Any) -> typing.Any:
return obj return obj
_base_types = str | int | bool | float | None | tuple["_base_types", ...] | dict["_base_types", "base_types"]
def convert_to_base_types(obj: typing.Any) -> _base_types:
if isinstance(obj, (tuple, list, set, frozenset)):
return tuple(convert_to_base_types(o) for o in obj)
elif isinstance(obj, dict):
return {convert_to_base_types(key): convert_to_base_types(value) for key, value in obj.items()}
elif obj is None or type(obj) in (str, int, float, bool):
return obj
# unwrap simple types to their base, such as StrEnum
elif isinstance(obj, str):
return str(obj)
elif isinstance(obj, int):
return int(obj)
elif isinstance(obj, float):
return float(obj)
else:
raise Exception(f"Cannot handle {type(obj)}")
_encode = JSONEncoder( _encode = JSONEncoder(
ensure_ascii=False, ensure_ascii=False,
check_circular=False, check_circular=False,
@@ -174,7 +140,7 @@ decode = JSONDecoder(object_hook=_object_hook).decode
class Endpoint: class Endpoint:
socket: "ServerConnection" socket: websockets.WebSocketServerProtocol
def __init__(self, socket): def __init__(self, socket):
self.socket = socket self.socket = socket
@@ -217,7 +183,6 @@ class JSONTypes(str, enum.Enum):
location_name = "location_name" location_name = "location_name"
location_id = "location_id" location_id = "location_id"
entrance_name = "entrance_name" entrance_name = "entrance_name"
hint_status = "hint_status"
class JSONtoTextParser(metaclass=HandlerMeta): class JSONtoTextParser(metaclass=HandlerMeta):
@@ -258,7 +223,7 @@ class JSONtoTextParser(metaclass=HandlerMeta):
def _handle_player_id(self, node: JSONMessagePart): def _handle_player_id(self, node: JSONMessagePart):
player = int(node["text"]) player = int(node["text"])
node["color"] = 'magenta' if self.ctx.slot_concerns_self(player) else 'yellow' node["color"] = 'magenta' if player == self.ctx.slot else 'yellow'
node["text"] = self.ctx.player_names[player] node["text"] = self.ctx.player_names[player]
return self._handle_color(node) return self._handle_color(node)
@@ -299,10 +264,6 @@ class JSONtoTextParser(metaclass=HandlerMeta):
node["color"] = 'blue' node["color"] = 'blue'
return self._handle_color(node) 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): class RawJSONtoTextParser(JSONtoTextParser):
def _handle_color(self, node: JSONMessagePart): def _handle_color(self, node: JSONMessagePart):
@@ -311,8 +272,7 @@ class RawJSONtoTextParser(JSONtoTextParser):
color_codes = {'reset': 0, 'bold': 1, 'underline': 4, 'black': 30, 'red': 31, 'green': 32, 'yellow': 33, 'blue': 34, 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, '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): def color_code(*args):
@@ -335,27 +295,6 @@ def add_json_location(parts: list, location_id: int, player: int = 0, **kwargs)
parts.append({"text": str(location_id), "player": player, "type": JSONTypes.location_id, **kwargs}) 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): class Hint(typing.NamedTuple):
receiving_player: int receiving_player: int
finding_player: int finding_player: int
@@ -364,21 +303,14 @@ class Hint(typing.NamedTuple):
found: bool found: bool
entrance: str = "" entrance: str = ""
item_flags: int = 0 item_flags: int = 0
status: HintStatus = HintStatus.HINT_UNSPECIFIED
def re_check(self, ctx, team) -> Hint: def re_check(self, ctx, team) -> Hint:
if self.found and self.status == HintStatus.HINT_FOUND: if self.found:
return self return self
found = self.location in ctx.location_checks[team, self.finding_player] found = self.location in ctx.location_checks[team, self.finding_player]
if found: if found:
return self._replace(found=found, status=HintStatus.HINT_FOUND) return Hint(self.receiving_player, self.finding_player, self.location, self.item, found, self.entrance,
return self self.item_flags)
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 return self
def __hash__(self): def __hash__(self):
@@ -400,7 +332,10 @@ class Hint(typing.NamedTuple):
else: else:
add_json_text(parts, "'s World") add_json_text(parts, "'s World")
add_json_text(parts, ". ") add_json_text(parts, ". ")
add_json_hint_status(parts, self.status) if self.found:
add_json_text(parts, "(found)", type="color", color="green")
else:
add_json_text(parts, "(not found)", type="color", color="red")
return {"cmd": "PrintJSON", "data": parts, "type": "Hint", return {"cmd": "PrintJSON", "data": parts, "type": "Hint",
"receiving": self.receiving_player, "receiving": self.receiving_player,
@@ -446,8 +381,6 @@ class _LocationStore(dict, typing.MutableMapping[int, typing.Dict[int, typing.Tu
checked = state[team, slot] checked = state[team, slot]
if not checked: if not checked:
# This optimizes the case where everyone connects to a fresh game at the same time. # 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 []
return [location_id for return [location_id for
location_id in self[slot] if location_id in self[slot] if
@@ -464,48 +397,12 @@ class _LocationStore(dict, typing.MutableMapping[int, typing.Dict[int, typing.Tu
location_id not in checked] location_id not in checked]
def get_remaining(self, state: typing.Dict[typing.Tuple[int, int], typing.Set[int]], team: int, slot: int def get_remaining(self, state: typing.Dict[typing.Tuple[int, int], typing.Set[int]], team: int, slot: int
) -> typing.List[typing.Tuple[int, int]]: ) -> typing.List[int]:
checked = state[team, slot] checked = state[team, slot]
player_locations = self[slot] player_locations = self[slot]
return sorted([(player_locations[location_id][1], player_locations[location_id][0]) for return sorted([player_locations[location_id][0] for
location_id in player_locations if location_id in player_locations if
location_id not in checked]) location_id not in checked])
class MinimumVersions(typing.TypedDict):
server: tuple[int, int, int]
clients: dict[int, tuple[int, int, int]]
class GamesPackage(typing.TypedDict, total=False):
item_name_groups: dict[str, list[str]]
item_name_to_id: dict[str, int]
location_name_groups: dict[str, list[str]]
location_name_to_id: dict[str, int]
checksum: str
class DataPackage(typing.TypedDict):
games: dict[str, GamesPackage]
class MultiData(typing.TypedDict):
slot_data: dict[int, Mapping[str, typing.Any]]
slot_info: dict[int, NetworkSlot]
connect_names: dict[str, tuple[int, int]]
locations: dict[int, dict[int, tuple[int, int, int]]]
checks_in_area: dict[int, dict[str, int | list[int]]]
server_options: dict[str, object]
er_hint_data: dict[int, dict[int, str]]
precollected_items: dict[int, list[int]]
precollected_hints: dict[int, set[Hint]]
version: tuple[int, int, int]
tags: list[str]
minimum_versions: MinimumVersions
seed_name: str
spheres: list[dict[int, set[int]]]
datapackage: dict[str, GamesPackage]
race_mode: int
if typing.TYPE_CHECKING: # type-check with pure python implementation until we have a typing stub if typing.TYPE_CHECKING: # type-check with pure python implementation until we have a typing stub

View File

@@ -1,6 +1,7 @@
import tkinter as tk import tkinter as tk
import argparse import argparse
import logging import logging
import random
import os import os
import zipfile import zipfile
from itertools import chain from itertools import chain
@@ -196,6 +197,7 @@ def set_icon(window):
def adjust(args): def adjust(args):
# Create a fake multiworld and OOTWorld to use as a base # Create a fake multiworld and OOTWorld to use as a base
multiworld = MultiWorld(1) multiworld = MultiWorld(1)
multiworld.per_slot_randoms = {1: random}
ootworld = OOTWorld(multiworld, 1) ootworld = OOTWorld(multiworld, 1)
# Set options in the fake OOTWorld # Set options in the fake OOTWorld
for name, option in chain(cosmetic_options.items(), sfx_options.items()): for name, option in chain(cosmetic_options.items(), sfx_options.items()):

View File

@@ -12,7 +12,6 @@ from CommonClient import CommonContext, server_loop, gui_enabled, \
import Utils import Utils
from Utils import async_start from Utils import async_start
from worlds import network_data_package from worlds import network_data_package
from worlds.oot import OOTWorld
from worlds.oot.Rom import Rom, compress_rom_file from worlds.oot.Rom import Rom, compress_rom_file
from worlds.oot.N64Patch import apply_patch_file from worlds.oot.N64Patch import apply_patch_file
from worlds.oot.Utils import data_path from worlds.oot.Utils import data_path
@@ -277,12 +276,11 @@ async def n64_sync_task(ctx: OoTContext):
except ConnectionRefusedError: except ConnectionRefusedError:
logger.debug("Connection Refused, Trying Again") logger.debug("Connection Refused, Trying Again")
ctx.n64_status = CONNECTION_REFUSED_STATUS ctx.n64_status = CONNECTION_REFUSED_STATUS
await asyncio.sleep(1)
continue continue
async def run_game(romfile): async def run_game(romfile):
auto_start = OOTWorld.settings.rom_start auto_start = Utils.get_options()["oot_options"].get("rom_start", True)
if auto_start is True: if auto_start is True:
import webbrowser import webbrowser
webbrowser.open(romfile) webbrowser.open(romfile)
@@ -297,7 +295,7 @@ async def patch_and_run_game(apz5_file):
decomp_path = base_name + '-decomp.z64' decomp_path = base_name + '-decomp.z64'
comp_path = base_name + '.z64' comp_path = base_name + '.z64'
# Load vanilla ROM, patch file, compress ROM # Load vanilla ROM, patch file, compress ROM
rom_file_name = OOTWorld.settings.rom_file rom_file_name = Utils.get_options()["oot_options"]["rom_file"]
rom = Rom(rom_file_name) rom = Rom(rom_file_name)
sub_file = None sub_file = None
@@ -348,7 +346,7 @@ if __name__ == '__main__':
import colorama import colorama
colorama.just_fix_windows_console() colorama.init()
asyncio.run(main()) asyncio.run(main())
colorama.deinit() colorama.deinit()

View File

@@ -1,7 +1,6 @@
from __future__ import annotations from __future__ import annotations
import abc import abc
import collections
import functools import functools
import logging import logging
import math import math
@@ -9,27 +8,20 @@ import numbers
import random import random
import typing import typing
import enum import enum
from collections import defaultdict
from copy import deepcopy from copy import deepcopy
from dataclasses import dataclass from dataclasses import dataclass
from schema import And, Optional, Or, Schema from schema import And, Optional, Or, Schema
from typing_extensions import Self from typing_extensions import Self
from Utils import get_file_safe_name, get_fuzzy_results, is_iterable_except_str, output_path from Utils import get_fuzzy_results, is_iterable_except_str
if typing.TYPE_CHECKING: if typing.TYPE_CHECKING:
from BaseClasses import MultiWorld, PlandoOptions from BaseClasses import PlandoOptions
from worlds.AutoWorld import World from worlds.AutoWorld import World
import pathlib import pathlib
def roll_percentage(percentage: int | float) -> bool:
"""Roll a percentage chance.
percentage is expected to be in range [0, 100]"""
return random.random() < (float(percentage) / 100)
class OptionError(ValueError): class OptionError(ValueError):
pass pass
@@ -144,7 +136,7 @@ class Option(typing.Generic[T], metaclass=AssembleOptions):
If this is False, the docstring is instead interpreted as plain text, and If this is False, the docstring is instead interpreted as plain text, and
displayed as-is on the WebHost with whitespace preserved. displayed as-is on the WebHost with whitespace preserved.
If this is None, it inherits the value of `WebWorld.rich_text_options_doc`. For If this is None, it inherits the value of `World.rich_text_options_doc`. For
backwards compatibility, this defaults to False, but worlds are encouraged to backwards compatibility, this defaults to False, but worlds are encouraged to
set it to True and use reStructuredText for their Option documentation. set it to True and use reStructuredText for their Option documentation.
@@ -494,30 +486,6 @@ class Choice(NumericOption):
else: else:
raise TypeError(f"Can't compare {self.__class__.__name__} with {other.__class__.__name__}") raise TypeError(f"Can't compare {self.__class__.__name__} with {other.__class__.__name__}")
def __lt__(self, other: typing.Union[Choice, int, str]):
if isinstance(other, str):
assert other in self.options, f"compared against an unknown string. {self} < {other}"
other = self.options[other]
return super(Choice, self).__lt__(other)
def __gt__(self, other: typing.Union[Choice, int, str]):
if isinstance(other, str):
assert other in self.options, f"compared against an unknown string. {self} > {other}"
other = self.options[other]
return super(Choice, self).__gt__(other)
def __le__(self, other: typing.Union[Choice, int, str]):
if isinstance(other, str):
assert other in self.options, f"compared against an unknown string. {self} <= {other}"
other = self.options[other]
return super(Choice, self).__le__(other)
def __ge__(self, other: typing.Union[Choice, int, str]):
if isinstance(other, str):
assert other in self.options, f"compared against an unknown string. {self} >= {other}"
other = self.options[other]
return super(Choice, self).__ge__(other)
__hash__ = Option.__hash__ # see https://docs.python.org/3/reference/datamodel.html#object.__hash__ __hash__ = Option.__hash__ # see https://docs.python.org/3/reference/datamodel.html#object.__hash__
@@ -527,7 +495,7 @@ class TextChoice(Choice):
def __init__(self, value: typing.Union[str, int]): def __init__(self, value: typing.Union[str, int]):
assert isinstance(value, str) or isinstance(value, 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 self.value = value
@property @property
@@ -648,17 +616,17 @@ class PlandoBosses(TextChoice, metaclass=BossMeta):
used_locations.append(location) used_locations.append(location)
used_bosses.append(boss) used_bosses.append(boss)
if not cls.valid_boss_name(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): 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): 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: else:
if cls.duplicate_bosses: if cls.duplicate_bosses:
if not cls.valid_boss_name(option): 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: else:
raise ValueError(f"'{option.title()}' is not formatted correctly.") raise ValueError(f"{option.title()} is not formatted correctly.")
@classmethod @classmethod
def can_place_boss(cls, boss: str, location: str) -> bool: def can_place_boss(cls, boss: str, location: str) -> bool:
@@ -720,9 +688,9 @@ class Range(NumericOption):
@classmethod @classmethod
def weighted_range(cls, text) -> Range: def weighted_range(cls, text) -> Range:
if text == "random-low": if text == "random-low":
return cls(cls.triangular(cls.range_start, cls.range_end, 0.0)) return cls(cls.triangular(cls.range_start, cls.range_end, cls.range_start))
elif text == "random-high": elif text == "random-high":
return cls(cls.triangular(cls.range_start, cls.range_end, 1.0)) return cls(cls.triangular(cls.range_start, cls.range_end, cls.range_end))
elif text == "random-middle": elif text == "random-middle":
return cls(cls.triangular(cls.range_start, cls.range_end)) return cls(cls.triangular(cls.range_start, cls.range_end))
elif text.startswith("random-range-"): elif text.startswith("random-range-"):
@@ -748,11 +716,11 @@ class Range(NumericOption):
f"{random_range[0]}-{random_range[1]} is outside allowed range " f"{random_range[0]}-{random_range[1]} is outside allowed range "
f"{cls.range_start}-{cls.range_end} for option {cls.__name__}") f"{cls.range_start}-{cls.range_end} for option {cls.__name__}")
if text.startswith("random-range-low"): if text.startswith("random-range-low"):
return cls(cls.triangular(random_range[0], random_range[1], 0.0)) return cls(cls.triangular(random_range[0], random_range[1], random_range[0]))
elif text.startswith("random-range-middle"): elif text.startswith("random-range-middle"):
return cls(cls.triangular(random_range[0], random_range[1])) return cls(cls.triangular(random_range[0], random_range[1]))
elif text.startswith("random-range-high"): elif text.startswith("random-range-high"):
return cls(cls.triangular(random_range[0], random_range[1], 1.0)) return cls(cls.triangular(random_range[0], random_range[1], random_range[1]))
else: else:
return cls(random.randint(random_range[0], random_range[1])) return cls(random.randint(random_range[0], random_range[1]))
@@ -770,16 +738,8 @@ class Range(NumericOption):
return str(self.value) return str(self.value)
@staticmethod @staticmethod
def triangular(lower: int, end: int, tri: float = 0.5) -> int: def triangular(lower: int, end: int, tri: typing.Optional[int] = None) -> int:
""" return int(round(random.triangular(lower, end, tri), 0))
Integer triangular distribution for `lower` inclusive to `end` inclusive.
Expects `lower <= end` and `0.0 <= tri <= 1.0`. The result of other inputs is undefined.
"""
# Use the continuous range [lower, end + 1) to produce an integer result in [lower, end].
# random.triangular is actually [a, b] and not [a, b), so there is a very small chance of getting exactly b even
# when a != b, so ensure the result is never more than `end`.
return min(end, math.floor(random.triangular(0.0, 1.0, tri) * (end - lower + 1) + lower))
class NamedRange(Range): class NamedRange(Range):
@@ -793,7 +753,7 @@ class NamedRange(Range):
elif value > self.range_end and value not in self.special_range_names.values(): 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__} " + 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}") f"and is also not one of the supported named special values: {self.special_range_names}")
# See docstring # See docstring
for key in self.special_range_names: for key in self.special_range_names:
if key != key.lower(): if key != key.lower():
@@ -826,22 +786,17 @@ class VerifyKeys(metaclass=FreezeValidKeys):
verify_location_name: bool = False verify_location_name: bool = False
value: typing.Any value: typing.Any
def verify_keys(self) -> None: @classmethod
if self.valid_keys: def verify_keys(cls, data: typing.Iterable[str]) -> None:
data = set(self.value) if cls.valid_keys:
dataset = set(word.casefold() for word in data) if self.valid_keys_casefold else set(data) data = set(data)
extra = dataset - self._valid_keys dataset = set(word.casefold() for word in data) if cls.valid_keys_casefold else set(data)
extra = dataset - cls._valid_keys
if extra: if extra:
raise OptionError( raise Exception(f"Found unexpected key {', '.join(extra)} in {cls}. "
f"Found unexpected key {', '.join(extra)} in {getattr(self, 'display_name', self)}. " f"Allowed keys: {cls._valid_keys}.")
f"Allowed keys: {self._valid_keys}."
)
def verify(self, world: typing.Type[World], player_name: str, plando_options: "PlandoOptions") -> None: 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: if self.convert_name_groups and self.verify_item_name:
new_value = type(self.value)() # empty container of whatever value is new_value = type(self.value)() # empty container of whatever value is
for item_name in self.value: for item_name in self.value:
@@ -856,21 +811,18 @@ class VerifyKeys(metaclass=FreezeValidKeys):
for item_name in self.value: for item_name in self.value:
if item_name not in world.item_names: if item_name not in world.item_names:
picks = get_fuzzy_results(item_name, world.item_names, limit=1) picks = get_fuzzy_results(item_name, world.item_names, limit=1)
raise Exception(f"Item '{item_name}' from option '{self}' " raise Exception(f"Item {item_name} from option {self} "
f"is not a valid item name from '{world.game}'. " f"is not a valid item name from {world.game}. "
f"Did you mean '{picks[0][0]}' ({picks[0][1]}% sure)") f"Did you mean '{picks[0][0]}' ({picks[0][1]}% sure)")
elif self.verify_location_name: elif self.verify_location_name:
for location_name in self.value: for location_name in self.value:
if location_name not in world.location_names: if location_name not in world.location_names:
picks = get_fuzzy_results(location_name, world.location_names, limit=1) picks = get_fuzzy_results(location_name, world.location_names, limit=1)
raise Exception(f"Location '{location_name}' from option '{self}' " raise Exception(f"Location {location_name} from option {self} "
f"is not a valid location name from '{world.game}'. " f"is not a valid location name from {world.game}. "
f"Did you mean '{picks[0][0]}' ({picks[0][1]}% sure)") 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]): class OptionDict(Option[typing.Dict[str, typing.Any]], VerifyKeys, typing.Mapping[str, typing.Any]):
default = {} default = {}
supports_weighting = False supports_weighting = False
@@ -881,6 +833,7 @@ class OptionDict(Option[typing.Dict[str, typing.Any]], VerifyKeys, typing.Mappin
@classmethod @classmethod
def from_any(cls, data: typing.Dict[str, typing.Any]) -> OptionDict: def from_any(cls, data: typing.Dict[str, typing.Any]) -> OptionDict:
if type(data) == dict: if type(data) == dict:
cls.verify_keys(data)
return cls(data) return cls(data)
else: else:
raise NotImplementedError(f"Cannot Convert from non-dictionary, got {type(data)}") raise NotImplementedError(f"Cannot Convert from non-dictionary, got {type(data)}")
@@ -889,57 +842,21 @@ class OptionDict(Option[typing.Dict[str, typing.Any]], VerifyKeys, typing.Mappin
return ", ".join(f"{key}: {v}" for key, v in value.items()) return ", ".join(f"{key}: {v}" for key, v in value.items())
def __getitem__(self, item: str) -> typing.Any: def __getitem__(self, item: str) -> typing.Any:
return self.value[item] return self.value.__getitem__(item)
def __iter__(self) -> typing.Iterator[str]: def __iter__(self) -> typing.Iterator[str]:
return iter(self.value) return self.value.__iter__()
def __len__(self) -> int: def __len__(self) -> int:
return len(self.value) return self.value.__len__()
# __getitem__ fallback fails for Counters, so we define this explicitly
def __contains__(self, item) -> bool:
return item in self.value
class OptionCounter(OptionDict): class ItemDict(OptionDict):
min: int | None = None
max: int | None = None
def __init__(self, value: dict[str, int]) -> None:
super(OptionCounter, self).__init__(collections.Counter(value))
def verify(self, world: type[World], player_name: str, plando_options: PlandoOptions) -> None:
super(OptionCounter, self).verify(world, player_name, plando_options)
range_errors = []
if self.max is not None:
range_errors += [
f"\"{key}: {value}\" is higher than maximum allowed value {self.max}."
for key, value in self.value.items() if value > self.max
]
if self.min is not None:
range_errors += [
f"\"{key}: {value}\" is lower than minimum allowed value {self.min}."
for key, value in self.value.items() if value < self.min
]
if range_errors:
range_errors = [f"For option {getattr(self, 'display_name', self)}:"] + range_errors
raise OptionError("\n".join(range_errors))
class ItemDict(OptionCounter):
verify_item_name = True verify_item_name = True
min = 0 def __init__(self, value: typing.Dict[str, int]):
if any(item_count < 1 for item_count in value.values()):
def __init__(self, value: dict[str, int]) -> None: raise Exception("Cannot have non-positive item counts.")
# 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) super(ItemDict, self).__init__(value)
@@ -962,6 +879,7 @@ class OptionList(Option[typing.List[typing.Any]], VerifyKeys):
@classmethod @classmethod
def from_any(cls, data: typing.Any): def from_any(cls, data: typing.Any):
if is_iterable_except_str(data): if is_iterable_except_str(data):
cls.verify_keys(data)
return cls(data) return cls(data)
return cls.from_text(str(data)) return cls.from_text(str(data))
@@ -987,6 +905,7 @@ class OptionSet(Option[typing.Set[str]], VerifyKeys):
@classmethod @classmethod
def from_any(cls, data: typing.Any): def from_any(cls, data: typing.Any):
if is_iterable_except_str(data): if is_iterable_except_str(data):
cls.verify_keys(data)
return cls(data) return cls(data)
return cls.from_text(str(data)) return cls.from_text(str(data))
@@ -1029,19 +948,6 @@ class PlandoTexts(Option[typing.List[PlandoText]], VerifyKeys):
self.value = [] self.value = []
logging.warning(f"The plando texts module is turned off, " logging.warning(f"The plando texts module is turned off, "
f"so text for {player_name} will be ignored.") 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 @classmethod
def from_any(cls, data: PlandoTextsFromAnyType) -> Self: def from_any(cls, data: PlandoTextsFromAnyType) -> Self:
@@ -1049,22 +955,10 @@ class PlandoTexts(Option[typing.List[PlandoText]], VerifyKeys):
if isinstance(data, typing.Iterable): if isinstance(data, typing.Iterable):
for text in data: for text in data:
if isinstance(text, typing.Mapping): if isinstance(text, typing.Mapping):
if roll_percentage(text.get("percentage", 100)): if random.random() < float(text.get("percentage", 100)/100):
at = text.get("at", None) at = text.get("at", None)
if at is not None: if at is not None:
if isinstance(at, dict):
if 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", []) 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): if isinstance(given_text, str):
given_text = [given_text] given_text = [given_text]
texts.append(PlandoText( texts.append(PlandoText(
@@ -1072,13 +966,12 @@ class PlandoTexts(Option[typing.List[PlandoText]], VerifyKeys):
given_text, given_text,
text.get("percentage", 100) text.get("percentage", 100)
)) ))
else:
raise OptionError("\"at\" must be a valid string or weighted list of strings!")
elif isinstance(text, PlandoText): elif isinstance(text, PlandoText):
if roll_percentage(text.percentage): if random.random() < float(text.percentage/100):
texts.append(text) texts.append(text)
else: else:
raise Exception(f"Cannot create plando text from non-dictionary type, got {type(text)}") raise Exception(f"Cannot create plando text from non-dictionary type, got {type(text)}")
cls.verify_keys([text.at for text in texts])
return cls(texts) return cls(texts)
else: else:
raise NotImplementedError(f"Cannot Convert from non-list, got {type(data)}") raise NotImplementedError(f"Cannot Convert from non-list, got {type(data)}")
@@ -1091,10 +984,10 @@ class PlandoTexts(Option[typing.List[PlandoText]], VerifyKeys):
yield from self.value yield from self.value
def __getitem__(self, index: typing.SupportsIndex) -> PlandoText: def __getitem__(self, index: typing.SupportsIndex) -> PlandoText:
return self.value[index] return self.value.__getitem__(index)
def __len__(self) -> int: def __len__(self) -> int:
return len(self.value) return self.value.__len__()
class ConnectionsMeta(AssembleOptions): class ConnectionsMeta(AssembleOptions):
@@ -1118,7 +1011,7 @@ class PlandoConnection(typing.NamedTuple):
entrance: str entrance: str
exit: str exit: str
direction: typing.Literal["entrance", "exit", "both"] # TODO: convert Direction to StrEnum once 3.10 is dropped direction: typing.Literal["entrance", "exit", "both"] # TODO: convert Direction to StrEnum once 3.8 is dropped
percentage: int = 100 percentage: int = 100
@@ -1184,11 +1077,11 @@ class PlandoConnections(Option[typing.List[PlandoConnection]], metaclass=Connect
used_entrances.append(entrance) used_entrances.append(entrance)
used_exits.append(exit) used_exits.append(exit)
if not cls.validate_entrance_name(entrance): if not cls.validate_entrance_name(entrance):
raise ValueError(f"'{entrance.title()}' is not a valid entrance.") raise ValueError(f"{entrance.title()} is not a valid entrance.")
if not cls.validate_exit_name(exit): if not cls.validate_exit_name(exit):
raise ValueError(f"'{exit.title()}' is not a valid exit.") raise ValueError(f"{exit.title()} is not a valid exit.")
if not cls.can_connect(entrance, exit): if not cls.can_connect(entrance, exit):
raise ValueError(f"Connection between '{entrance.title()}' and '{exit.title()}' is invalid.") raise ValueError(f"Connection between {entrance.title()} and {exit.title()} is invalid.")
@classmethod @classmethod
def from_any(cls, data: PlandoConFromAnyType) -> Self: def from_any(cls, data: PlandoConFromAnyType) -> Self:
@@ -1199,7 +1092,7 @@ class PlandoConnections(Option[typing.List[PlandoConnection]], metaclass=Connect
for connection in data: for connection in data:
if isinstance(connection, typing.Mapping): if isinstance(connection, typing.Mapping):
percentage = connection.get("percentage", 100) percentage = connection.get("percentage", 100)
if roll_percentage(percentage): if random.random() < float(percentage / 100):
entrance = connection.get("entrance", None) entrance = connection.get("entrance", None)
if is_iterable_except_str(entrance): if is_iterable_except_str(entrance):
entrance = random.choice(sorted(entrance)) entrance = random.choice(sorted(entrance))
@@ -1217,7 +1110,7 @@ class PlandoConnections(Option[typing.List[PlandoConnection]], metaclass=Connect
percentage percentage
)) ))
elif isinstance(connection, PlandoConnection): elif isinstance(connection, PlandoConnection):
if roll_percentage(connection.percentage): if random.random() < float(connection.percentage / 100):
value.append(connection) value.append(connection)
else: else:
raise Exception(f"Cannot create connection from non-Dict type, got {type(connection)}.") raise Exception(f"Cannot create connection from non-Dict type, got {type(connection)}.")
@@ -1241,7 +1134,7 @@ class PlandoConnections(Option[typing.List[PlandoConnection]], metaclass=Connect
connection.exit) for connection in value]) connection.exit) for connection in value])
def __getitem__(self, index: typing.SupportsIndex) -> PlandoConnection: def __getitem__(self, index: typing.SupportsIndex) -> PlandoConnection:
return self.value[index] return self.value.__getitem__(index)
def __iter__(self) -> typing.Iterator[PlandoConnection]: def __iter__(self) -> typing.Iterator[PlandoConnection]:
yield from self.value yield from self.value
@@ -1251,35 +1144,18 @@ class PlandoConnections(Option[typing.List[PlandoConnection]], metaclass=Connect
class Accessibility(Choice): class Accessibility(Choice):
""" """Set rules for reachability of your items/locations.
Set rules for reachability of your items/locations.
**Full:** ensure everything can be reached and acquired. - **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. - **Minimal:** ensure what is needed to reach your goal can be acquired.
""" """
display_name = "Accessibility" display_name = "Accessibility"
rich_text_doc = True rich_text_doc = True
option_full = 0 option_locations = 0
option_items = 1
option_minimal = 2 option_minimal = 2
alias_none = 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 default = 1
@@ -1322,48 +1198,35 @@ class CommonOptions(metaclass=OptionsMetaProperty):
progression_balancing: ProgressionBalancing progression_balancing: ProgressionBalancing
accessibility: Accessibility accessibility: Accessibility
def as_dict( def as_dict(self, *option_names: str, casing: str = "snake") -> typing.Dict[str, typing.Any]:
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] Returns a dictionary of [str, Option.value]
:param option_names: Names of the options to get the values of. :param option_names: names of the options to return
:param casing: Casing of the keys to return. Supports `snake`, `camel`, `pascal`, `kebab`. :param casing: case 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 = {} option_results = {}
for option_name in option_names: for option_name in option_names:
if option_name not in type(self).type_hints: if option_name 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
if casing == "snake": elif casing == "camel":
display_name = option_name split_name = [name.title() for name in option_name.split("_")]
elif casing == "camel": split_name[0] = split_name[0].lower()
split_name = [name.title() for name in option_name.split("_")] display_name = "".join(split_name)
split_name[0] = split_name[0].lower() elif casing == "pascal":
display_name = "".join(split_name) display_name = "".join([name.title() for name in option_name.split("_")])
elif casing == "pascal": elif casing == "kebab":
display_name = "".join([name.title() for name in option_name.split("_")]) display_name = option_name.replace("_", "-")
elif casing == "kebab": else:
display_name = option_name.replace("_", "-") 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)
option_results[display_name] = value
else: else:
raise ValueError(f"{casing} is invalid casing for as_dict. " raise ValueError(f"{option_name} not found in {tuple(type(self).type_hints)}")
"Valid names are 'snake', 'camel', 'pascal', 'kebab'.")
value = getattr(self, option_name).value
if isinstance(value, set):
value = sorted(value)
elif toggles_as_bools and issubclass(type(self).type_hints[option_name], Toggle):
value = bool(value)
option_results[display_name] = value
return option_results return option_results
@@ -1384,7 +1247,6 @@ class StartInventory(ItemDict):
verify_item_name = True verify_item_name = True
display_name = "Start Inventory" display_name = "Start Inventory"
rich_text_doc = True rich_text_doc = True
max = 10000
class StartInventoryPool(StartInventory): class StartInventoryPool(StartInventory):
@@ -1427,7 +1289,7 @@ class PriorityLocations(LocationSet):
class DeathLink(Toggle): class DeathLink(Toggle):
"""When you die, everyone who enabled death link dies. Of course, the reverse is true too.""" """When you die, everyone dies. Of course the reverse is true too."""
display_name = "Death Link" display_name = "Death Link"
rich_text_doc = True rich_text_doc = True
@@ -1459,8 +1321,8 @@ class ItemLinks(OptionList):
picks_group = get_fuzzy_results(item_name, world.item_name_groups.keys(), limit=1) 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 "" 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}' " raise Exception(f"Item {item_name} from item link {item_link} "
f"is not a valid item from '{world.game}' for '{pool_name}'. " 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}") f"Did you mean '{picks[0][0]}' ({picks[0][1]}% sure){picks_group}")
if allow_item_groups: if allow_item_groups:
pool |= world.item_name_groups.get(item_name, {item_name}) pool |= world.item_name_groups.get(item_name, {item_name})
@@ -1500,133 +1362,6 @@ class ItemLinks(OptionList):
link["item_pool"] = list(pool) link["item_pool"] = list(pool)
@dataclass(frozen=True)
class PlandoItem:
items: list[str] | dict[str, typing.Any]
locations: list[str]
world: int | str | bool | None | typing.Iterable[str] | set[int] = False
from_pool: bool = True
force: bool | typing.Literal["silent"] = "silent"
count: int | bool | dict[str, int] = False
percentage: int = 100
class PlandoItems(Option[typing.List[PlandoItem]]):
"""Generic items plando."""
default = ()
supports_weighting = False
display_name = "Plando Items"
def __init__(self, value: typing.Iterable[PlandoItem]) -> None:
self.value = list(deepcopy(value))
super().__init__()
@classmethod
def from_any(cls, data: typing.Any) -> Option[typing.List[PlandoItem]]:
if not isinstance(data, typing.Iterable):
raise OptionError(f"Cannot create plando items from non-Iterable type, got {type(data)}")
value: typing.List[PlandoItem] = []
for item in data:
if isinstance(item, typing.Mapping):
percentage = item.get("percentage", 100)
if not isinstance(percentage, int):
raise OptionError(f"Plando `percentage` has to be int, not {type(percentage)}.")
if not (0 <= percentage <= 100):
raise OptionError(f"Plando `percentage` has to be between 0 and 100 (inclusive) not {percentage}.")
if roll_percentage(percentage):
count = item.get("count", False)
items = item.get("items", [])
if not items:
items = item.get("item", None) # explicitly throw an error here if not present
if not items:
raise OptionError("You must specify at least one item to place items with plando.")
count = 1
if isinstance(items, str):
items = [items]
elif not isinstance(items, (dict, list)):
raise OptionError(f"Plando 'items' has to be string, list, or "
f"dictionary, not {type(items)}")
locations = item.get("locations", [])
if not locations:
locations = item.get("location", [])
if locations:
count = 1
else:
locations = ["Everywhere"]
if isinstance(locations, str):
locations = [locations]
if not isinstance(locations, list):
raise OptionError(f"Plando `location` has to be string or list, not {type(locations)}")
world = item.get("world", False)
from_pool = item.get("from_pool", True)
force = item.get("force", "silent")
if not isinstance(from_pool, bool):
raise OptionError(f"Plando 'from_pool' has to be true or false, not {from_pool!r}.")
if not (isinstance(force, bool) or force == "silent"):
raise OptionError(f"Plando `force` has to be true or false or `silent`, not {force!r}.")
value.append(PlandoItem(items, locations, world, from_pool, force, count, percentage))
elif isinstance(item, PlandoItem):
if roll_percentage(item.percentage):
value.append(item)
else:
raise OptionError(f"Cannot create plando item from non-Dict type, got {type(item)}.")
return cls(value)
def verify(self, world: typing.Type[World], player_name: str, plando_options: "PlandoOptions") -> None:
if not self.value:
return
from BaseClasses import PlandoOptions
if not (PlandoOptions.items & plando_options):
# plando is disabled but plando options were given so overwrite the options
self.value = []
logging.warning(f"The plando items module is turned off, "
f"so items for {player_name} will be ignored.")
else:
# filter down item groups
for plando in self.value:
# confirm a valid count
if isinstance(plando.count, dict):
if "min" in plando.count and "max" in plando.count:
if plando.count["min"] > plando.count["max"]:
raise OptionError("Plando cannot have count `min` greater than `max`.")
items_copy = plando.items.copy()
if isinstance(plando.items, dict):
for item in items_copy:
if item in world.item_name_groups:
value = plando.items.pop(item)
group = world.item_name_groups[item]
filtered_items = sorted(group.difference(list(plando.items.keys())))
if not filtered_items:
raise OptionError(f"Plando `items` contains the group \"{item}\" "
f"and every item in it. This is not allowed.")
if value is True:
for key in filtered_items:
plando.items[key] = True
else:
for key in random.choices(filtered_items, k=value):
plando.items[key] = plando.items.get(key, 0) + 1
else:
assert isinstance(plando.items, list) # pycharm can't figure out the hinting without the hint
for item in items_copy:
if item in world.item_name_groups:
plando.items.remove(item)
plando.items.extend(sorted(world.item_name_groups[item]))
@classmethod
def get_option_name(cls, value: list[PlandoItem]) -> str:
return ", ".join(["(%s: %s)" % (item.items, item.locations) for item in value]) #TODO: see what a better way to display would be
def __getitem__(self, index: typing.SupportsIndex) -> PlandoItem:
return self.value.__getitem__(index)
def __iter__(self) -> typing.Iterator[PlandoItem]:
yield from self.value
def __len__(self) -> int:
return len(self.value)
class Removed(FreeText): class Removed(FreeText):
"""This Option has been Removed.""" """This Option has been Removed."""
rich_text_doc = True rich_text_doc = True
@@ -1649,7 +1384,6 @@ class PerGameCommonOptions(CommonOptions):
exclude_locations: ExcludeLocations exclude_locations: ExcludeLocations
priority_locations: PriorityLocations priority_locations: PriorityLocations
item_links: ItemLinks item_links: ItemLinks
plando_items: PlandoItems
@dataclass @dataclass
@@ -1668,7 +1402,7 @@ class OptionGroup(typing.NamedTuple):
item_and_loc_options = [LocalItems, NonLocalItems, StartInventory, StartInventoryPool, StartHints, item_and_loc_options = [LocalItems, NonLocalItems, StartInventory, StartInventoryPool, StartHints,
StartLocationHints, ExcludeLocations, PriorityLocations, ItemLinks, PlandoItems] StartLocationHints, ExcludeLocations, PriorityLocations, ItemLinks]
""" """
Options that are always populated in "Item & Location Options" Option Group. Cannot be moved to another group. Options that are always populated in "Item & Location Options" Option Group. Cannot be moved to another group.
If desired, a custom "Item & Location Options" Option Group can be defined, but only for adding additional options to If desired, a custom "Item & Location Options" Option Group can be defined, but only for adding additional options to
@@ -1679,31 +1413,26 @@ it.
def get_option_groups(world: typing.Type[World], visibility_level: Visibility = Visibility.template) -> typing.Dict[ def get_option_groups(world: typing.Type[World], visibility_level: Visibility = Visibility.template) -> typing.Dict[
str, typing.Dict[str, typing.Type[Option[typing.Any]]]]: str, typing.Dict[str, typing.Type[Option[typing.Any]]]]:
"""Generates and returns a dictionary for the option groups of a specified world.""" """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()} option_groups = {option: option_group.name
for option_group in world.web.option_groups
ordered_groups = {group.name: group.options for group in world.web.option_groups} for option in option_group.options}
# add a default option group for uncategorized options to get thrown into # add a default option group for uncategorized options to get thrown into
if "Game Options" not in ordered_groups: ordered_groups = ["Game Options"]
grouped_options = set(option for group in ordered_groups.values() for option in group) [ordered_groups.append(group) for group in option_groups.values() if group not in ordered_groups]
ungrouped_options = [option for option in option_to_name if option not in grouped_options] grouped_options = {group: {} for group in ordered_groups}
# only add the game options group if we have ungrouped options for option_name, option in world.options_dataclass.type_hints.items():
if ungrouped_options: if visibility_level & option.visibility:
ordered_groups = {**{"Game Options": ungrouped_options}, **ordered_groups} grouped_options[option_groups.get(option, "Game Options")][option_name] = option
return { # if the world doesn't have any ungrouped options, this group will be empty so just remove it
group: { if not grouped_options["Game Options"]:
option_to_name[option]: option del grouped_options["Game Options"]
for option in group_options
if (visibility_level in option.visibility and option in option_to_name) return grouped_options
}
for group, group_options in ordered_groups.items()
}
def generate_yaml_templates(target_folder: typing.Union[str, "pathlib.Path"], generate_hidden: bool = True) -> None: def generate_yaml_templates(target_folder: typing.Union[str, "pathlib.Path"], generate_hidden: bool = True) -> None:
import os import os
from inspect import cleandoc
import yaml import yaml
from jinja2 import Template from jinja2 import Template
@@ -1742,60 +1471,46 @@ def generate_yaml_templates(target_folder: typing.Union[str, "pathlib.Path"], ge
# yaml dump may add end of document marker and newlines. # yaml dump may add end of document marker and newlines.
return yaml.dump(scalar).replace("...\n", "").strip() return yaml.dump(scalar).replace("...\n", "").strip()
with open(local_path("data", "options.yaml")) as f:
file_data = f.read()
template = Template(file_data)
for game_name, world in AutoWorldRegister.world_types.items(): for game_name, world in AutoWorldRegister.world_types.items():
if not world.hidden or generate_hidden: if not world.hidden or generate_hidden:
option_groups = get_option_groups(world) option_groups = get_option_groups(world)
with open(local_path("data", "options.yaml")) as f:
res = template.render( file_data = f.read()
res = Template(file_data).render(
option_groups=option_groups, option_groups=option_groups,
__version__=__version__, game=game_name, yaml_dump=yaml_dump_scalar, __version__=__version__, game=game_name, yaml_dump=yaml_dump_scalar,
dictify_range=dictify_range, dictify_range=dictify_range,
cleandoc=cleandoc,
) )
with open(os.path.join(target_folder, get_file_safe_name(game_name) + ".yaml"), "w", encoding="utf-8-sig") as f: del file_data
with open(os.path.join(target_folder, game_name + ".yaml"), "w", encoding="utf-8-sig") as f:
f.write(res) f.write(res)
def dump_player_options(multiworld: MultiWorld) -> None: if __name__ == "__main__":
from csv import DictWriter
game_players = defaultdict(list) from worlds.alttp.Options import Logic
for player, game in multiworld.game.items(): import argparse
game_players[game].append(player)
game_players = dict(sorted(game_players.items()))
output = [] map_shuffle = Toggle
per_game_option_names = [ compass_shuffle = Toggle
getattr(option, "display_name", option_key) key_shuffle = Toggle
for option_key, option in PerGameCommonOptions.type_hints.items() big_key_shuffle = Toggle
] hints = Toggle
all_option_names = per_game_option_names.copy() test = argparse.Namespace()
for game, players in game_players.items(): test.logic = Logic.from_text("no_logic")
game_option_names = per_game_option_names.copy() test.map_shuffle = map_shuffle.from_text("ON")
for player in players: test.hints = hints.from_text('OFF')
world = multiworld.worlds[player] try:
player_output = { test.logic = Logic.from_text("overworld_glitches_typo")
"Game": multiworld.game[player], except KeyError as e:
"Name": multiworld.get_player_name(player), print(e)
"ID": player, try:
} test.logic_owg = Logic.from_text("owg")
output.append(player_output) except KeyError as e:
for option_key, option in world.options_dataclass.type_hints.items(): print(e)
if option.visibility == Visibility.none: if test.map_shuffle:
continue print("map_shuffle is on")
display_name = getattr(option, "display_name", option_key) print(f"Hints are {bool(test.hints)}")
player_output[display_name] = getattr(world.options, option_key).current_option_name print(test)
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)

View File

@@ -7,19 +7,23 @@ Currently, the following games are supported:
* The Legend of Zelda: A Link to the Past * The Legend of Zelda: A Link to the Past
* Factorio * Factorio
* Minecraft
* Subnautica * Subnautica
* Slay the Spire
* Risk of Rain 2 * Risk of Rain 2
* The Legend of Zelda: Ocarina of Time * The Legend of Zelda: Ocarina of Time
* Timespinner * Timespinner
* Super Metroid * Super Metroid
* Secret of Evermore * Secret of Evermore
* Final Fantasy * Final Fantasy
* Rogue Legacy
* VVVVVV * VVVVVV
* Raft * Raft
* Super Mario 64 * Super Mario 64
* Meritous * Meritous
* Super Metroid/Link to the Past combo randomizer (SMZ3) * Super Metroid/Link to the Past combo randomizer (SMZ3)
* ChecksFinder * ChecksFinder
* ArchipIDLE
* Hollow Knight * Hollow Knight
* The Witness * The Witness
* Sonic Adventure 2: Battle * Sonic Adventure 2: Battle
@@ -39,6 +43,7 @@ Currently, the following games are supported:
* The Messenger * The Messenger
* Kingdom Hearts 2 * Kingdom Hearts 2
* The Legend of Zelda: Link's Awakening DX * The Legend of Zelda: Link's Awakening DX
* Clique
* Adventure * Adventure
* DLC Quest * DLC Quest
* Noita * Noita
@@ -58,6 +63,7 @@ Currently, the following games are supported:
* TUNIC * TUNIC
* Kirby's Dream Land 3 * Kirby's Dream Land 3
* Celeste 64 * Celeste 64
* Zork Grand Inquisitor
* Castlevania 64 * Castlevania 64
* A Short Hike * A Short Hike
* Yoshi's Island * Yoshi's Island
@@ -66,22 +72,6 @@ Currently, the following games are supported:
* Aquaria * Aquaria
* Yu-Gi-Oh! Ultimate Masters: World Championship Tournament 2006 * Yu-Gi-Oh! Ultimate Masters: World Championship Tournament 2006
* A Hat in Time * 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
For setup and instructions check out our [tutorials page](https://archipelago.gg/tutorial/). For setup and instructions check out our [tutorials page](https://archipelago.gg/tutorial/).
Downloads can be found at [Releases](https://github.com/ArchipelagoMW/Archipelago/releases), including compiled Downloads can be found at [Releases](https://github.com/ArchipelagoMW/Archipelago/releases), including compiled

View File

@@ -18,7 +18,6 @@ from json import loads, dumps
from CommonClient import CommonContext, server_loop, ClientCommandProcessor, gui_enabled, get_base_parser from CommonClient import CommonContext, server_loop, ClientCommandProcessor, gui_enabled, get_base_parser
import Utils import Utils
from settings import Settings
from Utils import async_start from Utils import async_start
from MultiServer import mark_raw from MultiServer import mark_raw
if typing.TYPE_CHECKING: if typing.TYPE_CHECKING:
@@ -244,9 +243,6 @@ class SNIContext(CommonContext):
# Once the games handled by SNIClient gets made to be remote items, # Once the games handled by SNIClient gets made to be remote items,
# this will no longer be needed. # this will no longer be needed.
async_start(self.send_msgs([{"cmd": "LocationScouts", "locations": list(new_locations)}])) 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: def run_gui(self) -> None:
from kvui import GameManager from kvui import GameManager
@@ -286,7 +282,7 @@ class SNESState(enum.IntEnum):
def launch_sni() -> None: def launch_sni() -> None:
sni_path = Settings.sni_options.sni_path sni_path = Utils.get_settings()["sni_options"]["sni_path"]
if not os.path.isdir(sni_path): if not os.path.isdir(sni_path):
sni_path = Utils.local_path(sni_path) sni_path = Utils.local_path(sni_path)
@@ -637,13 +633,7 @@ async def game_watcher(ctx: SNIContext) -> None:
if not ctx.client_handler: if not ctx.client_handler:
continue continue
try: rom_validated = await ctx.client_handler.validate_rom(ctx)
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): if not rom_validated or (ctx.auth and ctx.auth != ctx.rom):
snes_logger.warning("ROM change detected, please reconnect to the multiworld server") snes_logger.warning("ROM change detected, please reconnect to the multiworld server")
@@ -659,17 +649,12 @@ async def game_watcher(ctx: SNIContext) -> None:
perf_counter = time.perf_counter() perf_counter = time.perf_counter()
try: await ctx.client_handler.game_watcher(ctx)
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: async def run_game(romfile: str) -> None:
auto_start = Settings.sni_options.snes_rom_start auto_start = typing.cast(typing.Union[bool, str],
Utils.get_settings()["sni_options"].get("snes_rom_start", True))
if auto_start is True: if auto_start is True:
import webbrowser import webbrowser
webbrowser.open(romfile) webbrowser.open(romfile)
@@ -735,6 +720,6 @@ async def main() -> None:
if __name__ == '__main__': if __name__ == '__main__':
colorama.just_fix_windows_console() colorama.init()
asyncio.run(main()) asyncio.run(main())
colorama.deinit() colorama.deinit()

11
Starcraft2Client.py Normal file
View File

@@ -0,0 +1,11 @@
from __future__ import annotations
import ModuleUpdate
ModuleUpdate.update()
from worlds.sc2.Client import launch
import Utils
if __name__ == "__main__":
Utils.init_logging("Starcraft2Client", exception_logger="Client")
launch()

View File

@@ -29,7 +29,7 @@ class UndertaleCommandProcessor(ClientCommandProcessor):
def _cmd_patch(self): def _cmd_patch(self):
"""Patch the game. Only use this command if /auto_patch fails.""" """Patch the game. Only use this command if /auto_patch fails."""
if isinstance(self.ctx, UndertaleContext): if isinstance(self.ctx, UndertaleContext):
os.makedirs(name=Utils.user_path("Undertale"), exist_ok=True) os.makedirs(name=os.path.join(os.getcwd(), "Undertale"), exist_ok=True)
self.ctx.patch_game() self.ctx.patch_game()
self.output("Patched.") self.output("Patched.")
@@ -43,7 +43,7 @@ class UndertaleCommandProcessor(ClientCommandProcessor):
def _cmd_auto_patch(self, steaminstall: typing.Optional[str] = None): def _cmd_auto_patch(self, steaminstall: typing.Optional[str] = None):
"""Patch the game automatically.""" """Patch the game automatically."""
if isinstance(self.ctx, UndertaleContext): if isinstance(self.ctx, UndertaleContext):
os.makedirs(name=Utils.user_path("Undertale"), exist_ok=True) os.makedirs(name=os.path.join(os.getcwd(), "Undertale"), exist_ok=True)
tempInstall = steaminstall tempInstall = steaminstall
if not os.path.isfile(os.path.join(tempInstall, "data.win")): if not os.path.isfile(os.path.join(tempInstall, "data.win")):
tempInstall = None tempInstall = None
@@ -62,7 +62,7 @@ class UndertaleCommandProcessor(ClientCommandProcessor):
for file_name in os.listdir(tempInstall): for file_name in os.listdir(tempInstall):
if file_name != "steam_api.dll": if file_name != "steam_api.dll":
shutil.copy(os.path.join(tempInstall, file_name), shutil.copy(os.path.join(tempInstall, file_name),
Utils.user_path("Undertale", file_name)) os.path.join(os.getcwd(), "Undertale", file_name))
self.ctx.patch_game() self.ctx.patch_game()
self.output("Patching successful!") self.output("Patching successful!")
@@ -111,12 +111,12 @@ class UndertaleContext(CommonContext):
self.save_game_folder = os.path.expandvars(r"%localappdata%/UNDERTALE") self.save_game_folder = os.path.expandvars(r"%localappdata%/UNDERTALE")
def patch_game(self): def patch_game(self):
with open(Utils.user_path("Undertale", "data.win"), "rb") as f: with open(os.path.join(os.getcwd(), "Undertale", "data.win"), "rb") as f:
patchedFile = bsdiff4.patch(f.read(), undertale.data_path("patch.bsdiff")) patchedFile = bsdiff4.patch(f.read(), undertale.data_path("patch.bsdiff"))
with open(Utils.user_path("Undertale", "data.win"), "wb") as f: with open(os.path.join(os.getcwd(), "Undertale", "data.win"), "wb") as f:
f.write(patchedFile) f.write(patchedFile)
os.makedirs(name=Utils.user_path("Undertale", "Custom Sprites"), exist_ok=True) os.makedirs(name=os.path.join(os.getcwd(), "Undertale", "Custom Sprites"), exist_ok=True)
with open(os.path.expandvars(Utils.user_path("Undertale", "Custom Sprites", with open(os.path.expandvars(os.path.join(os.getcwd(), "Undertale", "Custom Sprites",
"Which Character.txt")), "w") as f: "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 " 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"]) "line other than this one.\n", "frisk"])
@@ -500,7 +500,7 @@ def main():
import colorama import colorama
colorama.just_fix_windows_console() colorama.init()
asyncio.run(_main()) asyncio.run(_main())
colorama.deinit() colorama.deinit()

225
Utils.py
View File

@@ -18,8 +18,8 @@ import warnings
from argparse import Namespace from argparse import Namespace
from settings import Settings, get_settings from settings import Settings, get_settings
from time import sleep from typing import BinaryIO, Coroutine, Optional, Set, Dict, Any, Union
from typing import BinaryIO, Coroutine, Optional, Set, Dict, Any, Union, TypeGuard from typing_extensions import TypeGuard
from yaml import load, load_all, dump from yaml import load, load_all, dump
try: try:
@@ -31,7 +31,6 @@ if typing.TYPE_CHECKING:
import tkinter import tkinter
import pathlib import pathlib
from BaseClasses import Region from BaseClasses import Region
import multiprocessing
def tuplize_version(version: str) -> Version: def tuplize_version(version: str) -> Version:
@@ -47,7 +46,7 @@ class Version(typing.NamedTuple):
return ".".join(str(item) for item in self) return ".".join(str(item) for item in self)
__version__ = "0.6.4" __version__ = "0.5.0"
version_tuple = tuplize_version(__version__) version_tuple = tuplize_version(__version__)
is_linux = sys.platform.startswith("linux") is_linux = sys.platform.startswith("linux")
@@ -114,8 +113,6 @@ def cache_self1(function: typing.Callable[[S, T], RetType]) -> typing.Callable[[
cache[arg] = res cache[arg] = res
return res return res
wrap.__defaults__ = function.__defaults__
return wrap return wrap
@@ -139,11 +136,8 @@ def local_path(*path: str) -> str:
local_path.cached_path = os.path.dirname(os.path.abspath(sys.argv[0])) local_path.cached_path = os.path.dirname(os.path.abspath(sys.argv[0]))
else: else:
import __main__ import __main__
if globals().get("__file__") and os.path.isfile(__file__): if hasattr(__main__, "__file__") and os.path.isfile(__main__.__file__):
# we are running in a normal Python environment # we are running in a normal Python environment
local_path.cached_path = os.path.dirname(os.path.abspath(__file__))
elif hasattr(__main__, "__file__") and os.path.isfile(__main__.__file__):
# we are running in a normal Python environment, but AP was imported weirdly
local_path.cached_path = os.path.dirname(os.path.abspath(__main__.__file__)) local_path.cached_path = os.path.dirname(os.path.abspath(__main__.__file__))
else: else:
# pray # pray
@@ -157,18 +151,7 @@ def home_path(*path: str) -> str:
if hasattr(home_path, 'cached_path'): if hasattr(home_path, 'cached_path'):
pass pass
elif sys.platform.startswith('linux'): elif sys.platform.startswith('linux'):
xdg_data_home = os.getenv('XDG_DATA_HOME', os.path.expanduser('~/.local/share')) home_path.cached_path = os.path.expanduser('~/Archipelago')
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) os.makedirs(home_path.cached_path, 0o700, exist_ok=True)
else: else:
# not implemented # not implemented
@@ -181,7 +164,7 @@ def user_path(*path: str) -> str:
"""Returns either local_path or home_path based on write permissions.""" """Returns either local_path or home_path based on write permissions."""
if hasattr(user_path, "cached_path"): if hasattr(user_path, "cached_path"):
pass pass
elif os.access(local_path(), os.W_OK) and not (is_macos and is_frozen()): elif os.access(local_path(), os.W_OK):
user_path.cached_path = local_path() user_path.cached_path = local_path()
else: else:
user_path.cached_path = home_path() user_path.cached_path = home_path()
@@ -230,12 +213,7 @@ def open_file(filename: typing.Union[str, "pathlib.Path"]) -> None:
from shutil import which from shutil import which
open_command = which("open") if is_macos else (which("xdg-open") or which("gnome-open") or which("kde-open")) open_command = which("open") if is_macos else (which("xdg-open") or which("gnome-open") or which("kde-open"))
assert open_command, "Didn't find program for open_file! Please report this together with system details." assert open_command, "Didn't find program for open_file! Please report this together with system details."
subprocess.call([open_command, filename])
env = os.environ
if "LD_LIBRARY_PATH" in env:
env = env.copy()
del env["LD_LIBRARY_PATH"] # exe is a system binary, so reset LD_LIBRARY_PATH
subprocess.call([open_command, filename], env=env)
# from https://gist.github.com/pypt/94d747fe5180851196eb#gistcomment-4015118 with some changes # from https://gist.github.com/pypt/94d747fe5180851196eb#gistcomment-4015118 with some changes
@@ -413,26 +391,13 @@ def get_adjuster_settings(game_name: str) -> Namespace:
@cache_argsless @cache_argsless
def get_unique_identifier(): def get_unique_identifier():
common_path = cache_path("common.json") uuid = persistent_load().get("client", {}).get("uuid", None)
try:
with open(common_path) as f:
common_file = json.load(f)
uuid = common_file.get("uuid", None)
except FileNotFoundError:
common_file = {}
uuid = None
if uuid: if uuid:
return uuid return uuid
from uuid import uuid4 import uuid
uuid = str(uuid4()) uuid = uuid.getnode()
common_file["uuid"] = uuid persistent_store("client", "uuid", uuid)
cache_folder = os.path.dirname(common_path)
os.makedirs(cache_folder, exist_ok=True)
with open(common_path, "w") as f:
json.dump(common_file, f, separators=(",", ":"))
return uuid return uuid
@@ -454,16 +419,11 @@ class RestrictedUnpickler(pickle.Unpickler):
def find_class(self, module: str, name: str) -> type: def find_class(self, module: str, name: str) -> type:
if module == "builtins" and name in safe_builtins: if module == "builtins" and name in safe_builtins:
return getattr(builtins, name) return getattr(builtins, name)
# used by OptionCounter
# necessary because the actual Options class instances are pickled when transfered to WebHost generation pool
if module == "collections" and name == "Counter":
return collections.Counter
# used by MultiServer -> savegame/multidata # used by MultiServer -> savegame/multidata
if module == "NetUtils" and name in {"NetworkItem", "ClientStatus", "Hint", if module == "NetUtils" and name in {"NetworkItem", "ClientStatus", "Hint", "SlotType", "NetworkSlot"}:
"SlotType", "NetworkSlot", "HintStatus"}:
return getattr(self.net_utils_module, name) return getattr(self.net_utils_module, name)
# Options and Plando are unpickled by WebHost -> Generate # Options and Plando are unpickled by WebHost -> Generate
if module == "worlds.generic" and name == "PlandoItem": if module == "worlds.generic" and name in {"PlandoItem", "PlandoConnection"}:
if not self.generic_properties_module: if not self.generic_properties_module:
self.generic_properties_module = importlib.import_module("worlds.generic") self.generic_properties_module = importlib.import_module("worlds.generic")
return getattr(self.generic_properties_module, name) return getattr(self.generic_properties_module, name)
@@ -474,8 +434,7 @@ class RestrictedUnpickler(pickle.Unpickler):
else: else:
mod = importlib.import_module(module) mod = importlib.import_module(module)
obj = getattr(mod, name) obj = getattr(mod, name)
if issubclass(obj, (self.options_module.Option, self.options_module.PlandoConnection, if issubclass(obj, self.options_module.Option):
self.options_module.PlandoText)):
return obj return obj
# Forbid everything else. # Forbid everything else.
raise pickle.UnpicklingError(f"global '{module}.{name}' is forbidden") raise pickle.UnpicklingError(f"global '{module}.{name}' is forbidden")
@@ -486,18 +445,6 @@ def restricted_loads(s: bytes) -> Any:
return RestrictedUnpickler(io.BytesIO(s)).load() return RestrictedUnpickler(io.BytesIO(s)).load()
def restricted_dumps(obj: Any) -> bytes:
"""Helper function analogous to pickle.dumps()."""
s = pickle.dumps(obj)
# Assert that the string can be successfully loaded by restricted_loads
try:
restricted_loads(s)
except pickle.UnpicklingError as e:
raise pickle.PicklingError(e) from e
return s
class ByValue: class ByValue:
""" """
Mixin for enums to pickle value instead of name (restores pre-3.11 behavior). Use as left-most parent. Mixin for enums to pickle value instead of name (restores pre-3.11 behavior). Use as left-most parent.
@@ -536,9 +483,9 @@ def get_text_after(text: str, start: str) -> str:
loglevel_mapping = {'error': logging.ERROR, 'info': logging.INFO, 'warning': logging.WARNING, 'debug': logging.DEBUG} 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, def init_logging(name: str, loglevel: typing.Union[str, int] = logging.INFO, write_mode: str = "w",
write_mode: str = "w", log_format: str = "[%(name)s at %(asctime)s]: %(message)s", log_format: str = "[%(name)s at %(asctime)s]: %(message)s",
add_timestamp: bool = False, exception_logger: typing.Optional[str] = None): exception_logger: typing.Optional[str] = None):
import datetime import datetime
loglevel: int = loglevel_mapping.get(loglevel, loglevel) loglevel: int = loglevel_mapping.get(loglevel, loglevel)
log_folder = user_path("logs") log_folder = user_path("logs")
@@ -565,18 +512,12 @@ def init_logging(name: str, loglevel: typing.Union[str, int] = logging.INFO,
def filter(self, record: logging.LogRecord) -> bool: def filter(self, record: logging.LogRecord) -> bool:
return self.condition(record) return self.condition(record)
file_handler.addFilter(Filter("NoStream", lambda record: not getattr(record, "NoFile", False))) 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) root_logger.addHandler(file_handler)
if sys.stdout: if 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 = logging.StreamHandler(sys.stdout)
stream_handler.addFilter(Filter("NoFile", lambda record: not getattr(record, "NoStream", False))) stream_handler.addFilter(Filter("NoFile", lambda record: not getattr(record, "NoStream", False)))
if add_timestamp:
stream_handler.setFormatter(formatter)
root_logger.addHandler(stream_handler) root_logger.addHandler(stream_handler)
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
# Relay unhandled exceptions to logger. # Relay unhandled exceptions to logger.
if not getattr(sys.excepthook, "_wrapped", False): # skip if already modified if not getattr(sys.excepthook, "_wrapped", False): # skip if already modified
@@ -587,8 +528,7 @@ def init_logging(name: str, loglevel: typing.Union[str, int] = logging.INFO,
sys.__excepthook__(exc_type, exc_value, exc_traceback) sys.__excepthook__(exc_type, exc_value, exc_traceback)
return return
logging.getLogger(exception_logger).exception("Uncaught exception", 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) return orig_hook(exc_type, exc_value, exc_traceback)
handle_exception._wrapped = True handle_exception._wrapped = True
@@ -611,7 +551,7 @@ def init_logging(name: str, loglevel: typing.Union[str, int] = logging.INFO,
import platform import platform
logging.info( logging.info(
f"Archipelago ({__version__}) logging initialized" f"Archipelago ({__version__}) logging initialized"
f" on {platform.platform()} process {os.getpid()}" f" on {platform.platform()}"
f" running Python {sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}" f" running Python {sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}"
f"{' (frozen)' if is_frozen() else ''}" f"{' (frozen)' if is_frozen() else ''}"
) )
@@ -627,8 +567,6 @@ def stream_input(stream: typing.TextIO, queue: "asyncio.Queue[str]"):
else: else:
if text: if text:
queue.put_nowait(text) queue.put_nowait(text)
else:
sleep(0.01) # non-blocking stream
from threading import Thread from threading import Thread
thread = Thread(target=queuer, name=f"Stream handler for {stream.name}", daemon=True) thread = Thread(target=queuer, name=f"Stream handler for {stream.name}", daemon=True)
@@ -675,8 +613,6 @@ def get_fuzzy_results(input_word: str, word_list: typing.Collection[str], limit:
import jellyfish import jellyfish
def get_fuzzy_ratio(word1: str, word2: str) -> float: def get_fuzzy_ratio(word1: str, word2: str) -> float:
if word1 == word2:
return 1.01
return (1 - jellyfish.damerau_levenshtein_distance(word1.lower(), word2.lower()) return (1 - jellyfish.damerau_levenshtein_distance(word1.lower(), word2.lower())
/ max(len(word1), len(word2))) / max(len(word1), len(word2)))
@@ -697,10 +633,8 @@ def get_intended_text(input_text: str, possible_answers) -> typing.Tuple[str, bo
picks = get_fuzzy_results(input_text, possible_answers, limit=2) picks = get_fuzzy_results(input_text, possible_answers, limit=2)
if len(picks) > 1: if len(picks) > 1:
dif = picks[0][1] - picks[1][1] dif = picks[0][1] - picks[1][1]
if picks[0][1] == 101: if picks[0][1] == 100:
return picks[0][0], True, "Perfect Match" return picks[0][0], True, "Perfect Match"
elif picks[0][1] == 100:
return picks[0][0], True, "Case Insensitive Perfect Match"
elif picks[0][1] < 75: elif picks[0][1] < 75:
return picks[0][0], False, f"Didn't find something that closely matches '{input_text}', " \ return picks[0][0], False, f"Didn't find something that closely matches '{input_text}', " \
f"did you mean '{picks[0][0]}'? ({picks[0][1]}% sure)" f"did you mean '{picks[0][0]}'? ({picks[0][1]}% sure)"
@@ -730,43 +664,25 @@ def get_input_text_from_response(text: str, command: str) -> typing.Optional[str
return None 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 _run_for_stdout(*args: str):
env = os.environ
if "LD_LIBRARY_PATH" in env:
env = env.copy()
del env["LD_LIBRARY_PATH"] # exe is a system binary, so reset LD_LIBRARY_PATH
return subprocess.run(args, capture_output=True, text=True, env=env).stdout.split("\n", 1)[0] or None
def open_filename(title: str, filetypes: typing.Iterable[typing.Tuple[str, typing.Iterable[str]]], suggest: str = "") \ def open_filename(title: str, filetypes: typing.Iterable[typing.Tuple[str, typing.Iterable[str]]], suggest: str = "") \
-> typing.Optional[str]: -> typing.Optional[str]:
logging.info(f"Opening file input dialog for {title}.") logging.info(f"Opening file input dialog for {title}.")
def run(*args: str):
return subprocess.run(args, capture_output=True, text=True).stdout.split("\n", 1)[0] or None
if is_linux: if is_linux:
# prefer native dialog # prefer native dialog
from shutil import which from shutil import which
kdialog = which("kdialog") kdialog = which("kdialog")
if kdialog: if kdialog:
k_filters = '|'.join((f'{text} (*{" *".join(ext)})' for (text, ext) in filetypes)) k_filters = '|'.join((f'{text} (*{" *".join(ext)})' for (text, ext) in filetypes))
return _run_for_stdout(kdialog, f"--title={title}", "--getopenfilename", suggest or ".", k_filters) return run(kdialog, f"--title={title}", "--getopenfilename", suggest or ".", k_filters)
zenity = which("zenity") zenity = which("zenity")
if zenity: if zenity:
z_filters = (f'--file-filter={text} ({", ".join(ext)}) | *{" *".join(ext)}' for (text, ext) in filetypes) z_filters = (f'--file-filter={text} ({", ".join(ext)}) | *{" *".join(ext)}' for (text, ext) in filetypes)
selection = (f"--filename={suggest}",) if suggest else () selection = (f"--filename={suggest}",) if suggest else ()
return _run_for_stdout(zenity, f"--title={title}", "--file-selection", *z_filters, *selection) return run(zenity, f"--title={title}", "--file-selection", *z_filters, *selection)
# fall back to tk # fall back to tk
try: try:
@@ -777,13 +693,6 @@ def open_filename(title: str, filetypes: typing.Iterable[typing.Tuple[str, typin
f'This attempt was made because open_filename was used for "{title}".') f'This attempt was made because open_filename was used for "{title}".')
raise e raise e
else: 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_open_filename, args=(res, title, filetypes, suggest)).start()
return res.get()
try: try:
root = tkinter.Tk() root = tkinter.Tk()
except tkinter.TclError: except tkinter.TclError:
@@ -793,25 +702,22 @@ def open_filename(title: str, filetypes: typing.Iterable[typing.Tuple[str, typin
initialfile=suggest or None) 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]: def open_directory(title: str, suggest: str = "") -> typing.Optional[str]:
def run(*args: str):
return subprocess.run(args, capture_output=True, text=True).stdout.split("\n", 1)[0] or None
if is_linux: if is_linux:
# prefer native dialog # prefer native dialog
from shutil import which from shutil import which
kdialog = which("kdialog") kdialog = which("kdialog")
if kdialog: if kdialog:
return _run_for_stdout(kdialog, f"--title={title}", "--getexistingdirectory", return run(kdialog, f"--title={title}", "--getexistingdirectory",
os.path.abspath(suggest) if suggest else ".") os.path.abspath(suggest) if suggest else ".")
zenity = which("zenity") zenity = which("zenity")
if zenity: if zenity:
z_filters = ("--directory",) z_filters = ("--directory",)
selection = (f"--filename={os.path.abspath(suggest)}/",) if suggest else () selection = (f"--filename={os.path.abspath(suggest)}/",) if suggest else ()
return _run_for_stdout(zenity, f"--title={title}", "--file-selection", *z_filters, *selection) return run(zenity, f"--title={title}", "--file-selection", *z_filters, *selection)
# fall back to tk # fall back to tk
try: try:
@@ -819,16 +725,9 @@ def open_directory(title: str, suggest: str = "") -> typing.Optional[str]:
import tkinter.filedialog import tkinter.filedialog
except Exception as e: except Exception as e:
logging.error('Could not load tkinter, which is likely not installed. ' logging.error('Could not load tkinter, which is likely not installed. '
f'This attempt was made because open_directory was used for "{title}".') f'This attempt was made because open_filename was used for "{title}".')
raise e raise e
else: 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_open_directory, args=(res, title, suggest)).start()
return res.get()
try: try:
root = tkinter.Tk() root = tkinter.Tk()
except tkinter.TclError: except tkinter.TclError:
@@ -838,6 +737,15 @@ def open_directory(title: str, suggest: str = "") -> typing.Optional[str]:
def messagebox(title: str, text: str, error: bool = False) -> None: def messagebox(title: str, text: str, error: bool = False) -> None:
def run(*args: str):
return subprocess.run(args, capture_output=True, text=True).stdout.split("\n", 1)[0] or None
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(): if is_kivy_running():
from kvui import MessageBox from kvui import MessageBox
MessageBox(title, text, error).open() MessageBox(title, text, error).open()
@@ -848,10 +756,10 @@ def messagebox(title: str, text: str, error: bool = False) -> None:
from shutil import which from shutil import which
kdialog = which("kdialog") kdialog = which("kdialog")
if kdialog: if kdialog:
return _run_for_stdout(kdialog, f"--title={title}", "--error" if error else "--msgbox", text) return run(kdialog, f"--title={title}", "--error" if error else "--msgbox", text)
zenity = which("zenity") zenity = which("zenity")
if zenity: if zenity:
return _run_for_stdout(zenity, f"--title={title}", f"--text={text}", "--error" if error else "--info") return run(zenity, f"--title={title}", f"--text={text}", "--error" if error else "--info")
elif is_windows: elif is_windows:
import ctypes import ctypes
@@ -903,7 +811,7 @@ def async_start(co: Coroutine[None, None, typing.Any], name: Optional[str] = Non
Use this to start a task when you don't keep a reference to it or immediately await it, Use this to start a task when you don't keep a reference to it or immediately await it,
to prevent early garbage collection. "fire-and-forget" to prevent early garbage collection. "fire-and-forget"
""" """
# https://docs.python.org/3.11/library/asyncio-task.html#asyncio.create_task # https://docs.python.org/3.10/library/asyncio-task.html#asyncio.create_task
# Python docs: # Python docs:
# ``` # ```
# Important: Save a reference to the result of [asyncio.create_task], # Important: Save a reference to the result of [asyncio.create_task],
@@ -916,10 +824,11 @@ def async_start(co: Coroutine[None, None, typing.Any], name: Optional[str] = Non
task.add_done_callback(_faf_tasks.discard) task.add_done_callback(_faf_tasks.discard)
def deprecate(message: str, add_stacklevels: int = 0): def deprecate(message: str):
if __debug__: if __debug__:
raise Exception(message) raise Exception(message)
warnings.warn(message, stacklevel=2 + add_stacklevels) import warnings
warnings.warn(message)
class DeprecateDict(dict): class DeprecateDict(dict):
@@ -933,22 +842,23 @@ class DeprecateDict(dict):
def __getitem__(self, item: Any) -> Any: def __getitem__(self, item: Any) -> Any:
if self.should_error: if self.should_error:
deprecate(self.log_message, add_stacklevels=1) deprecate(self.log_message)
elif __debug__: elif __debug__:
warnings.warn(self.log_message, stacklevel=2) import warnings
warnings.warn(self.log_message)
return super().__getitem__(item) return super().__getitem__(item)
def _extend_freeze_support() -> None: def _extend_freeze_support() -> None:
"""Extend multiprocessing.freeze_support() to also work on Non-Windows and without setting spawn method first.""" """Extend multiprocessing.freeze_support() to also work on Non-Windows for spawn."""
# original upstream issue: https://github.com/python/cpython/issues/76327 # upstream issue: https://github.com/python/cpython/issues/76327
# code based on https://github.com/pyinstaller/pyinstaller/blob/develop/PyInstaller/hooks/rthooks/pyi_rth_multiprocessing.py#L26 # code based on https://github.com/pyinstaller/pyinstaller/blob/develop/PyInstaller/hooks/rthooks/pyi_rth_multiprocessing.py#L26
import multiprocessing import multiprocessing
import multiprocessing.spawn import multiprocessing.spawn
def _freeze_support() -> None: def _freeze_support() -> None:
"""Minimal freeze_support. Only apply this if frozen.""" """Minimal freeze_support. Only apply this if frozen."""
from subprocess import _args_from_interpreter_flags # noqa from subprocess import _args_from_interpreter_flags
# Prevent `spawn` from trying to read `__main__` in from the main script # Prevent `spawn` from trying to read `__main__` in from the main script
multiprocessing.process.ORIGINAL_DIR = None multiprocessing.process.ORIGINAL_DIR = None
@@ -956,7 +866,8 @@ def _extend_freeze_support() -> None:
# Handle the first process that MP will create # Handle the first process that MP will create
if ( if (
len(sys.argv) >= 2 and sys.argv[-2] == '-c' and sys.argv[-1].startswith(( len(sys.argv) >= 2 and sys.argv[-2] == '-c' and sys.argv[-1].startswith((
'from multiprocessing.resource_tracker import main', 'from multiprocessing.semaphore_tracker import main', # Py<3.8
'from multiprocessing.resource_tracker import main', # Py>=3.8
'from multiprocessing.forkserver import main' 'from multiprocessing.forkserver import main'
)) and set(sys.argv[1:-2]) == set(_args_from_interpreter_flags()) )) and set(sys.argv[1:-2]) == set(_args_from_interpreter_flags())
): ):
@@ -975,26 +886,20 @@ def _extend_freeze_support() -> None:
multiprocessing.spawn.spawn_main(**kwargs) multiprocessing.spawn.spawn_main(**kwargs)
sys.exit() sys.exit()
def _noop() -> None: if not is_windows and is_frozen():
pass multiprocessing.freeze_support = multiprocessing.spawn.freeze_support = _freeze_support
multiprocessing.freeze_support = multiprocessing.spawn.freeze_support = _freeze_support if is_frozen() else _noop
def freeze_support() -> None: def freeze_support() -> None:
"""This now only calls multiprocessing.freeze_support since we are patching freeze_support on module load.""" """This behaves like multiprocessing.freeze_support but also works on Non-Windows."""
import multiprocessing import multiprocessing
_extend_freeze_support()
deprecate("Use multiprocessing.freeze_support() instead")
multiprocessing.freeze_support() multiprocessing.freeze_support()
_extend_freeze_support()
def visualize_regions(root_region: Region, file_name: str, *, def visualize_regions(root_region: Region, file_name: str, *,
show_entrance_names: bool = False, show_locations: bool = True, show_other_regions: bool = True, show_entrance_names: bool = False, show_locations: bool = True, show_other_regions: bool = True,
linetype_ortho: bool = True, regions_to_highlight: set[Region] | None = None) -> None: linetype_ortho: bool = True) -> None:
"""Visualize the layout of a world as a PlantUML diagram. """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 root_region: The region from which to start the diagram from. (Usually the "Menu" region of your world.)
@@ -1010,22 +915,16 @@ def visualize_regions(root_region: Region, file_name: str, *,
Items without ID will be shown in italics. 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 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 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: Example usage in World code:
from Utils import visualize_regions from Utils import visualize_regions
state = self.multiworld.get_all_state(False) visualize_regions(self.multiworld.get_region("Menu", self.player), "my_world.puml")
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: Example usage in Main code:
from Utils import visualize_regions from Utils import visualize_regions
for player in multiworld.player_ids: for player in multiworld.player_ids:
visualize_regions(multiworld.get_region("Menu", player), f"{multiworld.get_out_file_name_base(player)}.puml") 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" assert root_region.multiworld, "The multiworld attribute of root_region has to be filled"
from BaseClasses import Entrance, Item, Location, LocationProgressType, MultiWorld, Region from BaseClasses import Entrance, Item, Location, LocationProgressType, MultiWorld, Region
from collections import deque from collections import deque
@@ -1078,7 +977,7 @@ def visualize_regions(root_region: Region, file_name: str, *,
uml.append(f"\"{fmt(region)}\" : {{field}} {lock}{fmt(location)}") uml.append(f"\"{fmt(region)}\" : {{field}} {lock}{fmt(location)}")
def visualize_region(region: Region) -> None: def visualize_region(region: Region) -> None:
uml.append(f"class \"{fmt(region)}\" {'#00FF00' if region in regions_to_highlight else ''}") uml.append(f"class \"{fmt(region)}\"")
if show_locations: if show_locations:
visualize_locations(region) visualize_locations(region)
visualize_exits(region) visualize_exits(region)

View File

@@ -2,15 +2,14 @@ from __future__ import annotations
import atexit import atexit
import os import os
import pkgutil
import sys import sys
import asyncio import asyncio
import random import random
import typing import shutil
from typing import Tuple, List, Iterable, Dict from typing import Tuple, List, Iterable, Dict
from . import WargrooveWorld from worlds.wargroove import WargrooveWorld
from .Items import item_table, faction_table, CommanderData, ItemData from worlds.wargroove.Items import item_table, faction_table, CommanderData, ItemData
import ModuleUpdate import ModuleUpdate
ModuleUpdate.update() ModuleUpdate.update()
@@ -22,7 +21,7 @@ import logging
if __name__ == "__main__": if __name__ == "__main__":
Utils.init_logging("WargrooveClient", exception_logger="Client") Utils.init_logging("WargrooveClient", exception_logger="Client")
from NetUtils import ClientStatus from NetUtils import NetworkItem, ClientStatus
from CommonClient import gui_enabled, logger, get_base_parser, ClientCommandProcessor, \ from CommonClient import gui_enabled, logger, get_base_parser, ClientCommandProcessor, \
CommonContext, server_loop CommonContext, server_loop
@@ -30,34 +29,6 @@ wg_logger = logging.getLogger("WG")
class WargrooveClientCommandProcessor(ClientCommandProcessor): class WargrooveClientCommandProcessor(ClientCommandProcessor):
def _cmd_sacrifice_summon(self):
"""Toggles sacrifices and summons On/Off"""
if isinstance(self.ctx, WargrooveContext):
self.ctx.has_sacrifice_summon = not self.ctx.has_sacrifice_summon
if self.ctx.has_sacrifice_summon:
self.output(f"Sacrifices and summons are enabled.")
else:
unit_summon_response_file = os.path.join(self.ctx.game_communication_path, "unitSummonResponse")
if os.path.exists(unit_summon_response_file):
os.remove(unit_summon_response_file)
self.output(f"Sacrifices and summons are disabled.")
def _cmd_deathlink(self):
"""Toggles deathlink On/Off"""
if isinstance(self.ctx, WargrooveContext):
self.ctx.has_death_link = not self.ctx.has_death_link
Utils.async_start(self.ctx.update_death_link(self.ctx.has_death_link), name="Update Deathlink")
if self.ctx.has_death_link:
death_link_send_file = os.path.join(self.ctx.game_communication_path, "deathLinkSend")
if os.path.exists(death_link_send_file):
os.remove(death_link_send_file)
self.output(f"Deathlink enabled.")
else:
death_link_receive_file = os.path.join(self.ctx.game_communication_path, "deathLinkReceive")
if os.path.exists(death_link_receive_file):
os.remove(death_link_receive_file)
self.output(f"Deathlink disabled.")
def _cmd_resync(self): def _cmd_resync(self):
"""Manually trigger a resync.""" """Manually trigger a resync."""
self.output(f"Syncing items.") self.output(f"Syncing items.")
@@ -87,11 +58,6 @@ class WargrooveContext(CommonContext):
commander_defense_boost_multiplier: int = 0 commander_defense_boost_multiplier: int = 0
income_boost_multiplier: int = 0 income_boost_multiplier: int = 0
starting_groove_multiplier: float starting_groove_multiplier: float
has_death_link: bool = False
has_sacrifice_summon: bool = True
player_stored_units_key: str = ""
ai_stored_units_key: str = ""
max_stored_units: int = 1000
faction_item_ids = { faction_item_ids = {
'Starter': 0, 'Starter': 0,
'Cherrystone': 52025, 'Cherrystone': 52025,
@@ -105,31 +71,6 @@ class WargrooveContext(CommonContext):
'Income Boost': 52023, 'Income Boost': 52023,
'Commander Defense Boost': 52024, 'Commander Defense Boost': 52024,
} }
unit_classes = {
"archer",
"ballista",
"balloon",
"dog",
"dragon",
"giant",
"harpoonship",
"harpy",
"knight",
"mage",
"merman",
"rifleman",
"soldier",
"spearman",
"thief",
"thief_with_gold",
"travelboat",
"trebuchet",
"turtle",
"villager",
"wagon",
"warship",
"witch",
}
def __init__(self, server_address, password): def __init__(self, server_address, password):
super(WargrooveContext, self).__init__(server_address, password) super(WargrooveContext, self).__init__(server_address, password)
@@ -137,80 +78,31 @@ class WargrooveContext(CommonContext):
self.syncing = False self.syncing = False
self.awaiting_bridge = False self.awaiting_bridge = False
# self.game_communication_path: files go in this path to pass data between us and the actual game # self.game_communication_path: files go in this path to pass data between us and the actual game
game_options = WargrooveWorld.settings
# Validate the AppData directory with Wargroove save data.
# By default, Windows sets an environment variable we can leverage.
# However, other OSes don't usually have this value set, so we need to rely on a settings value instead.
appdata_wargroove = None
if "appdata" in os.environ: if "appdata" in os.environ:
appdata_wargroove = os.environ['appdata'] options = Utils.get_options()
else: root_directory = os.path.join(options["wargroove_options"]["root_directory"])
try: data_directory = os.path.join("lib", "worlds", "wargroove", "data")
appdata_wargroove = game_options.save_directory dev_data_directory = os.path.join("worlds", "wargroove", "data")
except FileNotFoundError: appdata_wargroove = os.path.expandvars(os.path.join("%APPDATA%", "Chucklefish", "Wargroove"))
print_error_and_close("WargrooveClient couldn't detect a path to the AppData folder.\n" if not os.path.isfile(os.path.join(root_directory, "win64_bin", "wargroove64.exe")):
"Unable to infer required game_communication_path.\n" print_error_and_close("WargrooveClient couldn't find wargroove64.exe. "
"Try setting the \"save_directory\" value in your local options file " "Unable to infer required game_communication_path")
"to the AppData folder containing your Wargroove saves.") self.game_communication_path = os.path.join(root_directory, "AP")
appdata_wargroove = os.path.expandvars(os.path.join(appdata_wargroove, "Chucklefish", "Wargroove")) if not os.path.exists(self.game_communication_path):
if not os.path.isdir(appdata_wargroove): os.makedirs(self.game_communication_path)
print_error_and_close(f"WargrooveClient couldn't find Wargroove data in your AppData folder.\n" self.remove_communication_files()
f"Looked in \"{appdata_wargroove}\".\n" atexit.register(self.remove_communication_files)
f"If you haven't yet booted the game at least once, boot Wargroove " if not os.path.isdir(appdata_wargroove):
f"and then close it to attempt to fix this error.\n" print_error_and_close("WargrooveClient couldn't find Wargoove in appdata!"
f"If the AppData folder above seems wrong, try setting the " "Boot Wargroove and then close it to attempt to fix this error")
f"\"save_directory\" value in your local options file " if not os.path.isdir(data_directory):
f"to the AppData folder containing your Wargroove saves.") data_directory = dev_data_directory
if not os.path.isdir(data_directory):
# Check for the Wargroove game executable path.
# This should always be set regardless of the OS.
root_directory = game_options["root_directory"]
if not os.path.isfile(os.path.join(root_directory, "win64_bin", "wargroove64.exe")):
print_error_and_close(f"WargrooveClient couldn't find wargroove64.exe in "
f"\"{root_directory}/win64_bin/\".\n"
f"Unable to infer required game_communication_path.\n"
f"Please verify the \"root_directory\" value in your local "
f"options file is set correctly.")
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")
mods_directory = os.path.join(appdata_wargroove, "mods", "ArchipelagoMod")
save_directory = os.path.join(appdata_wargroove, "save")
# Wargroove doesn't always create the mods directory, so we have to do it
if not os.path.isdir(mods_directory):
os.makedirs(mods_directory)
resources = ["data/mods/ArchipelagoMod/maps.dat",
"data/mods/ArchipelagoMod/mod.dat",
"data/mods/ArchipelagoMod/modAssets.dat",
"data/save/campaign-c40a6e5b0cdf86ddac03b276691c483d.cmp",
"data/save/campaign-c40a6e5b0cdf86ddac03b276691c483d.cmp.bak"]
file_paths = [os.path.join(mods_directory, "maps.dat"),
os.path.join(mods_directory, "mod.dat"),
os.path.join(mods_directory, "modAssets.dat"),
os.path.join(save_directory, "campaign-c40a6e5b0cdf86ddac03b276691c483d.cmp"),
os.path.join(save_directory, "campaign-c40a6e5b0cdf86ddac03b276691c483d.cmp.bak")]
for resource, destination in zip(resources, file_paths):
file_data = pkgutil.get_data("worlds.wargroove", resource)
if file_data is None:
print_error_and_close("WargrooveClient couldn't find Wargoove mod and save files in install!") print_error_and_close("WargrooveClient couldn't find Wargoove mod and save files in install!")
with open(destination, 'wb') as f: shutil.copytree(data_directory, appdata_wargroove, dirs_exist_ok=True)
f.write(file_data) else:
print_error_and_close("WargrooveClient couldn't detect system type. "
def on_deathlink(self, data: typing.Dict[str, typing.Any]) -> None: "Unable to infer required game_communication_path")
with open(os.path.join(self.game_communication_path, "deathLinkReceive"), 'w+') as f:
text = data.get("cause", "")
if text:
f.write(f"DeathLink: {text}")
else:
f.write(f"DeathLink: Received from {data['source']}")
super(WargrooveContext, self).on_deathlink(data)
async def server_auth(self, password_requested: bool = False): async def server_auth(self, password_requested: bool = False):
if password_requested and not self.password: if password_requested and not self.password:
@@ -246,25 +138,20 @@ class WargrooveContext(CommonContext):
def on_package(self, cmd: str, args: dict): def on_package(self, cmd: str, args: dict):
if cmd in {"Connected"}: if cmd in {"Connected"}:
slot_data = args["slot_data"]
self.has_death_link = slot_data.get("death_link", False)
filename = f"AP_settings.json" filename = f"AP_settings.json"
with open(os.path.join(self.game_communication_path, filename), 'w') as f: with open(os.path.join(self.game_communication_path, filename), 'w') as f:
json.dump(slot_data, f) slot_data = args["slot_data"]
json.dump(args["slot_data"], f)
self.can_choose_commander = slot_data["can_choose_commander"] self.can_choose_commander = slot_data["can_choose_commander"]
print('can choose commander:', self.can_choose_commander) print('can choose commander:', self.can_choose_commander)
self.starting_groove_multiplier = slot_data["starting_groove_multiplier"] self.starting_groove_multiplier = slot_data["starting_groove_multiplier"]
self.income_boost_multiplier = slot_data["income_boost"] self.income_boost_multiplier = slot_data["income_boost"]
self.commander_defense_boost_multiplier = slot_data["commander_defense_boost"] self.commander_defense_boost_multiplier = slot_data["commander_defense_boost"]
f.close()
for ss in self.checked_locations: for ss in self.checked_locations:
filename = f"send{ss}" filename = f"send{ss}"
with open(os.path.join(self.game_communication_path, filename), 'w') as f: with open(os.path.join(self.game_communication_path, filename), 'w') as f:
pass f.close()
self.player_stored_units_key = f"wargroove_player_units_{self.team}"
self.ai_stored_units_key = f"wargroove_ai_units_{self.team}"
self.set_notify(self.player_stored_units_key, self.ai_stored_units_key)
self.update_commander_data() self.update_commander_data()
self.ui.update_tracker() self.ui.update_tracker()
@@ -274,6 +161,7 @@ class WargrooveContext(CommonContext):
filename = f"seed{i}" filename = f"seed{i}"
with open(os.path.join(self.game_communication_path, filename), 'w') as f: with open(os.path.join(self.game_communication_path, filename), 'w') as f:
f.write(str(random.randint(0, 4294967295))) f.write(str(random.randint(0, 4294967295)))
f.close()
if cmd in {"RoomInfo"}: if cmd in {"RoomInfo"}:
self.seed_name = args["seed_name"] self.seed_name = args["seed_name"]
@@ -301,6 +189,7 @@ class WargrooveContext(CommonContext):
f.write(f"{item_count * self.commander_defense_boost_multiplier}") f.write(f"{item_count * self.commander_defense_boost_multiplier}")
else: else:
f.write(f"{item_count}") f.write(f"{item_count}")
f.close()
print_filename = f"AP_{str(network_item.item)}.item.print" print_filename = f"AP_{str(network_item.item)}.item.print"
print_path = os.path.join(self.game_communication_path, print_filename) print_path = os.path.join(self.game_communication_path, print_filename)
@@ -311,6 +200,7 @@ class WargrooveContext(CommonContext):
self.item_names.lookup_in_game(network_item.item) + self.item_names.lookup_in_game(network_item.item) +
" from " + " from " +
self.player_names[network_item.player]) self.player_names[network_item.player])
f.close()
self.update_commander_data() self.update_commander_data()
self.ui.update_tracker() self.ui.update_tracker()
@@ -319,16 +209,22 @@ class WargrooveContext(CommonContext):
for ss in self.checked_locations: for ss in self.checked_locations:
filename = f"send{ss}" filename = f"send{ss}"
with open(os.path.join(self.game_communication_path, filename), 'w') as f: with open(os.path.join(self.game_communication_path, filename), 'w') as f:
pass f.close()
def run_gui(self): def run_gui(self):
"""Import kivy UI system and start running it as self.ui_task.""" """Import kivy UI system and start running it as self.ui_task."""
from kvui import GameManager, HoverBehavior, ServerToolTip from kvui import GameManager, HoverBehavior, ServerToolTip
from kivymd.uix.tab import MDTabsItem, MDTabsItemText from kivy.uix.tabbedpanel import TabbedPanelItem
from kivy.lang import Builder from kivy.lang import Builder
from kivy.uix.button import Button
from kivy.uix.togglebutton import ToggleButton from kivy.uix.togglebutton import ToggleButton
from kivy.uix.boxlayout import BoxLayout 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.uix.label import Label
from kivy.properties import ColorProperty
from kivy.uix.image import Image
import pkgutil import pkgutil
class TrackerLayout(BoxLayout): class TrackerLayout(BoxLayout):
@@ -371,7 +267,9 @@ class WargrooveContext(CommonContext):
def build(self): def build(self):
container = super().build() container = super().build()
self.add_client_tab("Wargroove", self.build_tracker()) panel = TabbedPanelItem(text="Wargroove")
panel.content = self.build_tracker()
self.tabs.add_widget(panel)
return container return container
def build_tracker(self) -> TrackerLayout: def build_tracker(self) -> TrackerLayout:
@@ -495,75 +393,32 @@ class WargrooveContext(CommonContext):
async def game_watcher(ctx: WargrooveContext): async def game_watcher(ctx: WargrooveContext):
from worlds.wargroove.Locations import location_table
while not ctx.exit_event.is_set(): while not ctx.exit_event.is_set():
try: if ctx.syncing == True:
if ctx.syncing == True: sync_msg = [{'cmd': 'Sync'}]
sync_msg = [{'cmd': 'Sync'}] if ctx.locations_checked:
if ctx.locations_checked: sync_msg.append({"cmd": "LocationChecks", "locations": list(ctx.locations_checked)})
sync_msg.append({"cmd": "LocationChecks", "locations": list(ctx.locations_checked)}) await ctx.send_msgs(sync_msg)
await ctx.send_msgs(sync_msg) ctx.syncing = False
ctx.syncing = False sending = []
sending = [] victory = False
victory = False for root, dirs, files in os.walk(ctx.game_communication_path):
for root, dirs, files in os.walk(ctx.game_communication_path): for file in files:
for file in files: if file.find("send") > -1:
if file == "deathLinkSend" and ctx.has_death_link: st = file.split("send", -1)[1]
with open(os.path.join(ctx.game_communication_path, file), 'r') as f: sending = sending+[(int(st))]
failed_mission = f.read() os.remove(os.path.join(ctx.game_communication_path, file))
if ctx.slot is not None: if file.find("victory") > -1:
await ctx.send_death(f"{ctx.player_names[ctx.slot]} failed {failed_mission}") victory = True
os.remove(os.path.join(ctx.game_communication_path, file)) os.remove(os.path.join(ctx.game_communication_path, file))
if file.find("send") > -1: ctx.locations_checked = sending
st = file.split("send", -1)[1] message = [{"cmd": 'LocationChecks', "locations": sending}]
sending = sending+[(int(st))] await ctx.send_msgs(message)
os.remove(os.path.join(ctx.game_communication_path, file)) if not ctx.finished_game and victory:
if file.find("victory") > -1: await ctx.send_msgs([{"cmd": "StatusUpdate", "status": ClientStatus.CLIENT_GOAL}])
victory = True ctx.finished_game = True
os.remove(os.path.join(ctx.game_communication_path, file)) await asyncio.sleep(0.1)
if file == "unitSacrifice" or file == "unitSacrificeAI":
if ctx.has_sacrifice_summon:
stored_units_key = ctx.player_stored_units_key
if file == "unitSacrificeAI":
stored_units_key = ctx.ai_stored_units_key
with open(os.path.join(ctx.game_communication_path, file), 'r') as f:
unit_class = f.read()
message = [{"cmd": 'Set', "key": stored_units_key,
"default": [],
"want_reply": True,
"operations": [{"operation": "add", "value": [unit_class[:64]]}]}]
await ctx.send_msgs(message)
os.remove(os.path.join(ctx.game_communication_path, file))
if file == "unitSummonRequestAI" or file == "unitSummonRequest":
if ctx.has_sacrifice_summon:
stored_units_key = ctx.player_stored_units_key
if file == "unitSummonRequestAI":
stored_units_key = ctx.ai_stored_units_key
with open(os.path.join(ctx.game_communication_path, "unitSummonResponse"), 'w') as f:
if stored_units_key in ctx.stored_data:
stored_units = ctx.stored_data[stored_units_key]
if stored_units is None:
stored_units = []
wg1_stored_units = [unit for unit in stored_units if unit in ctx.unit_classes]
if len(wg1_stored_units) != 0:
summoned_unit = random.choice(wg1_stored_units)
message = [{"cmd": 'Set', "key": stored_units_key,
"default": [],
"want_reply": True,
"operations": [{"operation": "remove", "value": summoned_unit[:64]}]}]
await ctx.send_msgs(message)
f.write(summoned_unit)
os.remove(os.path.join(ctx.game_communication_path, file))
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)
except Exception as err:
logger.warn("Exception in communication thread, a check may not have been sent: " + str(err))
def print_error_and_close(msg): def print_error_and_close(msg):
@@ -571,9 +426,8 @@ def print_error_and_close(msg):
Utils.messagebox("Error", msg, error=True) Utils.messagebox("Error", msg, error=True)
sys.exit(1) sys.exit(1)
def launch(*launch_args: str): if __name__ == '__main__':
async def main(): async def main(args):
args = parser.parse_args(launch_args)
ctx = WargrooveContext(args.connect, args.password) ctx = WargrooveContext(args.connect, args.password)
ctx.server_task = asyncio.create_task(server_loop(ctx), name="server loop") ctx.server_task = asyncio.create_task(server_loop(ctx), name="server loop")
if gui_enabled: if gui_enabled:
@@ -593,6 +447,7 @@ def launch(*launch_args: str):
parser = get_base_parser(description="Wargroove Client, for text interfacing.") parser = get_base_parser(description="Wargroove Client, for text interfacing.")
colorama.just_fix_windows_console() args, rest = parser.parse_known_args()
asyncio.run(main()) colorama.init()
asyncio.run(main(args))
colorama.deinit() colorama.deinit()

View File

@@ -1,4 +1,3 @@
import argparse
import os import os
import multiprocessing import multiprocessing
import logging import logging
@@ -12,12 +11,11 @@ ModuleUpdate.update()
# in case app gets imported by something like gunicorn # in case app gets imported by something like gunicorn
import Utils import Utils
import settings import settings
from Utils import get_file_safe_name
if typing.TYPE_CHECKING: if typing.TYPE_CHECKING:
from flask import Flask from flask import Flask
Utils.local_path.cached_path = os.path.dirname(__file__) Utils.local_path.cached_path = os.path.dirname(__file__) or "." # py3.8 is not abs. remove "." when dropping 3.8
settings.no_gui = True settings.no_gui = True
configpath = os.path.abspath("config.yaml") configpath = os.path.abspath("config.yaml")
if not os.path.exists(configpath): # fall back to config.yaml in home if not os.path.exists(configpath): # fall back to config.yaml in home
@@ -33,15 +31,6 @@ def get_app() -> "Flask":
import yaml import yaml
app.config.from_file(configpath, yaml.safe_load) app.config.from_file(configpath, yaml.safe_load)
logging.info(f"Updated config from {configpath}") 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"]: if not app.config["HOST_ADDRESS"]:
logging.info("Getting public IP, as HOST_ADDRESS is empty.") logging.info("Getting public IP, as HOST_ADDRESS is empty.")
app.config["HOST_ADDRESS"] = Utils.get_public_ipv4() app.config["HOST_ADDRESS"] = Utils.get_public_ipv4()
@@ -54,15 +43,16 @@ def get_app() -> "Flask":
return app return app
def copy_tutorials_files_to_static() -> None: def create_ordered_tutorials_file() -> typing.List[typing.Dict[str, typing.Any]]:
import json
import shutil import shutil
import zipfile import zipfile
from werkzeug.utils import secure_filename
zfile: zipfile.ZipInfo zfile: zipfile.ZipInfo
from worlds.AutoWorld import AutoWorldRegister from worlds.AutoWorld import AutoWorldRegister
worlds = {} worlds = {}
data = []
for game, world in AutoWorldRegister.world_types.items(): for game, world in AutoWorldRegister.world_types.items():
if hasattr(world.web, 'tutorials') and (not world.hidden or game == 'Archipelago'): if hasattr(world.web, 'tutorials') and (not world.hidden or game == 'Archipelago'):
worlds[game] = world worlds[game] = world
@@ -71,7 +61,7 @@ def copy_tutorials_files_to_static() -> None:
shutil.rmtree(base_target_path, ignore_errors=True) shutil.rmtree(base_target_path, ignore_errors=True)
for game, world in worlds.items(): for game, world in worlds.items():
# copy files from world's docs folder to the generated folder # copy files from world's docs folder to the generated folder
target_path = os.path.join(base_target_path, secure_filename(game)) target_path = os.path.join(base_target_path, game)
os.makedirs(target_path, exist_ok=True) os.makedirs(target_path, exist_ok=True)
if world.zip_path: if world.zip_path:
@@ -84,14 +74,45 @@ def copy_tutorials_files_to_static() -> None:
for zfile in zf.infolist(): for zfile in zf.infolist():
if not zfile.is_dir() and "/docs/" in zfile.filename: if not zfile.is_dir() and "/docs/" in zfile.filename:
zfile.filename = os.path.basename(zfile.filename) zfile.filename = os.path.basename(zfile.filename)
with open(os.path.join(target_path, secure_filename(zfile.filename)), "wb") as f: zf.extract(zfile, target_path)
f.write(zf.read(zfile))
else: else:
source_path = Utils.local_path(os.path.dirname(world.__file__), "docs") source_path = Utils.local_path(os.path.dirname(world.__file__), "docs")
files = os.listdir(source_path) files = os.listdir(source_path)
for file in files: for file in files:
shutil.copyfile(Utils.local_path(source_path, file), shutil.copyfile(Utils.local_path(source_path, file), Utils.local_path(target_path, file))
Utils.local_path(target_path, secure_filename(file)))
# build a json tutorial dict per game
game_data = {'gameTitle': game, 'tutorials': []}
for tutorial in world.web.tutorials:
# build dict for the json file
current_tutorial = {
'name': tutorial.tutorial_name,
'description': tutorial.description,
'files': [{
'language': tutorial.language,
'filename': game + '/' + tutorial.file_name,
'link': f'{game}/{tutorial.link}',
'authors': tutorial.authors
}]
}
# check if the name of the current guide exists already
for guide in game_data['tutorials']:
if guide and tutorial.tutorial_name == guide['name']:
guide['files'].append(current_tutorial['files'][0])
break
else:
game_data['tutorials'].append(current_tutorial)
data.append(game_data)
with open(Utils.local_path("WebHostLib", "static", "generated", "tutorials.json"), 'w', encoding='utf-8-sig') as json_target:
generic_data = {}
for games in data:
if 'Archipelago' in games['gameTitle']:
generic_data = data.pop(data.index(games))
sorted_data = [generic_data] + Utils.title_sorted(data, key=lambda entry: entry["gameTitle"])
json.dump(sorted_data, json_target, indent=2, ensure_ascii=False)
return sorted_data
if __name__ == "__main__": if __name__ == "__main__":
@@ -99,18 +120,18 @@ if __name__ == "__main__":
multiprocessing.set_start_method('spawn') multiprocessing.set_start_method('spawn')
logging.basicConfig(format='[%(asctime)s] %(message)s', level=logging.INFO) logging.basicConfig(format='[%(asctime)s] %(message)s', level=logging.INFO)
from WebHostLib.lttpsprites import update_sprites_lttp
from WebHostLib.autolauncher import autohost, autogen, stop from WebHostLib.autolauncher import autohost, autogen, stop
from WebHostLib.options import create as create_options_files from WebHostLib.options import create as create_options_files
try: try:
from WebHostLib.lttpsprites import update_sprites_lttp
update_sprites_lttp() update_sprites_lttp()
except Exception as e: except Exception as e:
logging.exception(e) logging.exception(e)
logging.warning("Could not update LttP sprites.") logging.warning("Could not update LttP sprites.")
app = get_app() app = get_app()
create_options_files() create_options_files()
copy_tutorials_files_to_static() create_ordered_tutorials_file()
if app.config["SELFLAUNCH"]: if app.config["SELFLAUNCH"]:
autohost(app.config) autohost(app.config)
if app.config["SELFGEN"]: if app.config["SELFGEN"]:

View File

@@ -9,7 +9,7 @@ from flask_compress import Compress
from pony.flask import Pony from pony.flask import Pony
from werkzeug.routing import BaseConverter from werkzeug.routing import BaseConverter
from Utils import title_sorted, get_file_safe_name from Utils import title_sorted
UPLOAD_FOLDER = os.path.relpath('uploads') UPLOAD_FOLDER = os.path.relpath('uploads')
LOGS_FOLDER = os.path.relpath('logs') LOGS_FOLDER = os.path.relpath('logs')
@@ -20,7 +20,6 @@ Pony(app)
app.jinja_env.filters['any'] = any app.jinja_env.filters['any'] = any
app.jinja_env.filters['all'] = all 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["SELFHOST"] = True # application process is in charge of running the websites
app.config["GENERATORS"] = 8 # maximum concurrent world gens app.config["GENERATORS"] = 8 # maximum concurrent world gens
@@ -39,8 +38,6 @@ app.config["SECRET_KEY"] = bytes(socket.gethostname(), encoding="utf-8")
app.config["JOB_THRESHOLD"] = 1 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. # after what time in seconds should generation be aborted, freeing the queue slot. Can be set to None to disable.
app.config["JOB_TIME"] = 600 app.config["JOB_TIME"] = 600
# memory limit for generator processes in bytes
app.config["GENERATOR_MEMORY_LIMIT"] = 4294967296
app.config['SESSION_PERMANENT'] = True app.config['SESSION_PERMANENT'] = True
# waitress uses one thread for I/O, these are for processing of views that then get sent # waitress uses one thread for I/O, these are for processing of views that then get sent
@@ -61,43 +58,32 @@ cache = Cache()
Compress(app) Compress(app)
def to_python(value):
return uuid.UUID(bytes=base64.urlsafe_b64decode(value + '=='))
def to_url(value):
return base64.urlsafe_b64encode(value.bytes).rstrip(b'=').decode('ascii')
class B64UUIDConverter(BaseConverter): class B64UUIDConverter(BaseConverter):
def to_python(self, value): def to_python(self, value):
return to_python(value) return uuid.UUID(bytes=base64.urlsafe_b64decode(value + '=='))
def to_url(self, value): def to_url(self, value):
return to_url(value) return base64.urlsafe_b64encode(value.bytes).rstrip(b'=').decode('ascii')
# short UUID # short UUID
app.url_map.converters["suuid"] = B64UUIDConverter app.url_map.converters["suuid"] = B64UUIDConverter
app.jinja_env.filters["suuid"] = to_url app.jinja_env.filters['suuid'] = lambda value: base64.urlsafe_b64encode(value.bytes).rstrip(b'=').decode('ascii')
app.jinja_env.filters["title_sorted"] = title_sorted app.jinja_env.filters["title_sorted"] = title_sorted
def register(): def register():
"""Import submodules, triggering their registering on flask routing. """Import submodules, triggering their registering on flask routing.
Note: initializes worlds subsystem.""" Note: initializes worlds subsystem."""
import importlib
from werkzeug.utils import find_modules
# has automatic patch integration # has automatic patch integration
import worlds.AutoWorld
import worlds.Files import worlds.Files
app.jinja_env.filters['is_applayercontainer'] = worlds.Files.is_ap_player_container app.jinja_env.filters['supports_apdeltapatch'] = lambda game_name: \
game_name in worlds.Files.AutoPatchRegister.patch_types
from WebHostLib.customserver import run_server_process from WebHostLib.customserver import run_server_process
# to trigger app routing picking up on it
from . import tracker, upload, landing, check, generate, downloads, api, stats, misc, robots, options
for module in find_modules("WebHostLib", include_packages=True):
importlib.import_module(module)
from . import api
app.register_blueprint(api.api_endpoints) app.register_blueprint(api.api_endpoints)

View File

@@ -1,15 +1,51 @@
"""API endpoints package.""" """API endpoints package."""
from typing import List, Tuple from typing import List, Tuple
from uuid import UUID
from flask import Blueprint from flask import Blueprint, abort, url_for
from ..models import Seed, Slot import worlds.Files
from ..models import Room, Seed
api_endpoints = Blueprint('api', __name__, url_prefix="/api") api_endpoints = Blueprint('api', __name__, url_prefix="/api")
# unsorted/misc endpoints
def get_players(seed: Seed) -> List[Tuple[str, str]]: def get_players(seed: Seed) -> List[Tuple[str, str]]:
return [(slot.player_name, slot.game) for slot in seed.slots.order_by(Slot.player_id)] return [(slot.player_name, slot.game) for slot in seed.slots]
# trigger endpoint registration
from . import datapackage, generate, room, tracker, user @api_endpoints.route('/room_status/<suuid:room>')
def room_info(room: UUID):
room = Room.get(id=room)
if room is None:
return abort(404)
def supports_apdeltapatch(game: str):
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": room.tracker,
"players": get_players(room.seed),
"last_port": room.last_port,
"last_activity": room.last_activity,
"timeout": room.timeout,
"downloads": downloads,
}
from . import generate, user, datapackage # trigger registration

View File

@@ -1,11 +1,11 @@
import json import json
import pickle
from uuid import UUID from uuid import UUID
from flask import request, session, url_for from flask import request, session, url_for
from markupsafe import Markup from markupsafe import Markup
from pony.orm import commit from pony.orm import commit
from Utils import restricted_dumps
from WebHostLib import app from WebHostLib import app
from WebHostLib.check import get_yaml_data, roll_options from WebHostLib.check import get_yaml_data, roll_options
from WebHostLib.generate import get_meta from WebHostLib.generate import get_meta
@@ -56,7 +56,7 @@ def generate_api():
"detail": results}, 400 "detail": results}, 400
else: else:
gen = Generation( gen = Generation(
options=restricted_dumps({name: vars(options) for name, options in gen_options.items()}), options=pickle.dumps({name: vars(options) for name, options in gen_options.items()}),
# convert to json compatible # convert to json compatible
meta=json.dumps(meta), state=STATE_QUEUED, meta=json.dumps(meta), state=STATE_QUEUED,
owner=session["_id"]) owner=session["_id"])

View File

@@ -1,43 +0,0 @@
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/<suuid:room_id>')
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,
}

View File

@@ -1,232 +0,0 @@
from datetime import datetime, timezone
from typing import Any, TypedDict
from uuid import UUID
from flask import abort
from NetUtils import ClientStatus, Hint, NetworkItem, SlotType
from WebHostLib import cache
from WebHostLib.api import api_endpoints
from WebHostLib.models import Room
from WebHostLib.tracker import TrackerData
@api_endpoints.route("/tracker/<suuid:tracker>")
@cache.memoize(timeout=60)
def tracker_data(tracker: UUID) -> dict[str, Any]:
"""
Outputs json data to <root_path>/api/tracker/<id of current session tracker>.
:param tracker: UUID of current session tracker.
:return: Tracking data for all players in the room. Typing and docstrings describe the format of each value.
"""
room: Room | None = Room.get(tracker=tracker)
if not room:
abort(404)
tracker_data = TrackerData(room)
all_players: dict[int, list[int]] = tracker_data.get_all_players()
class PlayerAlias(TypedDict):
player: int
name: str | None
player_aliases: list[dict[str, int | list[PlayerAlias]]] = []
"""Slot aliases of all players."""
for team, players in all_players.items():
team_player_aliases: list[PlayerAlias] = []
team_aliases = {"team": team, "players": team_player_aliases}
player_aliases.append(team_aliases)
for player in players:
team_player_aliases.append({"player": player, "alias": tracker_data.get_player_alias(team, player)})
class PlayerItemsReceived(TypedDict):
player: int
items: list[NetworkItem]
player_items_received: list[dict[str, int | list[PlayerItemsReceived]]] = []
"""Items received by each player."""
for team, players in all_players.items():
player_received_items: list[PlayerItemsReceived] = []
team_items_received = {"team": team, "players": player_received_items}
player_items_received.append(team_items_received)
for player in players:
player_received_items.append(
{"player": player, "items": tracker_data.get_player_received_items(team, player)})
class PlayerChecksDone(TypedDict):
player: int
locations: list[int]
player_checks_done: list[dict[str, int | list[PlayerChecksDone]]] = []
"""ID of all locations checked by each player."""
for team, players in all_players.items():
per_player_checks: list[PlayerChecksDone] = []
team_checks_done = {"team": team, "players": per_player_checks}
player_checks_done.append(team_checks_done)
for player in players:
per_player_checks.append(
{"player": player, "locations": sorted(tracker_data.get_player_checked_locations(team, player))})
total_checks_done: list[dict[str, int]] = [
{"team": team, "checks_done": checks_done}
for team, checks_done in tracker_data.get_team_locations_checked_count().items()
]
"""Total number of locations checked for the entire multiworld per team."""
class PlayerHints(TypedDict):
player: int
hints: list[Hint]
hints: list[dict[str, int | list[PlayerHints]]] = []
"""Hints that all players have used or received."""
for team, players in tracker_data.get_all_slots().items():
per_player_hints: list[PlayerHints] = []
team_hints = {"team": team, "players": per_player_hints}
hints.append(team_hints)
for player in players:
player_hints = sorted(tracker_data.get_player_hints(team, player))
per_player_hints.append({"player": player, "hints": player_hints})
slot_info = tracker_data.get_slot_info(team, player)
# this assumes groups are always after players
if slot_info.type != SlotType.group:
continue
for member in slot_info.group_members:
team_hints[member]["hints"] += player_hints
class PlayerTimer(TypedDict):
player: int
time: datetime | None
activity_timers: list[dict[str, int | list[PlayerTimer]]] = []
"""Time of last activity per player. Returned as RFC 1123 format and null if no connection has been made."""
for team, players in all_players.items():
player_timers: list[PlayerTimer] = []
team_timers = {"team": team, "players": player_timers}
activity_timers.append(team_timers)
for player in players:
player_timers.append({"player": player, "time": None})
client_activity_timers: tuple[tuple[int, int], float] = tracker_data._multisave.get("client_activity_timers", ())
for (team, player), timestamp in client_activity_timers:
# use index since we can rely on order
# FIX: key is "players" (not "player_timers")
activity_timers[team]["players"][player - 1]["time"] = datetime.fromtimestamp(timestamp, timezone.utc)
connection_timers: list[dict[str, int | list[PlayerTimer]]] = []
"""Time of last connection per player. Returned as RFC 1123 format and null if no connection has been made."""
for team, players in all_players.items():
player_timers: list[PlayerTimer] = []
team_connection_timers = {"team": team, "players": player_timers}
connection_timers.append(team_connection_timers)
for player in players:
player_timers.append({"player": player, "time": None})
client_connection_timers: tuple[tuple[int, int], float] = tracker_data._multisave.get(
"client_connection_timers", ())
for (team, player), timestamp in client_connection_timers:
connection_timers[team]["players"][player - 1]["time"] = datetime.fromtimestamp(timestamp, timezone.utc)
class PlayerStatus(TypedDict):
player: int
status: ClientStatus
player_status: list[dict[str, int | list[PlayerStatus]]] = []
"""The current client status for each player."""
for team, players in all_players.items():
player_statuses: list[PlayerStatus] = []
team_status = {"team": team, "players": player_statuses}
player_status.append(team_status)
for player in players:
player_statuses.append({"player": player, "status": tracker_data.get_player_client_status(team, player)})
return {
**get_static_tracker_data(room),
"aliases": player_aliases,
"player_items_received": player_items_received,
"player_checks_done": player_checks_done,
"total_checks_done": total_checks_done,
"hints": hints,
"activity_timers": activity_timers,
"connection_timers": connection_timers,
"player_status": player_status,
"datapackage": tracker_data._multidata["datapackage"],
}
@cache.memoize()
def get_static_tracker_data(room: Room) -> dict[str, Any]:
"""
Builds and caches the static data for this active session tracker, so that it doesn't need to be recalculated.
"""
tracker_data = TrackerData(room)
all_players: dict[int, list[int]] = tracker_data.get_all_players()
class PlayerGroups(TypedDict):
slot: int
name: str
members: list[int]
groups: list[dict[str, int | list[PlayerGroups]]] = []
"""The Slot ID of groups and the IDs of the group's members."""
for team, players in tracker_data.get_all_slots().items():
groups_in_team: list[PlayerGroups] = []
team_groups = {"team": team, "groups": groups_in_team}
groups.append(team_groups)
for player in players:
slot_info = tracker_data.get_slot_info(team, player)
if slot_info.type != SlotType.group or not slot_info.group_members:
continue
groups_in_team.append(
{
"slot": player,
"name": slot_info.name,
"members": list(slot_info.group_members),
})
class PlayerName(TypedDict):
player: int
name: str
player_names: list[dict[str, str | list[PlayerName]]] = []
"""Slot names of all players."""
for team, players in all_players.items():
per_team_player_names: list[PlayerName] = []
team_names = {"team": team, "players": per_team_player_names}
player_names.append(team_names)
for player in players:
per_team_player_names.append({"player": player, "name": tracker_data.get_player_name(team, player)})
class PlayerGame(TypedDict):
player: int
game: str
games: list[dict[str, int | list[PlayerGame]]] = []
"""The game each player is playing."""
for team, players in all_players.items():
player_games: list[PlayerGame] = []
team_games = {"team": team, "players": player_games}
games.append(team_games)
for player in players:
player_games.append({"player": player, "game": tracker_data.get_player_game(team, player)})
class PlayerSlotData(TypedDict):
player: int
slot_data: dict[str, Any]
slot_data: list[dict[str, int | list[PlayerSlotData]]] = []
"""Slot data for each player."""
for team, players in all_players.items():
player_slot_data: list[PlayerSlotData] = []
team_slot_data = {"team": team, "players": player_slot_data}
slot_data.append(team_slot_data)
for player in players:
player_slot_data.append({"player": player, "slot_data": tracker_data.get_slot_data(team, player)})
return {
"groups": groups,
"slot_data": slot_data,
}

View File

@@ -1,7 +1,6 @@
from flask import session, jsonify from flask import session, jsonify
from pony.orm import select from pony.orm import select
from WebHostLib import to_url
from WebHostLib.models import Room, Seed from WebHostLib.models import Room, Seed
from . import api_endpoints, get_players from . import api_endpoints, get_players
@@ -11,13 +10,13 @@ def get_rooms():
response = [] response = []
for room in select(room for room in Room if room.owner == session["_id"]): for room in select(room for room in Room if room.owner == session["_id"]):
response.append({ response.append({
"room_id": to_url(room.id), "room_id": room.id,
"seed_id": to_url(room.seed.id), "seed_id": room.seed.id,
"creation_time": room.creation_time, "creation_time": room.creation_time,
"last_activity": room.last_activity, "last_activity": room.last_activity,
"last_port": room.last_port, "last_port": room.last_port,
"timeout": room.timeout, "timeout": room.timeout,
"tracker": to_url(room.tracker), "tracker": room.tracker,
}) })
return jsonify(response) return jsonify(response)
@@ -27,8 +26,8 @@ def get_seeds():
response = [] response = []
for seed in select(seed for seed in Seed if seed.owner == session["_id"]): for seed in select(seed for seed in Seed if seed.owner == session["_id"]):
response.append({ response.append({
"seed_id": to_url(seed.id), "seed_id": seed.id,
"creation_time": seed.creation_time, "creation_time": seed.creation_time,
"players": get_players(seed), "players": get_players(seed.slots),
}) })
return jsonify(response) return jsonify(response)

View File

@@ -6,10 +6,9 @@ import multiprocessing
import typing import typing
from datetime import timedelta, datetime from datetime import timedelta, datetime
from threading import Event, Thread from threading import Event, Thread
from typing import Any
from uuid import UUID from uuid import UUID
from pony.orm import db_session, select, commit, PrimaryKey from pony.orm import db_session, select, commit
from Utils import restricted_loads from Utils import restricted_loads
from .locker import Locker, AlreadyRunningException from .locker import Locker, AlreadyRunningException
@@ -36,21 +35,12 @@ def handle_generation_failure(result: BaseException):
logging.exception(e) logging.exception(e)
def _mp_gen_game(gen_options: dict, meta: dict[str, Any] | None = None, owner=None, sid=None) -> PrimaryKey | None:
from setproctitle import setproctitle
setproctitle(f"Generator ({sid})")
res = gen_game(gen_options, meta=meta, owner=owner, sid=sid)
setproctitle(f"Generator (idle)")
return res
def launch_generator(pool: multiprocessing.pool.Pool, generation: Generation): def launch_generator(pool: multiprocessing.pool.Pool, generation: Generation):
try: try:
meta = json.loads(generation.meta) meta = json.loads(generation.meta)
options = restricted_loads(generation.options) options = restricted_loads(generation.options)
logging.info(f"Generating {generation.id} for {len(options)} players") logging.info(f"Generating {generation.id} for {len(options)} players")
pool.apply_async(_mp_gen_game, (options,), pool.apply_async(gen_game, (options,),
{"meta": meta, {"meta": meta,
"sid": generation.id, "sid": generation.id,
"owner": generation.owner}, "owner": generation.owner},
@@ -63,25 +53,7 @@ def launch_generator(pool: multiprocessing.pool.Pool, generation: Generation):
generation.state = STATE_STARTED generation.state = STATE_STARTED
def init_generator(config: dict[str, Any]) -> None: def init_db(pony_config: dict):
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.bind(**pony_config)
db.generate_mapping() db.generate_mapping()
@@ -133,8 +105,8 @@ def autogen(config: dict):
try: try:
with Locker("autogen"): with Locker("autogen"):
with multiprocessing.Pool(config["GENERATORS"], initializer=init_generator, with multiprocessing.Pool(config["GENERATORS"], initializer=init_db,
initargs=(config,), maxtasksperchild=10) as generator_pool: initargs=(config["PONY"],), maxtasksperchild=10) as generator_pool:
with db_session: with db_session:
to_start = select(generation for generation in Generation if generation.state == STATE_STARTED) to_start = select(generation for generation in Generation if generation.state == STATE_STARTED)
@@ -164,6 +136,9 @@ def autogen(config: dict):
Thread(target=keep_running, name="AP_Autogen").start() Thread(target=keep_running, name="AP_Autogen").start()
multiworlds: typing.Dict[type(Room.id), MultiworldInstance] = {}
class MultiworldInstance(): class MultiworldInstance():
def __init__(self, config: dict, id: int): def __init__(self, config: dict, id: int):
self.room_ids = set() self.room_ids = set()

View File

@@ -1,7 +1,7 @@
import os import os
import zipfile import zipfile
import base64 import base64
from collections.abc import Set from typing import Union, Dict, Set, Tuple
from flask import request, flash, redirect, url_for, render_template from flask import request, flash, redirect, url_for, render_template
from markupsafe import Markup from markupsafe import Markup
@@ -43,7 +43,7 @@ def mysterycheck():
return redirect(url_for("check"), 301) return redirect(url_for("check"), 301)
def get_yaml_data(files) -> dict[str, str] | str | Markup: def get_yaml_data(files) -> Union[Dict[str, str], str, Markup]:
options = {} options = {}
for uploaded_file in files: for uploaded_file in files:
if banned_file(uploaded_file.filename): if banned_file(uploaded_file.filename):
@@ -84,12 +84,12 @@ def get_yaml_data(files) -> dict[str, str] | str | Markup:
return options return options
def roll_options(options: dict[str, dict | str], def roll_options(options: Dict[str, Union[dict, str]],
plando_options: Set[str] = frozenset({"bosses", "items", "connections", "texts"})) -> \ plando_options: Set[str] = frozenset({"bosses", "items", "connections", "texts"})) -> \
tuple[dict[str, str | bool], dict[str, dict]]: Tuple[Dict[str, Union[str, bool]], Dict[str, dict]]:
plando_options = PlandoOptions.from_set(set(plando_options)) plando_options = PlandoOptions.from_set(set(plando_options))
results: dict[str, str | bool] = {} results = {}
rolled_results: dict[str, dict] = {} rolled_results = {}
for filename, text in options.items(): for filename, text in options.items():
try: try:
if type(text) is dict: if type(text) is dict:
@@ -105,9 +105,8 @@ def roll_options(options: dict[str, dict | str],
plando_options=plando_options) plando_options=plando_options)
else: else:
for i, yaml_data in enumerate(yaml_datas): for i, yaml_data in enumerate(yaml_datas):
if yaml_data is not None: rolled_results[f"{filename}/{i + 1}"] = roll_settings(yaml_data,
rolled_results[f"{filename}/{i + 1}"] = roll_settings(yaml_data, plando_options=plando_options)
plando_options=plando_options)
except Exception as e: except Exception as e:
if e.__cause__: if e.__cause__:
results[filename] = f"Failed to generate options in {filename}: {e} - {e.__cause__}" results[filename] = f"Failed to generate options in {filename}: {e} - {e.__cause__}"

View File

@@ -72,14 +72,6 @@ class WebHostContext(Context):
self.video = {} self.video = {}
self.tags = ["AP", "WebHost"] 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): def _load_game_data(self):
for key, value in self.static_server_data.items(): 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 # NOTE: attributes are mutable and shared, so they will have to be copied before being modified
@@ -117,7 +109,6 @@ class WebHostContext(Context):
self.gamespackage = {"Archipelago": static_gamespackage.get("Archipelago", {})} # this may be modified by _load 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.item_name_groups = {"Archipelago": static_item_name_groups.get("Archipelago", {})}
self.location_name_groups = {"Archipelago": static_location_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", {})): for game in list(multidata.get("datapackage", {})):
game_data = multidata["datapackage"][game] game_data = multidata["datapackage"][game]
@@ -129,17 +120,15 @@ class WebHostContext(Context):
else: else:
row = GameDataPackage.get(checksum=game_data["checksum"]) row = GameDataPackage.get(checksum=game_data["checksum"])
if row: # None if rolled on >= 0.3.9 but uploaded to <= 0.3.8. multidata should be complete if row: # None if rolled on >= 0.3.9 but uploaded to <= 0.3.8. multidata should be complete
game_data_packages[game] = restricted_loads(row.data) game_data_packages[game] = Utils.restricted_loads(row.data)
continue continue
else: else:
self.logger.warning(f"Did not find game_data_package for {game}: {game_data['checksum']}") self.logger.warning(f"Did not find game_data_package for {game}: {game_data['checksum']}")
else:
missing_checksum = True # Game rolled on old AP and will load data package from multidata
self.gamespackage[game] = static_gamespackage.get(game, {}) self.gamespackage[game] = static_gamespackage.get(game, {})
self.item_name_groups[game] = static_item_name_groups.get(game, {}) self.item_name_groups[game] = static_item_name_groups.get(game, {})
self.location_name_groups[game] = static_location_name_groups.get(game, {}) self.location_name_groups[game] = static_location_name_groups.get(game, {})
if not game_data_packages and not missing_checksum: if not game_data_packages:
# all static -> use the static dicts directly # all static -> use the static dicts directly
self.gamespackage = static_gamespackage self.gamespackage = static_gamespackage
self.item_name_groups = static_item_name_groups self.item_name_groups = static_item_name_groups
@@ -159,7 +148,6 @@ class WebHostContext(Context):
@db_session @db_session
def _save(self, exit_save: bool = False) -> bool: def _save(self, exit_save: bool = False) -> bool:
room = Room.get(id=self.room_id) room = Room.get(id=self.room_id)
# Does not use Utils.restricted_dumps because we'd rather make a save than not make one
room.multisave = pickle.dumps(self.get_save()) room.multisave = pickle.dumps(self.get_save())
# saving only occurs on activity, so we can "abuse" this information to mark this as last_activity # saving only occurs on activity, so we can "abuse" this information to mark this as last_activity
if not exit_save: # we don't want to count a shutdown as activity, which would restart the server again if not exit_save: # we don't want to count a shutdown as activity, which would restart the server again
@@ -228,9 +216,6 @@ def set_up_logging(room_id) -> logging.Logger:
def run_server_process(name: str, ponyconfig: dict, static_server_data: dict, def run_server_process(name: str, ponyconfig: dict, static_server_data: dict,
cert_file: typing.Optional[str], cert_key_file: typing.Optional[str], cert_file: typing.Optional[str], cert_key_file: typing.Optional[str],
host: str, rooms_to_run: multiprocessing.Queue, rooms_shutting_down: multiprocessing.Queue): host: str, rooms_to_run: multiprocessing.Queue, rooms_shutting_down: multiprocessing.Queue):
from setproctitle import setproctitle
setproctitle(name)
Utils.init_logging(name) Utils.init_logging(name)
try: try:
import resource import resource
@@ -251,23 +236,8 @@ def run_server_process(name: str, ponyconfig: dict, static_server_data: dict,
raise Exception("Worlds system should not be loaded in the custom server.") raise Exception("Worlds system should not be loaded in the custom server.")
import gc import gc
ssl_context = load_server_cert(cert_file, cert_key_file) if cert_file else None
if not cert_file: del cert_file, cert_key_file, ponyconfig
def get_ssl_context():
return None
else:
load_date = None
ssl_context = load_server_cert(cert_file, cert_key_file)
def get_ssl_context():
nonlocal load_date, ssl_context
today = datetime.date.today()
if load_date != today:
ssl_context = load_server_cert(cert_file, cert_key_file)
load_date = today
return ssl_context
del ponyconfig
gc.collect() # free intermediate objects used during setup gc.collect() # free intermediate objects used during setup
loop = asyncio.get_event_loop() loop = asyncio.get_event_loop()
@@ -279,15 +249,14 @@ def run_server_process(name: str, ponyconfig: dict, static_server_data: dict,
ctx = WebHostContext(static_server_data, logger) ctx = WebHostContext(static_server_data, logger)
ctx.load(room_id) ctx.load(room_id)
ctx.init_save() ctx.init_save()
assert ctx.server is None
try: try:
ctx.server = websockets.serve( ctx.server = websockets.serve(
functools.partial(server, ctx=ctx), ctx.host, ctx.port, ssl=get_ssl_context()) functools.partial(server, ctx=ctx), ctx.host, ctx.port, ssl=ssl_context)
await ctx.server await ctx.server
except OSError: # likely port in use except OSError: # likely port in use
ctx.server = websockets.serve( ctx.server = websockets.serve(
functools.partial(server, ctx=ctx), ctx.host, 0, ssl=get_ssl_context()) functools.partial(server, ctx=ctx), ctx.host, 0, ssl=ssl_context)
await ctx.server await ctx.server
port = 0 port = 0
@@ -310,7 +279,6 @@ def run_server_process(name: str, ponyconfig: dict, static_server_data: dict,
ctx.auto_shutdown = Room.get(id=room_id).timeout ctx.auto_shutdown = Room.get(id=room_id).timeout
if ctx.saving: if ctx.saving:
setattr(asyncio.current_task(), "save", lambda: ctx._save(True)) setattr(asyncio.current_task(), "save", lambda: ctx._save(True))
assert ctx.shutdown_task is None
ctx.shutdown_task = asyncio.create_task(auto_shutdown(ctx, [])) ctx.shutdown_task = asyncio.create_task(auto_shutdown(ctx, []))
await ctx.shutdown_task await ctx.shutdown_task
@@ -357,12 +325,10 @@ def run_server_process(name: str, ponyconfig: dict, static_server_data: dict,
def run(self): def run(self):
while 1: while 1:
next_room = rooms_to_run.get(block=True, timeout=None) next_room = rooms_to_run.get(block=True, timeout=None)
gc.collect()
task = asyncio.run_coroutine_threadsafe(start_room(next_room), loop) task = asyncio.run_coroutine_threadsafe(start_room(next_room), loop)
self._tasks.append(task) self._tasks.append(task)
task.add_done_callback(self._done) task.add_done_callback(self._done)
logging.info(f"Starting room {next_room} on {name}.") logging.info(f"Starting room {next_room} on {name}.")
del task # delete reference to task object
starter = Starter() starter = Starter()
starter.daemon = True starter.daemon = True

View File

@@ -61,7 +61,12 @@ def download_slot_file(room_id, player_id: int):
else: else:
import io import io
if slot_data.game == "Factorio": 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":
with zipfile.ZipFile(io.BytesIO(slot_data.data)) as zf: with zipfile.ZipFile(io.BytesIO(slot_data.data)) as zf:
for name in zf.namelist(): for name in zf.namelist():
if name.endswith("info.json"): if name.endswith("info.json"):

View File

@@ -1,40 +1,41 @@
import concurrent.futures import concurrent.futures
import json import json
import os import os
import pickle
import random import random
import tempfile import tempfile
import zipfile import zipfile
from collections import Counter from collections import Counter
from pickle import PicklingError from typing import Any, Dict, List, Optional, Union, Set
from typing import Any
from flask import flash, redirect, render_template, request, session, url_for from flask import flash, redirect, render_template, request, session, url_for
from pony.orm import commit, db_session from pony.orm import commit, db_session
from BaseClasses import get_seed, seeddigits from BaseClasses import get_seed, seeddigits
from Generate import PlandoOptions, handle_name, mystery_argparse from Generate import PlandoOptions, handle_name
from Main import main as ERmain from Main import main as ERmain
from Utils import __version__, restricted_dumps from Utils import __version__
from WebHostLib import app from WebHostLib import app
from settings import ServerOptions, GeneratorOptions from settings import ServerOptions, GeneratorOptions
from worlds.alttp.EntranceRandomizer import parse_arguments
from .check import get_yaml_data, roll_options from .check import get_yaml_data, roll_options
from .models import Generation, STATE_ERROR, STATE_QUEUED, Seed, UUID from .models import Generation, STATE_ERROR, STATE_QUEUED, Seed, UUID
from .upload import upload_zip_to_db from .upload import upload_zip_to_db
def get_meta(options_source: dict, race: bool = False) -> dict[str, list[str] | dict[str, Any]]: def get_meta(options_source: dict, race: bool = False) -> Dict[str, Union[List[str], Dict[str, Any]]]:
plando_options: set[str] = set() plando_options: Set[str] = set()
for substr in ("bosses", "items", "connections", "texts"): for substr in ("bosses", "items", "connections", "texts"):
if options_source.get(f"plando_{substr}", substr in GeneratorOptions.plando_options): if options_source.get(f"plando_{substr}", substr in GeneratorOptions.plando_options):
plando_options.add(substr) plando_options.add(substr)
server_options = { server_options = {
"hint_cost": int(options_source.get("hint_cost", ServerOptions.hint_cost)), "hint_cost": int(options_source.get("hint_cost", ServerOptions.hint_cost)),
"release_mode": str(options_source.get("release_mode", ServerOptions.release_mode)), "release_mode": options_source.get("release_mode", ServerOptions.release_mode),
"remaining_mode": str(options_source.get("remaining_mode", ServerOptions.remaining_mode)), "remaining_mode": options_source.get("remaining_mode", ServerOptions.remaining_mode),
"collect_mode": str(options_source.get("collect_mode", ServerOptions.collect_mode)), "collect_mode": options_source.get("collect_mode", ServerOptions.collect_mode),
"item_cheat": bool(int(options_source.get("item_cheat", not ServerOptions.disable_item_cheat))), "item_cheat": bool(int(options_source.get("item_cheat", not ServerOptions.disable_item_cheat))),
"server_password": str(options_source.get("server_password", None)), "server_password": options_source.get("server_password", None),
} }
generator_options = { generator_options = {
"spoiler": int(options_source.get("spoiler", GeneratorOptions.spoiler)), "spoiler": int(options_source.get("spoiler", GeneratorOptions.spoiler)),
@@ -72,7 +73,7 @@ def generate(race=False):
return render_template("generate.html", race=race, version=__version__) return render_template("generate.html", race=race, version=__version__)
def start_generation(options: dict[str, dict | str], meta: dict[str, Any]): def start_generation(options: Dict[str, Union[dict, str]], meta: Dict[str, Any]):
results, gen_options = roll_options(options, set(meta["plando_options"])) results, gen_options = roll_options(options, set(meta["plando_options"]))
if any(type(result) == str for result in results.values()): if any(type(result) == str for result in results.values()):
@@ -80,20 +81,13 @@ def start_generation(options: dict[str, dict | str], meta: dict[str, Any]):
elif len(gen_options) > app.config["MAX_ROLL"]: elif len(gen_options) > app.config["MAX_ROLL"]:
flash(f"Sorry, generating of multiworlds is limited to {app.config['MAX_ROLL']} players. " 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.") 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"]: elif len(gen_options) >= app.config["JOB_THRESHOLD"]:
try: gen = Generation(
gen = Generation( options=pickle.dumps({name: vars(options) for name, options in gen_options.items()}),
options=restricted_dumps({name: vars(options) for name, options in gen_options.items()}), # convert to json compatible
# convert to json compatible meta=json.dumps(meta),
meta=json.dumps(meta), state=STATE_QUEUED,
state=STATE_QUEUED, owner=session["_id"])
owner=session["_id"])
except PicklingError as e:
from .autolauncher import handle_generation_failure
handle_generation_failure(e)
return render_template("seedError.html", seed_error=("PicklingError: " + str(e)))
commit() commit()
return redirect(url_for("wait_seed", seed=gen.id)) return redirect(url_for("wait_seed", seed=gen.id))
@@ -109,9 +103,9 @@ def start_generation(options: dict[str, dict | str], meta: dict[str, Any]):
return redirect(url_for("view_seed", seed=seed_id)) return redirect(url_for("view_seed", seed=seed_id))
def gen_game(gen_options: dict, meta: dict[str, Any] | None = None, owner=None, sid=None): def gen_game(gen_options: dict, meta: Optional[Dict[str, Any]] = None, owner=None, sid=None):
if meta is None: if not meta:
meta = {} meta: Dict[str, Any] = {}
meta.setdefault("server_options", {}).setdefault("hint_cost", 10) meta.setdefault("server_options", {}).setdefault("hint_cost", 10)
race = meta.setdefault("generator_options", {}).setdefault("race", False) race = meta.setdefault("generator_options", {}).setdefault("race", False)
@@ -128,39 +122,34 @@ def gen_game(gen_options: dict, meta: dict[str, Any] | None = None, owner=None,
seedname = "W" + (f"{random.randint(0, pow(10, seeddigits) - 1)}".zfill(seeddigits)) seedname = "W" + (f"{random.randint(0, pow(10, seeddigits) - 1)}".zfill(seeddigits))
args = mystery_argparse() erargs = parse_arguments(['--multi', str(playercount)])
args.multi = playercount erargs.seed = seed
args.seed = seed erargs.name = {x: "" for x in range(1, playercount + 1)} # only so it can be overwritten in mystery
args.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)
args.spoiler = meta["generator_options"].get("spoiler", 0) erargs.race = race
args.race = race erargs.outputname = seedname
args.outputname = seedname erargs.outputpath = target.name
args.outputpath = target.name erargs.teams = 1
args.teams = 1 erargs.plando_options = PlandoOptions.from_set(meta.setdefault("plando_options",
args.plando_options = PlandoOptions.from_set(meta.setdefault("plando_options", {"bosses", "items", "connections", "texts"}))
{"bosses", "items", "connections", "texts"})) erargs.skip_prog_balancing = False
args.skip_prog_balancing = False erargs.skip_output = False
args.skip_output = False
args.spoiler_only = False
args.csv_output = False
args.sprite = dict.fromkeys(range(1, args.multi+1), None)
args.sprite_pool = dict.fromkeys(range(1, args.multi+1), None)
name_counter = Counter() name_counter = Counter()
for player, (playerfile, settings) in enumerate(gen_options.items(), 1): for player, (playerfile, settings) in enumerate(gen_options.items(), 1):
for k, v in settings.items(): for k, v in settings.items():
if v is not None: if v is not None:
if hasattr(args, k): if hasattr(erargs, k):
getattr(args, k)[player] = v getattr(erargs, k)[player] = v
else: else:
setattr(args, k, {player: v}) setattr(erargs, k, {player: v})
if not args.name[player]: if not erargs.name[player]:
args.name[player] = os.path.splitext(os.path.split(playerfile)[-1])[0] erargs.name[player] = os.path.splitext(os.path.split(playerfile)[-1])[0]
args.name[player] = handle_name(args.name[player], player, name_counter) erargs.name[player] = handle_name(erargs.name[player], player, name_counter)
if len(set(args.name.values())) != len(args.name): if len(set(erargs.name.values())) != len(erargs.name):
raise Exception(f"Names have to be unique. Names: {Counter(args.name.values())}") raise Exception(f"Names have to be unique. Names: {Counter(erargs.name.values())}")
ERmain(args, seed, baked_server_options=meta["server_options"]) ERmain(erargs, seed, baked_server_options=meta["server_options"])
return upload_to_db(target.name, sid, owner, race) return upload_to_db(target.name, sid, owner, race)
thread_pool = concurrent.futures.ThreadPoolExecutor(max_workers=1) thread_pool = concurrent.futures.ThreadPoolExecutor(max_workers=1)

View File

@@ -3,10 +3,10 @@ import threading
import json import json
from Utils import local_path, user_path from Utils import local_path, user_path
from worlds.alttp.Rom import Sprite
def update_sprites_lttp(): def update_sprites_lttp():
from worlds.alttp.Rom import Sprite
from tkinter import Tk from tkinter import Tk
from LttPAdjuster import get_image_for_sprite from LttPAdjuster import get_image_for_sprite
from LttPAdjuster import BackgroundTaskProgress from LttPAdjuster import BackgroundTaskProgress
@@ -14,7 +14,7 @@ def update_sprites_lttp():
from LttPAdjuster import update_sprites from LttPAdjuster import update_sprites
# Target directories # Target directories
input_dir = user_path("data", "sprites", "alttp", "remote") input_dir = user_path("data", "sprites", "alttpr")
output_dir = local_path("WebHostLib", "static", "generated") # TODO: move to user_path output_dir = local_path("WebHostLib", "static", "generated") # TODO: move to user_path
os.makedirs(os.path.join(output_dir, "sprites"), exist_ok=True) os.makedirs(os.path.join(output_dir, "sprites"), exist_ok=True)

View File

@@ -1,73 +1,27 @@
import datetime import datetime
import os import os
from typing import Any, IO, Dict, Iterator, List, Tuple, Union from typing import List, Dict, Union
import jinja2.exceptions import jinja2.exceptions
from flask import request, redirect, url_for, render_template, Response, session, abort, send_from_directory from flask import request, redirect, url_for, render_template, Response, session, abort, send_from_directory
from pony.orm import count, commit, db_session from pony.orm import count, commit, db_session
from werkzeug.utils import secure_filename
from worlds.AutoWorld import AutoWorldRegister, World from worlds.AutoWorld import AutoWorldRegister
from . import app, cache from . import app, cache
from .models import Seed, Room, Command, UUID, uuid4 from .models import Seed, Room, Command, UUID, uuid4
from Utils import title_sorted
def get_world_theme(game_name: str) -> str: def get_world_theme(game_name: str):
if game_name in AutoWorldRegister.world_types: if game_name in AutoWorldRegister.world_types:
return AutoWorldRegister.world_types[game_name].web.theme return AutoWorldRegister.world_types[game_name].web.theme
return 'grass' return 'grass'
def get_visible_worlds() -> dict[str, type(World)]: @app.before_request
worlds = {} def register_session():
for game, world in AutoWorldRegister.world_types.items(): session.permanent = True # technically 31 days after the last visit
if not world.hidden: if not session.get("_id", None):
worlds[game] = world session["_id"] = uuid4() # uniquely identify each session without needing a login
return worlds
def render_markdown(path: str) -> str:
import mistune
from collections import Counter
markdown = mistune.create_markdown(
escape=False,
plugins=[
"strikethrough",
"footnotes",
"table",
"speedup",
],
)
heading_id_count: Counter[str] = Counter()
def heading_id(text: str) -> str:
nonlocal heading_id_count
import re # there is no good way to do this without regex
s = re.sub(r"[^\w\- ]", "", text.lower()).replace(" ", "-").strip("-")
n = heading_id_count[s]
heading_id_count[s] += 1
if n > 0:
s += f"-{n}"
return s
def id_hook(_: mistune.Markdown, state: mistune.BlockState) -> None:
for tok in state.tokens:
if tok["type"] == "heading" and tok["attrs"]["level"] < 4:
text = tok["text"]
assert isinstance(text, str)
unique_id = heading_id(text)
tok["attrs"]["id"] = unique_id
tok["text"] = f"<a href=\"#{unique_id}\">{text}</a>" # make header link to itself
markdown.before_render_hooks.append(id_hook)
with open(path, encoding="utf-8-sig") as f:
document = f.read()
return markdown(document)
@app.errorhandler(404) @app.errorhandler(404)
@@ -83,104 +37,46 @@ def start_playing():
return render_template(f"startPlaying.html") return render_template(f"startPlaying.html")
# Game Info Pages
@app.route('/games/<string:game>/info/<string:lang>') @app.route('/games/<string:game>/info/<string:lang>')
@cache.cached() @cache.cached()
def game_info(game, lang): def game_info(game, lang):
"""Game Info Pages""" return render_template('gameInfo.html', game=game, lang=lang, theme=get_world_theme(game))
try:
theme = get_world_theme(game)
secure_game_name = secure_filename(game)
lang = secure_filename(lang)
document = render_markdown(os.path.join(
app.static_folder, "generated", "docs",
secure_game_name, f"{lang}_{secure_game_name}.md"
))
return render_template(
"markdown_document.html",
title=f"{game} Guide",
html_from_markdown=document,
theme=theme,
)
except FileNotFoundError:
return abort(404)
# List of supported games
@app.route('/games') @app.route('/games')
@cache.cached() @cache.cached()
def games(): def games():
"""List of supported games""" worlds = {}
return render_template("supportedGames.html", worlds=get_visible_worlds()) for game, world in AutoWorldRegister.world_types.items():
if not world.hidden:
worlds[game] = world
@app.route('/tutorial/<string:game>/<string:file>') return render_template("supportedGames.html", worlds=worlds)
@cache.cached()
def tutorial(game: str, file: str):
try:
theme = get_world_theme(game)
secure_game_name = secure_filename(game)
file = secure_filename(file)
document = render_markdown(os.path.join(
app.static_folder, "generated", "docs",
secure_game_name, file+".md"
))
return render_template(
"markdown_document.html",
title=f"{game} Guide",
html_from_markdown=document,
theme=theme,
)
except FileNotFoundError:
return abort(404)
@app.route('/tutorial/<string:game>/<string:file>/<string:lang>') @app.route('/tutorial/<string:game>/<string:file>/<string:lang>')
def tutorial_redirect(game: str, file: str, lang: str): @cache.cached()
""" def tutorial(game, file, lang):
Permanent redirect old tutorial URLs to new ones to keep search engines happy. return render_template("tutorial.html", game=game, file=file, lang=lang, theme=get_world_theme(game))
e.g. /tutorial/Archipelago/setup/en -> /tutorial/Archipelago/setup_en
"""
return redirect(url_for("tutorial", game=game, file=f"{file}_{lang}"), code=301)
@app.route('/tutorial/') @app.route('/tutorial/')
@cache.cached() @cache.cached()
def tutorial_landing(): def tutorial_landing():
tutorials = {} return render_template("tutorialLanding.html")
worlds = AutoWorldRegister.world_types
for world_name, world_type in worlds.items():
current_world = tutorials[world_name] = {}
for tutorial in world_type.web.tutorials:
current_tutorial = current_world.setdefault(tutorial.tutorial_name, {
"description": tutorial.description, "files": {}})
current_tutorial["files"][secure_filename(tutorial.file_name).rsplit(".", 1)[0]] = {
"authors": tutorial.authors,
"language": tutorial.language
}
tutorials = {world_name: tutorials for world_name, tutorials in title_sorted(
tutorials.items(), key=lambda element: "\x00" if element[0] == "Archipelago" else worlds[element[0]].game)}
return render_template("tutorialLanding.html", worlds=worlds, tutorials=tutorials)
@app.route('/faq/<string:lang>/') @app.route('/faq/<string:lang>/')
@cache.cached() @cache.cached()
def faq(lang: str): def faq(lang):
document = render_markdown(os.path.join(app.static_folder, "assets", "faq", secure_filename(lang)+".md")) return render_template("faq.html", lang=lang)
return render_template(
"markdown_document.html",
title="Frequently Asked Questions",
html_from_markdown=document,
)
@app.route('/glossary/<string:lang>/') @app.route('/glossary/<string:lang>/')
@cache.cached() @cache.cached()
def glossary(lang: str): def terms(lang):
document = render_markdown(os.path.join(app.static_folder, "assets", "glossary", secure_filename(lang)+".md")) return render_template("glossary.html", lang=lang)
return render_template(
"markdown_document.html",
title="Glossary",
html_from_markdown=document,
)
@app.route('/seed/<suuid:seed>') @app.route('/seed/<suuid:seed>')
@@ -201,91 +97,49 @@ def new_room(seed: UUID):
return redirect(url_for("host_room", room=room.id)) return redirect(url_for("host_room", room=room.id))
def _read_log(log: IO[Any], offset: int = 0) -> Iterator[bytes]: def _read_log(path: str):
marker = log.read(3) # skip optional BOM if os.path.exists(path):
if marker != b'\xEF\xBB\xBF': with open(path, encoding="utf-8-sig") as log:
log.seek(0, os.SEEK_SET) yield from log
log.seek(offset, os.SEEK_CUR) else:
yield from log yield f"Logfile {path} does not exist. " \
log.close() # free file handle as soon as possible f"Likely a crash during spinup of multiworld instance or it is still spinning up."
@app.route('/log/<suuid:room>') @app.route('/log/<suuid:room>')
def display_log(room: UUID) -> Union[str, Response, Tuple[str, int]]: def display_log(room: UUID):
room = Room.get(id=room) room = Room.get(id=room)
if room is None: if room is None:
return abort(404) return abort(404)
if room.owner == session["_id"]: if room.owner == session["_id"]:
file_path = os.path.join("logs", str(room.id) + ".txt") file_path = os.path.join("logs", str(room.id) + ".txt")
try: if os.path.exists(file_path):
log = open(file_path, "rb") return Response(_read_log(file_path), mimetype="text/plain;charset=UTF-8")
range_header = request.headers.get("Range") return "Log File does not exist."
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 return "Access Denied", 403
@app.post("/room/<suuid:room>") @app.route('/room/<suuid:room>', methods=['GET', 'POST'])
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/<suuid:room>")
def host_room(room: UUID): def host_room(room: UUID):
room: Room = Room.get(id=room) room: Room = Room.get(id=room)
if room is None: if room is None:
return abort(404) return abort(404)
if request.method == "POST":
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))
now = datetime.datetime.utcnow() now = datetime.datetime.utcnow()
# indicate that the page should reload to get the assigned port # 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)) should_refresh = not room.last_port and now - room.creation_time < datetime.timedelta(seconds=3)
or room.last_activity < now - datetime.timedelta(seconds=room.timeout))
with db_session: with db_session:
room.last_activity = now # will trigger a spinup, if it's not already running room.last_activity = now # will trigger a spinup, if it's not already running
browser_tokens = "Mozilla", "Chrome", "Safari" return render_template("hostRoom.html", room=room, should_refresh=should_refresh)
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) -> str:
if max_size == 0:
return ""
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)
except FileNotFoundError:
return ""
return render_template("hostRoom.html", room=room, should_refresh=should_refresh, get_log=get_log)
@app.route('/favicon.ico') @app.route('/favicon.ico')

View File

@@ -6,7 +6,7 @@ from typing import Dict, Union
from docutils.core import publish_parts from docutils.core import publish_parts
import yaml import yaml
from flask import redirect, render_template, request, Response, abort from flask import redirect, render_template, request, Response
import Options import Options
from Utils import local_path from Utils import local_path
@@ -108,7 +108,7 @@ def option_presets(game: str) -> Response:
f"Expected {option.special_range_names.keys()} or {option.range_start}-{option.range_end}." f"Expected {option.special_range_names.keys()} or {option.range_start}-{option.range_end}."
presets[preset_name][preset_option_name] = option.value presets[preset_name][preset_option_name] = option.value
elif isinstance(option, (Options.Range, Options.OptionSet, Options.OptionList, Options.OptionCounter)): elif isinstance(option, (Options.Range, Options.OptionSet, Options.OptionList, Options.ItemDict)):
presets[preset_name][preset_option_name] = option.value presets[preset_name][preset_option_name] = option.value
elif isinstance(preset_option, str): elif isinstance(preset_option, str):
# Ensure the option value is valid for Choice and Toggle options # Ensure the option value is valid for Choice and Toggle options
@@ -142,10 +142,7 @@ def weighted_options_old():
@app.route("/games/<string:game>/weighted-options") @app.route("/games/<string:game>/weighted-options")
@cache.cached() @cache.cached()
def weighted_options(game: str): def weighted_options(game: str):
try: return render_options_page("weightedOptions/weightedOptions.html", game, is_complex=True)
return render_options_page("weightedOptions/weightedOptions.html", game, is_complex=True)
except KeyError:
return abort(404)
@app.route("/games/<string:game>/generate-weighted-yaml", methods=["POST"]) @app.route("/games/<string:game>/generate-weighted-yaml", methods=["POST"])
@@ -155,9 +152,7 @@ def generate_weighted_yaml(game: str):
options = {} options = {}
for key, val in request.form.items(): for key, val in request.form.items():
if val == "_ensure-empty-list": if "||" not in key:
options[key] = {}
elif "||" not in key:
if len(str(val)) == 0: if len(str(val)) == 0:
continue continue
@@ -202,10 +197,7 @@ def generate_weighted_yaml(game: str):
@app.route("/games/<string:game>/player-options") @app.route("/games/<string:game>/player-options")
@cache.cached() @cache.cached()
def player_options(game: str): def player_options(game: str):
try: return render_options_page("playerOptions/playerOptions.html", game, is_complex=False)
return render_options_page("playerOptions/playerOptions.html", game, is_complex=False)
except KeyError:
return abort(404)
# YAML generator for player-options # YAML generator for player-options
@@ -214,11 +206,8 @@ def generate_yaml(game: str):
if request.method == "POST": if request.method == "POST":
options = {} options = {}
intent_generate = False intent_generate = False
for key, val in request.form.items(multi=True): for key, val in request.form.items(multi=True):
if val == "_ensure-empty-list": if key in options:
options[key] = []
elif options.get(key):
if not isinstance(options[key], list): if not isinstance(options[key], list):
options[key] = [options[key]] options[key] = [options[key]]
options[key].append(val) options[key].append(val)
@@ -227,7 +216,7 @@ def generate_yaml(game: str):
for key, val in options.copy().items(): for key, val in options.copy().items():
key_parts = key.rsplit("||", 2) key_parts = key.rsplit("||", 2)
# Detect and build OptionCounter options from their name pattern # Detect and build ItemDict options from their name pattern
if key_parts[-1] == "qty": if key_parts[-1] == "qty":
if key_parts[0] not in options: if key_parts[0] not in options:
options[key_parts[0]] = {} options[key_parts[0]] = {}
@@ -242,13 +231,6 @@ def generate_yaml(game: str):
del options[key] del options[key]
# 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
del options[key]
# Detect random-* keys and set their options accordingly # Detect random-* keys and set their options accordingly
for key, val in options.copy().items(): for key, val in options.copy().items():
if key.startswith("random-"): if key.startswith("random-"):

View File

@@ -1,12 +1,10 @@
flask>=3.1.1 flask>=3.0.3
werkzeug>=3.1.3 werkzeug>=3.0.3
pony>=0.7.19; python_version <= '3.12' pony>=0.7.17
pony @ git+https://github.com/black-sliver/pony@7feb1221953b7fa4a6735466bf21a8b4d35e33ba#0.7.19; python_version >= '3.13' waitress>=3.0.0
waitress>=3.0.2
Flask-Caching>=2.3.0 Flask-Caching>=2.3.0
Flask-Compress>=1.17 Flask-Compress>=1.15
Flask-Limiter>=3.12 Flask-Limiter>=3.7.0
bokeh>=3.6.3 bokeh>=3.1.1; python_version <= '3.8'
markupsafe>=3.0.2 bokeh>=3.4.1; python_version >= '3.9'
setproctitle>=1.3.5 markupsafe>=2.1.5
mistune>=3.1.3

View File

@@ -8,8 +8,7 @@ from . import cache
def robots(): def robots():
# If this host is not official, do not allow search engine crawling # If this host is not official, do not allow search engine crawling
if not app.config["ASSET_RIGHTS"]: 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.txt')
return app.send_static_file('robots_file.txt')
# Send 404 if the host has affirmed this to be the official WebHost # Send 404 if the host has affirmed this to be the official WebHost
abort(404) abort(404)

View File

@@ -1,31 +0,0 @@
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/<string:_id>')
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,
)

View File

@@ -0,0 +1,51 @@
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 =
`<h2>This page is out of logic!</h2>
<h3>Click <a href="${window.location.origin}">here</a> to return to safety.</h3>`;
});
});

View File

@@ -22,7 +22,7 @@ players to rely upon each other to complete their game.
While a multiworld game traditionally requires all players to be playing the same game, a multi-game multiworld allows 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 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. games to interact with one another in a single multiplayer environment. Archipelago supports multi-game multiworld.
Here is a list of our [Supported Games](https://archipelago.gg/games). Here is a list of our [Supported Games](https://archipelago.gg/games).
## Can I generate a single-player game with Archipelago? ## Can I generate a single-player game with Archipelago?
@@ -77,4 +77,4 @@ There, you will find examples of games in the `worlds` folder:
You may also find developer documentation in the `docs` folder: You may also find developer documentation in the `docs` folder:
[/docs Folder in Archipelago Code](https://github.com/ArchipelagoMW/Archipelago/tree/main/docs). [/docs Folder in Archipelago Code](https://github.com/ArchipelagoMW/Archipelago/tree/main/docs).
If you have more questions, feel free to ask in the **#ap-world-dev** channel on our Discord. If you have more questions, feel free to ask in the **#archipelago-dev** channel on our Discord.

View File

@@ -0,0 +1,51 @@
window.addEventListener('load', () => {
const gameInfo = document.getElementById('game-info');
new Promise((resolve, reject) => {
const ajax = new XMLHttpRequest();
ajax.onreadystatechange = () => {
if (ajax.readyState !== 4) { return; }
if (ajax.status === 404) {
reject("Sorry, this game's info page is not available in that language yet.");
return;
}
if (ajax.status !== 200) {
reject("Something went wrong while loading the info page.");
return;
}
resolve(ajax.responseText);
};
ajax.open('GET', `${window.location.origin}/static/generated/docs/${gameInfo.getAttribute('data-game')}/` +
`${gameInfo.getAttribute('data-lang')}_${gameInfo.getAttribute('data-game')}.md`, true);
ajax.send();
}).then((results) => {
// Populate page with HTML generated from markdown
showdown.setOption('tables', true);
showdown.setOption('strikethrough', true);
showdown.setOption('literalMidWordUnderscores', true);
gameInfo.innerHTML += (new showdown.Converter()).makeHtml(results);
adjustHeaderWidth();
// Reset the id of all header divs to something nicer
for (const header of document.querySelectorAll('h1, h2, h3, h4, h5, h6')) {
const headerId = header.innerText.replace(/\s+/g, '-').toLowerCase();
header.setAttribute('id', headerId);
header.addEventListener('click', () => {
window.location.hash = `#${headerId}`;
header.scrollIntoView();
});
}
// Manually scroll the user to the appropriate header if anchor navigation is used
document.fonts.ready.finally(() => {
if (window.location.hash) {
const scrollTarget = document.getElementById(window.location.hash.substring(1));
scrollTarget?.scrollIntoView();
}
});
}).catch((error) => {
console.error(error);
gameInfo.innerHTML =
`<h2>This page is out of logic!</h2>
<h3>Click <a href="${window.location.origin}">here</a> to return to safety.</h3>`;
});
});

View File

@@ -0,0 +1,51 @@
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 =
`<h2>This page is out of logic!</h2>
<h3>Click <a href="${window.location.origin}">here</a> to return to safety.</h3>`;
});
});

View File

@@ -6,4 +6,6 @@ window.addEventListener('load', () => {
document.getElementById('file-input').addEventListener('change', () => { document.getElementById('file-input').addEventListener('change', () => {
document.getElementById('host-game-form').submit(); document.getElementById('host-game-form').submit();
}); });
adjustFooterHeight();
}); });

View File

@@ -0,0 +1,49 @@
window.addEventListener('load', () => {
// Reload tracker every 15 seconds
const url = window.location;
setInterval(() => {
const ajax = new XMLHttpRequest();
ajax.onreadystatechange = () => {
if (ajax.readyState !== 4) { return; }
// Create a fake DOM using the returned HTML
const domParser = new DOMParser();
const fakeDOM = domParser.parseFromString(ajax.responseText, 'text/html');
// Update item tracker
document.getElementById('inventory-table').innerHTML = fakeDOM.getElementById('inventory-table').innerHTML;
// Update only counters in the location-table
let counters = document.getElementsByClassName('counter');
const fakeCounters = fakeDOM.getElementsByClassName('counter');
for (let i = 0; i < counters.length; i++) {
counters[i].innerHTML = fakeCounters[i].innerHTML;
}
};
ajax.open('GET', url);
ajax.send();
}, 15000)
// Collapsible advancement sections
const categories = document.getElementsByClassName("location-category");
for (let i = 0; i < categories.length; i++) {
let hide_id = categories[i].id.split('-')[0];
if (hide_id == 'Total') {
continue;
}
categories[i].addEventListener('click', function() {
// Toggle the advancement list
document.getElementById(hide_id).classList.toggle("hide");
// Change text of the header
const tab_header = document.getElementById(hide_id+'-header').children[0];
const orig_text = tab_header.innerHTML;
let new_text;
if (orig_text.includes("▼")) {
new_text = orig_text.replace("▼", "▲");
}
else {
new_text = orig_text.replace("▲", "▼");
}
tab_header.innerHTML = new_text;
});
}
});

View File

@@ -288,11 +288,6 @@ const applyPresets = (presetName) => {
} }
}); });
namedRangeSelect.value = trueValue; 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" // Handle options whose presets are "random"

View File

@@ -1,43 +1,49 @@
let updateSection = (sectionName, fakeDOM) => {
document.getElementById(sectionName).innerHTML = fakeDOM.getElementById(sectionName).innerHTML;
}
window.addEventListener('load', () => { window.addEventListener('load', () => {
// Reload tracker every 60 seconds (sync'd) // Reload tracker every 15 seconds
const url = window.location; const url = window.location;
// Note: This synchronization code is adapted from code in trackerCommon.js setInterval(() => {
const targetSecond = parseInt(document.getElementById('player-tracker').getAttribute('data-second')) + 3; const ajax = new XMLHttpRequest();
console.log("Target second of refresh: " + targetSecond); ajax.onreadystatechange = () => {
if (ajax.readyState !== 4) { return; }
let getSleepTimeSeconds = () => { // Create a fake DOM using the returned HTML
// -40 % 60 is -40, which is absolutely wrong and should burn const domParser = new DOMParser();
var sleepSeconds = (((targetSecond - new Date().getSeconds()) % 60) + 60) % 60; const fakeDOM = domParser.parseFromString(ajax.responseText, 'text/html');
return sleepSeconds || 60;
};
let updateTracker = () => { // Update item tracker
const ajax = new XMLHttpRequest(); document.getElementById('inventory-table').innerHTML = fakeDOM.getElementById('inventory-table').innerHTML;
ajax.onreadystatechange = () => { // Update only counters in the location-table
if (ajax.readyState !== 4) { return; } let counters = document.getElementsByClassName('counter');
const fakeCounters = fakeDOM.getElementsByClassName('counter');
// Create a fake DOM using the returned HTML for (let i = 0; i < counters.length; i++) {
const domParser = new DOMParser(); counters[i].innerHTML = fakeCounters[i].innerHTML;
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); ajax.open('GET', url);
ajax.send();
}, 15000)
// Collapsible advancement sections
const categories = document.getElementsByClassName("location-category");
for (let category of categories) {
let hide_id = category.id.split('_')[0];
if (hide_id === 'Total') {
continue;
}
category.addEventListener('click', function() {
// Toggle the advancement list
document.getElementById(hide_id).classList.toggle("hide");
// Change text of the header
const tab_header = document.getElementById(hide_id+'_header').children[0];
const orig_text = tab_header.innerHTML;
let new_text;
if (orig_text.includes("▼")) {
new_text = orig_text.replace("▼", "▲");
}
else {
new_text = orig_text.replace("▲", "▼");
}
tab_header.innerHTML = new_text;
});
}
}); });

View File

@@ -0,0 +1,47 @@
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();
});

View File

@@ -0,0 +1,58 @@
window.addEventListener('load', () => {
const tutorialWrapper = document.getElementById('tutorial-wrapper');
new Promise((resolve, reject) => {
const ajax = new XMLHttpRequest();
ajax.onreadystatechange = () => {
if (ajax.readyState !== 4) { return; }
if (ajax.status === 404) {
reject("Sorry, the tutorial is not available in that language yet.");
return;
}
if (ajax.status !== 200) {
reject("Something went wrong while loading the tutorial.");
return;
}
resolve(ajax.responseText);
};
ajax.open('GET', `${window.location.origin}/static/generated/docs/` +
`${tutorialWrapper.getAttribute('data-game')}/${tutorialWrapper.getAttribute('data-file')}_` +
`${tutorialWrapper.getAttribute('data-lang')}.md`, true);
ajax.send();
}).then((results) => {
// Populate page with HTML generated from markdown
showdown.setOption('tables', true);
showdown.setOption('strikethrough', true);
showdown.setOption('literalMidWordUnderscores', true);
showdown.setOption('disableForced4SpacesIndentedSublists', true);
tutorialWrapper.innerHTML += (new showdown.Converter()).makeHtml(results);
adjustHeaderWidth();
const title = document.querySelector('h1')
if (title) {
document.title = title.textContent;
}
// Reset the id of all header divs to something nicer
for (const header of document.querySelectorAll('h1, h2, h3, h4, h5, h6')) {
const headerId = header.innerText.replace(/\s+/g, '-').toLowerCase();
header.setAttribute('id', headerId);
header.addEventListener('click', () => {
window.location.hash = `#${headerId}`;
header.scrollIntoView();
});
}
// Manually scroll the user to the appropriate header if anchor navigation is used
document.fonts.ready.finally(() => {
if (window.location.hash) {
const scrollTarget = document.getElementById(window.location.hash.substring(1));
scrollTarget?.scrollIntoView();
}
});
}).catch((error) => {
console.error(error);
tutorialWrapper.innerHTML =
`<h2>This page is out of logic!</h2>
<h3>Click <a href="${window.location.origin}/tutorial">here</a> to return to safety.</h3>`;
});
});

View File

@@ -0,0 +1,81 @@
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();
});

Binary file not shown.

Before

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.8 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.9 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Some files were not shown because too many files have changed in this diff Show More