Compare commits

..

1 Commits

Author SHA1 Message Date
CaitSith2
3738399348 Factorio: Add an Allow Collect Option 2023-11-26 21:25:43 -08:00
2317 changed files with 70387 additions and 310782 deletions

View File

@@ -1,5 +0,0 @@
[report]
exclude_lines =
pragma: no cover
if TYPE_CHECKING:
if typing.TYPE_CHECKING:

2
.gitattributes vendored
View File

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

31
.github/labeler.yml vendored
View File

@@ -1,31 +0,0 @@
'is: documentation':
- changed-files:
- all-globs-to-all-files: '{**/docs/**,**/README.md}'
'affects: webhost':
- changed-files:
- all-globs-to-any-file: 'WebHost.py'
- all-globs-to-any-file: 'WebHostLib/**/*'
'affects: core':
- changed-files:
- all-globs-to-any-file:
- '!*Client.py'
- '!README.md'
- '!LICENSE'
- '!*.yml'
- '!.gitignore'
- '!**/docs/**'
- '!typings/kivy/**'
- '!test/**'
- '!data/**'
- '!.run/**'
- '!.github/**'
- '!worlds_disabled/**'
- '!worlds/**'
- '!WebHost.py'
- '!WebHostLib/**'
- any-glob-to-any-file: # exceptions to the above rules of "stuff that isn't core"
- 'worlds/generic/**/*.py'
- 'worlds/*.py'
- 'CommonClient.py'

View File

@@ -1,40 +0,0 @@
{
"include": [
"../BizHawkClient.py",
"../Patch.py",
"../test/param.py",
"../test/general/test_groups.py",
"../test/general/test_helpers.py",
"../test/general/test_memory.py",
"../test/general/test_names.py",
"../test/multiworld/__init__.py",
"../test/multiworld/test_multiworlds.py",
"../test/netutils/__init__.py",
"../test/programs/__init__.py",
"../test/programs/test_multi_server.py",
"../test/utils/__init__.py",
"../test/webhost/test_descriptions.py",
"../worlds/AutoSNIClient.py",
"type_check.py"
],
"exclude": [
"**/__pycache__"
],
"stubPath": "../typings",
"typeCheckingMode": "strict",
"reportImplicitOverride": "error",
"reportMissingImports": true,
"reportMissingTypeStubs": true,
"pythonVersion": "3.10",
"pythonPlatform": "Windows",
"executionEnvironments": [
{
"root": ".."
}
]
}

15
.github/type_check.py vendored
View File

@@ -1,15 +0,0 @@
from pathlib import Path
import subprocess
config = Path(__file__).parent / "pyright-config.json"
command = ("pyright", "-p", str(config))
print(" ".join(command))
try:
result = subprocess.run(command)
except FileNotFoundError as e:
print(f"{e} - Is pyright installed?")
exit(1)
exit(result.returncode)

View File

@@ -25,7 +25,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v3
- name: "Determine modified files (pull_request)" - name: "Determine modified files (pull_request)"
if: github.event_name == 'pull_request' if: github.event_name == 'pull_request'
@@ -50,10 +50,10 @@ jobs:
run: | run: |
echo "diff=." >> $GITHUB_ENV echo "diff=." >> $GITHUB_ENV
- uses: actions/setup-python@v5 - uses: actions/setup-python@v4
if: env.diff != '' if: env.diff != ''
with: with:
python-version: '3.10' python-version: 3.8
- name: "Install dependencies" - name: "Install dependencies"
if: env.diff != '' if: env.diff != ''
@@ -71,7 +71,7 @@ jobs:
continue-on-error: true continue-on-error: true
if: env.diff != '' && matrix.task == 'flake8' if: env.diff != '' && matrix.task == 'flake8'
run: | run: |
flake8 --count --max-complexity=14 --max-doc-length=120 --max-line-length=120 --statistics ${{ env.diff }} flake8 --count --max-complexity=10 --max-doc-length=120 --max-line-length=120 --statistics ${{ env.diff }}
- name: "mypy: Type check modified files" - name: "mypy: Type check modified files"
continue-on-error: true continue-on-error: true

View File

@@ -8,13 +8,11 @@ on:
- '.github/workflows/build.yml' - '.github/workflows/build.yml'
- 'setup.py' - 'setup.py'
- 'requirements.txt' - 'requirements.txt'
- '*.iss'
pull_request: pull_request:
paths: paths:
- '.github/workflows/build.yml' - '.github/workflows/build.yml'
- 'setup.py' - 'setup.py'
- 'requirements.txt' - 'requirements.txt'
- '*.iss'
workflow_dispatch: workflow_dispatch:
env: env:
@@ -24,28 +22,22 @@ env:
jobs: jobs:
# build-release-macos: # LF volunteer # build-release-macos: # LF volunteer
build-win: # RCs will still be built and signed by hand build-win-py38: # RCs will still be built and signed by hand
runs-on: windows-latest runs-on: windows-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v3
- name: Install python - name: Install python
uses: actions/setup-python@v5 uses: actions/setup-python@v4
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
Expand-Archive -Path enemizer.zip -DestinationPath EnemizerCLI -Force Expand-Archive -Path enemizer.zip -DestinationPath EnemizerCLI -Force
choco install innosetup --version=6.2.2 --allow-downgrade
- name: Build - name: Build
run: | run: |
python -m pip install --upgrade pip python -m pip install --upgrade pip
python setup.py build_exe --yes 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] $NAME="$(ls build | Select-String -Pattern 'exe')".Split('.',2)[1]
$ZIP_NAME="Archipelago_$NAME.7z" $ZIP_NAME="Archipelago_$NAME.7z"
echo "$NAME -> $ZIP_NAME" echo "$NAME -> $ZIP_NAME"
@@ -54,69 +46,30 @@ jobs:
cd build cd build
Rename-Item "exe.$NAME" Archipelago Rename-Item "exe.$NAME" Archipelago
7z a -mx=9 -mhe=on -ms "../dist/$ZIP_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
- name: Check build loads expected worlds
shell: bash
run: |
cd build/exe*
mv Players/Templates/meta.yaml .
ls -1 Players/Templates | sort > setup-player-templates.txt
rm -R Players/Templates
timeout 30 ./ArchipelagoLauncher "Generate Template Options" || true
ls -1 Players/Templates | sort > generated-player-templates.txt
cmp setup-player-templates.txt generated-player-templates.txt \
|| diff setup-player-templates.txt generated-player-templates.txt
mv meta.yaml Players/Templates/
- name: Test Generate
shell: bash
run: |
cd build/exe*
cp Players/Templates/Clique.yaml Players/
timeout 30 ./ArchipelagoGenerate
- name: Store 7z - name: Store 7z
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v3
with: with:
name: ${{ env.ZIP_NAME }} name: ${{ env.ZIP_NAME }}
path: dist/${{ env.ZIP_NAME }} path: dist/${{ env.ZIP_NAME }}
compression-level: 0 # .7z is incompressible by zip
if-no-files-found: error
retention-days: 7 # keep for 7 days, should be enough
- name: Store Setup
uses: actions/upload-artifact@v4
with:
name: ${{ env.SETUP_NAME }}
path: setups/${{ env.SETUP_NAME }}
if-no-files-found: error
retention-days: 7 # keep for 7 days, should be enough retention-days: 7 # keep for 7 days, should be enough
build-ubuntu2004: build-ubuntu2004:
runs-on: ubuntu-20.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@v3
- name: Install base dependencies - name: Install base dependencies
run: | run: |
sudo apt update sudo apt update
sudo apt -y install build-essential p7zip xz-utils wget libglib2.0-0 sudo apt -y install build-essential p7zip xz-utils wget libglib2.0-0
sudo apt -y install python3-gi libgirepository1.0-dev # should pull dependencies for gi installation below sudo apt -y install python3-gi libgirepository1.0-dev # should pull dependencies for gi installation below
- name: Get a recent python - name: Get a recent python
uses: actions/setup-python@v5 uses: actions/setup-python@v4
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/AppImageKit/releases/download/$APPIMAGETOOL_VERSION/appimagetool-x86_64.AppImage wget -nv https://github.com/AppImage/AppImageKit/releases/download/$APPIMAGETOOL_VERSION/appimagetool-x86_64.AppImage
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
@@ -132,13 +85,13 @@ 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`"
cd dist && export APPIMAGE_NAME="`ls *.AppImage`" && cd .. cd dist && export APPIMAGE_NAME="`ls *.AppImage`" && cd ..
export TAR_NAME="${APPIMAGE_NAME%.AppImage}.tar.gz" export TAR_NAME="${APPIMAGE_NAME%.AppImage}.tar.gz"
(cd build && DIR_NAME="`ls | grep exe`" && mv "$DIR_NAME" Archipelago && tar -cv Archipelago | gzip -8 > ../dist/$TAR_NAME && mv Archipelago "$DIR_NAME") (cd build && DIR_NAME="`ls | grep exe`" && mv "$DIR_NAME" Archipelago && tar -czvf ../dist/$TAR_NAME Archipelago && mv Archipelago "$DIR_NAME")
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 -
@@ -146,36 +99,15 @@ jobs:
run: | run: |
source venv/bin/activate source venv/bin/activate
python setup.py build_exe --yes python setup.py build_exe --yes
- name: Check build loads expected worlds
shell: bash
run: |
cd build/exe*
mv Players/Templates/meta.yaml .
ls -1 Players/Templates | sort > setup-player-templates.txt
rm -R Players/Templates
timeout 30 ./ArchipelagoLauncher "Generate Template Options" || true
ls -1 Players/Templates | sort > generated-player-templates.txt
cmp setup-player-templates.txt generated-player-templates.txt \
|| diff setup-player-templates.txt generated-player-templates.txt
mv meta.yaml Players/Templates/
- name: Test Generate
shell: bash
run: |
cd build/exe*
cp Players/Templates/Clique.yaml Players/
timeout 30 ./ArchipelagoGenerate
- name: Store AppImage - name: Store AppImage
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v3
with: with:
name: ${{ env.APPIMAGE_NAME }} name: ${{ env.APPIMAGE_NAME }}
path: dist/${{ env.APPIMAGE_NAME }} path: dist/${{ env.APPIMAGE_NAME }}
if-no-files-found: error
retention-days: 7 retention-days: 7
- name: Store .tar.gz - name: Store .tar.gz
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v3
with: with:
name: ${{ env.TAR_NAME }} name: ${{ env.TAR_NAME }}
path: dist/${{ env.TAR_NAME }} path: dist/${{ env.TAR_NAME }}
compression-level: 0 # .gz is incompressible by zip
if-no-files-found: error
retention-days: 7 retention-days: 7

View File

@@ -43,11 +43,11 @@ jobs:
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v3
# 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

@@ -1,54 +0,0 @@
# Run CMake / CTest C++ unit tests
name: ctest
on:
push:
paths:
- '**.cc?'
- '**.cpp'
- '**.cxx'
- '**.hh?'
- '**.hpp'
- '**.hxx'
- '**/CMakeLists.txt'
- '.github/workflows/ctest.yml'
pull_request:
paths:
- '**.cc?'
- '**.cpp'
- '**.cxx'
- '**.hh?'
- '**.hpp'
- '**.hxx'
- '**/CMakeLists.txt'
- '.github/workflows/ctest.yml'
jobs:
ctest:
runs-on: ${{ matrix.os }}
name: Test C++ ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest]
steps:
- uses: actions/checkout@v4
- uses: ilammy/msvc-dev-cmd@0b201ec74fa43914dc39ae48a89fd1d8cb592756
if: startsWith(matrix.os,'windows')
- uses: Bacondish2023/setup-googletest@49065d1f7a6d21f6134864dd65980fe5dbe06c73
with:
build-type: 'Release'
- name: Build tests
run: |
cd test/cpp
mkdir build
cmake -S . -B build/ -DCMAKE_BUILD_TYPE=Release
cmake --build build/ --config Release
ls
- name: Run tests
run: |
cd test/cpp
ctest --test-dir build/ -C Release --output-on-failure

View File

@@ -1,46 +0,0 @@
name: Label Pull Request
on:
pull_request_target:
types: ['opened', 'reopened', 'synchronize', 'ready_for_review', 'converted_to_draft', 'closed']
branches: ['main']
permissions:
contents: read
pull-requests: write
jobs:
labeler:
name: 'Apply content-based labels'
if: github.event.action == 'opened' || github.event.action == 'reopened' || github.event.action == 'synchronize'
runs-on: ubuntu-latest
steps:
- uses: actions/labeler@v5
with:
sync-labels: false
peer_review:
name: 'Apply peer review label'
needs: labeler
if: >-
(github.event.action == 'opened' || github.event.action == 'reopened' ||
github.event.action == 'ready_for_review') && !github.event.pull_request.draft
runs-on: ubuntu-latest
steps:
- name: 'Add label'
run: "gh pr edit \"$PR_URL\" --add-label 'waiting-on: peer-review'"
env:
PR_URL: ${{ github.event.pull_request.html_url }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
unblock_draft_prs:
name: 'Remove waiting-on labels'
needs: labeler
if: github.event.action == 'converted_to_draft' || github.event.action == 'closed'
runs-on: ubuntu-latest
steps:
- name: 'Remove labels'
run: |-
gh pr edit "$PR_URL" --remove-label 'waiting-on: peer-review' \
--remove-label 'waiting-on: core-review' \
--remove-label 'waiting-on: world-maintainer' \
--remove-label 'waiting-on: author'
env:
PR_URL: ${{ github.event.pull_request.html_url }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -18,7 +18,7 @@ jobs:
- name: Set env - name: Set env
run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV # tag x.y.z will become "Archipelago x.y.z" run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV # tag x.y.z will become "Archipelago x.y.z"
- name: Create Release - name: Create Release
uses: softprops/action-gh-release@975c1b265e11dd76618af1c374e7981f9a6ff44a uses: softprops/action-gh-release@b7e450da2a4b4cb4bfbae528f788167786cfcedf
with: with:
draft: true # don't publish right away, especially since windows build is added by hand draft: true # don't publish right away, especially since windows build is added by hand
prerelease: false prerelease: false
@@ -35,20 +35,19 @@ jobs:
- name: Set env - name: Set env
run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV
# - code below copied from build.yml - # - code below copied from build.yml -
- uses: actions/checkout@v4 - uses: actions/checkout@v3
- name: Install base dependencies - name: Install base dependencies
run: | run: |
sudo apt update sudo apt update
sudo apt -y install build-essential p7zip xz-utils wget libglib2.0-0 sudo apt -y install build-essential p7zip xz-utils wget libglib2.0-0
sudo apt -y install python3-gi libgirepository1.0-dev # should pull dependencies for gi installation below sudo apt -y install python3-gi libgirepository1.0-dev # should pull dependencies for gi installation below
- name: Get a recent python - name: Get a recent python
uses: actions/setup-python@v5 uses: actions/setup-python@v4
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/AppImageKit/releases/download/$APPIMAGETOOL_VERSION/appimagetool-x86_64.AppImage wget -nv https://github.com/AppImage/AppImageKit/releases/download/$APPIMAGETOOL_VERSION/appimagetool-x86_64.AppImage
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
@@ -64,18 +63,18 @@ 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`"
cd dist && export APPIMAGE_NAME="`ls *.AppImage`" && cd .. cd dist && export APPIMAGE_NAME="`ls *.AppImage`" && cd ..
export TAR_NAME="${APPIMAGE_NAME%.AppImage}.tar.gz" export TAR_NAME="${APPIMAGE_NAME%.AppImage}.tar.gz"
(cd build && DIR_NAME="`ls | grep exe`" && mv "$DIR_NAME" Archipelago && tar -cv Archipelago | gzip -8 > ../dist/$TAR_NAME && mv Archipelago "$DIR_NAME") (cd build && DIR_NAME="`ls | grep exe`" && mv "$DIR_NAME" Archipelago && tar -czvf ../dist/$TAR_NAME Archipelago && mv Archipelago "$DIR_NAME")
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: Add to Release - name: Add to Release
uses: softprops/action-gh-release@975c1b265e11dd76618af1c374e7981f9a6ff44a uses: softprops/action-gh-release@b7e450da2a4b4cb4bfbae528f788167786cfcedf
with: with:
draft: true # see above draft: true # see above
prerelease: false prerelease: false

View File

@@ -1,65 +0,0 @@
name: Native Code Static Analysis
on:
push:
paths:
- '**.c'
- '**.cc'
- '**.cpp'
- '**.cxx'
- '**.h'
- '**.hh'
- '**.hpp'
- '**.pyx'
- 'setup.py'
- 'requirements.txt'
- '.github/workflows/scan-build.yml'
pull_request:
paths:
- '**.c'
- '**.cc'
- '**.cpp'
- '**.cxx'
- '**.h'
- '**.hh'
- '**.hpp'
- '**.pyx'
- 'setup.py'
- 'requirements.txt'
- '.github/workflows/scan-build.yml'
jobs:
scan-build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
- name: Install newer Clang
run: |
wget https://apt.llvm.org/llvm.sh
chmod +x ./llvm.sh
sudo ./llvm.sh 19
- name: Install scan-build command
run: |
sudo apt install clang-tools-19
- name: Get a recent python
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install dependencies
run: |
python -m venv venv
source venv/bin/activate
python -m pip install --upgrade pip -r requirements.txt
- name: scan-build
run: |
source venv/bin/activate
scan-build-19 --status-bugs -o scan-build-reports -disable-checker deadcode.DeadStores python setup.py build -y
- name: Store report
if: failure()
uses: actions/upload-artifact@v4
with:
name: scan-build-reports
path: scan-build-reports

View File

@@ -1,33 +0,0 @@
name: type check
on:
pull_request:
paths:
- "**.py"
- ".github/pyright-config.json"
- ".github/workflows/strict-type-check.yml"
- "**.pyi"
push:
paths:
- "**.py"
- ".github/pyright-config.json"
- ".github/workflows/strict-type-check.yml"
- "**.pyi"
jobs:
pyright:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: "Install dependencies"
run: |
python -m pip install --upgrade pip pyright==1.1.392.post0
python ModuleUpdate.py --append "WebHostLib/requirements.txt" --force --yes
- name: "pyright: strict check on specific files"
run: python .github/type_check.py

View File

@@ -24,7 +24,7 @@ on:
- '.github/workflows/unittests.yml' - '.github/workflows/unittests.yml'
jobs: jobs:
unit: build:
runs-on: ${{ matrix.os }} runs-on: ${{ matrix.os }}
name: Test Python ${{ matrix.python.version }} ${{ matrix.os }} name: Test Python ${{ matrix.python.version }} ${{ matrix.os }}
@@ -33,21 +33,22 @@ jobs:
matrix: matrix:
os: [ubuntu-latest] os: [ubuntu-latest]
python: python:
- {version: '3.8'}
- {version: '3.9'}
- {version: '3.10'} - {version: '3.10'}
- {version: '3.11'} - {version: '3.11'}
- {version: '3.12'}
include: include:
- python: {version: '3.10'} # old compat - python: {version: '3.8'} # win7 compat
os: windows-latest os: windows-latest
- python: {version: '3.12'} # current - python: {version: '3.11'} # current
os: windows-latest os: windows-latest
- python: {version: '3.12'} # current - python: {version: '3.11'} # current
os: macos-latest os: macos-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v3
- name: Set up Python ${{ matrix.python.version }} - name: Set up Python ${{ matrix.python.version }}
uses: actions/setup-python@v5 uses: actions/setup-python@v4
with: with:
python-version: ${{ matrix.python.version }} python-version: ${{ matrix.python.version }}
- name: Install dependencies - name: Install dependencies
@@ -59,32 +60,3 @@ jobs:
- name: Unittests - name: Unittests
run: | run: |
pytest -n auto pytest -n auto
hosting:
runs-on: ${{ matrix.os }}
name: Test hosting with ${{ matrix.python.version }} on ${{ matrix.os }}
strategy:
matrix:
os:
- ubuntu-latest
python:
- {version: '3.12'} # current
steps:
- uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python.version }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python.version }}
- name: Install dependencies
run: |
python -m venv venv
source venv/bin/activate
python -m pip install --upgrade pip
python ModuleUpdate.py --yes --force --append "WebHostLib/requirements.txt"
- name: Test hosting
run: |
source venv/bin/activate
export PYTHONPATH=$(pwd)
timeout 600 python test/hosting/__main__.py

6
.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
@@ -64,7 +62,6 @@ Output Logs/
/installdelete.iss /installdelete.iss
/data/user.kv /data/user.kv
/datapackage /datapackage
/custom_worlds
# Byte-compiled / optimized / DLL files # Byte-compiled / optimized / DLL files
__pycache__/ __pycache__/
@@ -152,7 +149,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
@@ -180,7 +177,6 @@ dmypy.json
cython_debug/ cython_debug/
# Cython intermediates # Cython intermediates
_speedups.c
_speedups.cpp _speedups.cpp
_speedups.html _speedups.html

View File

@@ -1,18 +0,0 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="Archipelago Unittests" type="tests" factoryName="Unittests">
<module name="Archipelago" />
<option name="INTERPRETER_OPTIONS" value="" />
<option name="PARENT_ENVS" value="true" />
<option name="SDK_HOME" value="" />
<option name="WORKING_DIRECTORY" value="$PROJECT_DIR$" />
<option name="IS_MODULE_SDK" value="true" />
<option name="ADD_CONTENT_ROOTS" value="true" />
<option name="ADD_SOURCE_ROOTS" value="true" />
<EXTENSION ID="PythonCoverageRunConfigurationExtension" runner="coverage.py" />
<option name="_new_pattern" value="&quot;&quot;" />
<option name="_new_additionalArguments" value="&quot;&quot;" />
<option name="_new_target" value="&quot;$PROJECT_DIR$/test&quot;" />
<option name="_new_targetType" value="&quot;PATH&quot;" />
<method v="2" />
</configuration>
</component>

View File

@@ -1,8 +0,0 @@
from worlds.ahit.Client import launch
import Utils
import ModuleUpdate
ModuleUpdate.update()
if __name__ == "__main__":
Utils.init_logging("AHITClient", exception_logger="Client")
launch()

View File

@@ -80,7 +80,7 @@ class AdventureContext(CommonContext):
self.local_item_locations = {} self.local_item_locations = {}
self.dragon_speed_info = {} self.dragon_speed_info = {}
options = Utils.get_settings() options = Utils.get_options()
self.display_msgs = options["adventure_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):
@@ -102,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 Utils.get_settings()["adventure_options"].get("death_link", False): if Utils.get_options()["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":
@@ -112,15 +112,14 @@ class AdventureContext(CommonContext):
if ': !' not in msg: if ': !' not in msg:
self._set_message(msg, SYSTEM_MESSAGE_ID) self._set_message(msg, SYSTEM_MESSAGE_ID)
elif cmd == "ReceivedItems": elif cmd == "ReceivedItems":
msg = f"Received {', '.join([self.item_names.lookup_in_game(item.item) for item in args['items']])}" msg = f"Received {', '.join([self.item_names[item.item] for item in args['items']])}"
self._set_message(msg, SYSTEM_MESSAGE_ID) self._set_message(msg, SYSTEM_MESSAGE_ID)
elif cmd == "Retrieved": elif cmd == "Retrieved":
if f"adventure_{self.auth}_freeincarnates_used" in args["keys"]: self.freeincarnates_used = args["keys"][f"adventure_{self.auth}_freeincarnates_used"]
self.freeincarnates_used = args["keys"][f"adventure_{self.auth}_freeincarnates_used"] if self.freeincarnates_used is None:
if self.freeincarnates_used is None: self.freeincarnates_used = 0
self.freeincarnates_used = 0 self.freeincarnates_used += self.freeincarnate_pending
self.freeincarnates_used += self.freeincarnate_pending self.send_pending_freeincarnates()
self.send_pending_freeincarnates()
elif cmd == "SetReply": elif cmd == "SetReply":
if args["key"] == f"adventure_{self.auth}_freeincarnates_used": if args["key"] == f"adventure_{self.auth}_freeincarnates_used":
self.freeincarnates_used = args["value"] self.freeincarnates_used = args["value"]
@@ -415,8 +414,8 @@ async def atari_sync_task(ctx: AdventureContext):
async def run_game(romfile): async def run_game(romfile):
auto_start = Utils.get_settings()["adventure_options"].get("rom_start", True) auto_start = Utils.get_options()["adventure_options"].get("rom_start", True)
rom_args = Utils.get_settings()["adventure_options"].get("rom_args") rom_args = Utils.get_options()["adventure_options"].get("rom_args")
if auto_start is True: if auto_start is True:
import webbrowser import webbrowser
webbrowser.open(romfile) webbrowser.open(romfile)
@@ -511,7 +510,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

@@ -1,6 +1,5 @@
from __future__ import annotations from __future__ import annotations
import collections
import copy import copy
import logging import logging
import asyncio import asyncio
@@ -9,7 +8,6 @@ import sys
import typing import typing
import time import time
import functools import functools
import warnings
import ModuleUpdate import ModuleUpdate
ModuleUpdate.update() ModuleUpdate.update()
@@ -22,8 +20,8 @@ if __name__ == "__main__":
Utils.init_logging("TextClient", exception_logger="Client") Utils.init_logging("TextClient", exception_logger="Client")
from MultiServer import CommandProcessor from MultiServer import CommandProcessor
from NetUtils import (Endpoint, decode, NetworkItem, encode, JSONtoTextParser, ClientStatus, Permission, NetworkSlot, from NetUtils import Endpoint, decode, NetworkItem, encode, JSONtoTextParser, \
RawJSONtoTextParser, add_json_text, add_json_location, add_json_item, JSONTypes, HintStatus, SlotType) ClientStatus, Permission, NetworkSlot, RawJSONtoTextParser
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 +29,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 +43,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 +59,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
@@ -87,16 +72,9 @@ class ClientCommandProcessor(CommandProcessor):
def _cmd_received(self) -> bool: def _cmd_received(self) -> bool:
"""List all received items""" """List all received items"""
item: NetworkItem self.output(f'{len(self.ctx.items_received)} received items:')
self.output(f'{len(self.ctx.items_received)} received items, sorted by time:')
for index, item in enumerate(self.ctx.items_received, 1): for index, item in enumerate(self.ctx.items_received, 1):
parts = [] self.output(f"{self.ctx.item_names[item.item]} from {self.ctx.player_names[item.player]}")
add_json_item(parts, item.item, self.ctx.slot, item.flags)
add_json_text(parts, " from ")
add_json_location(parts, item.location, item.player)
add_json_text(parts, " by ")
add_json_text(parts, item.player, type=JSONTypes.player_id)
self.ctx.on_print_json({"data": parts, "cmd": "PrintJSON"})
return True return True
def _cmd_missing(self, filter_text = "") -> bool: def _cmd_missing(self, filter_text = "") -> bool:
@@ -137,15 +115,6 @@ class ClientCommandProcessor(CommandProcessor):
for item_name in AutoWorldRegister.world_types[self.ctx.game].item_name_to_id: for item_name in AutoWorldRegister.world_types[self.ctx.game].item_name_to_id:
self.output(item_name) self.output(item_name)
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
self.output(f"Item Group Names for {self.ctx.game}")
for group_name in AutoWorldRegister.world_types[self.ctx.game].item_name_groups:
self.output(group_name)
def _cmd_locations(self): def _cmd_locations(self):
"""List all location names for the currently running game.""" """List all location names for the currently running game."""
if not self.ctx.game: if not self.ctx.game:
@@ -155,15 +124,6 @@ class ClientCommandProcessor(CommandProcessor):
for location_name in AutoWorldRegister.world_types[self.ctx.game].location_name_to_id: for location_name in AutoWorldRegister.world_types[self.ctx.game].location_name_to_id:
self.output(location_name) self.output(location_name)
def _cmd_location_groups(self):
"""List all location group names for the currently running game."""
if not self.ctx.game:
self.output("No game set, cannot determine existing location groups.")
return False
self.output(f"Location Group Names for {self.ctx.game}")
for group_name in AutoWorldRegister.world_types[self.ctx.game].location_name_groups:
self.output(group_name)
def _cmd_ready(self): def _cmd_ready(self):
"""Send ready status to server.""" """Send ready status to server."""
self.ctx.ready = not self.ctx.ready self.ctx.ready = not self.ctx.ready
@@ -176,96 +136,28 @@ class ClientCommandProcessor(CommandProcessor):
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")
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
want_slot_data: bool = True # should slot_data be retrieved via Connect want_slot_data: bool = True # should slot_data be retrieved via Connect
class NameLookupDict: # data package
"""A specialized dict, with helper methods, for id -> name item/location data package lookups by game.""" # Contents in flux until connection to server is made, to download correct data for this multiworld.
def __init__(self, ctx: CommonContext, lookup_type: typing.Literal["item", "location"]): item_names: typing.Dict[int, str] = Utils.KeyedDefaultDict(lambda code: f'Unknown item (ID:{code})')
self.ctx: CommonContext = ctx location_names: typing.Dict[int, str] = Utils.KeyedDefaultDict(lambda code: f'Unknown location (ID:{code})')
self.lookup_type: typing.Literal["item", "location"] = lookup_type
self._unknown_item: typing.Callable[[int], str] = lambda key: f"Unknown {lookup_type} (ID: {key})"
self._archipelago_lookup: typing.Dict[int, str] = {}
self._flat_store: typing.Dict[int, str] = Utils.KeyedDefaultDict(self._unknown_item)
self._game_store: typing.Dict[str, typing.ChainMap[int, str]] = collections.defaultdict(
lambda: collections.ChainMap(self._archipelago_lookup, Utils.KeyedDefaultDict(self._unknown_item)))
self.warned: bool = False
# noinspection PyTypeChecker
def __getitem__(self, key: str) -> typing.Mapping[int, str]:
# TODO: In a future version (0.6.0?) this should be simplified by removing implicit id lookups support.
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]
def __len__(self) -> int:
return len(self._game_store)
def __iter__(self) -> typing.Iterator[str]:
return iter(self._game_store)
def __repr__(self) -> str:
return self._game_store.__repr__()
def lookup_in_game(self, code: int, game_name: typing.Optional[str] = None) -> str:
"""Returns the name for an item/location id in the context of a specific game or own game if `game` is
omitted.
"""
if game_name is None:
game_name = self.ctx.game
assert game_name is not None, f"Attempted to lookup {self.lookup_type} with no game name available."
return self._game_store[game_name][code]
def lookup_in_slot(self, code: int, slot: typing.Optional[int] = None) -> str:
"""Returns the name for an item/location id in the context of a specific slot or own slot if `slot` is
omitted.
Use of `lookup_in_slot` should not be used when not connected to a server. If looking in own game, set
`ctx.game` and use `lookup_in_game` method instead.
"""
if slot is None:
slot = self.ctx.slot
assert slot is not None, f"Attempted to lookup {self.lookup_type} with no slot info available."
return self.lookup_in_game(code, self.ctx.slot_info[slot].game)
def update_game(self, game: str, name_to_id_lookup_table: typing.Dict[str, int]) -> None:
"""Overrides existing lookup tables for a particular game."""
id_to_name_lookup_table = Utils.KeyedDefaultDict(self._unknown_item)
id_to_name_lookup_table.update({code: name for name, code in name_to_id_lookup_table.items()})
self._game_store[game] = collections.ChainMap(self._archipelago_lookup, id_to_name_lookup_table)
self._flat_store.update(id_to_name_lookup_table) # Only needed for legacy lookup method.
if game == "Archipelago":
# Keep track of the Archipelago data package separately so if it gets updated in a custom datapackage,
# it updates in all chain maps automatically.
self._archipelago_lookup.clear()
self._archipelago_lookup.update(id_to_name_lookup_table)
# defaults # defaults
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
@@ -276,7 +168,6 @@ class CommonContext:
server_version: Version = Version(0, 0, 0) server_version: Version = Version(0, 0, 0)
generator_version: Version = Version(0, 0, 0) generator_version: Version = Version(0, 0, 0)
current_energy_link_value: typing.Optional[int] = None # to display in UI, gets set by server current_energy_link_value: typing.Optional[int] = None # to display in UI, gets set by server
max_size: int = 16*1024*1024 # 16 MB of max incoming packet size
last_death_link: float = time.time() # last send/received death link on AP layer last_death_link: float = time.time() # last send/received death link on AP layer
@@ -290,8 +181,6 @@ class CommonContext:
finished_game: bool finished_game: bool
ready: bool ready: bool
team: typing.Optional[int]
slot: typing.Optional[int]
auth: typing.Optional[str] auth: typing.Optional[str]
seed_name: typing.Optional[str] seed_name: typing.Optional[str]
@@ -314,7 +203,7 @@ class CommonContext:
# message box reporting a loss of connection # message box reporting a loss of connection
_messagebox_connection_loss: typing.Optional["kvui.MessageBox"] = None _messagebox_connection_loss: typing.Optional["kvui.MessageBox"] = None
def __init__(self, server_address: typing.Optional[str] = None, password: typing.Optional[str] = None) -> None: def __init__(self, server_address: typing.Optional[str], password: typing.Optional[str]) -> None:
# server state # server state
self.server_address = server_address self.server_address = server_address
self.username = None self.username = None
@@ -354,11 +243,6 @@ class CommonContext:
self.exit_event = asyncio.Event() self.exit_event = asyncio.Event()
self.watcher_event = asyncio.Event() self.watcher_event = asyncio.Event()
self.item_names = self.NameLookupDict(self, "item")
self.location_names = self.NameLookupDict(self, "location")
self.versions = {}
self.checksums = {}
self.jsontotextparser = JSONtoTextParser(self) self.jsontotextparser = JSONtoTextParser(self)
self.rawjsontotextparser = RawJSONtoTextParser(self) self.rawjsontotextparser = RawJSONtoTextParser(self)
self.update_data_package(network_data_package) self.update_data_package(network_data_package)
@@ -413,7 +297,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
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 """
@@ -445,10 +328,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,
@@ -458,14 +338,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:
@@ -486,7 +358,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:
@@ -494,7 +365,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
@@ -527,13 +397,7 @@ class CommonContext:
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:
"""Gets called by kivy when the user executes a command starting with `/` or `!`.
The command processor is still called; this is just intended for command echoing."""
self.ui.print_json([{"text": text, "type": "color", "color": "orange"}])
def update_permissions(self, permissions: typing.Dict[str, int]): 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)
@@ -545,7 +409,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()
@@ -560,14 +423,7 @@ 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_date_package_versions: typing.Dict[str, int],
@@ -589,45 +445,38 @@ class CommonContext:
needed_updates.add(game) needed_updates.add(game)
continue continue
cached_version: int = self.versions.get(game, 0) local_version: int = network_data_package["games"].get(game, {}).get("version", 0)
cached_checksum: typing.Optional[str] = self.checksums.get(game) local_checksum: typing.Optional[str] = network_data_package["games"].get(game, {}).get("checksum")
# no action required if cached version is new enough # no action required if local version is new enough
if (not remote_checksum and (remote_version > cached_version or remote_version == 0)) \ if (not remote_checksum and (remote_version > local_version or remote_version == 0)) \
or remote_checksum != cached_checksum: or remote_checksum != local_checksum:
local_version: int = network_data_package["games"].get(game, {}).get("version", 0) cached_game = Utils.load_data_package_for_checksum(game, remote_checksum)
local_checksum: typing.Optional[str] = network_data_package["games"].get(game, {}).get("checksum") cache_version: int = cached_game.get("version", 0)
if ((remote_checksum or remote_version <= local_version and remote_version != 0) cache_checksum: typing.Optional[str] = cached_game.get("checksum")
and remote_checksum == local_checksum): # download remote version if cache is not new enough
self.update_game(network_data_package["games"][game], game) 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)
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:
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": list(needed_updates)}])
def update_game(self, game_package: dict, game: str): def update_game(self, game_package: dict):
self.item_names.update_game(game, game_package["item_name_to_id"]) for item_name, item_id in game_package["item_name_to_id"].items():
self.location_names.update_game(game, game_package["location_name_to_id"]) self.item_names[item_id] = item_name
self.versions[game] = game_package.get("version", 0) for location_name, location_id in game_package["location_name_to_id"].items():
self.checksums[game] = game_package.get("checksum") self.location_names[location_id] = location_name
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():
self.update_game(game_data, game) self.update_game(game_data)
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 = Utils.persistent_load().get("datapackage", {}).get("games", {})
current_cache.update(data_package["games"]) current_cache.update(data_package["games"])
Utils.persistent_store("datapackage", "games", current_cache) Utils.persistent_store("datapackage", "games", current_cache)
logger.info(f"Got new ID/Name DataPackage for {', '.join(data_package['games'])}")
for game, game_data in data_package["games"].items(): 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)
@@ -658,7 +507,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()
@@ -672,7 +520,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")
@@ -682,7 +529,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"
@@ -709,36 +556,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.")
@@ -785,16 +617,15 @@ async def server_loop(ctx: CommonContext, address: typing.Optional[str] = None)
ctx.username = server_url.username ctx.username = server_url.username
if server_url.password: if server_url.password:
ctx.password = server_url.password ctx.password = server_url.password
port = server_url.port or 38281
def reconnect_hint() -> str: def reconnect_hint() -> str:
return ", type /connect to reconnect" if ctx.server_address else "" return ", type /connect to reconnect" if ctx.server_address else ""
logger.info(f'Connecting to Archipelago server at {address}') logger.info(f'Connecting to Archipelago server at {address}')
try: try:
port = server_url.port or 38281 # raises ValueError if invalid
socket = await websockets.connect(address, port=port, ping_timeout=None, ping_interval=None, socket = await websockets.connect(address, port=port, ping_timeout=None, ping_interval=None,
ssl=get_ssl_context() if address.startswith("wss://") else None, ssl=get_ssl_context() if address.startswith("wss://") else None)
max_size=ctx.max_size)
if ctx.ui is not None: if ctx.ui is not None:
ctx.ui.update_address_bar(server_url.netloc) ctx.ui.update_address_bar(server_url.netloc)
ctx.server = Endpoint(socket) ctx.server = Endpoint(socket)
@@ -896,18 +727,16 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
await ctx.server_auth(args['password']) await ctx.server_auth(args['password'])
elif cmd == 'DataPackage': elif cmd == 'DataPackage':
logger.info("Got new ID/Name DataPackage")
ctx.consume_network_data_package(args['data']) ctx.consume_network_data_package(args['data'])
elif cmd == 'ConnectionRefused': elif cmd == 'ConnectionRefused':
errors = args["errors"] errors = args["errors"]
if 'InvalidSlot' in errors: if 'InvalidSlot' in errors:
ctx.disconnected_intentionally = True
ctx.event_invalid_slot() ctx.event_invalid_slot()
elif 'InvalidGame' in errors: elif 'InvalidGame' in errors:
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:
@@ -927,8 +756,7 @@ 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}")
@@ -1048,7 +876,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.')
@@ -1058,33 +885,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"}
@@ -1096,7 +897,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":
@@ -1123,17 +924,21 @@ 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()
if __name__ == '__main__': if __name__ == '__main__':
logging.getLogger().setLevel(logging.INFO) # force log-level to work around log level resetting to WARNING run_as_textclient()
run_as_textclient(*sys.argv[1:]) # default value for parse_args

View File

@@ -261,7 +261,7 @@ if __name__ == '__main__':
parser = get_base_parser() parser = get_base_parser()
args = parser.parse_args() args = parser.parse_args()
colorama.just_fix_windows_console() colorama.init()
asyncio.run(main(args)) asyncio.run(main(args))
colorama.deinit() colorama.deinit()

448
Fill.py
View File

@@ -12,37 +12,30 @@ 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:
logging.info(f"Current fill step ({name}) at {placed}/{total_items} items placed.") logging.info(f"Current fill step ({name}) at {placed}/{total_items} items placed.")
def sweep_from_pool(base_state: CollectionState, itempool: typing.Sequence[Item] = tuple(), def sweep_from_pool(base_state: CollectionState, itempool: typing.Sequence[Item] = tuple()) -> CollectionState:
locations: typing.Optional[typing.List[Location]] = None) -> CollectionState:
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()
return new_state return new_state
def fill_restrictive(multiworld: MultiWorld, base_state: CollectionState, locations: typing.List[Location], def fill_restrictive(world: 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 world: Multiworld to be filled.
:param base_state: State assumed before fill. :param base_state: State assumed before fill.
:param locations: Locations to be filled with item_pool, gets mutated by removing locations that get filled. :param locations: Locations to be filled with item_pool
:param item_pool: Items to fill into the locations, gets mutated by removing items that get placed. :param item_pool: Items to fill into the locations
:param single_player_placement: if true, can speed up placement if everything belongs to a single player :param single_player_placement: if true, can speed up placement if everything belongs to a single player
:param lock: locations are set to locked as they are filled :param lock: locations are set to locked as they are filled
:param swap: if true, swaps of already place items are done in the event of a dead end :param swap: if true, swaps of already place items are done in the event of a dead end
@@ -64,27 +57,18 @@ 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:
for p, pool_item in enumerate(item_pool): for p, pool_item in enumerate(item_pool):
if pool_item is item: if pool_item is item:
item_pool.pop(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)
if single_player_placement else None)
has_beaten_game = multiworld.has_beaten_game(maximum_exploration_state) has_beaten_game = world.has_beaten_game(maximum_exploration_state)
while items_to_place: while items_to_place:
# if we have run out of locations to fill,break out of this loop # if we have run out of locations to fill,break out of this loop
@@ -96,8 +80,8 @@ def fill_restrictive(multiworld: MultiWorld, base_state: CollectionState, locati
spot_to_fill: typing.Optional[Location] = None spot_to_fill: typing.Optional[Location] = None
# 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 world.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 world.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:
@@ -128,9 +112,7 @@ 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, 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 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.
@@ -140,11 +122,11 @@ def fill_restrictive(multiworld: MultiWorld, base_state: CollectionState, locati
# Verify placing this item won't reduce available locations, which would be a useless swap. # Verify placing this item won't reduce available locations, which would be a useless swap.
prev_state = swap_state.copy() prev_state = swap_state.copy()
prev_loc_count = len( prev_loc_count = len(
multiworld.get_reachable_locations(prev_state)) world.get_reachable_locations(prev_state))
swap_state.collect(item_to_place, True) swap_state.collect(item_to_place, True)
new_loc_count = len( new_loc_count = len(
multiworld.get_reachable_locations(swap_state)) world.get_reachable_locations(swap_state))
if new_loc_count >= prev_loc_count: if new_loc_count >= prev_loc_count:
# Add this item to the existing placement, and # Add this item to the existing placement, and
@@ -174,9 +156,10 @@ def fill_restrictive(multiworld: MultiWorld, base_state: CollectionState, locati
else: else:
unplaced_items.append(item_to_place) unplaced_items.append(item_to_place)
continue continue
multiworld.push_item(spot_to_fill, item_to_place, False) world.push_item(spot_to_fill, item_to_place, False)
spot_to_fill.locked = lock spot_to_fill.locked = lock
placements.append(spot_to_fill) placements.append(spot_to_fill)
spot_to_fill.event = item_to_place.advancement
placed += 1 placed += 1
if not placed % 1000: if not placed % 1000:
_log_fill_progress(name, placed, total) _log_fill_progress(name, placed, total)
@@ -188,11 +171,9 @@ def fill_restrictive(multiworld: MultiWorld, base_state: CollectionState, locati
if cleanup_required: if cleanup_required:
# validate all placements and remove invalid ones # validate all placements and remove invalid ones
state = sweep_from_pool( state = sweep_from_pool(base_state, [])
base_state, [], multiworld.get_filled_locations(item.player)
if single_player_placement else None)
for placement in placements: for placement in placements:
if multiworld.worlds[placement.item.player].options.accessibility != "minimal" and not placement.can_reach(state): if world.accessibility[placement.item.player] != "minimal" and not placement.can_reach(state):
placement.item.location = None placement.item.location = None
unplaced_items.append(placement.item) unplaced_items.append(placement.item)
placement.item = None placement.item = None
@@ -207,7 +188,7 @@ def fill_restrictive(multiworld: MultiWorld, base_state: CollectionState, locati
if excluded_locations: if excluded_locations:
for location in excluded_locations: for location in excluded_locations:
location.progress_type = location.progress_type.DEFAULT location.progress_type = location.progress_type.DEFAULT
fill_restrictive(multiworld, base_state, excluded_locations, unplaced_items, single_player_placement, lock, fill_restrictive(world, base_state, excluded_locations, unplaced_items, single_player_placement, lock,
swap, on_place, allow_partial, False) swap, on_place, allow_partial, False)
for location in excluded_locations: for location in excluded_locations:
if not location.item: if not location.item:
@@ -215,50 +196,30 @@ def fill_restrictive(multiworld: MultiWorld, base_state: CollectionState, locati
if not allow_partial and len(unplaced_items) > 0 and len(locations) > 0: if not allow_partial and len(unplaced_items) > 0 and len(locations) > 0:
# There are leftover unplaceable items and locations that won't accept them # There are leftover unplaceable items and locations that won't accept them
if multiworld.can_beat_game(): if world.can_beat_game():
logging.warning( logging.warning(
f"Not all items placed. Game beatable anyway.\nCould not place:\n" f'Not all items placed. Game beatable anyway. (Could not place {unplaced_items})')
f"{', '.join(str(item) for item in unplaced_items)}")
else: else:
raise FillError(f"No more spots to place {len(unplaced_items)} items. Remaining locations are invalid.\n" raise FillError(f'No more spots to place {unplaced_items}, locations {locations} are invalid. '
f"Unplaced items:\n" f'Already placed {len(placements)}: {", ".join(str(place) for place in placements)}')
f"{', '.join(str(item) for item in unplaced_items)}\n"
f"Unfilled locations:\n"
f"{', '.join(str(location) for location in locations)}\n"
f"Already placed {len(placements)}:\n"
f"{', '.join(str(place) for place in placements)}", multiworld=multiworld)
item_pool.extend(unplaced_items) item_pool.extend(unplaced_items)
def remaining_fill(multiworld: MultiWorld, def remaining_fill(world: MultiWorld,
locations: typing.List[Location], locations: typing.List[Location],
itempool: typing.List[Item], itempool: typing.List[Item]) -> None:
name: str = "Remaining",
move_unplaceable_to_start_inventory: bool = False,
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
@@ -279,7 +240,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)
@@ -300,49 +261,36 @@ def remaining_fill(multiworld: MultiWorld,
unplaced_items.append(item_to_place) unplaced_items.append(item_to_place)
continue continue
multiworld.push_item(spot_to_fill, item_to_place, False) world.push_item(spot_to_fill, item_to_place, False)
placements.append(spot_to_fill) placements.append(spot_to_fill)
placed += 1 placed += 1
if not placed % 1000: if not placed % 1000:
_log_fill_progress(name, placed, total) _log_fill_progress("Remaining", placed, total)
if total > 1000: if total > 1000:
_log_fill_progress(name, placed, total) _log_fill_progress("Remaining", placed, total)
if unplaced_items and locations: if unplaced_items and locations:
# There are leftover unplaceable items and locations that won't accept them # There are leftover unplaceable items and locations that won't accept them
if move_unplaceable_to_start_inventory: raise FillError(f'No more spots to place {unplaced_items}, locations {locations} are invalid. '
last_batch = [] f'Already placed {len(placements)}: {", ".join(str(place) for place in placements)}')
for item in unplaced_items:
logging.debug(f"Moved {item} to start_inventory to prevent fill failure.")
multiworld.push_precollected(item)
last_batch.append(multiworld.worlds[item.player].create_filler())
remaining_fill(multiworld, locations, unplaced_items, name + " Start Inventory Retry")
else:
raise FillError(f"No more spots to place {len(unplaced_items)} items. Remaining locations are invalid.\n"
f"Unplaced items:\n"
f"{', '.join(str(item) for item in unplaced_items)}\n"
f"Unfilled locations:\n"
f"{', '.join(str(location) for location in locations)}\n"
f"Already placed {len(placements)}:\n"
f"{', '.join(str(place) for place in placements)}", multiworld=multiworld)
itempool.extend(unplaced_items) itempool.extend(unplaced_items)
def fast_fill(multiworld: MultiWorld, def fast_fill(world: MultiWorld,
item_pool: typing.List[Item], item_pool: typing.List[Item],
fill_locations: typing.List[Location]) -> typing.Tuple[typing.List[Item], typing.List[Location]]: fill_locations: typing.List[Location]) -> typing.Tuple[typing.List[Item], typing.List[Location]]:
placing = min(len(item_pool), len(fill_locations)) placing = min(len(item_pool), len(fill_locations))
for item, location in zip(item_pool, fill_locations): for item, location in zip(item_pool, fill_locations):
multiworld.push_item(location, item, False) world.push_item(location, item, False)
return item_pool[placing:], fill_locations[placing:] return item_pool[placing:], fill_locations[placing:]
def accessibility_corrections(multiworld: MultiWorld, state: CollectionState, locations, pool=[]): def accessibility_corrections(world: MultiWorld, state: CollectionState, locations, pool=[]):
maximum_exploration_state = sweep_from_pool(state, pool) maximum_exploration_state = sweep_from_pool(state, pool)
minimal_players = {player for player in multiworld.player_ids if multiworld.worlds[player].options.accessibility == "minimal"} minimal_players = {player for player in world.player_ids if world.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 world.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
@@ -350,41 +298,42 @@ def accessibility_corrections(multiworld: MultiWorld, state: CollectionState, lo
pool.append(location.item) pool.append(location.item)
state.remove(location.item) state.remove(location.item)
location.item = None location.item = None
if location in state.advancements: location.event = False
state.advancements.remove(location) if location in state.events:
state.events.remove(location)
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)
fill_restrictive(multiworld, state, locations, pool, name="Accessibility Corrections") fill_restrictive(world, state, locations, pool, name="Accessibility Corrections")
def inaccessible_location_rules(multiworld: MultiWorld, state: CollectionState, locations): def inaccessible_location_rules(world: MultiWorld, state: CollectionState, locations):
maximum_exploration_state = sweep_from_pool(state) maximum_exploration_state = sweep_from_pool(state)
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 world.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)
def distribute_early_items(multiworld: MultiWorld, def distribute_early_items(world: MultiWorld,
fill_locations: typing.List[Location], fill_locations: typing.List[Location],
itempool: typing.List[Item]) -> typing.Tuple[typing.List[Location], typing.List[Item]]: itempool: typing.List[Item]) -> typing.Tuple[typing.List[Location], typing.List[Item]]:
""" returns new fill_locations and itempool """ """ returns new fill_locations and itempool """
early_items_count: typing.Dict[typing.Tuple[str, int], typing.List[int]] = {} early_items_count: typing.Dict[typing.Tuple[str, int], typing.List[int]] = {}
for player in multiworld.player_ids: for player in world.player_ids:
items = itertools.chain(multiworld.early_items[player], multiworld.local_early_items[player]) items = itertools.chain(world.early_items[player], world.local_early_items[player])
for item in items: for item in items:
early_items_count[item, player] = [multiworld.early_items[player].get(item, 0), early_items_count[item, player] = [world.early_items[player].get(item, 0),
multiworld.local_early_items[player].get(item, 0)] world.local_early_items[player].get(item, 0)]
if early_items_count: if early_items_count:
early_locations: typing.List[Location] = [] early_locations: typing.List[Location] = []
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 = world.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 world.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:
@@ -396,8 +345,8 @@ def distribute_early_items(multiworld: MultiWorld,
early_prog_items: typing.List[Item] = [] early_prog_items: typing.List[Item] = []
early_rest_items: typing.List[Item] = [] early_rest_items: typing.List[Item] = []
early_local_prog_items: typing.Dict[int, typing.List[Item]] = {player: [] for player in multiworld.player_ids} early_local_prog_items: typing.Dict[int, typing.List[Item]] = {player: [] for player in world.player_ids}
early_local_rest_items: typing.Dict[int, typing.List[Item]] = {player: [] for player in multiworld.player_ids} early_local_rest_items: typing.Dict[int, typing.List[Item]] = {player: [] for player in world.player_ids}
item_indexes_to_remove: typing.Set[int] = set() item_indexes_to_remove: typing.Set[int] = set()
for i, item in enumerate(itempool): for i, item in enumerate(itempool):
if (item.name, item.player) in early_items_count: if (item.name, item.player) in early_items_count:
@@ -421,28 +370,28 @@ def distribute_early_items(multiworld: MultiWorld,
if len(early_items_count) == 0: if len(early_items_count) == 0:
break break
itempool = [item for i, item in enumerate(itempool) if i not in item_indexes_to_remove] itempool = [item for i, item in enumerate(itempool) if i not in item_indexes_to_remove]
for player in multiworld.player_ids: for player in world.player_ids:
player_local = early_local_rest_items[player] player_local = early_local_rest_items[player]
fill_restrictive(multiworld, base_state, fill_restrictive(world, base_state,
[loc for loc in early_locations if loc.player == player], [loc for loc in early_locations if loc.player == player],
player_local, lock=True, allow_partial=True, name=f"Local Early Items P{player}") player_local, lock=True, allow_partial=True, name=f"Local Early Items P{player}")
if player_local: if player_local:
logging.warning(f"Could not fulfill rules of early items: {player_local}") logging.warning(f"Could not fulfill rules of early items: {player_local}")
early_rest_items.extend(early_local_rest_items[player]) early_rest_items.extend(early_local_rest_items[player])
early_locations = [loc for loc in early_locations if not loc.item] early_locations = [loc for loc in early_locations if not loc.item]
fill_restrictive(multiworld, base_state, early_locations, early_rest_items, lock=True, allow_partial=True, fill_restrictive(world, base_state, early_locations, early_rest_items, lock=True, allow_partial=True,
name="Early Items") name="Early Items")
early_locations += early_priority_locations early_locations += early_priority_locations
for player in multiworld.player_ids: for player in world.player_ids:
player_local = early_local_prog_items[player] player_local = early_local_prog_items[player]
fill_restrictive(multiworld, base_state, fill_restrictive(world, base_state,
[loc for loc in early_locations if loc.player == player], [loc for loc in early_locations if loc.player == player],
player_local, lock=True, allow_partial=True, name=f"Local Early Progression P{player}") player_local, lock=True, allow_partial=True, name=f"Local Early Progression P{player}")
if player_local: if player_local:
logging.warning(f"Could not fulfill rules of early items: {player_local}") logging.warning(f"Could not fulfill rules of early items: {player_local}")
early_prog_items.extend(player_local) early_prog_items.extend(player_local)
early_locations = [loc for loc in early_locations if not loc.item] early_locations = [loc for loc in early_locations if not loc.item]
fill_restrictive(multiworld, base_state, early_locations, early_prog_items, lock=True, allow_partial=True, fill_restrictive(world, base_state, early_locations, early_prog_items, lock=True, allow_partial=True,
name="Early Progression") name="Early Progression")
unplaced_early_items = early_rest_items + early_prog_items unplaced_early_items = early_rest_items + early_prog_items
if unplaced_early_items: if unplaced_early_items:
@@ -451,19 +400,18 @@ def distribute_early_items(multiworld: MultiWorld,
itempool += unplaced_early_items itempool += unplaced_early_items
fill_locations.extend(early_locations) fill_locations.extend(early_locations)
multiworld.random.shuffle(fill_locations) world.random.shuffle(fill_locations)
return fill_locations, itempool return fill_locations, itempool
def distribute_items_restrictive(multiworld: MultiWorld, def distribute_items_restrictive(world: MultiWorld) -> None:
panic_method: typing.Literal["swap", "raise", "start_inventory"] = "swap") -> None: fill_locations = sorted(world.get_unfilled_locations())
fill_locations = sorted(multiworld.get_unfilled_locations()) world.random.shuffle(fill_locations)
multiworld.random.shuffle(fill_locations)
# get items to distribute # get items to distribute
itempool = sorted(multiworld.itempool) itempool = sorted(world.itempool)
multiworld.random.shuffle(itempool) world.random.shuffle(itempool)
fill_locations, itempool = distribute_early_items(multiworld, fill_locations, itempool) fill_locations, itempool = distribute_early_items(world, fill_locations, itempool)
progitempool: typing.List[Item] = [] progitempool: typing.List[Item] = []
usefulitempool: typing.List[Item] = [] usefulitempool: typing.List[Item] = []
@@ -477,7 +425,7 @@ def distribute_items_restrictive(multiworld: MultiWorld,
else: else:
filleritempool.append(item) filleritempool.append(item)
call_all(multiworld, "fill_hook", progitempool, usefulitempool, filleritempool, fill_locations) call_all(world, "fill_hook", progitempool, usefulitempool, filleritempool, fill_locations)
locations: typing.Dict[LocationProgressType, typing.List[Location]] = { locations: typing.Dict[LocationProgressType, typing.List[Location]] = {
loc_type: [] for loc_type in LocationProgressType} loc_type: [] for loc_type in LocationProgressType}
@@ -496,134 +444,77 @@ 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:
# "priority fill" # "priority fill"
fill_restrictive(multiworld, multiworld.state, prioritylocations, progitempool, fill_restrictive(world, world.state, prioritylocations, progitempool, swap=False, on_place=mark_for_locking,
single_player_placement=single_player, swap=False, on_place=mark_for_locking, name="Priority")
name="Priority", one_item_per_player=True, allow_partial=True) accessibility_corrections(world, world.state, prioritylocations, progitempool)
if prioritylocations:
# retry with one_item_per_player off because some priority fills can fail to fill with that optimization
fill_restrictive(multiworld, multiworld.state, prioritylocations, progitempool,
single_player_placement=single_player, swap=False, on_place=mark_for_locking,
name="Priority Retry", one_item_per_player=False)
accessibility_corrections(multiworld, multiworld.state, prioritylocations, progitempool)
defaultlocations = prioritylocations + defaultlocations defaultlocations = prioritylocations + defaultlocations
if progitempool: if progitempool:
# "advancement/progression fill" # "advancement/progression fill"
if panic_method == "swap": fill_restrictive(world, world.state, defaultlocations, progitempool, name="Progression")
fill_restrictive(multiworld, multiworld.state, defaultlocations, progitempool, swap=True,
name="Progression", single_player_placement=single_player)
elif panic_method == "raise":
fill_restrictive(multiworld, multiworld.state, defaultlocations, progitempool, swap=False,
name="Progression", single_player_placement=single_player)
elif panic_method == "start_inventory":
fill_restrictive(multiworld, multiworld.state, defaultlocations, progitempool, swap=False,
allow_partial=True, name="Progression", single_player_placement=single_player)
if progitempool:
for item in progitempool:
logging.debug(f"Moved {item} to start_inventory to prevent fill failure.")
multiworld.push_precollected(item)
filleritempool.append(multiworld.worlds[item.player].create_filler())
logging.warning(f"{len(progitempool)} items moved to start inventory,"
f" due to failure in Progression fill step.")
progitempool[:] = []
else:
raise ValueError(f"Generator Panic Method {panic_method} not recognized.")
if progitempool: if progitempool:
raise FillError( raise FillError(
f"Not enough locations for progression items. " f'Not enough locations for progress items. There are {len(progitempool)} more items than locations')
f"There are {len(progitempool)} more progression items than there are available locations.\n" accessibility_corrections(world, world.state, defaultlocations)
f"Unfilled locations:\n{multiworld.get_unfilled_locations()}.",
multiworld=multiworld,
)
accessibility_corrections(multiworld, multiworld.state, defaultlocations)
for location in lock_later: for location in lock_later:
if location.item: if location.item:
location.locked = True location.locked = True
del mark_for_locking, lock_later del mark_for_locking, lock_later
inaccessible_location_rules(multiworld, multiworld.state, defaultlocations) inaccessible_location_rules(world, world.state, defaultlocations)
remaining_fill(multiworld, excludedlocations, filleritempool, "Remaining Excluded",
move_unplaceable_to_start_inventory=panic_method=="start_inventory")
remaining_fill(world, excludedlocations, filleritempool)
if excludedlocations: if excludedlocations:
raise FillError( raise FillError(
f"Not enough filler items for excluded locations. " f"Not enough filler items for excluded locations. There are {len(excludedlocations)} more locations than items")
f"There are {len(excludedlocations)} more excluded locations than excludable items.",
multiworld=multiworld,
)
restitempool = filleritempool + usefulitempool restitempool = filleritempool + usefulitempool
remaining_fill(multiworld, defaultlocations, restitempool, remaining_fill(world, defaultlocations, restitempool)
move_unplaceable_to_start_inventory=panic_method=="start_inventory")
unplaced = restitempool unplaced = restitempool
unfilled = defaultlocations unfilled = defaultlocations
if unplaced or unfilled: if unplaced or unfilled:
logging.warning( logging.warning(
f"Unplaced items({len(unplaced)}): {unplaced} - Unfilled Locations({len(unfilled)}): {unfilled}") f'Unplaced items({len(unplaced)}): {unplaced} - Unfilled Locations({len(unfilled)}): {unfilled}')
items_counter = Counter(location.item.player for location in multiworld.get_filled_locations()) items_counter = Counter(location.item.player for location in world.get_locations() if location.item)
locations_counter = Counter(location.player for location in multiworld.get_locations()) locations_counter = Counter(location.player for location in world.get_locations())
items_counter.update(item.player for item in unplaced) items_counter.update(item.player for item in unplaced)
locations_counter.update(location.player for location in unfilled)
print_data = {"items": items_counter, "locations": locations_counter} 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(world: MultiWorld) -> None:
# get items to distribute # get items to distribute
multiworld.random.shuffle(multiworld.itempool) world.random.shuffle(world.itempool)
itempool = multiworld.itempool itempool = world.itempool
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() world.state.sweep_for_events()
# fill multiworld from top of itempool while we can # fill world from top of itempool while we can
while not progress_done: while not progress_done:
location_list = multiworld.get_unfilled_locations() location_list = world.get_unfilled_locations()
multiworld.random.shuffle(location_list) world.random.shuffle(location_list)
spot_to_fill = None spot_to_fill = None
for location in location_list: for location in location_list:
if location.can_fill(multiworld.state, itempool[0]): if location.can_fill(world.state, itempool[0]):
spot_to_fill = location spot_to_fill = location
break break
if spot_to_fill: if spot_to_fill:
item = itempool.pop(0) item = itempool.pop(0)
multiworld.push_item(spot_to_fill, item, True) world.push_item(spot_to_fill, item, True)
continue continue
# ran out of spots, check if we need to step in and correct things # ran out of spots, check if we need to step in and correct things
if len(multiworld.get_reachable_locations()) == len(multiworld.get_locations()): if len(world.get_reachable_locations()) == len(world.get_locations()):
progress_done = True progress_done = True
continue continue
@@ -633,7 +524,7 @@ def flood_items(multiworld: MultiWorld) -> None:
for item in itempool: for item in itempool:
if item.advancement: if item.advancement:
candidate_item_to_place = item candidate_item_to_place = item
if multiworld.unlocks_new_location(item): if world.unlocks_new_location(item):
item_to_place = item item_to_place = item
break break
@@ -643,23 +534,23 @@ 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 = world.get_reachable_locations()
multiworld.random.shuffle(location_list) world.random.shuffle(location_list)
for location in location_list: for location in location_list:
if location.item is not None and not location.item.advancement: if location.item is not None and not location.item.advancement:
# safe to replace # safe to replace
replace_item = location.item replace_item = location.item
replace_item.location = None replace_item.location = None
itempool.append(replace_item) itempool.append(replace_item)
multiworld.push_item(location, item_to_place, True) world.push_item(location, item_to_place, True)
itempool.remove(item_to_place) itempool.remove(item_to_place)
break break
def balance_multiworld_progression(multiworld: MultiWorld) -> None: def balance_multiworld_progression(world: MultiWorld) -> None:
# A system to reduce situations where players have no checks remaining, popularly known as "BK mode." # A system to reduce situations where players have no checks remaining, popularly known as "BK mode."
# Overall progression balancing algorithm: # Overall progression balancing algorithm:
# Gather up all locations in a sphere. # Gather up all locations in a sphere.
@@ -667,28 +558,28 @@ def balance_multiworld_progression(multiworld: MultiWorld) -> None:
# If other players are below the threshold value, swap progression in this sphere into earlier spheres, # If other players are below the threshold value, swap progression in this sphere into earlier spheres,
# which gives more locations available by this sphere. # which gives more locations available by this sphere.
balanceable_players: typing.Dict[int, float] = { balanceable_players: typing.Dict[int, float] = {
player: multiworld.worlds[player].options.progression_balancing / 100 player: world.worlds[player].options.progression_balancing / 100
for player in multiworld.player_ids for player in world.player_ids
if multiworld.worlds[player].options.progression_balancing > 0 if world.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(world)
checked_locations: typing.Set[Location] = set() checked_locations: typing.Set[Location] = set()
unchecked_locations: typing.Set[Location] = set(multiworld.get_locations()) unchecked_locations: typing.Set[Location] = set(world.get_locations())
total_locations_count: typing.Counter[int] = Counter( total_locations_count: typing.Counter[int] = Counter(
location.player location.player
for location in multiworld.get_locations() for location in world.get_locations()
if not location.locked if not location.locked
) )
reachable_locations_count: typing.Dict[int, int] = { reachable_locations_count: typing.Dict[int, int] = {
player: 0 player: 0
for player in multiworld.player_ids for player in world.player_ids
if total_locations_count[player] and len(multiworld.get_filled_locations(player)) != 0 if total_locations_count[player] and len(world.get_filled_locations(player)) != 0
} }
balanceable_players = { balanceable_players = {
player: balanceable_players[player] player: balanceable_players[player]
@@ -700,6 +591,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:
@@ -751,7 +643,7 @@ def balance_multiworld_progression(multiworld: MultiWorld) -> None:
while True: while True:
# Check locations in the current sphere and gather progression items to swap earlier # Check locations in the current sphere and gather progression items to swap earlier
for location in balancing_sphere: for location in balancing_sphere:
if location.advancement: if location.event:
balancing_state.collect(location.item, True, location) balancing_state.collect(location.item, True, location)
player = location.item.player player = location.item.player
# only replace items that end up in another player's world # only replace items that end up in another player's world
@@ -766,7 +658,7 @@ def balance_multiworld_progression(multiworld: MultiWorld) -> None:
balancing_unchecked_locations.remove(location) balancing_unchecked_locations.remove(location)
if not location.locked: if not location.locked:
balancing_reachables[location.player] += 1 balancing_reachables[location.player] += 1
if multiworld.has_beaten_game(balancing_state) or all( if world.has_beaten_game(balancing_state) or all(
item_percentage(player, reachables) >= threshold_percentages[player] item_percentage(player, reachables) >= threshold_percentages[player]
for player, reachables in balancing_reachables.items() for player, reachables in balancing_reachables.items()
if player in threshold_percentages): if player in threshold_percentages):
@@ -783,7 +675,7 @@ def balance_multiworld_progression(multiworld: MultiWorld) -> None:
locations_to_test = unlocked_locations[player] locations_to_test = unlocked_locations[player]
items_to_test = list(candidate_items[player]) items_to_test = list(candidate_items[player])
items_to_test.sort() items_to_test.sort()
multiworld.random.shuffle(items_to_test) world.random.shuffle(items_to_test)
while items_to_test: while items_to_test:
testing = items_to_test.pop() testing = items_to_test.pop()
reducing_state = state.copy() reducing_state = state.copy()
@@ -793,10 +685,10 @@ def balance_multiworld_progression(multiworld: MultiWorld) -> None:
), 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 world.has_beaten_game(balancing_state):
if not multiworld.has_beaten_game(reducing_state): if not world.has_beaten_game(reducing_state):
items_to_replace.append(testing) items_to_replace.append(testing)
else: else:
reduced_sphere = get_sphere_locations(reducing_state, locations_to_test) reduced_sphere = get_sphere_locations(reducing_state, locations_to_test)
@@ -804,32 +696,33 @@ def balance_multiworld_progression(multiworld: MultiWorld) -> None:
if p < threshold_percentages[player]: if p < threshold_percentages[player]:
items_to_replace.append(testing) items_to_replace.append(testing)
old_moved_item_count = moved_item_count replaced_items = False
# sort then shuffle to maintain deterministic behaviour, # sort then shuffle to maintain deterministic behaviour,
# while allowing use of set for better algorithm growth behaviour elsewhere # while allowing use of set for better algorithm growth behaviour elsewhere
replacement_locations = sorted(l for l in checked_locations if not l.advancement and not l.locked) replacement_locations = sorted(l for l in checked_locations if not l.event and not l.locked)
multiworld.random.shuffle(replacement_locations) world.random.shuffle(replacement_locations)
items_to_replace.sort() items_to_replace.sort()
multiworld.random.shuffle(items_to_replace) world.random.shuffle(items_to_replace)
# Start swapping items. Since we swap into earlier spheres, no need for accessibility checks. # Start swapping items. Since we swap into earlier spheres, no need for accessibility checks.
while replacement_locations and items_to_replace: while replacement_locations and items_to_replace:
old_location = items_to_replace.pop() old_location = items_to_replace.pop()
for i, new_location in enumerate(replacement_locations): for new_location in replacement_locations:
if new_location.can_fill(state, old_location.item, False) and \ if new_location.can_fill(state, old_location.item, False) and \
old_location.can_fill(state, new_location.item, False): old_location.can_fill(state, new_location.item, False):
replacement_locations.pop(i) replacement_locations.remove(new_location)
swap_location_item(old_location, new_location) swap_location_item(old_location, new_location)
logging.debug(f"Progression balancing moved {new_location.item} to {new_location}, " logging.debug(f"Progression balancing moved {new_location.item} to {new_location}, "
f"displacing {old_location.item} into {old_location}") f"displacing {old_location.item} into {old_location}")
moved_item_count += 1 moved_item_count += 1
state.collect(new_location.item, True, new_location) state.collect(new_location.item, True, new_location)
replaced_items = True
break break
else: else:
logging.warning(f"Could not Progression Balance {old_location.item}") logging.warning(f"Could not Progression Balance {old_location.item}")
if old_moved_item_count < moved_item_count: if replaced_items:
logging.debug(f"Moved {moved_item_count} items so far\n") logging.debug(f"Moved {moved_item_count} items so far\n")
unlocked = {fresh for player in balancing_players for fresh in unlocked_locations[player]} unlocked = {fresh for player in balancing_players for fresh in unlocked_locations[player]}
for location in get_sphere_locations(state, unlocked): for location in get_sphere_locations(state, unlocked):
@@ -839,11 +732,11 @@ def balance_multiworld_progression(multiworld: MultiWorld) -> None:
sphere_locations.add(location) sphere_locations.add(location)
for location in sphere_locations: for location in sphere_locations:
if location.advancement: if location.event:
state.collect(location.item, True, location) state.collect(location.item, True, location)
checked_locations |= sphere_locations checked_locations |= sphere_locations
if multiworld.has_beaten_game(state): if world.has_beaten_game(state):
break break
elif not sphere_locations: elif not sphere_locations:
logging.warning("Progression Balancing ran out of paths.") logging.warning("Progression Balancing ran out of paths.")
@@ -860,9 +753,10 @@ def swap_location_item(location_1: Location, location_2: Location, check_locked:
location_2.item, location_1.item = location_1.item, location_2.item location_2.item, location_1.item = location_1.item, location_2.item
location_1.item.location = location_1 location_1.item.location = location_1
location_2.item.location = location_2 location_2.item.location = location_2
location_1.event, location_2.event = location_2.event, location_1.event
def distribute_planned(multiworld: MultiWorld) -> None: def distribute_planned(world: MultiWorld) -> None:
def warn(warning: str, force: typing.Union[bool, str]) -> None: def warn(warning: str, force: typing.Union[bool, str]) -> None:
if force in [True, 'fail', 'failure', 'none', False, 'warn', 'warning']: if force in [True, 'fail', 'failure', 'none', False, 'warn', 'warning']:
logging.warning(f'{warning}') logging.warning(f'{warning}')
@@ -875,24 +769,24 @@ def distribute_planned(multiworld: MultiWorld) -> None:
else: else:
warn(warning, force) warn(warning, force)
swept_state = multiworld.state.copy() swept_state = world.state.copy()
swept_state.sweep_for_advancements() swept_state.sweep_for_events()
reachable = frozenset(multiworld.get_reachable_locations(swept_state)) reachable = frozenset(world.get_reachable_locations(swept_state))
early_locations: typing.Dict[int, typing.List[str]] = collections.defaultdict(list) early_locations: typing.Dict[int, typing.List[str]] = collections.defaultdict(list)
non_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(): for loc in world.get_unfilled_locations():
if loc in reachable: if loc in reachable:
early_locations[loc.player].append(loc.name) early_locations[loc.player].append(loc.name)
else: # not reachable with swept state else: # not reachable with swept state
non_early_locations[loc.player].append(loc.name) non_early_locations[loc.player].append(loc.name)
world_name_lookup = multiworld.world_name_lookup world_name_lookup = world.world_name_lookup
block_value = typing.Union[typing.List[str], typing.Dict[str, typing.Any], str] block_value = typing.Union[typing.List[str], typing.Dict[str, typing.Any], str]
plando_blocks: typing.List[typing.Dict[str, typing.Any]] = [] plando_blocks: typing.List[typing.Dict[str, typing.Any]] = []
player_ids = set(multiworld.player_ids) player_ids = set(world.player_ids)
for player in player_ids: for player in player_ids:
for block in multiworld.plando_items[player]: for block in world.plando_items[player]:
block['player'] = player block['player'] = player
if 'force' not in block: if 'force' not in block:
block['force'] = 'silent' block['force'] = 'silent'
@@ -906,12 +800,12 @@ def distribute_planned(multiworld: MultiWorld) -> None:
else: else:
target_world = block['world'] target_world = block['world']
if target_world is False or multiworld.players == 1: # target own world if target_world is False or world.players == 1: # target own world
worlds: typing.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(world.player_ids) - {player}
elif target_world is None: # target all worlds elif target_world is None: # target all worlds
worlds = set(multiworld.player_ids) worlds = set(world.player_ids)
elif type(target_world) == list: # list of target worlds elif type(target_world) == list: # list of target worlds
worlds = set() worlds = set()
for listed_world in target_world: for listed_world in target_world:
@@ -921,9 +815,9 @@ def distribute_planned(multiworld: MultiWorld) -> None:
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, world.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, {world.players})",
block['force']) block['force'])
continue continue
worlds = {target_world} worlds = {target_world}
@@ -951,7 +845,7 @@ def distribute_planned(multiworld: MultiWorld) -> None:
item_list: typing.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 = world.itempool.count(world.worlds[player].create_item(key))
item_list += [key] * value item_list += [key] * value
items = item_list items = item_list
if isinstance(items, str): if isinstance(items, str):
@@ -1001,17 +895,17 @@ def distribute_planned(multiworld: MultiWorld) -> None:
count = block['count'] count = block['count']
failed(f"Plando count {count} greater than locations specified", block['force']) failed(f"Plando count {count} greater than locations specified", block['force'])
block['count'] = len(block['locations']) block['count'] = len(block['locations'])
block['count']['target'] = multiworld.random.randint(block['count']['min'], block['count']['max']) block['count']['target'] = world.random.randint(block['count']['min'], block['count']['max'])
if block['count']['target'] > 0: if block['count']['target'] > 0:
plando_blocks.append(block) plando_blocks.append(block)
# 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) world.random.shuffle(plando_blocks)
plando_blocks.sort(key=lambda block: (len(block['locations']) - block['count']['target'] plando_blocks.sort(key=lambda block: (len(block['locations']) - block['count']['target']
if len(block['locations']) > 0 if len(block['locations']) > 0
else len(multiworld.get_unfilled_locations(player)) - block['count']['target'])) else len(world.get_unfilled_locations(player)) - block['count']['target']))
for placement in plando_blocks: for placement in plando_blocks:
player = placement['player'] player = placement['player']
@@ -1022,37 +916,20 @@ def distribute_planned(multiworld: MultiWorld) -> None:
maxcount = placement['count']['target'] maxcount = placement['count']['target']
from_pool = placement['from_pool'] from_pool = placement['from_pool']
candidates = list(multiworld.get_unfilled_locations_for_players(locations, sorted(worlds))) candidates = list(world.get_unfilled_locations_for_players(locations, sorted(worlds)))
multiworld.random.shuffle(candidates) world.random.shuffle(candidates)
multiworld.random.shuffle(items) world.random.shuffle(items)
count = 0 count = 0
err: typing.List[str] = [] err: typing.List[str] = []
successful_pairs: typing.List[typing.Tuple[int, Item, Location]] = [] successful_pairs: typing.List[typing.Tuple[Item, Location]] = []
claimed_indices: typing.Set[typing.Optional[int]] = set()
for item_name in items: for item_name in items:
index_to_delete: typing.Optional[int] = None item = world.worlds[player].create_item(item_name)
if from_pool:
try:
# If from_pool, try to find an existing item with this name & player in the itempool and use it
index_to_delete, item = next(
(i, item) for i, item in enumerate(multiworld.itempool)
if item.player == player and item.name == item_name and i not in claimed_indices
)
except StopIteration:
warn(
f"Could not remove {item_name} from pool for {multiworld.player_name[player]} as it's already missing from it.",
placement['force'])
item = multiworld.worlds[player].create_item(item_name)
else:
item = multiworld.worlds[player].create_item(item_name)
for location in reversed(candidates): for location in reversed(candidates):
if (location.address is None) == (item.code is None): # either both None or both not None if (location.address is None) == (item.code is None): # either both None or both not None
if not location.item: if not location.item:
if location.item_rule(item): if location.item_rule(item):
if location.can_fill(multiworld.state, item, False): if location.can_fill(world.state, item, False):
successful_pairs.append((index_to_delete, item, location)) successful_pairs.append((item, location))
claimed_indices.add(index_to_delete)
candidates.remove(location) candidates.remove(location)
count = count + 1 count = count + 1
break break
@@ -1064,25 +941,26 @@ def distribute_planned(multiworld: MultiWorld) -> None:
err.append(f"Cannot place {item_name} into already filled location {location}.") err.append(f"Cannot place {item_name} into already filled location {location}.")
else: else:
err.append(f"Mismatch between {item_name} and {location}, only one is an event.") err.append(f"Mismatch between {item_name} and {location}, only one is an event.")
if count == maxcount: if count == maxcount:
break break
if count < placement['count']['min']: if count < placement['count']['min']:
m = placement['count']['min'] m = placement['count']['min']
failed( failed(
f"Plando block failed to place {m - count} of {m} item(s) for {multiworld.player_name[player]}, error(s): {' '.join(err)}", f"Plando block failed to place {m - count} of {m} item(s) for {world.player_name[player]}, error(s): {' '.join(err)}",
placement['force']) placement['force'])
for (item, location) in successful_pairs:
# Sort indices in reverse so we can remove them one by one world.push_item(location, item, collect=False)
successful_pairs = sorted(successful_pairs, key=lambda successful_pair: successful_pair[0] or 0, reverse=True) location.event = True # flag location to be checked during fill
for (index, item, location) in successful_pairs:
multiworld.push_item(location, item, collect=False)
location.locked = True location.locked = True
logging.debug(f"Plando placed {item} at {location}") logging.debug(f"Plando placed {item} at {location}")
if index is not None: # If this item is from_pool and was found in the pool, remove it. if from_pool:
multiworld.itempool.pop(index) try:
world.itempool.remove(item)
except ValueError:
warn(
f"Could not remove {item} from pool for {world.player_name[player]} as it's already missing from it.",
placement['force'])
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} ({world.player_name[player]})") from e

View File

@@ -1,54 +1,56 @@
from __future__ import annotations from __future__ import annotations
import argparse import argparse
import copy
import logging import logging
import os import os
import random import random
import string import string
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 typing import Any, Dict, Tuple, Union
from itertools import chain
import ModuleUpdate import ModuleUpdate
ModuleUpdate.update() ModuleUpdate.update()
import copy
import Utils import Utils
import Options import Options
from BaseClasses import seeddigits, get_seed, PlandoOptions from BaseClasses import seeddigits, get_seed, PlandoOptions
from Main import main as ERmain
from settings import get_settings
from Utils import parse_yamls, version_tuple, __version__, tuplize_version from Utils import parse_yamls, version_tuple, __version__, tuplize_version
from worlds.alttp import Options as LttPOptions
from worlds.alttp.EntranceRandomizer import parse_arguments
from worlds.alttp.Text import TextTable
from worlds.AutoWorld import AutoWorldRegister
from worlds.generic import PlandoConnection
def mystery_argparse(): def mystery_argparse():
from settings import get_settings options = get_settings()
settings = get_settings() defaults = options.generator
defaults = settings.generator
parser = argparse.ArgumentParser(description="CMD Generation Interface, defaults come from host.yaml.") parser = argparse.ArgumentParser(description="CMD Generation Interface, defaults come from host.yaml.")
parser.add_argument('--weights_file_path', default=defaults.weights_file_path, parser.add_argument('--weights_file_path', default=defaults.weights_file_path,
help='Path to the weights file to use for rolling game options, urls are also valid') help='Path to the weights file to use for rolling game settings, urls are also valid')
parser.add_argument('--sameoptions', help='Rolls options per weights file rather than per player', parser.add_argument('--samesettings', help='Rolls settings per weights file rather than per player',
action='store_true') action='store_true')
parser.add_argument('--player_files_path', default=defaults.player_files_path, parser.add_argument('--player_files_path', default=defaults.player_files_path,
help="Input directory for player files.") help="Input directory for player files.")
parser.add_argument('--seed', help='Define seed number to generate.', type=int) parser.add_argument('--seed', help='Define seed number to generate.', type=int)
parser.add_argument('--multi', default=defaults.players, type=lambda value: max(int(value), 1)) parser.add_argument('--multi', default=defaults.players, type=lambda value: max(int(value), 1))
parser.add_argument('--spoiler', type=int, default=defaults.spoiler) parser.add_argument('--spoiler', type=int, default=defaults.spoiler)
parser.add_argument('--outputpath', default=settings.general_options.output_path, parser.add_argument('--outputpath', default=options.general_options.output_path,
help="Path to output folder. Absolute or relative to cwd.") # absolute or relative to cwd 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",
@@ -60,24 +62,21 @@ def mystery_argparse():
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, options
def get_seed_name(random_source) -> str: 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, callback=ERmain):
# __name__ == "__main__" check so unittests that already imported worlds don't trip this.
if __name__ == "__main__" and "worlds" in sys.modules:
raise Exception("Worlds system should not be loaded before logging init.")
if not args: if not args:
args = mystery_argparse() args, options = mystery_argparse()
else:
options = get_settings()
seed = get_seed(args.seed) seed = get_seed(args.seed)
Utils.init_logging(f"Generate_{seed}", loglevel=args.log_level)
Utils.init_logging(f"Generate_{seed}", loglevel=args.log_level, add_timestamp=args.log_time)
random.seed(seed) random.seed(seed)
seed_name = get_seed_name(random) seed_name = get_seed_name(random)
@@ -104,31 +103,24 @@ def main(args=None) -> Tuple[argparse.Namespace, int]:
del(meta_weights["meta_description"]) del(meta_weights["meta_description"])
except Exception as e: except Exception as e:
raise ValueError("No meta description found for meta.yaml. Unable to verify.") from e raise ValueError("No meta description found for meta.yaml. Unable to verify.") from e
if args.sameoptions: if args.samesettings:
raise Exception("Cannot mix --sameoptions with --meta") raise Exception("Cannot mix --samesettings 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
# sort dict for consistent results across platforms: # sort dict for consistent results across platforms:
weights_cache = {key: value for key, value in sorted(weights_cache.items(), key=lambda k: k[0].casefold())} weights_cache = {key: value for key, value in sorted(weights_cache.items())}
for filename, yaml_data in weights_cache.items(): for filename, yaml_data in weights_cache.items():
if filename not in {args.meta_file_path, args.weights_file_path}: if filename not in {args.meta_file_path, args.weights_file_path}:
for yaml in yaml_data: for yaml in yaml_data:
@@ -152,23 +144,19 @@ def main(args=None) -> Tuple[argparse.Namespace, int]:
raise Exception(f"No weights found. " raise Exception(f"No weights found. "
f"Provide a general weights file ({args.weights_file_path}) or individual player files. " f"Provide a general weights file ({args.weights_file_path}) or individual player files. "
f"A mix is also permitted.") f"A mix is also permitted.")
from worlds.AutoWorld import AutoWorldRegister
from worlds.alttp.EntranceRandomizer import parse_arguments
erargs = parse_arguments(['--multi', str(args.multi)]) erargs = parse_arguments(['--multi', str(args.multi)])
erargs.seed = seed erargs.seed = seed
erargs.plando_options = args.plando erargs.plando_options = args.plando
erargs.glitch_triforce = options.generator.glitch_triforce_room
erargs.spoiler = args.spoiler erargs.spoiler = args.spoiler
erargs.race = args.race erargs.race = args.race
erargs.outputname = seed_name erargs.outputname = seed_name
erargs.outputpath = args.outputpath erargs.outputpath = args.outputpath
erargs.skip_prog_balancing = args.skip_prog_balancing erargs.skip_prog_balancing = args.skip_prog_balancing
erargs.skip_output = args.skip_output erargs.skip_output = args.skip_output
erargs.name = {}
erargs.csv_output = args.csv_output
settings_cache: Dict[str, Tuple[argparse.Namespace, ...]] = \ settings_cache: Dict[str, Tuple[argparse.Namespace, ...]] = \
{fname: (tuple(roll_settings(yaml, args.plando) for yaml in yamls) if args.sameoptions else None) {fname: (tuple(roll_settings(yaml, args.plando) for yaml in yamls) if args.samesettings else None)
for fname, yamls in weights_cache.items()} for fname, yamls in weights_cache.items()}
if meta_weights: if meta_weights:
@@ -213,7 +201,7 @@ def main(args=None) -> Tuple[argparse.Namespace, int]:
if path == args.weights_file_path: # if name came from the weights file, just use base player name if path == args.weights_file_path: # if name came from the weights file, just use base player name
erargs.name[player] = f"Player{player}" erargs.name[player] = f"Player{player}"
elif player not in erargs.name: # if name was not specified, generate it from filename elif not erargs.name[player]: # if name was not specified, generate it from filename
erargs.name[player] = os.path.splitext(os.path.split(path)[-1])[0] erargs.name[player] = os.path.splitext(os.path.split(path)[-1])[0]
erargs.name[player] = handle_name(erargs.name[player], player, name_counter) erargs.name[player] = handle_name(erargs.name[player], player, name_counter)
@@ -226,7 +214,29 @@ def main(args=None) -> Tuple[argparse.Namespace, int]:
if len(set(name.lower() for name in erargs.name.values())) != len(erargs.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 erargs.name.values())}") raise Exception(f"Names have to be unique. Names: {Counter(name.lower() for name in erargs.name.values())}")
return erargs, 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 callback(erargs, seed)
def read_weights_yamls(path) -> Tuple[Any, ...]: def read_weights_yamls(path) -> Tuple[Any, ...]:
@@ -292,67 +302,52 @@ def handle_name(name: str, player: int, name_counter: Counter):
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. new_name = new_name.strip()[:16]
# Could cause issues for some clients that cannot handle the additional whitespace.
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 prefer_int(input_data: str) -> Union[str, int]:
try:
return int(input_data)
except:
return input_data
goals = {
'ganon': 'ganon',
'crystals': 'crystals',
'bosses': 'bosses',
'pedestal': 'pedestal',
'ganon_pedestal': 'ganonpedestal',
'triforce_hunt': 'triforcehunt',
'local_triforce_hunt': 'localtriforcehunt',
'ganon_triforce_hunt': 'ganontriforcehunt',
'local_ganon_triforce_hunt': 'localganontriforcehunt',
'ice_rod_hunt': 'icerodhunt',
}
def roll_percentage(percentage: Union[int, float]) -> bool: def roll_percentage(percentage: Union[int, float]) -> bool:
"""Roll a percentage chance. """Roll a percentage chance.
percentage is expected to be in range [0, 100]""" percentage is expected to be in range [0, 100]"""
return random.random() < (float(percentage) / 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, type: str, name: str) -> dict:
logging.debug(f'Applying {new_weights}') logging.debug(f'Applying {new_weights}')
cleaned_weights = {} new_options = set(new_weights) - set(weights)
for option in new_weights: weights.update(new_weights)
option_name = option.lstrip("+-")
if option.startswith("+") and option_name in weights:
cleaned_value = weights[option_name]
new_value = new_weights[option]
if isinstance(new_value, set):
cleaned_value.update(new_value)
elif isinstance(new_value, list):
cleaned_value.extend(new_value)
elif isinstance(new_value, dict):
cleaned_value = dict(Counter(cleaned_value) + Counter(new_value))
else:
raise Exception(f"Cannot apply merge to non-dict, set, or list type {option_name},"
f" received {type(new_value).__name__}.")
cleaned_weights[option_name] = cleaned_value
elif option.startswith("-") and option_name in weights:
cleaned_value = weights[option_name]
new_value = new_weights[option]
if isinstance(new_value, set):
cleaned_value.difference_update(new_value)
elif isinstance(new_value, list):
for element in new_value:
cleaned_value.remove(element)
elif isinstance(new_value, dict):
cleaned_value = dict(Counter(cleaned_value) - Counter(new_value))
else:
raise Exception(f"Cannot apply remove to non-dict, set, or list type {option_name},"
f" received {type(new_value).__name__}.")
cleaned_weights[option_name] = cleaned_value
else:
cleaned_weights[option_name] = new_weights[option]
new_options = set(cleaned_weights) - set(weights)
weights.update(cleaned_weights)
if new_options: if new_options:
for new_option in new_options: for new_option in new_options:
logging.warning(f'{update_type} Suboption "{new_option}" of "{name}" did not ' logging.warning(f'{type} Suboption "{new_option}" of "{name}" did not '
f'overwrite a root option. ' f'overwrite a root option. '
f'This is probably in error.') f'This is probably in error.')
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
if not game: if not game:
return get_choice(option_key, category_dict) return get_choice(option_key, category_dict)
if game in AutoWorldRegister.world_types: if game in AutoWorldRegister.world_types:
@@ -362,7 +357,16 @@ def roll_meta_option(option_key, game: str, category_dict: Dict) -> Any:
if options[option_key].supports_weighting: if options[option_key].supports_weighting:
return get_choice(option_key, category_dict) return get_choice(option_key, category_dict)
return category_dict[option_key] return category_dict[option_key]
raise Options.OptionError(f"Error generating meta option {option_key} for {game}.") if game == "A Link to the Past": # TODO wow i hate this
if option_key in {"glitches_required", "dark_room_logic", "entrance_shuffle", "goals", "triforce_pieces_mode",
"triforce_pieces_percentage", "triforce_pieces_available", "triforce_pieces_extra",
"triforce_pieces_required", "shop_shuffle", "mode", "item_pool", "item_functionality",
"boss_shuffle", "enemy_damage", "enemy_health", "timer", "countdown_start_time",
"red_clock_time", "blue_clock_time", "green_clock_time", "dungeon_counters", "shuffle_prizes",
"misery_mire_medallion", "turtle_rock_medallion", "sprite_pool", "sprite",
"random_sprite_on_event"}:
return get_choice(option_key, category_dict)
raise Exception(f"Error generating meta option {option_key} for {game}.")
def roll_linked_options(weights: dict) -> dict: def roll_linked_options(weights: dict) -> dict:
@@ -387,7 +391,7 @@ def roll_linked_options(weights: dict) -> dict:
return weights return weights
def roll_triggers(weights: dict, triggers: list, valid_keys: set) -> dict: def roll_triggers(weights: dict, triggers: list) -> dict:
weights = copy.deepcopy(weights) # make sure we don't write back to other weights sets in same_settings weights = copy.deepcopy(weights) # make sure we don't write back to other weights sets in same_settings
weights["_Generator_Version"] = Utils.__version__ weights["_Generator_Version"] = Utils.__version__
for i, option_set in enumerate(triggers): for i, option_set in enumerate(triggers):
@@ -410,7 +414,7 @@ def roll_triggers(weights: dict, triggers: list, valid_keys: set) -> dict:
if category_name: if category_name:
currently_targeted_weights = currently_targeted_weights[category_name] currently_targeted_weights = currently_targeted_weights[category_name]
update_weights(currently_targeted_weights, category_options, "Triggered", option_set["option_name"]) update_weights(currently_targeted_weights, category_options, "Triggered", option_set["option_name"])
valid_keys.add(key)
except Exception as e: except Exception as e:
raise ValueError(f"Your trigger number {i + 1} is invalid. " raise ValueError(f"Your trigger number {i + 1} is invalid. "
f"Please fix your triggers.") from e f"Please fix your triggers.") from e
@@ -418,31 +422,27 @@ def roll_triggers(weights: dict, triggers: list, valid_keys: set) -> dict:
def handle_option(ret: argparse.Namespace, game_weights: dict, option_key: str, option: type(Options.Option), plando_options: PlandoOptions): def handle_option(ret: argparse.Namespace, game_weights: dict, option_key: str, option: type(Options.Option), plando_options: PlandoOptions):
try: if option_key in game_weights:
if option_key in game_weights: try:
if not option.supports_weighting: if not option.supports_weighting:
player_option = option.from_any(game_weights[option_key]) player_option = option.from_any(game_weights[option_key])
else: else:
player_option = option.from_any(get_choice(option_key, game_weights)) player_option = option.from_any(get_choice(option_key, game_weights))
setattr(ret, option_key, player_option)
except Exception as e:
raise Exception(f"Error generating option {option_key} in {ret.game}") from e
else: else:
player_option = option.from_any(option.default) # call the from_any here to support default "random" player_option.verify(AutoWorldRegister.world_types[ret.game], ret.name, plando_options)
setattr(ret, option_key, player_option)
except Exception as e:
raise Options.OptionError(f"Error generating option {option_key} in {ret.game}") from e
else: else:
from worlds import AutoWorldRegister setattr(ret, option_key, option.from_any(option.default)) # call the from_any here to support default "random"
player_option.verify(AutoWorldRegister.world_types[ret.game], ret.name, plando_options)
def roll_settings(weights: dict, plando_options: PlandoOptions = PlandoOptions.bosses): def roll_settings(weights: dict, plando_options: PlandoOptions = PlandoOptions.bosses):
from worlds import AutoWorldRegister
if "linked_options" in weights: if "linked_options" in weights:
weights = roll_linked_options(weights) weights = roll_linked_options(weights)
valid_keys = {"triggers"}
if "triggers" in weights: if "triggers" in weights:
weights = roll_triggers(weights, weights["triggers"], valid_keys) weights = roll_triggers(weights, weights["triggers"])
requirements = weights.get("requires", {}) requirements = weights.get("requires", {})
if requirements: if requirements:
@@ -462,17 +462,8 @@ 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 picks = Utils.get_fuzzy_results(ret.game, AutoWorldRegister.world_types, limit=1)[0]
picks = Utils.get_fuzzy_results(ret.game, list(AutoWorldRegister.world_types) + failed_world_loads, limit=1)[0]
if picks[0] in failed_world_loads:
raise Exception(f"No functional world found to handle game {ret.game}. "
f"Did you mean '{picks[0]}' ({picks[1]}% sure)? "
f"If so, it appears the world failed to initialize correctly.")
raise Exception(f"No world found to handle game {ret.game}. Did you mean '{picks[0]}' ({picks[1]}% sure)? " raise Exception(f"No world found to handle game {ret.game}. Did you mean '{picks[0]}' ({picks[1]}% sure)? "
f"Check your spelling or installation of that world.") f"Check your spelling or installation of that world.")
@@ -482,14 +473,8 @@ def roll_settings(weights: dict, plando_options: PlandoOptions = PlandoOptions.b
world_type = AutoWorldRegister.world_types[ret.game] world_type = AutoWorldRegister.world_types[ret.game]
game_weights = weights[ret.game] game_weights = weights[ret.game]
for weight in chain(game_weights, weights):
if weight.startswith("+"):
raise Exception(f"Merge tag cannot be used outside of trigger contexts. Found {weight}")
if weight.startswith("-"):
raise Exception(f"Remove tag cannot be used outside of trigger contexts. Found {weight}")
if "triggers" in game_weights: if "triggers" in game_weights:
weights = roll_triggers(weights, game_weights["triggers"], valid_keys) weights = roll_triggers(weights, game_weights["triggers"])
game_weights = weights[ret.game] game_weights = weights[ret.game]
ret.name = get_choice('name', weights) ret.name = get_choice('name', weights)
@@ -498,28 +483,146 @@ 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)
# TODO remove plando_items after moving it to the options system
valid_keys.add("plando_items")
if PlandoOptions.items in plando_options: if PlandoOptions.items in plando_options:
ret.plando_items = copy.deepcopy(game_weights.get("plando_items", [])) ret.plando_items = game_weights.get("plando_items", [])
if ret.game == "A Link to the Past": if ret.game == "Minecraft" or ret.game == "Ocarina of Time":
# TODO there are still more LTTP options not on the options system # bad hardcoded behavior to make this work for now
valid_keys |= {"sprite_pool", "sprite", "random_sprite_on_event"} ret.plando_connections = []
roll_alttp_settings(ret, game_weights) if PlandoOptions.connections in plando_options:
options = game_weights.get("plando_connections", [])
# log a warning for options within a game section that aren't determined as valid for placement in options:
for option_key in game_weights: if roll_percentage(get_choice("percentage", placement, 100)):
if option_key in valid_keys: ret.plando_connections.append(PlandoConnection(
continue get_choice("entrance", placement),
logging.warning(f"{option_key} is not a valid option name for {ret.game} and is not present in triggers " get_choice("exit", placement),
f"for player {ret.name}.") get_choice("direction", placement)
))
elif ret.game == "A Link to the Past":
roll_alttp_settings(ret, game_weights, plando_options)
return ret return ret
def roll_alttp_settings(ret: argparse.Namespace, weights): def roll_alttp_settings(ret: argparse.Namespace, weights, plando_options):
if "dungeon_items" in weights and get_choice_legacy('dungeon_items', weights, "none") != "none":
raise Exception(f"dungeon_items key in A Link to the Past was removed, but is present in these weights as {get_choice_legacy('dungeon_items', weights, False)}.")
glitches_required = get_choice_legacy('glitches_required', weights)
if glitches_required not in [None, 'none', 'no_logic', 'overworld_glitches', 'hybrid_major_glitches', 'minor_glitches']:
logging.warning("Only NMG, OWG, HMG and No Logic supported")
glitches_required = 'none'
ret.logic = {None: 'noglitches', 'none': 'noglitches', 'no_logic': 'nologic', 'overworld_glitches': 'owglitches',
'minor_glitches': 'minorglitches', 'hybrid_major_glitches': 'hybridglitches'}[
glitches_required]
ret.dark_room_logic = get_choice_legacy("dark_room_logic", weights, "lamp")
if not ret.dark_room_logic: # None/False
ret.dark_room_logic = "none"
if ret.dark_room_logic == "sconces":
ret.dark_room_logic = "torches"
if ret.dark_room_logic not in {"lamp", "torches", "none"}:
raise ValueError(f"Unknown Dark Room Logic: \"{ret.dark_room_logic}\"")
entrance_shuffle = get_choice_legacy('entrance_shuffle', weights, 'vanilla')
if entrance_shuffle.startswith('none-'):
ret.shuffle = 'vanilla'
else:
ret.shuffle = entrance_shuffle if entrance_shuffle != 'none' else 'vanilla'
goal = get_choice_legacy('goals', weights, 'ganon')
ret.goal = goals[goal]
extra_pieces = get_choice_legacy('triforce_pieces_mode', weights, 'available')
ret.triforce_pieces_required = LttPOptions.TriforcePieces.from_any(get_choice_legacy('triforce_pieces_required', weights, 20))
# sum a percentage to required
if extra_pieces == 'percentage':
percentage = max(100, float(get_choice_legacy('triforce_pieces_percentage', weights, 150))) / 100
ret.triforce_pieces_available = int(round(ret.triforce_pieces_required * percentage, 0))
# vanilla mode (specify how many pieces are)
elif extra_pieces == 'available':
ret.triforce_pieces_available = LttPOptions.TriforcePieces.from_any(
get_choice_legacy('triforce_pieces_available', weights, 30))
# required pieces + fixed extra
elif extra_pieces == 'extra':
extra_pieces = max(0, int(get_choice_legacy('triforce_pieces_extra', weights, 10)))
ret.triforce_pieces_available = ret.triforce_pieces_required + extra_pieces
# change minimum to required pieces to avoid problems
ret.triforce_pieces_available = min(max(ret.triforce_pieces_required, int(ret.triforce_pieces_available)), 90)
ret.shop_shuffle = get_choice_legacy('shop_shuffle', weights, '')
if not ret.shop_shuffle:
ret.shop_shuffle = ''
ret.mode = get_choice_legacy("mode", weights)
ret.difficulty = get_choice_legacy('item_pool', weights)
ret.item_functionality = get_choice_legacy('item_functionality', weights)
ret.enemy_damage = {None: 'default',
'default': 'default',
'shuffled': 'shuffled',
'random': 'chaos', # to be removed
'chaos': 'chaos',
}[get_choice_legacy('enemy_damage', weights)]
ret.enemy_health = get_choice_legacy('enemy_health', weights)
ret.timer = {'none': False,
None: False,
False: False,
'timed': 'timed',
'timed_ohko': 'timed-ohko',
'ohko': 'ohko',
'timed_countdown': 'timed-countdown',
'display': 'display'}[get_choice_legacy('timer', weights, False)]
ret.countdown_start_time = int(get_choice_legacy('countdown_start_time', weights, 10))
ret.red_clock_time = int(get_choice_legacy('red_clock_time', weights, -2))
ret.blue_clock_time = int(get_choice_legacy('blue_clock_time', weights, 2))
ret.green_clock_time = int(get_choice_legacy('green_clock_time', weights, 4))
ret.dungeon_counters = get_choice_legacy('dungeon_counters', weights, 'default')
ret.shuffle_prizes = get_choice_legacy('shuffle_prizes', weights, "g")
ret.required_medallions = [get_choice_legacy("misery_mire_medallion", weights, "random"),
get_choice_legacy("turtle_rock_medallion", weights, "random")]
for index, medallion in enumerate(ret.required_medallions):
ret.required_medallions[index] = {"ether": "Ether", "quake": "Quake", "bombos": "Bombos", "random": "random"} \
.get(medallion.lower(), None)
if not ret.required_medallions[index]:
raise Exception(f"unknown Medallion {medallion} for {'misery mire' if index == 0 else 'turtle rock'}")
ret.plando_texts = {}
if PlandoOptions.texts in plando_options:
tt = TextTable()
tt.removeUnwantedText()
options = weights.get("plando_texts", [])
for placement in options:
if roll_percentage(get_choice_legacy("percentage", placement, 100)):
at = str(get_choice_legacy("at", placement))
if at not in tt:
raise Exception(f"No text target \"{at}\" found.")
ret.plando_texts[at] = str(get_choice_legacy("text", placement))
ret.plando_connections = []
if PlandoOptions.connections in plando_options:
options = weights.get("plando_connections", [])
for placement in options:
if roll_percentage(get_choice_legacy("percentage", placement, 100)):
ret.plando_connections.append(PlandoConnection(
get_choice_legacy("entrance", placement),
get_choice_legacy("exit", placement),
get_choice_legacy("direction", placement, "both")
))
ret.sprite_pool = weights.get('sprite_pool', []) ret.sprite_pool = weights.get('sprite_pool', [])
ret.sprite = get_choice_legacy('sprite', weights, "Link") ret.sprite = get_choice_legacy('sprite', weights, "Link")
if 'random_sprite_on_event' in weights: if 'random_sprite_on_event' in weights:
@@ -547,9 +650,7 @@ def roll_alttp_settings(ret: argparse.Namespace, weights):
if __name__ == '__main__': if __name__ == '__main__':
import atexit import atexit
confirmation = atexit.register(input, "Press enter to close.") confirmation = atexit.register(input, "Press enter to close.")
erargs, seed = main() multiworld = main()
from Main import main as ERmain
multiworld = ERmain(erargs, seed)
if __debug__: if __debug__:
import gc import gc
import sys import sys

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

@@ -16,27 +16,25 @@ import multiprocessing
import shlex import shlex
import subprocess import subprocess
import sys import sys
import urllib.parse
import webbrowser import webbrowser
from os.path import isfile from os.path import isfile
from shutil import which from shutil import which
from typing import Callable, Optional, Sequence, Tuple, Union from typing import 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 \
@@ -102,72 +100,14 @@ components.extend([
# Functions # Functions
Component("Open host.yaml", func=open_host_yaml), Component("Open host.yaml", func=open_host_yaml),
Component("Open Patch", func=open_patch), Component("Open Patch", func=open_patch),
Component("Generate Template Options", func=generate_yamls), Component("Generate Template Settings", func=generate_yamls),
Component("Archipelago Website", func=lambda: webbrowser.open("https://archipelago.gg/")),
Component("Discord Server", icon="discord", func=lambda: webbrowser.open("https://discord.gg/8Z65BR2")), Component("Discord Server", icon="discord", func=lambda: webbrowser.open("https://discord.gg/8Z65BR2")),
Component("Unrated/18+ Discord Server", icon="discord", func=lambda: webbrowser.open("https://discord.gg/fqvNCCRsu4")), Component("18+ Discord Server", icon="discord", func=lambda: webbrowser.open("https://discord.gg/fqvNCCRsu4")),
Component("Browse Files", func=browse_files), Component("Browse Files", func=browse_files),
]) ])
def handle_uri(path: str, launch_args: Tuple[str, ...]) -> None: def identify(path: Union[None, str]):
url = urllib.parse.urlparse(path)
queries = urllib.parse.parse_qs(url.query)
launch_args = (path, *launch_args)
client_component = None
text_client_component = None
if "game" in queries:
game = queries["game"][0]
else: # TODO around 0.6.0 - this is for pre this change webhost uri's
game = "Archipelago"
for component in components:
if component.supports_uri and component.game_name == game:
client_component = component
elif component.display_name == "Text Client":
text_client_component = component
if client_component is None:
run_component(text_client_component, *launch_args)
return
from kvui import App, Button, BoxLayout, Label, Window
class Popup(App):
def __init__(self):
self.title = "Connect to Multiworld"
self.icon = r"data/icon.png"
super().__init__()
def build(self):
layout = BoxLayout(orientation="vertical")
layout.add_widget(Label(text="Select client to open and connect with."))
button_row = BoxLayout(orientation="horizontal", size_hint=(1, 0.4))
text_client_button = Button(
text=text_client_component.display_name,
on_release=lambda *args: run_component(text_client_component, *launch_args)
)
button_row.add_widget(text_client_button)
game_client_button = Button(
text=client_component.display_name,
on_release=lambda *args: run_component(client_component, *launch_args)
)
button_row.add_widget(game_client_button)
layout.add_widget(button_row)
return layout
def _stop(self, *largs):
# see run_gui Launcher _stop comment for details
self.root_window.close()
super()._stop(*largs)
Popup().run()
def identify(path: Union[None, str]) -> Tuple[Union[None, str], Union[None, Component]]:
if path is None: if path is None:
return None, None return None, None
for component in components: for component in components:
@@ -220,30 +160,36 @@ def launch(exe, in_terminal=False):
subprocess.Popen(exe) subprocess.Popen(exe)
refresh_components: Optional[Callable[[], None]] = None
def run_gui(): def run_gui():
from kvui import App, ContainerLayout, GridLayout, Button, Label, ScrollBox, Widget, ApAsyncImage from kvui import App, ContainerLayout, GridLayout, Button, Label
from kivy.core.window import Window from kivy.uix.image import AsyncImage
from kivy.uix.relativelayout import RelativeLayout from kivy.uix.relativelayout import RelativeLayout
class Launcher(App): class Launcher(App):
base_title: str = "Archipelago Launcher" base_title: str = "Archipelago Launcher"
container: ContainerLayout container: ContainerLayout
grid: GridLayout grid: GridLayout
_tool_layout: Optional[ScrollBox] = None
_client_layout: Optional[ScrollBox] = None _tools = {c.display_name: c for c in components if c.type == Type.TOOL}
_clients = {c.display_name: c for c in components if c.type == Type.CLIENT}
_adjusters = {c.display_name: c for c in components if c.type == Type.ADJUSTER}
_miscs = {c.display_name: c for c in components if c.type == Type.MISC}
def __init__(self, ctx=None): 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"
super().__init__() super().__init__()
def _refresh_components(self) -> None: def build(self):
self.container = ContainerLayout()
self.grid = GridLayout(cols=2)
self.container.add_widget(self.grid)
self.grid.add_widget(Label(text="General"))
self.grid.add_widget(Label(text="Clients"))
button_layout = self.grid # make buttons fill the window
def build_button(component: Component) -> Widget: def build_button(component: Component):
""" """
Builds a button widget for a given component. Builds a button widget for a given component.
@@ -254,61 +200,31 @@ def run_gui():
None. The button is added to the parent grid layout. None. The button is added to the parent grid layout.
""" """
button = Button(text=component.display_name, size_hint_y=None, height=40) button = Button(text=component.display_name)
button.component = component button.component = component
button.bind(on_release=self.component_action) button.bind(on_release=self.component_action)
if component.icon != "icon": if component.icon != "icon":
image = ApAsyncImage(source=icon_paths[component.icon], image = AsyncImage(source=icon_paths[component.icon],
size=(38, 38), size_hint=(None, 1), pos=(5, 0)) size=(38, 38), size_hint=(None, 1), pos=(5, 0))
box_layout = RelativeLayout(size_hint_y=None, height=40) box_layout = RelativeLayout()
box_layout.add_widget(button) box_layout.add_widget(button)
box_layout.add_widget(image) box_layout.add_widget(image)
return box_layout button_layout.add_widget(box_layout)
return button else:
button_layout.add_widget(button)
# clear before repopulating
assert self._tool_layout and self._client_layout, "must call `build` first"
tool_children = reversed(self._tool_layout.layout.children)
for child in tool_children:
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)
_tools = {c.display_name: c for c in components if c.type == Type.TOOL}
_clients = {c.display_name: c for c in components if c.type == Type.CLIENT}
_adjusters = {c.display_name: c for c in components if c.type == Type.ADJUSTER}
_miscs = {c.display_name: c for c in components if c.type == Type.MISC}
for (tool, client) in itertools.zip_longest(itertools.chain( for (tool, client) in itertools.zip_longest(itertools.chain(
_tools.items(), _miscs.items(), _adjusters.items() self._tools.items(), self._miscs.items(), self._adjusters.items()), self._clients.items()):
), _clients.items()):
# column 1 # column 1
if tool: if tool:
self._tool_layout.layout.add_widget(build_button(tool[1])) build_button(tool[1])
else:
button_layout.add_widget(Label())
# column 2 # column 2
if client: if client:
self._client_layout.layout.add_widget(build_button(client[1])) build_button(client[1])
else:
def build(self): button_layout.add_widget(Label())
self.container = ContainerLayout()
self.grid = GridLayout(cols=2)
self.container.add_widget(self.grid)
self.grid.add_widget(Label(text="General", size_hint_y=None, height=40))
self.grid.add_widget(Label(text="Clients", size_hint_y=None, height=40))
self._tool_layout = ScrollBox()
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
refresh_components = self._refresh_components
Window.bind(on_drop_file=self._on_drop_file)
return self.container return self.container
@@ -319,14 +235,6 @@ def run_gui():
else: else:
launch(get_exe(button.component), button.component.cli) launch(get_exe(button.component), button.component.cli)
def _on_drop_file(self, window: Window, filename: bytes, x: int, y: int) -> None:
""" When a patch file is dropped into the window, run the associated component. """
file, component = identify(filename.decode())
if file and component:
run_component(component, file)
else:
logging.warning(f"unable to identify component for {file}")
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.
@@ -335,17 +243,10 @@ def run_gui():
Launcher().run() Launcher().run()
# avoiding Launcher reference leak
# and don't try to do something with widgets after window closed
global refresh_components
refresh_components = None
def run_component(component: Component, *args): def run_component(component: Component, *args):
if component.func: if component.func:
component.func(*args) component.func(*args)
if refresh_components:
refresh_components()
elif component.script_name: elif component.script_name:
subprocess.run([*get_exe(component.script_name), *args]) subprocess.run([*get_exe(component.script_name), *args])
else: else:
@@ -358,24 +259,20 @@ def main(args: Optional[Union[argparse.Namespace, dict]] = None):
elif not args: elif not args:
args = {} args = {}
path = args.get("Patch|Game|Component|url", None) if "Patch|Game|Component" in args:
if path is not None: file, component = identify(args["Patch|Game|Component"])
if path.startswith("archipelago://"):
handle_uri(path, args.get("args", ()))
return
file, component = identify(path)
if file: if file:
args['file'] = file args['file'] = file
if component: if component:
args['component'] = component args['component'] = component
if not component: if not component:
logging.warning(f"Could not identify Component responsible for {path}") logging.warning(f"Could not identify Component responsible for {args['Patch|Game|Component']}")
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() run_gui()
@@ -385,16 +282,12 @@ if __name__ == '__main__':
init_logging('Launcher') init_logging('Launcher')
Utils.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())

View File

@@ -28,7 +28,6 @@ from CommonClient import (CommonContext, get_base_parser, gui_enabled, logger,
from NetUtils import ClientStatus from NetUtils import ClientStatus
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
@@ -101,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)
@@ -136,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:
@@ -198,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:
@@ -281,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:
@@ -394,8 +348,7 @@ class LinksAwakeningClient():
await asyncio.sleep(1.0) await asyncio.sleep(1.0)
continue continue
self.stop_bizhawk_spam = False self.stop_bizhawk_spam = False
logger.info(f"Connected to Retroarch {version.decode('ascii', errors='replace')} " logger.info(f"Connected to Retroarch {version.decode('ascii')} running {rom_name.decode('ascii')}")
f"running {rom_name.decode('ascii', errors='replace')}")
return return
except (BlockingIOError, TimeoutError, ConnectionResetError): except (BlockingIOError, TimeoutError, ConnectionResetError):
await asyncio.sleep(1.0) await asyncio.sleep(1.0)
@@ -405,12 +358,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
@@ -452,11 +404,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:
@@ -506,7 +456,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
@@ -514,14 +464,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()
@@ -558,17 +502,9 @@ class LinksAwakeningContext(CommonContext):
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]):
# Store the entrances we find on the server for future sessions
message = [{
"cmd": "Set",
"key": self.slot_storage_key,
"default": {},
"want_reply": False,
"operations": [{"operation": "update", "value": entrances}],
}]
async def send_checks(self):
message = [{"cmd": 'LocationChecks', "locations": self.found_checks}]
await self.send_msgs(message) await self.send_msgs(message)
had_invalid_slot_data = None had_invalid_slot_data = None
@@ -597,20 +533,14 @@ class LinksAwakeningContext(CommonContext):
logger.info("victory!") logger.info("victory!")
await self.send_msgs(message) await self.send_msgs(message)
self.won = True self.won = True
async def request_found_entrances(self):
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))
@@ -627,28 +557,16 @@ 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", {})
# 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'}]
@@ -662,12 +580,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])
for check in ladxr_checks:
if check.value and check.linkedItem:
linkedItem = check.linkedItem
if 'condition' not in linkedItem or linkedItem['condition'](self.slot_data):
self.client.item_tracker.setExtraItem(check.linkedItem['item'], check.linkedItem['qty'])
async def victory(): async def victory():
await self.send_victory() await self.send_victory()
@@ -701,36 +613,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)
self.magpie.slot_data = self.slot_data await self.magpie.send_gps(self.client.gps_tracker)
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
@@ -799,6 +695,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.per_slot_randoms = {1: random} self.per_slot_randoms = {1: random}
self.worlds = {1: self.AdjusterSubWorld(random)}
class ArgumentDefaultsHelpFormatter(argparse.RawTextHelpFormatter): class ArgumentDefaultsHelpFormatter(argparse.RawTextHelpFormatter):
@@ -248,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)
@@ -268,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():
@@ -338,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)
@@ -582,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)
@@ -602,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()
@@ -665,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)

View File

@@ -370,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()

401
Main.py
View File

@@ -11,10 +11,9 @@ from typing import Dict, List, Optional, Set, Tuple, Union
import worlds import worlds
from BaseClasses import CollectionState, Item, Location, LocationProgressType, MultiWorld, Region from BaseClasses import CollectionState, Item, Location, LocationProgressType, MultiWorld, Region
from Fill import FillError, balance_multiworld_progression, distribute_items_restrictive, distribute_planned, \ from Fill import balance_multiworld_progression, distribute_items_restrictive, distribute_planned, flood_items
flood_items
from Options import StartInventoryPool from Options import StartInventoryPool
from Utils import __version__, output_path, version_tuple, get_settings from Utils import __version__, output_path, version_tuple
from settings import get_settings from settings import get_settings
from worlds import AutoWorld from worlds import AutoWorld
from worlds.generic.Rules import exclusion_rules, locality_rules from worlds.generic.Rules import exclusion_rules, locality_rules
@@ -31,27 +30,49 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
output_path.cached_path = args.outputpath output_path.cached_path = args.outputpath
start = time.perf_counter() start = time.perf_counter()
# initialize the multiworld # initialize the world
multiworld = MultiWorld(args.multi) world = MultiWorld(args.multi)
logger = logging.getLogger() logger = logging.getLogger()
multiworld.set_seed(seed, args.race, str(args.outputname) if args.outputname else None) world.set_seed(seed, args.race, str(args.outputname) if args.outputname else None)
multiworld.plando_options = args.plando_options world.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.player_name = args.name.copy()
multiworld.sprite = args.sprite.copy()
multiworld.sprite_pool = args.sprite_pool.copy()
multiworld.set_options(args) world.shuffle = args.shuffle.copy()
if args.csv_output: world.logic = args.logic.copy()
from Options import dump_player_options world.mode = args.mode.copy()
dump_player_options(multiworld) world.difficulty = args.difficulty.copy()
multiworld.set_item_links() world.item_functionality = args.item_functionality.copy()
multiworld.state = CollectionState(multiworld) world.timer = args.timer.copy()
logger.info('Archipelago Version %s - Seed: %s\n', __version__, multiworld.seed) world.goal = args.goal.copy()
world.boss_shuffle = args.shufflebosses.copy()
world.enemy_health = args.enemy_health.copy()
world.enemy_damage = args.enemy_damage.copy()
world.beemizer_total_chance = args.beemizer_total_chance.copy()
world.beemizer_trap_chance = args.beemizer_trap_chance.copy()
world.countdown_start_time = args.countdown_start_time.copy()
world.red_clock_time = args.red_clock_time.copy()
world.blue_clock_time = args.blue_clock_time.copy()
world.green_clock_time = args.green_clock_time.copy()
world.dungeon_counters = args.dungeon_counters.copy()
world.triforce_pieces_available = args.triforce_pieces_available.copy()
world.triforce_pieces_required = args.triforce_pieces_required.copy()
world.shop_shuffle = args.shop_shuffle.copy()
world.shuffle_prizes = args.shuffle_prizes.copy()
world.sprite_pool = args.sprite_pool.copy()
world.dark_room_logic = args.dark_room_logic.copy()
world.plando_items = args.plando_items.copy()
world.plando_texts = args.plando_texts.copy()
world.plando_connections = args.plando_connections.copy()
world.required_medallions = args.required_medallions.copy()
world.game = args.game.copy()
world.player_name = args.name.copy()
world.sprite = args.sprite.copy()
world.glitch_triforce = args.glitch_triforce # This is enabled/disabled globally, no per player option.
world.set_options(args)
world.set_item_links()
world.state = CollectionState(world)
logger.info('Archipelago Version %s - Seed: %s\n', __version__, world.seed)
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)
@@ -82,247 +103,287 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
# This assertion method should not be necessary to run if we are not outputting any multidata. # This assertion method should not be necessary to run if we are not outputting any multidata.
if not args.skip_output: if not args.skip_output:
AutoWorld.call_stage(multiworld, "assert_generate") AutoWorld.call_stage(world, "assert_generate")
AutoWorld.call_all(multiworld, "generate_early") AutoWorld.call_all(world, "generate_early")
logger.info('') logger.info('')
for player in multiworld.player_ids: for player in world.player_ids:
for item_name, count in multiworld.worlds[player].options.start_inventory.value.items(): for item_name, count in world.worlds[player].options.start_inventory.value.items():
for _ in range(count): for _ in range(count):
multiworld.push_precollected(multiworld.create_item(item_name, player)) world.push_precollected(world.create_item(item_name, player))
for item_name, count in getattr(multiworld.worlds[player].options, for item_name, count in world.start_inventory_from_pool.setdefault(player, StartInventoryPool({})).value.items():
"start_inventory_from_pool",
StartInventoryPool({})).value.items():
for _ in range(count): for _ in range(count):
multiworld.push_precollected(multiworld.create_item(item_name, player)) world.push_precollected(world.create_item(item_name, player))
# remove from_pool items also from early items handling, as starting is plenty early.
early = multiworld.early_items[player].get(item_name, 0)
if early:
multiworld.early_items[player][item_name] = max(0, early-count)
remaining_count = count-early
if remaining_count > 0:
local_early = multiworld.local_early_items[player].get(item_name, 0)
if local_early:
multiworld.early_items[player][item_name] = max(0, local_early - remaining_count)
del local_early
del early
logger.info('Creating MultiWorld.') logger.info('Creating World.')
AutoWorld.call_all(multiworld, "create_regions") AutoWorld.call_all(world, "create_regions")
logger.info('Creating Items.') logger.info('Creating Items.')
AutoWorld.call_all(multiworld, "create_items") AutoWorld.call_all(world, "create_items")
logger.info('Calculating Access Rules.') logger.info('Calculating Access Rules.')
for player in multiworld.player_ids: for player in world.player_ids:
# items can't be both local and non-local, prefer local # 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 world.worlds[player].options.non_local_items.value -= world.worlds[player].options.local_items.value
multiworld.worlds[player].options.non_local_items.value -= set(multiworld.local_early_items[player]) world.worlds[player].options.non_local_items.value -= set(world.local_early_items[player])
AutoWorld.call_all(multiworld, "set_rules") AutoWorld.call_all(world, "set_rules")
for player in multiworld.player_ids: for player in world.player_ids:
exclusion_rules(multiworld, player, multiworld.worlds[player].options.exclude_locations.value) exclusion_rules(world, player, world.worlds[player].options.exclude_locations.value)
multiworld.worlds[player].options.priority_locations.value -= multiworld.worlds[player].options.exclude_locations.value world.worlds[player].options.priority_locations.value -= world.worlds[player].options.exclude_locations.value
world_excluded_locations = set() for location_name in world.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 = world.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 world.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.
if multiworld.players > 1: if world.players > 1:
locality_rules(multiworld) locality_rules(world)
else: else:
multiworld.worlds[1].options.non_local_items.value = set() world.worlds[1].options.non_local_items.value = set()
multiworld.worlds[1].options.local_items.value = set() world.worlds[1].options.local_items.value = set()
AutoWorld.call_all(multiworld, "connect_entrances") AutoWorld.call_all(world, "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(world.start_inventory_from_pool[player].value for player in world.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: world.start_inventory_from_pool[player].value.copy() for player in world.player_ids}
} for player, items in depletion_pool.items():
target_per_player = { player_world: AutoWorld.World = world.worlds[player]
player: sum(target_items.values()) for player, target_items in depletion_pool.items() if target_items for count in items.values():
} for _ in range(count):
new_items.append(player_world.create_filler())
if target_per_player: target: int = sum(sum(items.values()) for items in depletion_pool.values())
new_itempool: List[Item] = [] for i, item in enumerate(world.itempool):
# Make new itempool with start_inventory_from_pool items removed
for item in 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(world.itempool[i+1:])
break
else:
new_items.append(item)
# leftovers?
if target:
for player, remaining_items in depletion_pool.items():
remaining_items = {name: count for name, count in remaining_items.items() if count}
if remaining_items:
raise Exception(f"{world.get_player_name(player)}"
f" is trying to remove items from their pool that don't exist: {remaining_items}")
assert len(world.itempool) == len(new_items), "Item Pool amounts should not change."
world.itempool[:] = new_items
# temporary home for item links, should be moved out of Main
for group_id, group in world.groups.items():
def find_common_pool(players: Set[int], shared_pool: Set[str]) -> Tuple[
Optional[Dict[int, Dict[str, int]]], Optional[Dict[str, int]]
]:
classifications: Dict[str, int] = collections.defaultdict(int)
counters = {player: {name: 0 for name in shared_pool} for player in players}
for item in world.itempool:
if item.player in counters and item.name in shared_pool:
counters[item.player][item.name] += 1
classifications[item.name] |= item.classification
for player in players.copy():
if all([counters[player][item] == 0 for item in shared_pool]):
players.remove(player)
del (counters[player])
if not players:
return None, None
for item in shared_pool:
count = min(counters[player][item] for player in players)
if count:
for player in players:
counters[player][item] = count
else:
for player in players:
del (counters[player][item])
return counters, classifications
common_item_count, classifications = find_common_pool(group["players"], group["item_pool"])
if not common_item_count:
continue
new_itempool: List[Item] = []
for item_name, item_count in next(iter(common_item_count.values())).items():
for _ in range(item_count):
new_item = group["world"].create_item(item_name)
# mangle together all original classification bits
new_item.classification |= classifications[item_name]
new_itempool.append(new_item)
region = Region("Menu", group_id, world, "ItemLink")
world.regions.append(region)
locations = region.locations
for item in world.itempool:
count = common_item_count.get(item.player, {}).get(item.name, 0)
if count:
loc = Location(group_id, f"Item Link: {item.name} -> {world.player_name[item.player]} {count}",
None, region)
loc.access_rule = lambda state, item_name = item.name, group_id_ = group_id, count_ = count: \
state.has(item_name, group_id_, count_)
locations.append(loc)
loc.place_locked_item(item)
common_item_count[item.player][item.name] -= 1
else: 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(world.itempool)
for player, target in target_per_player.items(): world.itempool = new_itempool
unfound_items = {item: count for item, count in depletion_pool[player].items() if count}
if unfound_items: while itemcount > len(world.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(world, "create_item", item_player,
group["replacement_items"][player]))
else:
items_to_add.append(AutoWorld.call_single(world, "create_filler", item_player))
world.random.shuffle(items_to_add)
world.itempool.extend(items_to_add[:itemcount - len(world.itempool)])
needed_items = target_per_player[player] - sum(unfound_items.values()) if any(world.item_links.values()):
new_itempool += [multiworld.worlds[player].create_filler() for _ in range(needed_items)] world._all_state = None
assert len(multiworld.itempool) == len(new_itempool), "Item Pool amounts should not change."
multiworld.itempool[:] = new_itempool
multiworld.link_items()
if any(multiworld.item_links.values()):
multiworld._all_state = None
logger.info("Running Item Plando.") logger.info("Running Item Plando.")
distribute_planned(multiworld) distribute_planned(world)
logger.info('Running Pre Main Fill.') logger.info('Running Pre Main Fill.')
AutoWorld.call_all(multiworld, "pre_fill") AutoWorld.call_all(world, "pre_fill")
logger.info(f'Filling the multiworld with {len(multiworld.itempool)} items.') logger.info(f'Filling the world with {len(world.itempool)} items.')
if multiworld.algorithm == 'flood': if world.algorithm == 'flood':
flood_items(multiworld) # different algo, biased towards early game progress items flood_items(world) # different algo, biased towards early game progress items
elif multiworld.algorithm == 'balanced': elif world.algorithm == 'balanced':
distribute_items_restrictive(multiworld, get_settings().generator.panic_method) distribute_items_restrictive(world)
AutoWorld.call_all(multiworld, 'post_fill') AutoWorld.call_all(world, 'post_fill')
if multiworld.players > 1 and not args.skip_prog_balancing: if world.players > 1 and not args.skip_prog_balancing:
balance_multiworld_progression(multiworld) balance_multiworld_progression(world)
else: else:
logger.info("Progression balancing skipped.") logger.info("Progression balancing skipped.")
# we're about to output using multithreading, so we're removing the global random state to prevent accidental use # we're about to output using multithreading, so we're removing the global random state to prevent accidental use
multiworld.random.passthrough = False world.random.passthrough = False
if args.skip_output: if args.skip_output:
logger.info('Done. Skipped output/spoiler generation. Total Time: %s', time.perf_counter() - start) logger.info('Done. Skipped output/spoiler generation. Total Time: %s', time.perf_counter() - start)
return multiworld return world
logger.info(f'Beginning output...') logger.info(f'Beginning output...')
outfilebase = 'AP_' + multiworld.seed_name outfilebase = 'AP_' + world.seed_name
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 world.player_ids if AutoWorld.World.generate_output.__code__
is not multiworld.worlds[player].generate_output.__code__] is not world.worlds[player].generate_output.__code__]
with concurrent.futures.ThreadPoolExecutor(len(output_players) + 2) as pool: with concurrent.futures.ThreadPoolExecutor(len(output_players) + 2) as pool:
check_accessibility_task = pool.submit(multiworld.fulfills_accessibility) check_accessibility_task = pool.submit(world.fulfills_accessibility)
output_file_futures = [pool.submit(AutoWorld.call_stage, multiworld, "generate_output", temp_dir)] output_file_futures = [pool.submit(AutoWorld.call_stage, world, "generate_output", temp_dir)]
for player in output_players: for player in output_players:
# skip starting a thread for methods that say "pass". # skip starting a thread for methods that say "pass".
output_file_futures.append( output_file_futures.append(
pool.submit(AutoWorld.call_single, multiworld, "generate_output", player, temp_dir)) pool.submit(AutoWorld.call_single, world, "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(world, 'extend_hint_information', er_hint_data)
def write_multidata(): def write_multidata():
import NetUtils import NetUtils
from NetUtils import HintStatus
slot_data = {} slot_data = {}
client_versions = {} client_versions = {}
games = {} games = {}
minimum_versions = {"server": AutoWorld.World.required_server_version, "clients": client_versions} minimum_versions = {"server": AutoWorld.World.required_server_version, "clients": client_versions}
slot_info = {} slot_info = {}
names = [[name for player, name in sorted(multiworld.player_name.items())]] names = [[name for player, name in sorted(world.player_name.items())]]
for slot in multiworld.player_ids: for slot in world.player_ids:
player_world: AutoWorld.World = multiworld.worlds[slot] player_world: AutoWorld.World = world.worlds[slot]
minimum_versions["server"] = max(minimum_versions["server"], player_world.required_server_version) minimum_versions["server"] = max(minimum_versions["server"], player_world.required_server_version)
client_versions[slot] = player_world.required_client_version client_versions[slot] = player_world.required_client_version
games[slot] = multiworld.game[slot] games[slot] = world.game[slot]
slot_info[slot] = NetUtils.NetworkSlot(names[0][slot - 1], multiworld.game[slot], slot_info[slot] = NetUtils.NetworkSlot(names[0][slot - 1], world.game[slot],
multiworld.player_types[slot]) world.player_types[slot])
for slot, group in multiworld.groups.items(): for slot, group in world.groups.items():
games[slot] = multiworld.game[slot] games[slot] = world.game[slot]
slot_info[slot] = NetUtils.NetworkSlot(group["name"], multiworld.game[slot], multiworld.player_types[slot], slot_info[slot] = NetUtils.NetworkSlot(group["name"], world.game[slot], world.player_types[slot],
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 world.precollected_items.items()}
precollected_hints = {player: set() for player in range(1, multiworld.players + 1 + len(multiworld.groups))} precollected_hints = {player: set() for player in range(1, world.players + 1 + len(world.groups))}
for slot in multiworld.player_ids: for slot in world.player_ids:
slot_data[slot] = multiworld.worlds[slot].fill_slot_data() slot_data[slot] = world.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 world.groups:
precollected_hints[location.item.player].add(hint) precollected_hints[location.item.player].add(hint)
else: else:
for player in multiworld.groups[location.item.player]["players"]: for player in world.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 world.player_ids}
for location in multiworld.get_filled_locations(): for location in world.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 world.worlds[location.player].options.start_location_hints:
if location.name in multiworld.worlds[location.player].options.start_location_hints: precollect_hint(location)
if not location.item.trap: # Unspecified status for location hints, except traps elif location.item.name in world.worlds[location.item.player].options.start_hints:
auto_status = HintStatus.HINT_UNSPECIFIED precollect_hint(location)
precollect_hint(location, auto_status) elif any([location.item.name in world.worlds[player].options.start_hints
elif location.item.name in multiworld.worlds[location.item.player].options.start_hints: for player in world.groups.get(location.item.player, {}).get("players", [])]):
precollect_hint(location, auto_status) precollect_hint(location)
elif any([location.item.name in multiworld.worlds[player].options.start_hints
for player in multiworld.groups.get(location.item.player, {}).get("players", [])]):
precollect_hint(location, auto_status)
# embedded data package # 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 world.worlds.values()
} }
checks_in_area: Dict[int, Dict[str, Union[int, List[int]]]] = {} checks_in_area: Dict[int, Dict[str, Union[int, List[int]]]] = {}
# get spheres -> filter address==None -> skip empty
spheres: List[Dict[int, Set[int]]] = []
for sphere in multiworld.get_sendable_spheres():
current_sphere: Dict[int, Set[int]] = collections.defaultdict(set)
for sphere_location in sphere:
current_sphere[sphere_location.player].add(sphere_location.address)
if current_sphere:
spheres.append(dict(current_sphere))
multidata = { 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 world.player_name.items()},
"locations": locations_data, "locations": locations_data,
"checks_in_area": checks_in_area, "checks_in_area": checks_in_area,
"server_options": baked_server_options, "server_options": baked_server_options,
@@ -332,12 +393,10 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
"version": tuple(version_tuple), "version": tuple(version_tuple),
"tags": ["AP"], "tags": ["AP"],
"minimum_versions": minimum_versions, "minimum_versions": minimum_versions,
"seed_name": multiworld.seed_name, "seed_name": world.seed_name,
"spheres": spheres,
"datapackage": data_package, "datapackage": data_package,
"race_mode": int(multiworld.is_race),
} }
AutoWorld.call_all(multiworld, "modify_multidata", multidata) AutoWorld.call_all(world, "modify_multidata", multidata)
multidata = zlib.compress(pickle.dumps(multidata), 9) multidata = zlib.compress(pickle.dumps(multidata), 9)
@@ -347,8 +406,8 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
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 world.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.")
@@ -360,12 +419,12 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
if args.spoiler > 1: if args.spoiler > 1:
logger.info('Calculating playthrough.') logger.info('Calculating playthrough.')
multiworld.spoiler.create_playthrough(create_paths=args.spoiler > 2) world.spoiler.create_playthrough(create_paths=args.spoiler > 2)
if args.spoiler: if args.spoiler:
multiworld.spoiler.to_file(os.path.join(temp_dir, '%s_Spoiler.txt' % outfilebase)) world.spoiler.to_file(os.path.join(temp_dir, '%s_Spoiler.txt' % outfilebase))
zipfilename = output_path(f"AP_{multiworld.seed_name}.zip") zipfilename = output_path(f"AP_{world.seed_name}.zip")
logger.info(f"Creating final archive at {zipfilename}") logger.info(f"Creating final archive at {zipfilename}")
with zipfile.ZipFile(zipfilename, mode="w", compression=zipfile.ZIP_DEFLATED, with zipfile.ZipFile(zipfilename, mode="w", compression=zipfile.ZIP_DEFLATED,
compresslevel=9) as zf: compresslevel=9) as zf:
@@ -373,4 +432,4 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
zf.write(file.path, arcname=file.name) zf.write(file.path, arcname=file.name)
logger.info('Done. Enjoy. Total Time: %s', time.perf_counter() - start) logger.info('Done. Enjoy. Total Time: %s', time.perf_counter() - start)
return multiworld return world

View File

@@ -4,36 +4,14 @@ import subprocess
import multiprocessing import multiprocessing
import warnings import warnings
local_dir = os.path.dirname(__file__)
requirements_files = {os.path.join(local_dir, 'requirements.txt')}
if sys.platform in ("win32", "darwin") and sys.version_info < (3, 10, 11): 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.10.15+ is supported.")
elif sys.platform in ("win32", "darwin") and sys.version_info < (3, 10, 15):
# 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, 10, 1):
# Other platforms may get security backports instead of micro updates, so the number is unreliable.
raise RuntimeError(f"Incompatible Python Version found: {sys.version_info}. 3.10.1+ is supported.")
# don't run update if environment is frozen/compiled or if not the parent process (skip in subprocess) # don't run update if environment is frozen/compiled or if not the parent process (skip in subprocess)
_skip_update = bool(getattr(sys, "frozen", False) or multiprocessing.parent_process()) update_ran = getattr(sys, "frozen", False) or multiprocessing.parent_process()
update_ran = _skip_update
class RequirementsSet(set):
def add(self, e):
global update_ran
update_ran &= _skip_update
super().add(e)
def update(self, *s):
global update_ran
update_ran &= _skip_update
super().update(*s)
local_dir = os.path.dirname(__file__)
requirements_files = RequirementsSet((os.path.join(local_dir, 'requirements.txt'),))
if not update_ran: if not update_ran:
for entry in os.scandir(os.path.join(local_dir, "worlds")): for entry in os.scandir(os.path.join(local_dir, "worlds")):
@@ -77,18 +55,18 @@ def install_pkg_resources(yes=False):
subprocess.call([sys.executable, "-m", "pip", "install", "--upgrade", "setuptools"]) subprocess.call([sys.executable, "-m", "pip", "install", "--upgrade", "setuptools"])
def update(yes: bool = False, force: bool = False) -> None: def update(yes=False, force=False):
global update_ran global update_ran
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)

File diff suppressed because it is too large Load Diff

View File

@@ -5,20 +5,11 @@ 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
@@ -28,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):
@@ -90,7 +79,6 @@ 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
@@ -152,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
@@ -195,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):
@@ -211,8 +198,7 @@ class JSONtoTextParser(metaclass=HandlerMeta):
"slateblue": "6D8BE8", "slateblue": "6D8BE8",
"plum": "AF99EF", "plum": "AF99EF",
"salmon": "FA8072", "salmon": "FA8072",
"white": "FFFFFF", "white": "FFFFFF"
"orange": "FF7700",
} }
def __init__(self, ctx): def __init__(self, ctx):
@@ -236,7 +222,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)
@@ -261,7 +247,7 @@ class JSONtoTextParser(metaclass=HandlerMeta):
def _handle_item_id(self, node: JSONMessagePart): def _handle_item_id(self, node: JSONMessagePart):
item_id = int(node["text"]) item_id = int(node["text"])
node["text"] = self.ctx.item_names.lookup_in_slot(item_id, node["player"]) node["text"] = self.ctx.item_names[item_id]
return self._handle_item_name(node) return self._handle_item_name(node)
def _handle_location_name(self, node: JSONMessagePart): def _handle_location_name(self, node: JSONMessagePart):
@@ -269,18 +255,14 @@ class JSONtoTextParser(metaclass=HandlerMeta):
return self._handle_color(node) return self._handle_color(node)
def _handle_location_id(self, node: JSONMessagePart): def _handle_location_id(self, node: JSONMessagePart):
location_id = int(node["text"]) item_id = int(node["text"])
node["text"] = self.ctx.location_names.lookup_in_slot(location_id, node["player"]) node["text"] = self.ctx.location_names[item_id]
return self._handle_location_name(node) return self._handle_location_name(node)
def _handle_entrance_name(self, node: JSONMessagePart): def _handle_entrance_name(self, node: JSONMessagePart):
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):
@@ -289,8 +271,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):
@@ -309,29 +290,8 @@ def add_json_item(parts: list, item_id: int, player: int = 0, item_flags: int =
parts.append({"text": str(item_id), "player": player, "flags": item_flags, "type": JSONTypes.item_id, **kwargs}) parts.append({"text": str(item_id), "player": player, "flags": item_flags, "type": JSONTypes.item_id, **kwargs})
def add_json_location(parts: list, location_id: int, player: int = 0, **kwargs) -> None: def add_json_location(parts: list, item_id: int, player: int = 0, **kwargs) -> None:
parts.append({"text": str(location_id), "player": player, "type": JSONTypes.location_id, **kwargs}) parts.append({"text": str(item_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):
@@ -342,21 +302,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):
@@ -378,7 +331,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,
@@ -424,8 +380,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
@@ -442,12 +396,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])
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
@@ -194,9 +195,10 @@ def set_icon(window):
window.tk.call('wm', 'iconphoto', window._w, logo) window.tk.call('wm', 'iconphoto', window._w, logo)
def adjust(args): def adjust(args):
# Create a fake multiworld and OOTWorld to use as a base # Create a fake world and OOTWorld to use as a base
multiworld = MultiWorld(1) world = MultiWorld(1)
ootworld = OOTWorld(multiworld, 1) world.per_slot_randoms = {1: random}
ootworld = OOTWorld(world, 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()):
result = getattr(args, name, None) result = getattr(args, name, None)

View File

@@ -346,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()

File diff suppressed because it is too large Load Diff

View File

@@ -8,7 +8,7 @@ if __name__ == "__main__":
import ModuleUpdate import ModuleUpdate
ModuleUpdate.update() ModuleUpdate.update()
from worlds.Files import AutoPatchRegister, APAutoPatchInterface from worlds.Files import AutoPatchRegister, APDeltaPatch
class RomMeta(TypedDict): class RomMeta(TypedDict):
@@ -20,7 +20,7 @@ class RomMeta(TypedDict):
def create_rom_file(patch_file: str) -> Tuple[RomMeta, str]: def create_rom_file(patch_file: str) -> Tuple[RomMeta, str]:
auto_handler = AutoPatchRegister.get_handler(patch_file) auto_handler = AutoPatchRegister.get_handler(patch_file)
if auto_handler: if auto_handler:
handler: APAutoPatchInterface = auto_handler(patch_file) handler: APDeltaPatch = auto_handler(patch_file)
target = os.path.splitext(patch_file)[0]+handler.result_file_ending target = os.path.splitext(patch_file)[0]+handler.result_file_ending
handler.patch(target) handler.patch(target)
return {"server": handler.server, return {"server": handler.server,

View File

@@ -1,10 +1,8 @@
# [Archipelago](https://archipelago.gg) ![Discord Shield](https://discordapp.com/api/guilds/731205301247803413/widget.png?style=shield) | [Install](https://github.com/ArchipelagoMW/Archipelago/releases) # [Archipelago](https://archipelago.gg) ![Discord Shield](https://discordapp.com/api/guilds/731205301247803413/widget.png?style=shield) | [Install](https://github.com/ArchipelagoMW/Archipelago/releases)
Archipelago provides a generic framework for developing multiworld capability for game randomizers. In all cases, Archipelago provides a generic framework for developing multiworld capability for game randomizers. In all cases, presently, Archipelago is also the randomizer itself.
presently, Archipelago is also the randomizer itself.
Currently, the following games are supported: 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 * Minecraft
@@ -27,7 +25,7 @@ Currently, the following games are supported:
* Hollow Knight * Hollow Knight
* The Witness * The Witness
* Sonic Adventure 2: Battle * Sonic Adventure 2: Battle
* Starcraft 2 * Starcraft 2: Wings of Liberty
* Donkey Kong Country 3 * Donkey Kong Country 3
* Dark Souls 3 * Dark Souls 3
* Super Mario World * Super Mario World
@@ -60,28 +58,6 @@ Currently, the following games are supported:
* Heretic * Heretic
* Landstalker: The Treasures of King Nole * Landstalker: The Treasures of King Nole
* Final Fantasy Mystic Quest * Final Fantasy Mystic Quest
* TUNIC
* Kirby's Dream Land 3
* Celeste 64
* Zork Grand Inquisitor
* Castlevania 64
* A Short Hike
* Yoshi's Island
* Mario & Luigi: Superstar Saga
* Bomb Rush Cyberfunk
* Aquaria
* Yu-Gi-Oh! Ultimate Masters: World Championship Tournament 2006
* A Hat in Time
* Old School Runescape
* Kingdom Hearts 1
* Mega Man 2
* Yacht Dice
* Faxanadu
* Saving Princess
* Castlevania: Circle of the Moon
* Inscryption
* Civilization VI
* The Legend of Zelda: The Wind Waker
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
@@ -89,57 +65,36 @@ windows binaries.
## History ## History
Archipelago is built upon a strong legacy of brilliant hobbyists. We want to honor that legacy by showing it here. Archipelago is built upon a strong legacy of brilliant hobbyists. We want to honor that legacy by showing it here. The repositories which Archipelago is built upon, inspired by, or otherwise owes its gratitude to are:
The repositories which Archipelago is built upon, inspired by, or otherwise owes its gratitude to are:
* [bonta0's MultiWorld](https://github.com/Bonta0/ALttPEntranceRandomizer/tree/multiworld_31) * [bonta0's MultiWorld](https://github.com/Bonta0/ALttPEntranceRandomizer/tree/multiworld_31)
* [AmazingAmpharos' Entrance Randomizer](https://github.com/AmazingAmpharos/ALttPEntranceRandomizer) * [AmazingAmpharos' Entrance Randomizer](https://github.com/AmazingAmpharos/ALttPEntranceRandomizer)
* [VT Web Randomizer](https://github.com/sporchia/alttp_vt_randomizer) * [VT Web Randomizer](https://github.com/sporchia/alttp_vt_randomizer)
* [Dessyreqt's alttprandomizer](https://github.com/Dessyreqt/alttprandomizer) * [Dessyreqt's alttprandomizer](https://github.com/Dessyreqt/alttprandomizer)
* [Zarby89's](https://github.com/Ijwu/Enemizer/commits?author=Zarby89) * [Zarby89's](https://github.com/Ijwu/Enemizer/commits?author=Zarby89) and [sosuke3's](https://github.com/Ijwu/Enemizer/commits?author=sosuke3) contributions to Enemizer, which make the vast majority of Enemizer contributions.
and [sosuke3's](https://github.com/Ijwu/Enemizer/commits?author=sosuke3) contributions to Enemizer, which make up the
vast majority of Enemizer contributions.
We recognize that there is a strong community of incredibly smart people that have come before us and helped pave the We recognize that there is a strong community of incredibly smart people that have come before us and helped pave the path. Just because one person's name may be in a repository title does not mean that only one person made that project happen. We can't hope to perfectly cover every single contribution that lead up to Archipelago but we hope to honor them fairly.
path. Just because one person's name may be in a repository title does not mean that only one person made that project
happen. We can't hope to perfectly cover every single contribution that lead up to Archipelago, but we hope to honor
them fairly.
### Path to the Archipelago ### Path to the Archipelago
Archipelago was directly forked from bonta0's `multiworld_31` branch of ALttPEntranceRandomizer (this project has a long legacy of its own, please check it out linked above) on January 12, 2020. The repository was then named to _MultiWorld-Utilities_ to better encompass its intended function. As Archipelago matured, then known as "Berserker's MultiWorld" by some, we found it necessary to transform our repository into a root level repository (as opposed to a 'forked repo') and change the name (which came later) to better reflect our project.
Archipelago was directly forked from bonta0's `multiworld_31` branch of ALttPEntranceRandomizer (this project has a
long legacy of its own, please check it out linked above) on January 12, 2020. The repository was then named to
_MultiWorld-Utilities_ to better encompass its intended function. As Archipelago matured, then known as
"Berserker's MultiWorld" by some, we found it necessary to transform our repository into a root level repository
(as opposed to a 'forked repo') and change the name (which came later) to better reflect our project.
## Running Archipelago ## Running Archipelago
For most people all you need to do is head over to the [releases](https://github.com/ArchipelagoMW/Archipelago/releases) page then download and run the appropriate installer. The installers function on Windows only.
For most people, all you need to do is head over to If you are running Archipelago from a non-Windows system then the likely scenario is that you are comfortable running source code directly. Please see our doc on [running Archipelago from source](docs/running%20from%20source.md).
the [releases page](https://github.com/ArchipelagoMW/Archipelago/releases), then download and run the appropriate
installer, or AppImage for Linux-based systems.
If you are a developer or are running on a platform with no compiled releases available, please see our doc on
[running Archipelago from source](docs/running%20from%20source.md).
## Related Repositories ## Related Repositories
This project makes use of multiple other projects. We wouldn't be here without these other repositories and the contributions of their developers, past and present.
This project makes use of multiple other projects. We wouldn't be here without these other repositories and the
contributions of their developers, past and present.
* [z3randomizer](https://github.com/ArchipelagoMW/z3randomizer) * [z3randomizer](https://github.com/ArchipelagoMW/z3randomizer)
* [Enemizer](https://github.com/Ijwu/Enemizer) * [Enemizer](https://github.com/Ijwu/Enemizer)
* [Ocarina of Time Randomizer](https://github.com/TestRunnerSRL/OoT-Randomizer) * [Ocarina of Time Randomizer](https://github.com/TestRunnerSRL/OoT-Randomizer)
## Contributing ## Contributing
For contribution guidelines, please see our [Contributing doc.](/docs/contributing.md)
To contribute to Archipelago, including the WebHost, core program, or by adding a new game, see our
[Contributing guidelines](/docs/contributing.md).
## FAQ ## FAQ
For Frequently asked questions, please see the website's [FAQ Page.](https://archipelago.gg/faq/en/)
For Frequently asked questions, please see the website's [FAQ Page](https://archipelago.gg/faq/en/).
## Code of Conduct ## Code of Conduct
Please refer to our [code of conduct.](/docs/code_of_conduct.md)
Please refer to our [code of conduct](/docs/code_of_conduct.md).

View File

@@ -85,7 +85,6 @@ class SNIClientCommandProcessor(ClientCommandProcessor):
"""Close connection to a currently connected snes""" """Close connection to a currently connected snes"""
self.ctx.snes_reconnect_address = None self.ctx.snes_reconnect_address = None
self.ctx.cancel_snes_autoreconnect() self.ctx.cancel_snes_autoreconnect()
self.ctx.snes_state = SNESState.SNES_DISCONNECTED
if self.ctx.snes_socket and not self.ctx.snes_socket.closed: if self.ctx.snes_socket and not self.ctx.snes_socket.closed:
async_start(self.ctx.snes_socket.close()) async_start(self.ctx.snes_socket.close())
return True return True
@@ -243,9 +242,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
@@ -285,7 +281,7 @@ class SNESState(enum.IntEnum):
def launch_sni() -> None: def launch_sni() -> None:
sni_path = Utils.get_settings()["sni_options"]["sni_path"] sni_path = Utils.get_options()["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)
@@ -568,12 +564,16 @@ async def snes_write(ctx: SNIContext, write_list: typing.List[typing.Tuple[int,
PutAddress_Request: SNESRequest = {"Opcode": "PutAddress", "Operands": [], 'Space': 'SNES'} PutAddress_Request: SNESRequest = {"Opcode": "PutAddress", "Operands": [], 'Space': 'SNES'}
try: try:
for address, data in write_list: for address, data in write_list:
PutAddress_Request['Operands'] = [hex(address)[2:], hex(len(data))[2:]] while data:
if ctx.snes_socket is not None: # Divide the write into packets of 256 bytes.
await ctx.snes_socket.send(dumps(PutAddress_Request)) PutAddress_Request['Operands'] = [hex(address)[2:], hex(min(len(data), 256))[2:]]
await ctx.snes_socket.send(data) if ctx.snes_socket is not None:
else: await ctx.snes_socket.send(dumps(PutAddress_Request))
snes_logger.warning(f"Could not send data to SNES: {data}") await ctx.snes_socket.send(data[:256])
address += 256
data = data[256:]
else:
snes_logger.warning(f"Could not send data to SNES: {data}")
except ConnectionClosed: except ConnectionClosed:
return False return False
@@ -636,13 +636,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")
@@ -658,18 +652,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 = typing.cast(typing.Union[bool, str], auto_start = typing.cast(typing.Union[bool, str],
Utils.get_settings()["sni_options"].get("snes_rom_start", True)) Utils.get_options()["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 +723,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()

View File

@@ -3,7 +3,7 @@ from __future__ import annotations
import ModuleUpdate import ModuleUpdate
ModuleUpdate.update() ModuleUpdate.update()
from worlds.sc2.Client import launch from worlds.sc2wol.Client import launch
import Utils import Utils
if __name__ == "__main__": if __name__ == "__main__":

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"])
@@ -247,8 +247,8 @@ async def process_undertale_cmd(ctx: UndertaleContext, cmd: str, args: dict):
with open(os.path.join(ctx.save_game_folder, filename), "w") as f: with open(os.path.join(ctx.save_game_folder, filename), "w") as f:
toDraw = "" toDraw = ""
for i in range(20): for i in range(20):
if i < len(str(ctx.item_names.lookup_in_game(l.item))): if i < len(str(ctx.item_names[l.item])):
toDraw += str(ctx.item_names.lookup_in_game(l.item))[i] toDraw += str(ctx.item_names[l.item])[i]
else: else:
break break
f.write(toDraw) f.write(toDraw)
@@ -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()

247
Utils.py
View File

@@ -18,20 +18,20 @@ 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 yaml import load, load_all, dump, SafeLoader
from yaml import load, load_all, dump
try: try:
from yaml import CLoader as UnsafeLoader, CSafeLoader as SafeLoader, CDumper as Dumper from yaml import CLoader as UnsafeLoader
from yaml import CDumper as Dumper
except ImportError: except ImportError:
from yaml import Loader as UnsafeLoader, SafeLoader, Dumper from yaml import Loader as UnsafeLoader
from yaml import Dumper
if typing.TYPE_CHECKING: 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 +47,7 @@ class Version(typing.NamedTuple):
return ".".join(str(item) for item in self) return ".".join(str(item) for item in self)
__version__ = "0.6.0" __version__ = "0.4.4"
version_tuple = tuplize_version(__version__) version_tuple = tuplize_version(__version__)
is_linux = sys.platform.startswith("linux") is_linux = sys.platform.startswith("linux")
@@ -102,7 +102,8 @@ def cache_self1(function: typing.Callable[[S, T], RetType]) -> typing.Callable[[
@functools.wraps(function) @functools.wraps(function)
def wrap(self: S, arg: T) -> RetType: def wrap(self: S, arg: T) -> RetType:
cache: Optional[Dict[T, RetType]] = getattr(self, cache_name, None) cache: Optional[Dict[T, RetType]] = typing.cast(Optional[Dict[T, RetType]],
getattr(self, cache_name, None))
if cache is None: if cache is None:
res = function(self, arg) res = function(self, arg)
setattr(self, cache_name, {arg: res}) setattr(self, cache_name, {arg: res})
@@ -152,15 +153,8 @@ 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' os.makedirs(home_path.cached_path, 0o700, exist_ok=True)
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)
else: else:
# not implemented # not implemented
home_path.cached_path = local_path() # this will generate the same exceptions we got previously home_path.cached_path = local_path() # this will generate the same exceptions we got previously
@@ -208,7 +202,7 @@ def cache_path(*path: str) -> str:
def output_path(*path: str) -> str: def output_path(*path: str) -> str:
if hasattr(output_path, 'cached_path'): if hasattr(output_path, 'cached_path'):
return os.path.join(output_path.cached_path, *path) return os.path.join(output_path.cached_path, *path)
output_path.cached_path = user_path(get_settings()["general_options"]["output_path"]) output_path.cached_path = user_path(get_options()["general_options"]["output_path"])
path = os.path.join(output_path.cached_path, *path) path = os.path.join(output_path.cached_path, *path)
os.makedirs(os.path.dirname(path), exist_ok=True) os.makedirs(os.path.dirname(path), exist_ok=True)
return path return path
@@ -216,11 +210,10 @@ def output_path(*path: str) -> str:
def open_file(filename: typing.Union[str, "pathlib.Path"]) -> None: def open_file(filename: typing.Union[str, "pathlib.Path"]) -> None:
if is_windows: if is_windows:
os.startfile(filename) # type: ignore os.startfile(filename)
else: else:
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."
subprocess.call([open_command, filename]) subprocess.call([open_command, filename])
@@ -233,9 +226,6 @@ class UniqueKeyLoader(SafeLoader):
if key in mapping: if key in mapping:
logging.error(f"YAML duplicates sanity check failed{key_node.start_mark}") logging.error(f"YAML duplicates sanity check failed{key_node.start_mark}")
raise KeyError(f"Duplicate key {key} found in YAML. Already found keys: {mapping}.") raise KeyError(f"Duplicate key {key} found in YAML. Already found keys: {mapping}.")
if (str(key).startswith("+") and (str(key)[1:] in mapping)) or (f"+{key}" in mapping):
logging.error(f"YAML merge duplicates sanity check failed{key_node.start_mark}")
raise KeyError(f"Equivalent key {key} found in YAML. Already found keys: {mapping}.")
mapping.add(key) mapping.add(key)
return super().construct_mapping(node, deep) return super().construct_mapping(node, deep)
@@ -308,21 +298,21 @@ def get_options() -> Settings:
return get_settings() return get_settings()
def persistent_store(category: str, key: str, value: typing.Any): def persistent_store(category: str, key: typing.Any, value: typing.Any):
path = user_path("_persistent_storage.yaml") path = user_path("_persistent_storage.yaml")
storage = persistent_load() storage: dict = persistent_load()
category_dict = storage.setdefault(category, {}) category = storage.setdefault(category, {})
category_dict[key] = value category[key] = value
with open(path, "wt") as f: with open(path, "wt") as f:
f.write(dump(storage, Dumper=Dumper)) f.write(dump(storage, Dumper=Dumper))
def persistent_load() -> Dict[str, Dict[str, Any]]: def persistent_load() -> typing.Dict[str, dict]:
storage: Union[Dict[str, Dict[str, Any]], None] = getattr(persistent_load, "storage", None) storage = getattr(persistent_load, "storage", None)
if storage: if storage:
return storage return storage
path = user_path("_persistent_storage.yaml") path = user_path("_persistent_storage.yaml")
storage = {} storage: dict = {}
if os.path.exists(path): if os.path.exists(path):
try: try:
with open(path, "r") as f: with open(path, "r") as f:
@@ -331,7 +321,7 @@ def persistent_load() -> Dict[str, Dict[str, Any]]:
logging.debug(f"Could not read store: {e}") logging.debug(f"Could not read store: {e}")
if storage is None: if storage is None:
storage = {} storage = {}
setattr(persistent_load, "storage", storage) persistent_load.storage = storage
return storage return storage
@@ -373,7 +363,6 @@ def store_data_package_for_checksum(game: str, data: typing.Dict[str, Any]) -> N
except Exception as e: except Exception as e:
logging.debug(f"Could not store data package: {e}") logging.debug(f"Could not store data package: {e}")
def get_default_adjuster_settings(game_name: str) -> Namespace: def get_default_adjuster_settings(game_name: str) -> Namespace:
import LttPAdjuster import LttPAdjuster
adjuster_settings = Namespace() adjuster_settings = Namespace()
@@ -392,9 +381,7 @@ def get_adjuster_settings(game_name: str) -> Namespace:
default_settings = get_default_adjuster_settings(game_name) default_settings = get_default_adjuster_settings(game_name)
# Fill in any arguments from the argparser that we haven't seen before # Fill in any arguments from the argparser that we haven't seen before
return Namespace(**vars(adjuster_settings), **{ return Namespace(**vars(adjuster_settings), **{k:v for k,v in vars(default_settings).items() if k not in vars(adjuster_settings)})
k: v for k, v in vars(default_settings).items() if k not in vars(adjuster_settings)
})
@cache_argsless @cache_argsless
@@ -418,21 +405,20 @@ safe_builtins = frozenset((
class RestrictedUnpickler(pickle.Unpickler): class RestrictedUnpickler(pickle.Unpickler):
generic_properties_module: Optional[object] generic_properties_module: Optional[object]
def __init__(self, *args: Any, **kwargs: Any) -> None: def __init__(self, *args, **kwargs):
super(RestrictedUnpickler, self).__init__(*args, **kwargs) super(RestrictedUnpickler, self).__init__(*args, **kwargs)
self.options_module = importlib.import_module("Options") self.options_module = importlib.import_module("Options")
self.net_utils_module = importlib.import_module("NetUtils") self.net_utils_module = importlib.import_module("NetUtils")
self.generic_properties_module = None self.generic_properties_module = None
def find_class(self, module: str, name: str) -> type: def find_class(self, module, name):
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 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)
@@ -443,14 +429,13 @@ 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")
def restricted_loads(s: bytes) -> Any: def restricted_loads(s):
"""Helper function analogous to pickle.loads().""" """Helper function analogous to pickle.loads()."""
return RestrictedUnpickler(io.BytesIO(s)).load() return RestrictedUnpickler(io.BytesIO(s)).load()
@@ -468,15 +453,6 @@ class KeyedDefaultDict(collections.defaultdict):
"""defaultdict variant that uses the missing key as argument to default_factory""" """defaultdict variant that uses the missing key as argument to default_factory"""
default_factory: typing.Callable[[typing.Any], typing.Any] default_factory: typing.Callable[[typing.Any], typing.Any]
def __init__(self,
default_factory: typing.Callable[[Any], Any] = None,
seq: typing.Union[typing.Mapping, typing.Iterable, None] = None,
**kwargs):
if seq is not None:
super().__init__(default_factory, seq, **kwargs)
else:
super().__init__(default_factory, **kwargs)
def __missing__(self, key): def __missing__(self, key):
self[key] = value = self.default_factory(key) self[key] = value = self.default_factory(key)
return value return value
@@ -493,9 +469,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")
@@ -515,22 +491,18 @@ def init_logging(name: str, loglevel: typing.Union[str, int] = logging.INFO,
file_handler.setFormatter(logging.Formatter(log_format)) file_handler.setFormatter(logging.Formatter(log_format))
class Filter(logging.Filter): class Filter(logging.Filter):
def __init__(self, filter_name: str, condition: typing.Callable[[logging.LogRecord], bool]) -> None: def __init__(self, filter_name, condition):
super().__init__(filter_name) super().__init__(filter_name)
self.condition = condition self.condition = condition
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)
# Relay unhandled exceptions to logger. # Relay unhandled exceptions to logger.
@@ -542,8 +514,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
@@ -566,13 +537,12 @@ 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 ''}"
) )
def stream_input(stream: typing.TextIO, queue: "asyncio.Queue[str]"): def stream_input(stream, queue):
def queuer(): def queuer():
while 1: while 1:
try: try:
@@ -582,8 +552,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)
@@ -602,7 +570,7 @@ class VersionException(Exception):
pass pass
def chaining_prefix(index: int, labels: typing.Sequence[str]) -> str: def chaining_prefix(index: int, labels: typing.Tuple[str]) -> str:
text = "" text = ""
max_label = len(labels) - 1 max_label = len(labels) - 1
while index > max_label: while index > max_label:
@@ -625,7 +593,7 @@ def format_SI_prefix(value, power=1000, power_labels=("", "k", "M", "G", "T", "P
return f"{value.quantize(decimal.Decimal('1.00'))} {chaining_prefix(n, power_labels)}" return f"{value.quantize(decimal.Decimal('1.00'))} {chaining_prefix(n, power_labels)}"
def get_fuzzy_results(input_word: str, word_list: typing.Collection[str], limit: typing.Optional[int] = None) \ def get_fuzzy_results(input_word: str, wordlist: typing.Sequence[str], limit: typing.Optional[int] = None) \
-> typing.List[typing.Tuple[str, int]]: -> typing.List[typing.Tuple[str, int]]:
import jellyfish import jellyfish
@@ -633,71 +601,22 @@ def get_fuzzy_results(input_word: str, word_list: typing.Collection[str], limit:
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)))
limit = limit if limit else len(word_list) limit: int = limit if limit else len(wordlist)
return list( return list(
map( map(
lambda container: (container[0], int(container[1]*100)), # convert up to limit to int % lambda container: (container[0], int(container[1]*100)), # convert up to limit to int %
sorted( sorted(
map(lambda candidate: (candidate, get_fuzzy_ratio(input_word, candidate)), word_list), map(lambda candidate:
(candidate, get_fuzzy_ratio(input_word, candidate)),
wordlist),
key=lambda element: element[1], key=lambda element: element[1],
reverse=True reverse=True)[0:limit]
)[0:limit]
) )
) )
def get_intended_text(input_text: str, possible_answers) -> typing.Tuple[str, bool, str]: def open_filename(title: str, filetypes: typing.Sequence[typing.Tuple[str, typing.Sequence[str]]], suggest: str = "") \
picks = get_fuzzy_results(input_text, possible_answers, limit=2)
if len(picks) > 1:
dif = picks[0][1] - picks[1][1]
if picks[0][1] == 100:
return picks[0][0], True, "Perfect Match"
elif picks[0][1] < 75:
return picks[0][0], False, f"Didn't find something that closely matches '{input_text}', " \
f"did you mean '{picks[0][0]}'? ({picks[0][1]}% sure)"
elif dif > 5:
return picks[0][0], True, "Close Match"
else:
return picks[0][0], False, f"Too many close matches for '{input_text}', " \
f"did you mean '{picks[0][0]}'? ({picks[0][1]}% sure)"
else:
if picks[0][1] > 90:
return picks[0][0], True, "Only Option Match"
else:
return picks[0][0], False, f"Didn't find something that closely matches '{input_text}', " \
f"did you mean '{picks[0][0]}'? ({picks[0][1]}% sure)"
def get_input_text_from_response(text: str, command: str) -> typing.Optional[str]:
if "did you mean " in text:
for question in ("Didn't find something that closely matches",
"Too many close matches"):
if text.startswith(question):
name = get_text_between(text, "did you mean '",
"'? (")
return f"!{command} {name}"
elif text.startswith("Missing: "):
return text.replace("Missing: ", "!hint_location ")
return None
def is_kivy_running() -> bool:
if "kivy" in sys.modules:
from kivy.app import App
return App.get_running_app() is not None
return False
def _mp_open_filename(res: "multiprocessing.Queue[typing.Optional[str]]", *args: Any) -> None:
if is_kivy_running():
raise RuntimeError("kivy should not be running in multiprocess")
res.put(open_filename(*args))
def 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}.")
def run(*args: str): def run(*args: str):
return subprocess.run(args, capture_output=True, text=True).stdout.split("\n", 1)[0] or None return subprocess.run(args, capture_output=True, text=True).stdout.split("\n", 1)[0] or None
@@ -723,13 +642,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:
@@ -739,12 +651,6 @@ 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): def run(*args: str):
return subprocess.run(args, capture_output=True, text=True).stdout.split("\n", 1)[0] or None return subprocess.run(args, capture_output=True, text=True).stdout.split("\n", 1)[0] or None
@@ -768,16 +674,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:
@@ -790,6 +689,12 @@ def messagebox(title: str, text: str, error: bool = False) -> None:
def run(*args: str): def run(*args: str):
return subprocess.run(args, capture_output=True, text=True).stdout.split("\n", 1)[0] or None 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()
@@ -809,7 +714,7 @@ def messagebox(title: str, text: str, error: bool = False) -> None:
import ctypes import ctypes
style = 0x10 if error else 0x0 style = 0x10 if error else 0x0
return ctypes.windll.user32.MessageBoxW(0, text, title, style) return ctypes.windll.user32.MessageBoxW(0, text, title, style)
# fall back to tk # fall back to tk
try: try:
import tkinter import tkinter
@@ -825,7 +730,7 @@ def messagebox(title: str, text: str, error: bool = False) -> None:
root.update() root.update()
def title_sorted(data: typing.Iterable, key=None, ignore: typing.AbstractSet[str] = frozenset(("a", "the"))): def title_sorted(data: typing.Sequence, key=None, ignore: typing.Set = frozenset(("a", "the"))):
"""Sorts a sequence of text ignoring typical articles like "a" or "the" in the beginning.""" """Sorts a sequence of text ignoring typical articles like "a" or "the" in the beginning."""
def sorter(element: Union[str, Dict[str, Any]]) -> str: def sorter(element: Union[str, Dict[str, Any]]) -> str:
if (not isinstance(element, str)): if (not isinstance(element, str)):
@@ -868,28 +773,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):
log_message: str
should_error: bool
def __init__(self, message: str, error: bool = False) -> None:
self.log_message = message
self.should_error = error
super().__init__()
def __getitem__(self, item: Any) -> Any:
if self.should_error:
deprecate(self.log_message, add_stacklevels=1)
elif __debug__:
warnings.warn(self.log_message, stacklevel=2)
return super().__getitem__(item)
def _extend_freeze_support() -> None: def _extend_freeze_support() -> None:
"""Extend multiprocessing.freeze_support() to also work on Non-Windows for spawn.""" """Extend multiprocessing.freeze_support() to also work on Non-Windows for spawn."""
@@ -941,7 +829,7 @@ def freeze_support() -> None:
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.)
@@ -957,22 +845,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 world.player_ids:
visualize_regions(multiworld.get_region("Menu", player), f"{multiworld.get_out_file_name_base(player)}.puml") visualize_regions(world.get_region("Menu", player), f"{world.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
@@ -1025,7 +907,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)
@@ -1067,10 +949,3 @@ class RepeatableChain:
def __len__(self): def __len__(self):
return sum(len(iterable) for iterable in self.iterable) return sum(len(iterable) for iterable in self.iterable)
def is_iterable_except_str(obj: object) -> TypeGuard[typing.Iterable[typing.Any]]:
""" `str` is `Iterable`, but that's not what we want """
if isinstance(obj, str):
return False
return isinstance(obj, typing.Iterable)

View File

@@ -176,7 +176,7 @@ class WargrooveContext(CommonContext):
if not os.path.isfile(path): if not os.path.isfile(path):
open(path, 'w').close() open(path, 'w').close()
# Announcing commander unlocks # Announcing commander unlocks
item_name = self.item_names.lookup_in_game(network_item.item) item_name = self.item_names[network_item.item]
if item_name in faction_table.keys(): if item_name in faction_table.keys():
for commander in faction_table[item_name]: for commander in faction_table[item_name]:
logger.info(f"{commander.name} has been unlocked!") logger.info(f"{commander.name} has been unlocked!")
@@ -197,7 +197,7 @@ class WargrooveContext(CommonContext):
open(print_path, 'w').close() open(print_path, 'w').close()
with open(print_path, 'w') as f: with open(print_path, 'w') as f:
f.write("Received " + f.write("Received " +
self.item_names.lookup_in_game(network_item.item) + self.item_names[network_item.item] +
" from " + " from " +
self.player_names[network_item.player]) self.player_names[network_item.player])
f.close() f.close()
@@ -267,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:
@@ -340,7 +342,7 @@ class WargrooveContext(CommonContext):
faction_items = 0 faction_items = 0
faction_item_names = [faction + ' Commanders' for faction in faction_table.keys()] faction_item_names = [faction + ' Commanders' for faction in faction_table.keys()]
for network_item in self.items_received: for network_item in self.items_received:
if self.item_names.lookup_in_game(network_item.item) in faction_item_names: if self.item_names[network_item.item] in faction_item_names:
faction_items += 1 faction_items += 1
starting_groove = (faction_items - 1) * self.starting_groove_multiplier starting_groove = (faction_items - 1) * self.starting_groove_multiplier
# Must be an integer larger than 0 # Must be an integer larger than 0
@@ -446,6 +448,6 @@ if __name__ == '__main__':
parser = get_base_parser(description="Wargroove Client, for text interfacing.") parser = get_base_parser(description="Wargroove Client, for text interfacing.")
args, rest = parser.parse_known_args() args, rest = parser.parse_known_args()
colorama.just_fix_windows_console() colorama.init()
asyncio.run(main(args)) 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,42 +11,29 @@ 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: Utils.local_path.cached_path = os.path.dirname(__file__) or "." # py3.8 is not abs. remove "." when dropping 3.8
from flask import Flask
Utils.local_path.cached_path = os.path.dirname(__file__)
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
configpath = os.path.abspath(Utils.user_path('config.yaml')) configpath = os.path.abspath(Utils.user_path('config.yaml'))
def get_app() -> "Flask": def get_app():
from WebHostLib import register, cache, app as raw_app from WebHostLib import register, cache, app as raw_app
from WebHostLib.models import db from WebHostLib.models import db
register()
app = raw_app app = raw_app
if os.path.exists(configpath) and not app.config["TESTING"]: if os.path.exists(configpath) and not app.config["TESTING"]:
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()
logging.info(f"HOST_ADDRESS was set to {app.config['HOST_ADDRESS']}") logging.info(f"HOST_ADDRESS was set to {app.config['HOST_ADDRESS']}")
register()
cache.init_app(app) cache.init_app(app)
db.bind(**app.config["PONY"]) db.bind(**app.config["PONY"])
db.generate_mapping(create_tables=True) db.generate_mapping(create_tables=True)
@@ -69,10 +55,9 @@ def create_ordered_tutorials_file() -> typing.List[typing.Dict[str, typing.Any]]
worlds[game] = world worlds[game] = world
base_target_path = Utils.local_path("WebHostLib", "static", "generated", "docs") base_target_path = Utils.local_path("WebHostLib", "static", "generated", "docs")
shutil.rmtree(base_target_path, ignore_errors=True)
for game, world in worlds.items(): for game, world in worlds.items():
# copy files from world's docs folder to the generated folder # copy files from world's docs folder to the generated folder
target_path = os.path.join(base_target_path, get_file_safe_name(game)) target_path = os.path.join(base_target_path, game)
os.makedirs(target_path, exist_ok=True) os.makedirs(target_path, exist_ok=True)
if world.zip_path: if world.zip_path:
@@ -132,7 +117,7 @@ if __name__ == "__main__":
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.lttpsprites import update_sprites_lttp
from WebHostLib.autolauncher import autohost, autogen, stop from WebHostLib.autolauncher import autohost, autogen
from WebHostLib.options import create as create_options_files from WebHostLib.options import create as create_options_files
try: try:
@@ -153,11 +138,3 @@ if __name__ == "__main__":
else: else:
from waitress import serve from waitress import serve
serve(app, port=app.config["PORT"], threads=app.config["WAITRESS_THREADS"]) serve(app, port=app.config["PORT"], threads=app.config["WAITRESS_THREADS"])
else:
from time import sleep
try:
while True:
sleep(1) # wait for process to be killed
except (SystemExit, KeyboardInterrupt):
pass
stop() # stop worker threads

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,11 +20,9 @@ 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
app.config["HOSTERS"] = 8 # maximum concurrent room hosters
app.config["SELFLAUNCH"] = True # application process is in charge of launching Rooms. app.config["SELFLAUNCH"] = True # application process is in charge of launching Rooms.
app.config["SELFLAUNCHCERT"] = None # can point to a SSL Certificate to encrypt Room websocket connections app.config["SELFLAUNCHCERT"] = None # can point to a SSL Certificate to encrypt Room websocket connections
app.config["SELFLAUNCHKEY"] = None # can point to a SSL Certificate Key to encrypt Room websocket connections app.config["SELFLAUNCHKEY"] = None # can point to a SSL Certificate Key to encrypt Room websocket connections
@@ -39,8 +37,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
@@ -55,7 +51,6 @@ app.config["PONY"] = {
app.config["MAX_ROLL"] = 20 app.config["MAX_ROLL"] = 20
app.config["CACHE_TYPE"] = "SimpleCache" app.config["CACHE_TYPE"] = "SimpleCache"
app.config["HOST_ADDRESS"] = "" app.config["HOST_ADDRESS"] = ""
app.config["ASSET_RIGHTS"] = False
cache = Cache() cache = Cache()
Compress(app) Compress(app)
@@ -87,6 +82,6 @@ def register():
from WebHostLib.customserver import run_server_process from WebHostLib.customserver import run_server_process
# to trigger app routing picking up on it # to trigger app routing picking up on it
from . import tracker, upload, landing, check, generate, downloads, api, stats, misc, robots, options, session from . import tracker, upload, landing, check, generate, downloads, api, stats, misc
app.register_blueprint(api.api_endpoints) app.register_blueprint(api.api_endpoints)

View File

@@ -1,15 +1,59 @@
"""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
from ..models import Seed, Slot from .. import cache
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]
from . import datapackage, generate, room, user # trigger registration @api_endpoints.route('/room_status/<suuid:room>')
def room_info(room: UUID):
room = Room.get(id=room)
if room is None:
return abort(404)
return {
"tracker": room.tracker,
"players": get_players(room.seed),
"last_port": room.last_port,
"last_activity": room.last_activity,
"timeout": room.timeout
}
@api_endpoints.route('/datapackage')
@cache.cached()
def get_datapackage():
from worlds import network_data_package
return network_data_package
@api_endpoints.route('/datapackage_version')
@cache.cached()
def get_datapackage_versions():
from worlds import AutoWorldRegister
version_package = {game: world.data_version for game, world in AutoWorldRegister.world_types.items()}
return version_package
@api_endpoints.route('/datapackage_checksum')
@cache.cached()
def get_datapackage_checksums():
from worlds import network_data_package
version_package = {
game: game_data["checksum"] for game, game_data in network_data_package["games"].items()
}
return version_package
from . import generate, user # trigger registration

View File

@@ -1,32 +0,0 @@
from flask import abort
from Utils import restricted_loads
from WebHostLib import cache
from WebHostLib.models import GameDataPackage
from . import api_endpoints
@api_endpoints.route('/datapackage')
@cache.cached()
def get_datapackage():
from worlds import network_data_package
return network_data_package
@api_endpoints.route('/datapackage/<string:checksum>')
@cache.memoize(timeout=3600)
def get_datapackage_by_checksum(checksum: str):
package = GameDataPackage.get(checksum=checksum)
if package:
return restricted_loads(package.data)
return abort(404)
@api_endpoints.route('/datapackage_checksum')
@cache.cached()
def get_datapackage_checksums():
from worlds import network_data_package
version_package = {
game: game_data["checksum"] for game, game_data in network_data_package["games"].items()
}
return version_package

View File

@@ -20,8 +20,8 @@ def generate_api():
race = False race = False
meta_options_source = {} meta_options_source = {}
if 'file' in request.files: if 'file' in request.files:
files = request.files.getlist('file') file = request.files['file']
options = get_yaml_data(files) options = get_yaml_data(file)
if isinstance(options, Markup): if isinstance(options, Markup):
return {"text": options.striptags()}, 400 return {"text": options.striptags()}, 400
if isinstance(options, str): if isinstance(options, str):

View File

@@ -1,42 +0,0 @@
from typing import Any, Dict
from uuid import UUID
from flask import abort, url_for
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": room.tracker,
"players": get_players(room.seed),
"last_port": room.last_port,
"last_activity": room.last_activity,
"timeout": room.timeout,
"downloads": downloads,
}

View File

@@ -30,4 +30,4 @@ def get_seeds():
"creation_time": seed.creation_time, "creation_time": seed.creation_time,
"players": get_players(seed.slots), "players": get_players(seed.slots),
}) })
return jsonify(response) return jsonify(response)

View File

@@ -3,26 +3,25 @@ from __future__ import annotations
import json import json
import logging import logging
import multiprocessing import multiprocessing
import threading
import time
import typing import typing
from datetime import timedelta, datetime from datetime import timedelta, datetime
from threading import Event, Thread
from typing import Any
from uuid import UUID
from pony.orm import db_session, select, commit from pony.orm import db_session, select, commit
from Utils import restricted_loads from Utils import restricted_loads
from .locker import Locker, AlreadyRunningException from .locker import Locker, AlreadyRunningException
_stop_event = Event()
def launch_room(room: Room, config: dict):
# requires db_session!
if room.last_activity >= datetime.utcnow() - timedelta(seconds=room.timeout):
multiworld = multiworlds.get(room.id, None)
if not multiworld:
multiworld = MultiworldInstance(room, config)
def stop(): multiworld.start()
"""Stops previously launched threads"""
global _stop_event
stop_event = _stop_event
_stop_event = Event() # new event for new threads
stop_event.set()
def handle_generation_success(seed_id): def handle_generation_success(seed_id):
@@ -54,74 +53,39 @@ 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):
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()
def cleanup():
"""delete unowned user-content"""
with db_session:
# >>> bool(uuid.UUID(int=0))
# True
rooms = Room.select(lambda room: room.owner == UUID(int=0)).delete(bulk=True)
seeds = Seed.select(lambda seed: seed.owner == UUID(int=0) and not seed.rooms).delete(bulk=True)
slots = Slot.select(lambda slot: not slot.seed).delete(bulk=True)
# Command gets deleted by ponyorm Cascade Delete, as Room is Required
if rooms or seeds or slots:
logging.info(f"{rooms} Rooms, {seeds} Seeds and {slots} Slots have been deleted.")
def autohost(config: dict): def autohost(config: dict):
def keep_running(): def keep_running():
stop_event = _stop_event
try: try:
with Locker("autohost"): with Locker("autohost"):
cleanup() run_guardian()
hosters = [] while 1:
for x in range(config["HOSTERS"]): time.sleep(0.1)
hoster = MultiworldInstance(config, x)
hosters.append(hoster)
hoster.start()
while not stop_event.wait(0.1):
with db_session: with db_session:
rooms = select( rooms = select(
room for room in Room if room for room in Room if
room.last_activity >= datetime.utcnow() - timedelta(days=3)) room.last_activity >= datetime.utcnow() - timedelta(days=3))
for room in rooms: for room in rooms:
# we have to filter twice, as the per-room timeout can't currently be PonyORM transpiled. launch_room(room, config)
if room.last_activity >= datetime.utcnow() - timedelta(seconds=room.timeout + 5):
hosters[room.id.int % len(hosters)].start_room(room.id)
except AlreadyRunningException: except AlreadyRunningException:
logging.info("Autohost reports as already running, not starting another.") logging.info("Autohost reports as already running, not starting another.")
Thread(target=keep_running, name="AP_Autohost").start() import threading
threading.Thread(target=keep_running, name="AP_Autohost").start()
def autogen(config: dict): def autogen(config: dict):
def keep_running(): def keep_running():
stop_event = _stop_event
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)
@@ -137,7 +101,8 @@ def autogen(config: dict):
commit() commit()
select(generation for generation in Generation if generation.state == STATE_ERROR).delete() select(generation for generation in Generation if generation.state == STATE_ERROR).delete()
while not stop_event.wait(0.1): while 1:
time.sleep(0.1)
with db_session: with db_session:
# for update locks the database row(s) during transaction, preventing writes from elsewhere # for update locks the database row(s) during transaction, preventing writes from elsewhere
to_start = select( to_start = select(
@@ -148,45 +113,37 @@ def autogen(config: dict):
except AlreadyRunningException: except AlreadyRunningException:
logging.info("Autogen reports as already running, not starting another.") logging.info("Autogen reports as already running, not starting another.")
Thread(target=keep_running, name="AP_Autogen").start() import threading
threading.Thread(target=keep_running, name="AP_Autogen").start()
multiworlds: typing.Dict[type(Room.id), MultiworldInstance] = {} multiworlds: typing.Dict[type(Room.id), MultiworldInstance] = {}
class MultiworldInstance(): class MultiworldInstance():
def __init__(self, config: dict, id: int): def __init__(self, room: Room, config: dict):
self.room_ids = set() self.room_id = room.id
self.process: typing.Optional[multiprocessing.Process] = None self.process: typing.Optional[multiprocessing.Process] = None
with guardian_lock:
multiworlds[self.room_id] = self
self.ponyconfig = config["PONY"] self.ponyconfig = config["PONY"]
self.cert = config["SELFLAUNCHCERT"] self.cert = config["SELFLAUNCHCERT"]
self.key = config["SELFLAUNCHKEY"] self.key = config["SELFLAUNCHKEY"]
self.host = config["HOST_ADDRESS"] self.host = config["HOST_ADDRESS"]
self.rooms_to_start = multiprocessing.Queue()
self.rooms_shutting_down = multiprocessing.Queue()
self.name = f"MultiHoster{id}"
def start(self): def start(self):
if self.process and self.process.is_alive(): if self.process and self.process.is_alive():
return False return False
logging.info(f"Spinning up {self.room_id}")
process = multiprocessing.Process(group=None, target=run_server_process, process = multiprocessing.Process(group=None, target=run_server_process,
args=(self.name, self.ponyconfig, get_static_server_data(), args=(self.room_id, self.ponyconfig, get_static_server_data(),
self.cert, self.key, self.host, self.cert, self.key, self.host),
self.rooms_to_start, self.rooms_shutting_down), name="MultiHost")
name=self.name)
process.start() process.start()
# bind after start to prevent thread sync issues with guardian.
self.process = process self.process = process
def start_room(self, room_id):
while not self.rooms_shutting_down.empty():
self.room_ids.remove(self.rooms_shutting_down.get(block=True, timeout=None))
if room_id in self.room_ids:
pass # should already be hosted currently.
else:
self.room_ids.add(room_id)
self.rooms_to_start.put(room_id)
def stop(self): def stop(self):
if self.process: if self.process:
self.process.terminate() self.process.terminate()
@@ -200,6 +157,40 @@ class MultiworldInstance():
self.process = None self.process = None
from .models import Room, Generation, STATE_QUEUED, STATE_STARTED, STATE_ERROR, db, Seed, Slot guardian = None
guardian_lock = threading.Lock()
def run_guardian():
global guardian
global multiworlds
with guardian_lock:
if not guardian:
try:
import resource
except ModuleNotFoundError:
pass # unix only module
else:
# Each Server is another file handle, so request as many as we can from the system
file_limit = resource.getrlimit(resource.RLIMIT_NOFILE)[1]
# set soft limit to hard limit
resource.setrlimit(resource.RLIMIT_NOFILE, (file_limit, file_limit))
def guard():
while 1:
time.sleep(1)
done = []
with guardian_lock:
for key, instance in multiworlds.items():
if instance.done():
instance.collect()
done.append(key)
for key in done:
del (multiworlds[key])
guardian = threading.Thread(name="Guardian", target=guard)
from .models import Room, Generation, STATE_QUEUED, STATE_STARTED, STATE_ERROR, db, Seed
from .customserver import run_server_process, get_static_server_data from .customserver import run_server_process, get_static_server_data
from .generate import gen_game from .generate import gen_game

View File

@@ -1,4 +1,3 @@
import os
import zipfile import zipfile
import base64 import base64
from typing import Union, Dict, Set, Tuple from typing import Union, Dict, Set, Tuple
@@ -7,7 +6,13 @@ from flask import request, flash, redirect, url_for, render_template
from markupsafe import Markup from markupsafe import Markup
from WebHostLib import app from WebHostLib import app
from WebHostLib.upload import allowed_options, allowed_options_extensions, banned_file
banned_zip_contents = (".sfc",)
def allowed_file(filename):
return filename.endswith(('.txt', ".yaml", ".zip"))
from Generate import roll_settings, PlandoOptions from Generate import roll_settings, PlandoOptions
from Utils import parse_yamls from Utils import parse_yamls
@@ -28,7 +33,7 @@ def check():
results, _ = roll_options(options) results, _ = roll_options(options)
if len(options) > 1: if len(options) > 1:
# offer combined file back # offer combined file back
combined_yaml = "\n---\n".join(f"# original filename: {file_name}\n{file_content.decode('utf-8-sig')}" combined_yaml = "---\n".join(f"# original filename: {file_name}\n{file_content.decode('utf-8-sig')}"
for file_name, file_content in options.items()) for file_name, file_content in options.items())
combined_yaml = base64.b64encode(combined_yaml.encode("utf-8-sig")).decode() combined_yaml = base64.b64encode(combined_yaml.encode("utf-8-sig")).decode()
else: else:
@@ -46,41 +51,33 @@ def mysterycheck():
def get_yaml_data(files) -> Union[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 user does not select file, browser also
return ("Uploaded data contained a rom file, which is likely to contain copyrighted material. " # submit an empty part without filename
"Your file was deleted.") if uploaded_file.filename == '':
# If the user does not select file, the browser will still submit an empty string without a file name. return 'No selected file'
elif uploaded_file.filename == "":
return "No selected file."
elif uploaded_file.filename in options: elif uploaded_file.filename in options:
return f"Conflicting files named {uploaded_file.filename} submitted." return f'Conflicting files named {uploaded_file.filename} submitted'
elif uploaded_file and allowed_options(uploaded_file.filename): elif uploaded_file and allowed_file(uploaded_file.filename):
if uploaded_file.filename.endswith(".zip"): if uploaded_file.filename.endswith(".zip"):
if not zipfile.is_zipfile(uploaded_file):
return f"Uploaded file {uploaded_file.filename} is not a valid .zip file and cannot be opened."
uploaded_file.seek(0) # offset from is_zipfile check with zipfile.ZipFile(uploaded_file, 'r') as zfile:
with zipfile.ZipFile(uploaded_file, "r") as zfile: infolist = zfile.infolist()
for file in zfile.infolist():
# Remove folder pathing from str (e.g. "__MACOSX/" folder paths from archives created by macOS).
base_filename = os.path.basename(file.filename)
if base_filename.endswith(".archipelago"): if any(file.filename.endswith(".archipelago") for file in infolist):
return Markup("Error: Your .zip file contains an .archipelago file. " return Markup("Error: Your .zip file contains an .archipelago file. "
'Did you mean to <a href="/uploads">host a game</a>?') 'Did you mean to <a href="/uploads">host a game</a>?')
elif base_filename.endswith(".zip"):
return "Nested .zip files inside a .zip are not supported." for file in infolist:
elif banned_file(base_filename): if file.filename.endswith(banned_zip_contents):
return ("Uploaded data contained a rom file, which is likely to contain copyrighted " return ("Uploaded data contained a rom file, "
"material. Your file was deleted.") "which is likely to contain copyrighted material. "
# Ignore dot-files. "Your file was deleted.")
elif not base_filename.startswith(".") and allowed_options(base_filename): elif file.filename.endswith((".yaml", ".json", ".yml", ".txt")):
options[file.filename] = zfile.open(file, "r").read() options[file.filename] = zfile.open(file, "r").read()
else: else:
options[uploaded_file.filename] = uploaded_file.read() options[uploaded_file.filename] = uploaded_file.read()
if not options: if not options:
return f"Did not find any valid files to process. Accepted formats: {allowed_options_extensions}" return "Did not find a .yaml file to process."
return options return options
@@ -105,14 +102,10 @@ def roll_options(options: Dict[str, Union[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__: results[filename] = f"Failed to generate options in {filename}: {e}"
results[filename] = f"Failed to generate options in {filename}: {e} - {e.__cause__}"
else:
results[filename] = f"Failed to generate options in {filename}: {e}"
else: else:
results[filename] = True results[filename] = True
return results, rolled_results return results, rolled_results

View File

@@ -5,7 +5,6 @@ import collections
import datetime import datetime
import functools import functools
import logging import logging
import multiprocessing
import pickle import pickle
import random import random
import socket import socket
@@ -54,35 +53,24 @@ del MultiServer
class DBCommandProcessor(ServerCommandProcessor): class DBCommandProcessor(ServerCommandProcessor):
def output(self, text: str): def output(self, text: str):
self.ctx.logger.info(text) logging.info(text)
class WebHostContext(Context): class WebHostContext(Context):
room_id: int room_id: int
def __init__(self, static_server_data: dict, logger: logging.Logger): def __init__(self, static_server_data: dict):
# static server data is used during _load_game_data to load required data, # static server data is used during _load_game_data to load required data,
# without needing to import worlds system, which takes quite a bit of memory # without needing to import worlds system, which takes quite a bit of memory
self.static_server_data = static_server_data self.static_server_data = static_server_data
super(WebHostContext, self).__init__("", 0, "", "", 1, super(WebHostContext, self).__init__("", 0, "", "", 1, 40, True, "enabled", "enabled", "enabled", 0, 2)
40, True, "enabled", "enabled",
"enabled", 0, 2, logger=logger)
del self.static_server_data del self.static_server_data
self.main_loop = asyncio.get_running_loop() self.main_loop = asyncio.get_running_loop()
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
setattr(self, key, value) setattr(self, key, value)
self.non_hintable_names = collections.defaultdict(frozenset, self.non_hintable_names) self.non_hintable_names = collections.defaultdict(frozenset, self.non_hintable_names)
@@ -110,40 +98,18 @@ class WebHostContext(Context):
multidata = self.decompress(room.seed.multidata) multidata = self.decompress(room.seed.multidata)
game_data_packages = {} game_data_packages = {}
static_gamespackage = self.gamespackage # this is shared across all rooms
static_item_name_groups = self.item_name_groups
static_location_name_groups = self.location_name_groups
self.gamespackage = {"Archipelago": static_gamespackage.get("Archipelago", {})} # this may be modified by _load
self.item_name_groups = {"Archipelago": static_item_name_groups.get("Archipelago", {})}
self.location_name_groups = {"Archipelago": static_location_name_groups.get("Archipelago", {})}
missing_checksum = False
for game in list(multidata.get("datapackage", {})): for game in list(multidata.get("datapackage", {})):
game_data = multidata["datapackage"][game] game_data = multidata["datapackage"][game]
if "checksum" in game_data: if "checksum" in game_data:
if static_gamespackage.get(game, {}).get("checksum") == game_data["checksum"]: if self.gamespackage.get(game, {}).get("checksum") == game_data["checksum"]:
# non-custom. remove from multidata and use static data # non-custom. remove from multidata
# games package could be dropped from static data once all rooms embed data package # games package could be dropped from static data once all rooms embed data package
del multidata["datapackage"][game] del multidata["datapackage"][game]
else: else:
row = GameDataPackage.get(checksum=game_data["checksum"]) row = GameDataPackage.get(checksum=game_data["checksum"])
if row: # None if rolled on >= 0.3.9 but uploaded to <= 0.3.8. multidata should be complete if row: # None if rolled on >= 0.3.9 but uploaded to <= 0.3.8. multidata should be complete
game_data_packages[game] = Utils.restricted_loads(row.data) game_data_packages[game] = Utils.restricted_loads(row.data)
continue
else:
self.logger.warning(f"Did not find game_data_package for {game}: {game_data['checksum']}")
else:
missing_checksum = True # Game rolled on old AP and will load data package from multidata
self.gamespackage[game] = static_gamespackage.get(game, {})
self.item_name_groups[game] = static_item_name_groups.get(game, {})
self.location_name_groups[game] = static_location_name_groups.get(game, {})
if not game_data_packages and not missing_checksum:
# all static -> use the static dicts directly
self.gamespackage = static_gamespackage
self.item_name_groups = static_item_name_groups
self.location_name_groups = static_location_name_groups
return self._load(multidata, game_data_packages, True) return self._load(multidata, game_data_packages, True)
@db_session @db_session
@@ -153,7 +119,7 @@ class WebHostContext(Context):
savegame_data = Room.get(id=self.room_id).multisave savegame_data = Room.get(id=self.room_id).multisave
if savegame_data: if savegame_data:
self.set_save(restricted_loads(Room.get(id=self.room_id).multisave)) self.set_save(restricted_loads(Room.get(id=self.room_id).multisave))
self._start_async_saving(atexit_save=False) self._start_async_saving()
threading.Thread(target=self.listen_to_db_commands, daemon=True).start() threading.Thread(target=self.listen_to_db_commands, daemon=True).start()
@db_session @db_session
@@ -179,180 +145,80 @@ def get_random_port():
def get_static_server_data() -> dict: def get_static_server_data() -> dict:
import worlds import worlds
data = { data = {
"non_hintable_names": { "non_hintable_names": {},
world_name: world.hint_blacklist "gamespackage": worlds.network_data_package["games"],
for world_name, world in worlds.AutoWorldRegister.world_types.items() "item_name_groups": {world_name: world.item_name_groups for world_name, world in
}, worlds.AutoWorldRegister.world_types.items()},
"gamespackage": { "location_name_groups": {world_name: world.location_name_groups for world_name, world in
world_name: { worlds.AutoWorldRegister.world_types.items()},
key: value
for key, value in game_package.items()
if key not in ("item_name_groups", "location_name_groups")
}
for world_name, game_package in worlds.network_data_package["games"].items()
},
"item_name_groups": {
world_name: world.item_name_groups
for world_name, world in worlds.AutoWorldRegister.world_types.items()
},
"location_name_groups": {
world_name: world.location_name_groups
for world_name, world in worlds.AutoWorldRegister.world_types.items()
},
} }
for world_name, world in worlds.AutoWorldRegister.world_types.items():
data["non_hintable_names"][world_name] = world.hint_blacklist
return data return data
def set_up_logging(room_id) -> logging.Logger: def run_server_process(room_id, ponyconfig: dict, static_server_data: dict,
import os
# logger setup
logger = logging.getLogger(f"RoomLogger {room_id}")
# this *should* be empty, but just in case.
for handler in logger.handlers[:]:
logger.removeHandler(handler)
handler.close()
file_handler = logging.FileHandler(
os.path.join(Utils.user_path("logs"), f"{room_id}.txt"),
"a",
encoding="utf-8-sig")
file_handler.setFormatter(logging.Formatter("[%(asctime)s]: %(message)s"))
logger.setLevel(logging.INFO)
logger.addHandler(file_handler)
return logger
def run_server_process(name: str, ponyconfig: dict, static_server_data: dict,
cert_file: typing.Optional[str], cert_key_file: typing.Optional[str], 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):
Utils.init_logging(name)
try:
import resource
except ModuleNotFoundError:
pass # unix only module
else:
# Each Server is another file handle, so request as many as we can from the system
file_limit = resource.getrlimit(resource.RLIMIT_NOFILE)[1]
# set soft limit to hard limit
resource.setrlimit(resource.RLIMIT_NOFILE, (file_limit, file_limit))
del resource, file_limit
# establish DB connection for multidata and multisave # establish DB connection for multidata and multisave
db.bind(**ponyconfig) db.bind(**ponyconfig)
db.generate_mapping(check_tables=False) db.generate_mapping(check_tables=False)
if "worlds" in sys.modules: async def main():
raise Exception("Worlds system should not be loaded in the custom server.") if "worlds" in sys.modules:
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 Utils.init_logging(str(room_id), write_mode="a")
del cert_file, cert_key_file, ponyconfig ctx = WebHostContext(static_server_data)
gc.collect() # free intermediate objects used during setup ctx.load(room_id)
ctx.init_save()
ssl_context = load_server_cert(cert_file, cert_key_file) if cert_file else None
gc.collect() # free intermediate objects used during setup
try:
ctx.server = websockets.serve(functools.partial(server, ctx=ctx), ctx.host, ctx.port, ssl=ssl_context)
loop = asyncio.get_event_loop() await ctx.server
except OSError: # likely port in use
ctx.server = websockets.serve(functools.partial(server, ctx=ctx), ctx.host, 0, ssl=ssl_context)
async def start_room(room_id): await ctx.server
with Locker(f"RoomLocker {room_id}"): port = 0
try: for wssocket in ctx.server.ws_server.sockets:
logger = set_up_logging(room_id) socketname = wssocket.getsockname()
ctx = WebHostContext(static_server_data, logger) if wssocket.family == socket.AF_INET6:
ctx.load(room_id) # Prefer IPv4, as most users seem to not have working ipv6 support
ctx.init_save() if not port:
assert ctx.server is None port = socketname[1]
try: elif wssocket.family == socket.AF_INET:
ctx.server = websockets.serve( port = socketname[1]
functools.partial(server, ctx=ctx), ctx.host, ctx.port, ssl=ssl_context) if port:
logging.info(f'Hosting game at {host}:{port}')
with db_session:
room = Room.get(id=ctx.room_id)
room.last_port = port
else:
logging.exception("Could not determine port. Likely hosting failure.")
with db_session:
ctx.auto_shutdown = Room.get(id=room_id).timeout
ctx.shutdown_task = asyncio.create_task(auto_shutdown(ctx, []))
await ctx.shutdown_task
logging.info("Shutting down")
await ctx.server with Locker(room_id):
except OSError: # likely port in use try:
ctx.server = websockets.serve( asyncio.run(main())
functools.partial(server, ctx=ctx), ctx.host, 0, ssl=ssl_context) except (KeyboardInterrupt, SystemExit):
with db_session:
await ctx.server room = Room.get(id=room_id)
port = 0 # ensure the Room does not spin up again on its own, minute of safety buffer
for wssocket in ctx.server.ws_server.sockets: room.last_activity = datetime.datetime.utcnow() - datetime.timedelta(minutes=1, seconds=room.timeout)
socketname = wssocket.getsockname() except Exception:
if wssocket.family == socket.AF_INET6: with db_session:
# Prefer IPv4, as most users seem to not have working ipv6 support room = Room.get(id=room_id)
if not port: room.last_port = -1
port = socketname[1] # ensure the Room does not spin up again on its own, minute of safety buffer
elif wssocket.family == socket.AF_INET: room.last_activity = datetime.datetime.utcnow() - datetime.timedelta(minutes=1, seconds=room.timeout)
port = socketname[1] raise
if port:
ctx.logger.info(f'Hosting game at {host}:{port}')
with db_session:
room = Room.get(id=ctx.room_id)
room.last_port = port
else:
ctx.logger.exception("Could not determine port. Likely hosting failure.")
with db_session:
ctx.auto_shutdown = Room.get(id=room_id).timeout
if ctx.saving:
setattr(asyncio.current_task(), "save", lambda: ctx._save(True))
assert ctx.shutdown_task is None
ctx.shutdown_task = asyncio.create_task(auto_shutdown(ctx, []))
await ctx.shutdown_task
except (KeyboardInterrupt, SystemExit):
if ctx.saving:
ctx._save()
setattr(asyncio.current_task(), "save", None)
except Exception as e:
with db_session:
room = Room.get(id=room_id)
room.last_port = -1
logger.exception(e)
raise
else:
if ctx.saving:
ctx._save()
setattr(asyncio.current_task(), "save", None)
finally:
try:
ctx.save_dirty = False # make sure the saving thread does not write to DB after final wakeup
ctx.exit_event.set() # make sure the saving thread stops at some point
# NOTE: async saving should probably be an async task and could be merged with shutdown_task
with (db_session):
# ensure the Room does not spin up again on its own, minute of safety buffer
room = Room.get(id=room_id)
room.last_activity = datetime.datetime.utcnow() - \
datetime.timedelta(minutes=1, seconds=room.timeout)
logging.info(f"Shutting down room {room_id} on {name}.")
finally:
await asyncio.sleep(5)
rooms_shutting_down.put(room_id)
class Starter(threading.Thread):
_tasks: typing.List[asyncio.Future]
def __init__(self):
super().__init__()
self._tasks = []
def _done(self, task: asyncio.Future):
self._tasks.remove(task)
task.result()
def run(self):
while 1:
next_room = rooms_to_run.get(block=True, timeout=None)
gc.collect()
task = asyncio.run_coroutine_threadsafe(start_room(next_room), loop)
self._tasks.append(task)
task.add_done_callback(self._done)
logging.info(f"Starting room {next_room} on {name}.")
del task # delete reference to task object
starter = Starter()
starter.daemon = True
starter.start()
try:
loop.run_forever()
finally:
# save all tasks that want to be saved during shutdown
for task in asyncio.all_tasks(loop):
save: typing.Optional[typing.Callable[[], typing.Any]] = getattr(task, "save", None)
if save:
save()

View File

@@ -6,7 +6,7 @@ import random
import tempfile import tempfile
import zipfile import zipfile
from collections import Counter from collections import Counter
from typing import Any, Dict, List, Optional, Union, Set from typing import Any, Dict, List, Optional, Union
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
@@ -16,7 +16,6 @@ from Generate import PlandoOptions, handle_name
from Main import main as ERmain from Main import main as ERmain
from Utils import __version__ from Utils import __version__
from WebHostLib import app from WebHostLib import app
from settings import ServerOptions, GeneratorOptions
from worlds.alttp.EntranceRandomizer import parse_arguments 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
@@ -24,22 +23,25 @@ from .upload import upload_zip_to_db
def get_meta(options_source: dict, race: bool = False) -> Dict[str, Union[List[str], Dict[str, Any]]]: def get_meta(options_source: dict, race: bool = False) -> Dict[str, Union[List[str], Dict[str, Any]]]:
plando_options: Set[str] = set() plando_options = {
for substr in ("bosses", "items", "connections", "texts"): options_source.get("plando_bosses", ""),
if options_source.get(f"plando_{substr}", substr in GeneratorOptions.plando_options): options_source.get("plando_items", ""),
plando_options.add(substr) options_source.get("plando_connections", ""),
options_source.get("plando_texts", "")
}
plando_options -= {""}
server_options = { server_options = {
"hint_cost": int(options_source.get("hint_cost", ServerOptions.hint_cost)), "hint_cost": int(options_source.get("hint_cost", 10)),
"release_mode": str(options_source.get("release_mode", ServerOptions.release_mode)), "release_mode": options_source.get("release_mode", "goal"),
"remaining_mode": str(options_source.get("remaining_mode", ServerOptions.remaining_mode)), "remaining_mode": options_source.get("remaining_mode", "disabled"),
"collect_mode": str(options_source.get("collect_mode", ServerOptions.collect_mode)), "collect_mode": options_source.get("collect_mode", "disabled"),
"item_cheat": bool(int(options_source.get("item_cheat", not ServerOptions.disable_item_cheat))), "item_cheat": bool(int(options_source.get("item_cheat", 1))),
"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", 0)),
"race": race, "race": race
} }
if race: if race:
@@ -68,42 +70,37 @@ def generate(race=False):
flash(options) flash(options)
else: else:
meta = get_meta(request.form, race) meta = get_meta(request.form, race)
return start_generation(options, meta) results, gen_options = roll_options(options, set(meta["plando_options"]))
if any(type(result) == str for result in results.values()):
return render_template("checkResult.html", results=results)
elif len(gen_options) > app.config["MAX_ROLL"]:
flash(f"Sorry, generating of multiworlds is limited to {app.config['MAX_ROLL']} players. "
f"If you have a larger group, please generate it yourself and upload it.")
elif len(gen_options) >= app.config["JOB_THRESHOLD"]:
gen = Generation(
options=pickle.dumps({name: vars(options) for name, options in gen_options.items()}),
# convert to json compatible
meta=json.dumps(meta),
state=STATE_QUEUED,
owner=session["_id"])
commit()
return redirect(url_for("wait_seed", seed=gen.id))
else:
try:
seed_id = gen_game({name: vars(options) for name, options in gen_options.items()},
meta=meta, owner=session["_id"].int)
except BaseException as e:
from .autolauncher import handle_generation_failure
handle_generation_failure(e)
return render_template("seedError.html", seed_error=(e.__class__.__name__ + ": " + str(e)))
return redirect(url_for("view_seed", seed=seed_id))
return render_template("generate.html", race=race, version=__version__) return render_template("generate.html", race=race, version=__version__)
def start_generation(options: Dict[str, Union[dict, str]], meta: Dict[str, Any]):
results, gen_options = roll_options(options, set(meta["plando_options"]))
if any(type(result) == str for result in results.values()):
return render_template("checkResult.html", results=results)
elif len(gen_options) > app.config["MAX_ROLL"]:
flash(f"Sorry, generating of multiworlds is limited to {app.config['MAX_ROLL']} players. "
f"If you have a larger group, please generate it yourself and upload it.")
return redirect(url_for(request.endpoint, **(request.view_args or {})))
elif len(gen_options) >= app.config["JOB_THRESHOLD"]:
gen = Generation(
options=pickle.dumps({name: vars(options) for name, options in gen_options.items()}),
# convert to json compatible
meta=json.dumps(meta),
state=STATE_QUEUED,
owner=session["_id"])
commit()
return redirect(url_for("wait_seed", seed=gen.id))
else:
try:
seed_id = gen_game({name: vars(options) for name, options in gen_options.items()},
meta=meta, owner=session["_id"].int)
except BaseException as e:
from .autolauncher import handle_generation_failure
handle_generation_failure(e)
return render_template("seedError.html", seed_error=(e.__class__.__name__ + ": " + str(e)))
return redirect(url_for("view_seed", seed=seed_id))
def gen_game(gen_options: dict, meta: Optional[Dict[str, Any]] = None, owner=None, sid=None): def gen_game(gen_options: dict, meta: Optional[Dict[str, Any]] = None, owner=None, sid=None):
if not meta: if not meta:
meta: Dict[str, Any] = {} meta: Dict[str, Any] = {}
@@ -135,7 +132,6 @@ def gen_game(gen_options: dict, meta: Optional[Dict[str, Any]] = None, owner=Non
{"bosses", "items", "connections", "texts"})) {"bosses", "items", "connections", "texts"}))
erargs.skip_prog_balancing = False erargs.skip_prog_balancing = False
erargs.skip_output = False erargs.skip_output = False
erargs.csv_output = False
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):

View File

@@ -1,11 +1,10 @@
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 from worlds.AutoWorld import AutoWorldRegister
from . import app, cache from . import app, cache
@@ -18,6 +17,13 @@ def get_world_theme(game_name: str):
return 'grass' return 'grass'
@app.before_request
def register_session():
session.permanent = True # technically 31 days after the last visit
if not session.get("_id", None):
session["_id"] = uuid4() # uniquely identify each session without needing a login
@app.errorhandler(404) @app.errorhandler(404)
@app.errorhandler(jinja2.exceptions.TemplateNotFound) @app.errorhandler(jinja2.exceptions.TemplateNotFound)
def page_not_found(err): def page_not_found(err):
@@ -31,6 +37,31 @@ def start_playing():
return render_template(f"startPlaying.html") return render_template(f"startPlaying.html")
# TODO for back compat. remove around 0.4.5
@app.route("/weighted-settings")
def weighted_settings():
return redirect("weighted-options", 301)
@app.route("/weighted-options")
@cache.cached()
def weighted_options():
return render_template("weighted-options.html")
# TODO for back compat. remove around 0.4.5
@app.route("/games/<string:game>/player-settings")
def player_settings(game: str):
return redirect(url_for("player_options", game=game), 301)
# Player options pages
@app.route("/games/<string:game>/player-options")
@cache.cached()
def player_options(game: str):
return render_template("player-options.html", game=game, theme=get_world_theme(game))
# Game Info Pages # Game Info Pages
@app.route('/games/<string:game>/info/<string:lang>') @app.route('/games/<string:game>/info/<string:lang>')
@cache.cached() @cache.cached()
@@ -63,40 +94,14 @@ def tutorial_landing():
@app.route('/faq/<string:lang>/') @app.route('/faq/<string:lang>/')
@cache.cached() @cache.cached()
def faq(lang: str): def faq(lang):
import markdown return render_template("faq.html", lang=lang)
with open(os.path.join(app.static_folder, "assets", "faq", secure_filename(lang)+".md")) as f:
document = f.read()
return render_template(
"markdown_document.html",
title="Frequently Asked Questions",
html_from_markdown=markdown.markdown(
document,
extensions=["toc", "mdx_breakless_lists"],
extension_configs={
"toc": {"anchorlink": True}
}
),
)
@app.route('/glossary/<string:lang>/') @app.route('/glossary/<string:lang>/')
@cache.cached() @cache.cached()
def glossary(lang: str): def terms(lang):
import markdown return render_template("glossary.html", lang=lang)
with open(os.path.join(app.static_folder, "assets", "glossary", secure_filename(lang)+".md")) as f:
document = f.read()
return render_template(
"markdown_document.html",
title="Glossary",
html_from_markdown=markdown.markdown(
document,
extensions=["toc", "mdx_breakless_lists"],
extension_configs={
"toc": {"anchorlink": True}
}
),
)
@app.route('/seed/<suuid:seed>') @app.route('/seed/<suuid:seed>')
@@ -117,91 +122,48 @@ 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()
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

@@ -1,281 +1,188 @@
import collections.abc
import json import json
import logging
import os import os
from textwrap import dedent import typing
from typing import Dict, Union
from docutils.core import publish_parts
import yaml
from flask import redirect, render_template, request, Response
import Options import Options
from Utils import local_path from Utils import local_path
from worlds.AutoWorld import AutoWorldRegister from worlds.AutoWorld import AutoWorldRegister
from . import app, cache
from .generate import get_meta handled_in_js = {"start_inventory", "local_items", "non_local_items", "start_hints", "start_location_hints",
"exclude_locations", "priority_locations"}
def create() -> None: def create():
target_folder = local_path("WebHostLib", "static", "generated") target_folder = local_path("WebHostLib", "static", "generated")
yaml_folder = os.path.join(target_folder, "configs") yaml_folder = os.path.join(target_folder, "configs")
Options.generate_yaml_templates(yaml_folder) Options.generate_yaml_templates(yaml_folder)
def get_html_doc(option_type: type(Options.Option)) -> str:
if not option_type.__doc__:
return "Please document me!"
return "\n".join(line.strip() for line in option_type.__doc__.split("\n")).strip()
def get_world_theme(game_name: str) -> str: weighted_options = {
if game_name in AutoWorldRegister.world_types: "baseOptions": {
return AutoWorldRegister.world_types[game_name].web.theme "description": "Generated by https://archipelago.gg/",
return 'grass' "name": "",
"game": {},
},
"games": {},
}
for game_name, world in AutoWorldRegister.world_types.items():
def render_options_page(template: str, world_name: str, is_complex: bool = False) -> Union[Response, str]: all_options: typing.Dict[str, Options.AssembleOptions] = world.options_dataclass.type_hints
world = AutoWorldRegister.world_types[world_name]
if world.hidden or world.web.options_page is False:
return redirect("games")
visibility_flag = Options.Visibility.complex_ui if is_complex else Options.Visibility.simple_ui
start_collapsed = {"Game Options": False} # Generate JSON files for player-options pages
for group in world.web.option_groups: player_options = {
start_collapsed[group.name] = group.start_collapsed "baseOptions": {
"description": f"Generated by https://archipelago.gg/ for {game_name}",
return render_template( "game": game_name,
template, "name": "",
world_name=world_name, },
world=world,
option_groups=Options.get_option_groups(world, visibility_level=visibility_flag),
start_collapsed=start_collapsed,
issubclass=issubclass,
Options=Options,
theme=get_world_theme(world_name),
)
def generate_game(options: Dict[str, Union[dict, str]]) -> Union[Response, str]:
from .generate import start_generation
return start_generation(options, get_meta({}))
def send_yaml(player_name: str, formatted_options: dict) -> Response:
response = Response(yaml.dump(formatted_options, sort_keys=False))
response.headers["Content-Type"] = "text/yaml"
response.headers["Content-Disposition"] = f"attachment; filename={player_name}.yaml"
return response
@app.template_filter("dedent")
def filter_dedent(text: str) -> str:
return dedent(text).strip("\n ")
@app.template_filter("rst_to_html")
def filter_rst_to_html(text: str) -> str:
"""Converts reStructuredText (such as a Python docstring) to HTML."""
if text.startswith(" ") or text.startswith("\t"):
text = dedent(text)
elif "\n" in text:
lines = text.splitlines()
text = lines[0] + "\n" + dedent("\n".join(lines[1:]))
return publish_parts(text, writer_name='html', settings=None, settings_overrides={
'raw_enable': False,
'file_insertion_enabled': False,
'output_encoding': 'unicode'
})['body']
@app.template_test("ordered")
def test_ordered(obj):
return isinstance(obj, collections.abc.Sequence)
@app.route("/games/<string:game>/option-presets", methods=["GET"])
@cache.cached()
def option_presets(game: str) -> Response:
world = AutoWorldRegister.world_types[game]
presets = {}
for preset_name, preset in world.web.options_presets.items():
presets[preset_name] = {}
for preset_option_name, preset_option in preset.items():
if preset_option == "random":
presets[preset_name][preset_option_name] = preset_option
continue
option = world.options_dataclass.type_hints[preset_option_name].from_any(preset_option)
if isinstance(option, Options.NamedRange) and isinstance(preset_option, str):
assert preset_option in option.special_range_names, \
f"Invalid preset value '{preset_option}' for '{preset_option_name}' in '{preset_name}'. " \
f"Expected {option.special_range_names.keys()} or {option.range_start}-{option.range_end}."
presets[preset_name][preset_option_name] = option.value
elif isinstance(option, (Options.Range, Options.OptionSet, Options.OptionList, Options.ItemDict)):
presets[preset_name][preset_option_name] = option.value
elif isinstance(preset_option, str):
# Ensure the option value is valid for Choice and Toggle options
assert option.name_lookup[option.value] == preset_option, \
f"Invalid option value '{preset_option}' for '{preset_option_name}' in preset '{preset_name}'. " \
f"Values must not be resolved to a different option via option.from_text (or an alias)."
# Use the name of the option
presets[preset_name][preset_option_name] = option.current_key
else:
# Use the name of the option
presets[preset_name][preset_option_name] = option.current_key
class SetEncoder(json.JSONEncoder):
def default(self, obj):
from collections.abc import Set
if isinstance(obj, Set):
return list(obj)
return json.JSONEncoder.default(self, obj)
json_data = json.dumps(presets, cls=SetEncoder)
response = Response(json_data)
response.headers["Content-Type"] = "application/json"
return response
@app.route("/weighted-options")
def weighted_options_old():
return redirect("games", 301)
@app.route("/games/<string:game>/weighted-options")
@cache.cached()
def weighted_options(game: str):
return render_options_page("weightedOptions/weightedOptions.html", game, is_complex=True)
@app.route("/games/<string:game>/generate-weighted-yaml", methods=["POST"])
def generate_weighted_yaml(game: str):
if request.method == "POST":
intent_generate = False
options = {}
for key, val in request.form.items():
if "||" not in key:
if len(str(val)) == 0:
continue
options[key] = val
else:
if int(val) == 0:
continue
[option, setting] = key.split("||")
options.setdefault(option, {})[setting] = int(val)
# Error checking
if "name" not in options:
return "Player name is required."
# Remove POST data irrelevant to YAML
if "intent-generate" in options:
intent_generate = True
del options["intent-generate"]
if "intent-export" in options:
del options["intent-export"]
# Properly format YAML output
player_name = options["name"]
del options["name"]
formatted_options = {
"name": player_name,
"game": game,
"description": f"Generated by https://archipelago.gg/ for {game}",
game: options,
} }
if intent_generate: game_options = {}
return generate_game({player_name: formatted_options}) for option_name, option in all_options.items():
if option_name in handled_in_js:
pass
else: elif issubclass(option, Options.Choice) or issubclass(option, Options.Toggle):
return send_yaml(player_name, formatted_options) game_options[option_name] = this_option = {
"type": "select",
"displayName": option.display_name if hasattr(option, "display_name") else option_name,
"description": get_html_doc(option),
"defaultValue": None,
"options": []
}
for sub_option_id, sub_option_name in option.name_lookup.items():
if sub_option_name != "random":
this_option["options"].append({
"name": option.get_option_name(sub_option_id),
"value": sub_option_name,
})
if sub_option_id == option.default:
this_option["defaultValue"] = sub_option_name
# Player options pages if not this_option["defaultValue"]:
@app.route("/games/<string:game>/player-options") this_option["defaultValue"] = "random"
@cache.cached()
def player_options(game: str):
return render_options_page("playerOptions/playerOptions.html", game, is_complex=False)
elif issubclass(option, Options.Range):
game_options[option_name] = {
"type": "range",
"displayName": option.display_name if hasattr(option, "display_name") else option_name,
"description": get_html_doc(option),
"defaultValue": option.default if hasattr(
option, "default") and option.default != "random" else option.range_start,
"min": option.range_start,
"max": option.range_end,
}
if issubclass(option, Options.NamedRange):
game_options[option_name]["type"] = 'named_range'
game_options[option_name]["value_names"] = {}
for key, val in option.special_range_names.items():
game_options[option_name]["value_names"][key] = val
elif issubclass(option, Options.ItemSet):
game_options[option_name] = {
"type": "items-list",
"displayName": option.display_name if hasattr(option, "display_name") else option_name,
"description": get_html_doc(option),
"defaultValue": list(option.default)
}
elif issubclass(option, Options.LocationSet):
game_options[option_name] = {
"type": "locations-list",
"displayName": option.display_name if hasattr(option, "display_name") else option_name,
"description": get_html_doc(option),
"defaultValue": list(option.default)
}
elif issubclass(option, Options.VerifyKeys) and not issubclass(option, Options.OptionDict):
if option.valid_keys:
game_options[option_name] = {
"type": "custom-list",
"displayName": option.display_name if hasattr(option, "display_name") else option_name,
"description": get_html_doc(option),
"options": list(option.valid_keys),
"defaultValue": list(option.default) if hasattr(option, "default") else []
}
# YAML generator for player-options
@app.route("/games/<string:game>/generate-yaml", methods=["POST"])
def generate_yaml(game: str):
if request.method == "POST":
options = {}
intent_generate = False
for key, val in request.form.items(multi=True):
if key in options:
if not isinstance(options[key], list):
options[key] = [options[key]]
options[key].append(val)
else: else:
options[key] = val logging.debug(f"{option} not exported to Web Options.")
for key, val in options.copy().items(): player_options["gameOptions"] = game_options
key_parts = key.rsplit("||", 2)
# Detect and build ItemDict options from their name pattern
if key_parts[-1] == "qty":
if key_parts[0] not in options:
options[key_parts[0]] = {}
if val != "0":
options[key_parts[0]][key_parts[1]] = int(val)
del options[key]
# Detect keys which end with -custom, indicating a TextChoice with a possible custom value player_options["presetOptions"] = {}
elif key_parts[-1].endswith("-custom"): for preset_name, preset in world.web.options_presets.items():
if val: player_options["presetOptions"][preset_name] = {}
options[key_parts[-1][:-7]] = val for option_name, option_value in preset.items():
# Random range type settings are not valid.
assert (not str(option_value).startswith("random-")), \
f"Invalid preset value '{option_value}' for '{option_name}' in '{preset_name}'. Special random " \
f"values are not supported for presets."
del options[key] # Normal random is supported, but needs to be handled explicitly.
if option_value == "random":
player_options["presetOptions"][preset_name][option_name] = option_value
continue
# Detect keys which end with -range, indicating a NamedRange with a possible custom value option = world.options_dataclass.type_hints[option_name].from_any(option_value)
elif key_parts[-1].endswith("-range"): if isinstance(option, Options.NamedRange) and isinstance(option_value, str):
if options[key_parts[-1][:-6]] == "custom": assert option_value in option.special_range_names, \
options[key_parts[-1][:-6]] = val f"Invalid preset value '{option_value}' for '{option_name}' in '{preset_name}'. " \
f"Expected {option.special_range_names.keys()} or {option.range_start}-{option.range_end}."
del options[key] # Still use the true value for the option, not the name.
player_options["presetOptions"][preset_name][option_name] = option.value
elif isinstance(option, Options.Range):
player_options["presetOptions"][preset_name][option_name] = option.value
elif isinstance(option_value, str):
# For Choice and Toggle options, the value should be the name of the option. This is to prevent
# setting a preset for an option with an overridden from_text method that would normally be okay,
# but would not be okay for the webhost's current implementation of player options UI.
assert option.name_lookup[option.value] == option_value, \
f"Invalid option value '{option_value}' for '{option_name}' in preset '{preset_name}'. " \
f"Values must not be resolved to a different option via option.from_text (or an alias)."
player_options["presetOptions"][preset_name][option_name] = option.current_key
else:
# int and bool values are fine, just resolve them to the current key for webhost.
player_options["presetOptions"][preset_name][option_name] = option.current_key
# Detect random-* keys and set their options accordingly os.makedirs(os.path.join(target_folder, 'player-options'), exist_ok=True)
for key, val in options.copy().items():
if key.startswith("random-"):
options[key.removeprefix("random-")] = "random"
del options[key]
# Error checking with open(os.path.join(target_folder, 'player-options', game_name + ".json"), "w") as f:
if not options["name"]: json.dump(player_options, f, indent=2, separators=(',', ': '))
return "Player name is required."
# Remove POST data irrelevant to YAML if not world.hidden and world.web.options_page is True:
preset_name = 'default' # Add the random option to Choice, TextChoice, and Toggle options
if "intent-generate" in options: for option in game_options.values():
intent_generate = True if option["type"] == "select":
del options["intent-generate"] option["options"].append({"name": "Random", "value": "random"})
if "intent-export" in options:
del options["intent-export"]
if "game-options-preset" in options:
preset_name = options["game-options-preset"]
del options["game-options-preset"]
# Properly format YAML output if not option["defaultValue"]:
player_name = options["name"] option["defaultValue"] = "random"
del options["name"]
description = f"Generated by https://archipelago.gg/ for {game}" weighted_options["baseOptions"]["game"][game_name] = 0
if preset_name != 'default' and preset_name != 'custom': weighted_options["games"][game_name] = {
description += f" using {preset_name} preset" "gameSettings": game_options,
"gameItems": tuple(world.item_names),
"gameItemGroups": [
group for group in world.item_name_groups.keys() if group != "Everything"
],
"gameItemDescriptions": world.item_descriptions,
"gameLocations": tuple(world.location_names),
"gameLocationGroups": [
group for group in world.location_name_groups.keys() if group != "Everywhere"
],
"gameLocationDescriptions": world.location_descriptions,
}
formatted_options = { with open(os.path.join(target_folder, 'weighted-options.json'), "w") as f:
"name": player_name, json.dump(weighted_options, f, indent=2, separators=(',', ': '))
"game": game,
"description": description,
game: options,
}
if intent_generate:
return generate_game({player_name: formatted_options})
else:
return send_yaml(player_name, formatted_options)

View File

@@ -1,11 +1,9 @@
flask>=3.1.0 flask>=3.0.0
werkzeug>=3.1.3 pony>=0.7.17
pony>=0.7.19 waitress>=2.1.2
waitress>=3.0.2 Flask-Caching>=2.1.0
Flask-Caching>=2.3.0 Flask-Compress>=1.14
Flask-Compress>=1.17 Flask-Limiter>=3.5.0
Flask-Limiter>=3.12 bokeh>=3.1.1; python_version <= '3.8'
bokeh>=3.6.3 bokeh>=3.2.2; python_version >= '3.9'
markupsafe>=3.0.2 markupsafe>=2.1.3
Markdown>=3.7
mdx-breakless-lists>=1.0.1

View File

@@ -1,15 +0,0 @@
from WebHostLib import app
from flask import abort
from . import cache
@cache.cached()
@app.route('/robots.txt')
def robots():
# If this host is not official, do not allow search engine crawling
if not app.config["ASSET_RIGHTS"]:
# filename changed in case the path is intercepted and served by an outside service
return app.send_static_file('robots_file.txt')
# Send 404 if the host has affirmed this to be the official WebHost
abort(404)

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 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

@@ -0,0 +1,20 @@
window.addEventListener('load', () => {
const url = window.location;
setInterval(() => {
const ajax = new XMLHttpRequest();
ajax.onreadystatechange = () => {
if (ajax.readyState !== 4) { return; }
// Create a fake DOM using the returned HTML
const domParser = new DOMParser();
const fakeDOM = domParser.parseFromString(ajax.responseText, 'text/html');
// Update item and location trackers
document.getElementById('inventory-table').innerHTML = fakeDOM.getElementById('inventory-table').innerHTML;
document.getElementById('location-table').innerHTML = fakeDOM.getElementById('location-table').innerHTML;
};
ajax.open('GET', url);
ajax.send();
}, 15000)
});

View File

@@ -0,0 +1,523 @@
let gameName = null;
window.addEventListener('load', () => {
gameName = document.getElementById('player-options').getAttribute('data-game');
// Update game name on page
document.getElementById('game-name').innerText = gameName;
fetchOptionData().then((results) => {
let optionHash = localStorage.getItem(`${gameName}-hash`);
if (!optionHash) {
// If no hash data has been set before, set it now
optionHash = md5(JSON.stringify(results));
localStorage.setItem(`${gameName}-hash`, optionHash);
localStorage.removeItem(gameName);
}
if (optionHash !== md5(JSON.stringify(results))) {
showUserMessage(
'Your options are out of date! Click here to update them! Be aware this will reset them all to default.'
);
document.getElementById('user-message').addEventListener('click', resetOptions);
}
// Page setup
createDefaultOptions(results);
buildUI(results);
adjustHeaderWidth();
// Event listeners
document.getElementById('export-options').addEventListener('click', () => exportOptions());
document.getElementById('generate-race').addEventListener('click', () => generateGame(true));
document.getElementById('generate-game').addEventListener('click', () => generateGame());
// Name input field
const playerOptions = JSON.parse(localStorage.getItem(gameName));
const nameInput = document.getElementById('player-name');
nameInput.addEventListener('keyup', (event) => updateBaseOption(event));
nameInput.value = playerOptions.name;
// Presets
const presetSelect = document.getElementById('game-options-preset');
presetSelect.addEventListener('change', (event) => setPresets(results, event.target.value));
for (const preset in results['presetOptions']) {
const presetOption = document.createElement('option');
presetOption.innerText = preset;
presetSelect.appendChild(presetOption);
}
presetSelect.value = localStorage.getItem(`${gameName}-preset`);
results['presetOptions']['__default'] = {};
}).catch((e) => {
console.error(e);
const url = new URL(window.location.href);
window.location.replace(`${url.protocol}//${url.hostname}/page-not-found`);
})
});
const resetOptions = () => {
localStorage.removeItem(gameName);
localStorage.removeItem(`${gameName}-hash`);
localStorage.removeItem(`${gameName}-preset`);
window.location.reload();
};
const fetchOptionData = () => new Promise((resolve, reject) => {
const ajax = new XMLHttpRequest();
ajax.onreadystatechange = () => {
if (ajax.readyState !== 4) { return; }
if (ajax.status !== 200) {
reject(ajax.responseText);
return;
}
try{ resolve(JSON.parse(ajax.responseText)); }
catch(error){ reject(error); }
};
ajax.open('GET', `${window.location.origin}/static/generated/player-options/${gameName}.json`, true);
ajax.send();
});
const createDefaultOptions = (optionData) => {
if (!localStorage.getItem(gameName)) {
const newOptions = {
[gameName]: {},
};
for (let baseOption of Object.keys(optionData.baseOptions)){
newOptions[baseOption] = optionData.baseOptions[baseOption];
}
for (let gameOption of Object.keys(optionData.gameOptions)){
newOptions[gameName][gameOption] = optionData.gameOptions[gameOption].defaultValue;
}
localStorage.setItem(gameName, JSON.stringify(newOptions));
}
if (!localStorage.getItem(`${gameName}-preset`)) {
localStorage.setItem(`${gameName}-preset`, '__default');
}
};
const buildUI = (optionData) => {
// Game Options
const leftGameOpts = {};
const rightGameOpts = {};
Object.keys(optionData.gameOptions).forEach((key, index) => {
if (index < Object.keys(optionData.gameOptions).length / 2) {
leftGameOpts[key] = optionData.gameOptions[key];
} else {
rightGameOpts[key] = optionData.gameOptions[key];
}
});
document.getElementById('game-options-left').appendChild(buildOptionsTable(leftGameOpts));
document.getElementById('game-options-right').appendChild(buildOptionsTable(rightGameOpts));
};
const buildOptionsTable = (options, romOpts = false) => {
const currentOptions = JSON.parse(localStorage.getItem(gameName));
const table = document.createElement('table');
const tbody = document.createElement('tbody');
Object.keys(options).forEach((option) => {
const tr = document.createElement('tr');
// td Left
const tdl = document.createElement('td');
const label = document.createElement('label');
label.textContent = `${options[option].displayName}: `;
label.setAttribute('for', option);
const questionSpan = document.createElement('span');
questionSpan.classList.add('interactive');
questionSpan.setAttribute('data-tooltip', options[option].description);
questionSpan.innerText = '(?)';
label.appendChild(questionSpan);
tdl.appendChild(label);
tr.appendChild(tdl);
// td Right
const tdr = document.createElement('td');
let element = null;
const randomButton = document.createElement('button');
switch(options[option].type) {
case 'select':
element = document.createElement('div');
element.classList.add('select-container');
let select = document.createElement('select');
select.setAttribute('id', option);
select.setAttribute('data-key', option);
if (romOpts) { select.setAttribute('data-romOpt', '1'); }
options[option].options.forEach((opt) => {
const optionElement = document.createElement('option');
optionElement.setAttribute('value', opt.value);
optionElement.innerText = opt.name;
if ((isNaN(currentOptions[gameName][option]) &&
(parseInt(opt.value, 10) === parseInt(currentOptions[gameName][option]))) ||
(opt.value === currentOptions[gameName][option]))
{
optionElement.selected = true;
}
select.appendChild(optionElement);
});
select.addEventListener('change', (event) => updateGameOption(event.target));
element.appendChild(select);
// Randomize button
randomButton.innerText = '🎲';
randomButton.classList.add('randomize-button');
randomButton.setAttribute('data-key', option);
randomButton.setAttribute('data-tooltip', 'Toggle randomization for this option!');
randomButton.addEventListener('click', (event) => toggleRandomize(event, select));
if (currentOptions[gameName][option] === 'random') {
randomButton.classList.add('active');
select.disabled = true;
}
element.appendChild(randomButton);
break;
case 'range':
element = document.createElement('div');
element.classList.add('range-container');
let range = document.createElement('input');
range.setAttribute('id', option);
range.setAttribute('type', 'range');
range.setAttribute('data-key', option);
range.setAttribute('min', options[option].min);
range.setAttribute('max', options[option].max);
range.value = currentOptions[gameName][option];
range.addEventListener('change', (event) => {
document.getElementById(`${option}-value`).innerText = event.target.value;
updateGameOption(event.target);
});
element.appendChild(range);
let rangeVal = document.createElement('span');
rangeVal.classList.add('range-value');
rangeVal.setAttribute('id', `${option}-value`);
rangeVal.innerText = currentOptions[gameName][option] !== 'random' ?
currentOptions[gameName][option] : options[option].defaultValue;
element.appendChild(rangeVal);
// Randomize button
randomButton.innerText = '🎲';
randomButton.classList.add('randomize-button');
randomButton.setAttribute('data-key', option);
randomButton.setAttribute('data-tooltip', 'Toggle randomization for this option!');
randomButton.addEventListener('click', (event) => toggleRandomize(event, range));
if (currentOptions[gameName][option] === 'random') {
randomButton.classList.add('active');
range.disabled = true;
}
element.appendChild(randomButton);
break;
case 'named_range':
element = document.createElement('div');
element.classList.add('named-range-container');
// Build the select element
let namedRangeSelect = document.createElement('select');
namedRangeSelect.setAttribute('data-key', option);
Object.keys(options[option].value_names).forEach((presetName) => {
let presetOption = document.createElement('option');
presetOption.innerText = presetName;
presetOption.value = options[option].value_names[presetName];
const words = presetOption.innerText.split('_');
for (let i = 0; i < words.length; i++) {
words[i] = words[i][0].toUpperCase() + words[i].substring(1);
}
presetOption.innerText = words.join(' ');
namedRangeSelect.appendChild(presetOption);
});
let customOption = document.createElement('option');
customOption.innerText = 'Custom';
customOption.value = 'custom';
customOption.selected = true;
namedRangeSelect.appendChild(customOption);
if (Object.values(options[option].value_names).includes(Number(currentOptions[gameName][option]))) {
namedRangeSelect.value = Number(currentOptions[gameName][option]);
}
// Build range element
let namedRangeWrapper = document.createElement('div');
namedRangeWrapper.classList.add('named-range-wrapper');
let namedRange = document.createElement('input');
namedRange.setAttribute('type', 'range');
namedRange.setAttribute('data-key', option);
namedRange.setAttribute('min', options[option].min);
namedRange.setAttribute('max', options[option].max);
namedRange.value = currentOptions[gameName][option];
// Build rage value element
let namedRangeVal = document.createElement('span');
namedRangeVal.classList.add('range-value');
namedRangeVal.setAttribute('id', `${option}-value`);
namedRangeVal.innerText = currentOptions[gameName][option] !== 'random' ?
currentOptions[gameName][option] : options[option].defaultValue;
// Configure select event listener
namedRangeSelect.addEventListener('change', (event) => {
if (event.target.value === 'custom') { return; }
// Update range slider
namedRange.value = event.target.value;
document.getElementById(`${option}-value`).innerText = event.target.value;
updateGameOption(event.target);
});
// Configure range event handler
namedRange.addEventListener('change', (event) => {
// Update select element
namedRangeSelect.value =
(Object.values(options[option].value_names).includes(parseInt(event.target.value))) ?
parseInt(event.target.value) : 'custom';
document.getElementById(`${option}-value`).innerText = event.target.value;
updateGameOption(event.target);
});
element.appendChild(namedRangeSelect);
namedRangeWrapper.appendChild(namedRange);
namedRangeWrapper.appendChild(namedRangeVal);
element.appendChild(namedRangeWrapper);
// Randomize button
randomButton.innerText = '🎲';
randomButton.classList.add('randomize-button');
randomButton.setAttribute('data-key', option);
randomButton.setAttribute('data-tooltip', 'Toggle randomization for this option!');
randomButton.addEventListener('click', (event) => toggleRandomize(
event, namedRange, namedRangeSelect)
);
if (currentOptions[gameName][option] === 'random') {
randomButton.classList.add('active');
namedRange.disabled = true;
namedRangeSelect.disabled = true;
}
namedRangeWrapper.appendChild(randomButton);
break;
default:
console.error(`Ignoring unknown option type: ${options[option].type} with name ${option}`);
return;
}
tdr.appendChild(element);
tr.appendChild(tdr);
tbody.appendChild(tr);
});
table.appendChild(tbody);
return table;
};
const setPresets = (optionsData, presetName) => {
const defaults = optionsData['gameOptions'];
const preset = optionsData['presetOptions'][presetName];
localStorage.setItem(`${gameName}-preset`, presetName);
if (!preset) {
console.error(`No presets defined for preset name: '${presetName}'`);
return;
}
const updateOptionElement = (option, presetValue) => {
const optionElement = document.querySelector(`#${option}[data-key='${option}']`);
const randomElement = document.querySelector(`.randomize-button[data-key='${option}']`);
if (presetValue === 'random') {
randomElement.classList.add('active');
optionElement.disabled = true;
updateGameOption(randomElement, false);
} else {
optionElement.value = presetValue;
randomElement.classList.remove('active');
optionElement.disabled = undefined;
updateGameOption(optionElement, false);
}
};
for (const option in defaults) {
let presetValue = preset[option];
if (presetValue === undefined) {
// Using the default value if not set in presets.
presetValue = defaults[option]['defaultValue'];
}
switch (defaults[option].type) {
case 'range':
const numberElement = document.querySelector(`#${option}-value`);
if (presetValue === 'random') {
numberElement.innerText = defaults[option]['defaultValue'] === 'random'
? defaults[option]['min'] // A fallback so we don't print 'random' in the UI.
: defaults[option]['defaultValue'];
} else {
numberElement.innerText = presetValue;
}
updateOptionElement(option, presetValue);
break;
case 'select': {
updateOptionElement(option, presetValue);
break;
}
case 'special_range': {
const selectElement = document.querySelector(`select[data-key='${option}']`);
const rangeElement = document.querySelector(`input[data-key='${option}']`);
const randomElement = document.querySelector(`.randomize-button[data-key='${option}']`);
if (presetValue === 'random') {
randomElement.classList.add('active');
selectElement.disabled = true;
rangeElement.disabled = true;
updateGameOption(randomElement, false);
} else {
rangeElement.value = presetValue;
selectElement.value = Object.values(defaults[option]['value_names']).includes(parseInt(presetValue)) ?
parseInt(presetValue) : 'custom';
document.getElementById(`${option}-value`).innerText = presetValue;
randomElement.classList.remove('active');
selectElement.disabled = undefined;
rangeElement.disabled = undefined;
updateGameOption(rangeElement, false);
}
break;
}
default:
console.warn(`Ignoring preset value for unknown option type: ${defaults[option].type} with name ${option}`);
break;
}
}
};
const toggleRandomize = (event, inputElement, optionalSelectElement = null) => {
const active = event.target.classList.contains('active');
const randomButton = event.target;
if (active) {
randomButton.classList.remove('active');
inputElement.disabled = undefined;
if (optionalSelectElement) {
optionalSelectElement.disabled = undefined;
}
} else {
randomButton.classList.add('active');
inputElement.disabled = true;
if (optionalSelectElement) {
optionalSelectElement.disabled = true;
}
}
updateGameOption(active ? inputElement : randomButton);
};
const updateBaseOption = (event) => {
const options = JSON.parse(localStorage.getItem(gameName));
options[event.target.getAttribute('data-key')] = isNaN(event.target.value) ?
event.target.value : parseInt(event.target.value);
localStorage.setItem(gameName, JSON.stringify(options));
};
const updateGameOption = (optionElement, toggleCustomPreset = true) => {
const options = JSON.parse(localStorage.getItem(gameName));
if (toggleCustomPreset) {
localStorage.setItem(`${gameName}-preset`, '__custom');
const presetElement = document.getElementById('game-options-preset');
presetElement.value = '__custom';
}
if (optionElement.classList.contains('randomize-button')) {
// If the event passed in is the randomize button, then we know what we must do.
options[gameName][optionElement.getAttribute('data-key')] = 'random';
} else {
options[gameName][optionElement.getAttribute('data-key')] = isNaN(optionElement.value) ?
optionElement.value : parseInt(optionElement.value, 10);
}
localStorage.setItem(gameName, JSON.stringify(options));
};
const exportOptions = () => {
const options = JSON.parse(localStorage.getItem(gameName));
const preset = localStorage.getItem(`${gameName}-preset`);
switch (preset) {
case '__default':
options['description'] = `Generated by https://archipelago.gg with the default preset.`;
break;
case '__custom':
options['description'] = `Generated by https://archipelago.gg.`;
break;
default:
options['description'] = `Generated by https://archipelago.gg with the ${preset} preset.`;
}
if (!options.name || options.name.toString().trim().length === 0) {
return showUserMessage('You must enter a player name!');
}
const yamlText = jsyaml.safeDump(options, { noCompatMode: true }).replaceAll(/'(\d+)':/g, (x, y) => `${y}:`);
download(`${document.getElementById('player-name').value}.yaml`, yamlText);
};
/** Create an anchor and trigger a download of a text file. */
const download = (filename, text) => {
const downloadLink = document.createElement('a');
downloadLink.setAttribute('href','data:text/yaml;charset=utf-8,'+ encodeURIComponent(text))
downloadLink.setAttribute('download', filename);
downloadLink.style.display = 'none';
document.body.appendChild(downloadLink);
downloadLink.click();
document.body.removeChild(downloadLink);
};
const generateGame = (raceMode = false) => {
const options = JSON.parse(localStorage.getItem(gameName));
if (!options.name || options.name.toLowerCase() === 'player' || options.name.trim().length === 0) {
return showUserMessage('You must enter a player name!');
}
axios.post('/api/generate', {
weights: { player: options },
presetData: { player: options },
playerCount: 1,
spoiler: 3,
race: raceMode ? '1' : '0',
}).then((response) => {
window.location.href = response.data.url;
}).catch((error) => {
let userMessage = 'Something went wrong and your game could not be generated.';
if (error.response.data.text) {
userMessage += ' ' + error.response.data.text;
}
showUserMessage(userMessage);
console.error(error);
});
};
const showUserMessage = (message) => {
const userMessage = document.getElementById('user-message');
userMessage.innerText = message;
userMessage.classList.add('visible');
window.scrollTo(0, 0);
userMessage.addEventListener('click', () => {
userMessage.classList.remove('visible');
userMessage.addEventListener('click', hideUserMessage);
});
};
const hideUserMessage = () => {
const userMessage = document.getElementById('user-message');
userMessage.classList.remove('visible');
userMessage.removeEventListener('click', hideUserMessage);
};

View File

@@ -1,340 +0,0 @@
let presets = {};
window.addEventListener('load', async () => {
// Load settings from localStorage, if available
loadSettings();
// Fetch presets if available
await fetchPresets();
// Handle changes to range inputs
document.querySelectorAll('input[type=range]').forEach((range) => {
const optionName = range.getAttribute('id');
range.addEventListener('change', () => {
document.getElementById(`${optionName}-value`).innerText = range.value;
// Handle updating named range selects to "custom" if appropriate
const select = document.querySelector(`select[data-option-name=${optionName}]`);
if (select) {
let updated = false;
select?.childNodes.forEach((option) => {
if (option.value === range.value) {
select.value = range.value;
updated = true;
}
});
if (!updated) {
select.value = 'custom';
}
}
});
});
// Handle changes to named range selects
document.querySelectorAll('.named-range-container select').forEach((select) => {
const optionName = select.getAttribute('data-option-name');
select.addEventListener('change', (evt) => {
document.getElementById(optionName).value = evt.target.value;
document.getElementById(`${optionName}-value`).innerText = evt.target.value;
});
});
// Handle changes to randomize checkboxes
document.querySelectorAll('.randomize-checkbox').forEach((checkbox) => {
const optionName = checkbox.getAttribute('data-option-name');
checkbox.addEventListener('change', () => {
const optionInput = document.getElementById(optionName);
const namedRangeSelect = document.querySelector(`select[data-option-name=${optionName}]`);
const customInput = document.getElementById(`${optionName}-custom`);
if (checkbox.checked) {
optionInput.setAttribute('disabled', '1');
namedRangeSelect?.setAttribute('disabled', '1');
if (customInput) {
customInput.setAttribute('disabled', '1');
}
} else {
optionInput.removeAttribute('disabled');
namedRangeSelect?.removeAttribute('disabled');
if (customInput) {
customInput.removeAttribute('disabled');
}
}
});
});
// Handle changes to TextChoice input[type=text]
document.querySelectorAll('.text-choice-container input[type=text]').forEach((input) => {
const optionName = input.getAttribute('data-option-name');
input.addEventListener('input', () => {
const select = document.getElementById(optionName);
const optionValues = [];
select.childNodes.forEach((option) => optionValues.push(option.value));
select.value = (optionValues.includes(input.value)) ? input.value : 'custom';
});
});
// Handle changes to TextChoice select
document.querySelectorAll('.text-choice-container select').forEach((select) => {
const optionName = select.getAttribute('id');
select.addEventListener('change', () => {
document.getElementById(`${optionName}-custom`).value = '';
});
});
// Update the "Option Preset" select to read "custom" when changes are made to relevant inputs
const presetSelect = document.getElementById('game-options-preset');
document.querySelectorAll('input, select').forEach((input) => {
if ( // Ignore inputs which have no effect on yaml generation
(input.id === 'player-name') ||
(input.id === 'game-options-preset') ||
(input.classList.contains('group-toggle')) ||
(input.type === 'submit')
) {
return;
}
input.addEventListener('change', () => {
presetSelect.value = 'custom';
});
});
// Handle changes to presets select
document.getElementById('game-options-preset').addEventListener('change', choosePreset);
// Save settings to localStorage when form is submitted
document.getElementById('options-form').addEventListener('submit', (evt) => {
const playerName = document.getElementById('player-name');
if (!playerName.value.trim()) {
evt.preventDefault();
window.scrollTo(0, 0);
showUserMessage('You must enter a player name!');
}
saveSettings();
});
});
// Save all settings to localStorage
const saveSettings = () => {
const options = {
inputs: {},
checkboxes: {},
};
document.querySelectorAll('input, select').forEach((input) => {
if (input.type === 'submit') {
// Ignore submit inputs
}
else if (input.type === 'checkbox') {
options.checkboxes[input.id] = input.checked;
}
else {
options.inputs[input.id] = input.value
}
});
const game = document.getElementById('player-options').getAttribute('data-game');
localStorage.setItem(game, JSON.stringify(options));
};
// Load all options from localStorage
const loadSettings = () => {
const game = document.getElementById('player-options').getAttribute('data-game');
const options = JSON.parse(localStorage.getItem(game));
if (options) {
if (!options.inputs || !options.checkboxes) {
localStorage.removeItem(game);
return;
}
// Restore value-based inputs and selects
Object.keys(options.inputs).forEach((key) => {
try{
document.getElementById(key).value = options.inputs[key];
const rangeValue = document.getElementById(`${key}-value`);
if (rangeValue) {
rangeValue.innerText = options.inputs[key];
}
} catch (err) {
console.error(`Unable to restore value to input with id ${key}`);
}
});
// Restore checkboxes
Object.keys(options.checkboxes).forEach((key) => {
try{
if (options.checkboxes[key]) {
document.getElementById(key).setAttribute('checked', '1');
}
} catch (err) {
console.error(`Unable to restore value to input with id ${key}`);
}
});
}
// Ensure any input for which the randomize checkbox is checked by default, the relevant inputs are disabled
document.querySelectorAll('.randomize-checkbox').forEach((checkbox) => {
const optionName = checkbox.getAttribute('data-option-name');
if (checkbox.checked) {
const input = document.getElementById(optionName);
if (input) {
input.setAttribute('disabled', '1');
}
const customInput = document.getElementById(`${optionName}-custom`);
if (customInput) {
customInput.setAttribute('disabled', '1');
}
}
});
};
/**
* Fetch the preset data for this game and apply the presets if localStorage indicates one was previously chosen
* @returns {Promise<void>}
*/
const fetchPresets = async () => {
const response = await fetch('option-presets');
presets = await response.json();
const presetSelect = document.getElementById('game-options-preset');
presetSelect.removeAttribute('disabled');
const game = document.getElementById('player-options').getAttribute('data-game');
const presetToApply = localStorage.getItem(`${game}-preset`);
const playerName = localStorage.getItem(`${game}-player`);
if (presetToApply) {
localStorage.removeItem(`${game}-preset`);
presetSelect.value = presetToApply;
applyPresets(presetToApply);
}
if (playerName) {
document.getElementById('player-name').value = playerName;
localStorage.removeItem(`${game}-player`);
}
};
/**
* Clear the localStorage for this game and set a preset to be loaded upon page reload
* @param evt
*/
const choosePreset = (evt) => {
if (evt.target.value === 'custom') { return; }
const game = document.getElementById('player-options').getAttribute('data-game');
localStorage.removeItem(game);
localStorage.setItem(`${game}-player`, document.getElementById('player-name').value);
if (evt.target.value !== 'default') {
localStorage.setItem(`${game}-preset`, evt.target.value);
}
document.querySelectorAll('#options-form input, #options-form select').forEach((input) => {
if (input.id === 'player-name') { return; }
input.removeAttribute('value');
});
window.location.replace(window.location.href);
};
const applyPresets = (presetName) => {
// Ignore the "default" preset, because it gets set automatically by Jinja
if (presetName === 'default') {
saveSettings();
return;
}
if (!presets[presetName]) {
console.error(`Unknown preset ${presetName} chosen`);
return;
}
const preset = presets[presetName];
Object.keys(preset).forEach((optionName) => {
const optionValue = preset[optionName];
// Handle List and Set options
if (Array.isArray(optionValue)) {
document.querySelectorAll(`input[type=checkbox][name=${optionName}]`).forEach((checkbox) => {
if (optionValue.includes(checkbox.value)) {
checkbox.setAttribute('checked', '1');
} else {
checkbox.removeAttribute('checked');
}
});
return;
}
// Handle Dict options
if (typeof(optionValue) === 'object' && optionValue !== null) {
const itemNames = Object.keys(optionValue);
document.querySelectorAll(`input[type=number][data-option-name=${optionName}]`).forEach((input) => {
const itemName = input.getAttribute('data-item-name');
input.value = (itemNames.includes(itemName)) ? optionValue[itemName] : 0
});
return;
}
// Identify all possible elements
const normalInput = document.getElementById(optionName);
const customInput = document.getElementById(`${optionName}-custom`);
const rangeValue = document.getElementById(`${optionName}-value`);
const randomizeInput = document.getElementById(`random-${optionName}`);
const namedRangeSelect = document.getElementById(`${optionName}-select`);
// It is possible for named ranges to use name of a value rather than the value itself. This is accounted for here
let trueValue = optionValue;
if (namedRangeSelect) {
namedRangeSelect.querySelectorAll('option').forEach((opt) => {
if (opt.innerText.startsWith(optionValue)) {
trueValue = opt.value;
}
});
namedRangeSelect.value = trueValue;
// It is also possible for a preset to use an unnamed value. If this happens, set the dropdown to "Custom"
if (namedRangeSelect.selectedIndex == -1)
{
namedRangeSelect.value = "custom";
}
}
// Handle options whose presets are "random"
if (optionValue === 'random') {
normalInput.setAttribute('disabled', '1');
randomizeInput.setAttribute('checked', '1');
if (customInput) {
customInput.setAttribute('disabled', '1');
}
if (rangeValue) {
rangeValue.innerText = normalInput.value;
}
if (namedRangeSelect) {
namedRangeSelect.setAttribute('disabled', '1');
}
return;
}
// Handle normal (text, number, select, etc.) and custom inputs (custom inputs exist with TextChoice only)
normalInput.value = trueValue;
normalInput.removeAttribute('disabled');
randomizeInput.removeAttribute('checked');
if (customInput) {
document.getElementById(`${optionName}-custom`).removeAttribute('disabled');
}
if (rangeValue) {
rangeValue.innerText = trueValue;
}
});
saveSettings();
};
const showUserMessage = (text) => {
const userMessage = document.getElementById('user-message');
userMessage.innerText = text;
userMessage.addEventListener('click', hideUserMessage);
userMessage.style.display = 'block';
};
const hideUserMessage = () => {
const userMessage = document.getElementById('user-message');
userMessage.removeEventListener('click', hideUserMessage);
userMessage.style.display = 'none';
};

View File

@@ -25,16 +25,16 @@ window.addEventListener('load', () => {
// Collapsible advancement sections // Collapsible advancement sections
const categories = document.getElementsByClassName("location-category"); const categories = document.getElementsByClassName("location-category");
for (let category of categories) { for (let i = 0; i < categories.length; i++) {
let hide_id = category.id.split('_')[0]; let hide_id = categories[i].id.split('-')[0];
if (hide_id === 'Total') { if (hide_id == 'Total') {
continue; continue;
} }
category.addEventListener('click', function() { categories[i].addEventListener('click', function() {
// Toggle the advancement list // Toggle the advancement list
document.getElementById(hide_id).classList.toggle("hide"); document.getElementById(hide_id).classList.toggle("hide");
// Change text of the header // Change text of the header
const tab_header = document.getElementById(hide_id+'_header').children[0]; const tab_header = document.getElementById(hide_id+'-header').children[0];
const orig_text = tab_header.innerHTML; const orig_text = tab_header.innerHTML;
let new_text; let new_text;
if (orig_text.includes("▼")) { if (orig_text.includes("▼")) {

View File

@@ -1,16 +1,18 @@
window.addEventListener('load', () => { window.addEventListener('load', () => {
// Add toggle listener to all elements with .collapse-toggle // Add toggle listener to all elements with .collapse-toggle
const toggleButtons = document.querySelectorAll('details'); const toggleButtons = document.querySelectorAll('.collapse-toggle');
toggleButtons.forEach((e) => e.addEventListener('click', toggleCollapse));
// Handle game filter input // Handle game filter input
const gameSearch = document.getElementById('game-search'); const gameSearch = document.getElementById('game-search');
gameSearch.value = ''; gameSearch.value = '';
gameSearch.addEventListener('input', (evt) => { gameSearch.addEventListener('input', (evt) => {
if (!evt.target.value.trim()) { if (!evt.target.value.trim()) {
// If input is empty, display all games as collapsed // If input is empty, display all collapsed games
return toggleButtons.forEach((header) => { return toggleButtons.forEach((header) => {
header.style.display = null; header.style.display = null;
header.removeAttribute('open'); header.firstElementChild.innerText = '▶';
header.nextElementSibling.classList.add('collapsed');
}); });
} }
@@ -19,10 +21,12 @@ window.addEventListener('load', () => {
// If the game name includes the search string, display the game. If not, hide it // If the game name includes the search string, display the game. If not, hide it
if (header.getAttribute('data-game').toLowerCase().includes(evt.target.value.toLowerCase())) { if (header.getAttribute('data-game').toLowerCase().includes(evt.target.value.toLowerCase())) {
header.style.display = null; header.style.display = null;
header.setAttribute('open', '1'); header.firstElementChild.innerText = '▼';
header.nextElementSibling.classList.remove('collapsed');
} else { } else {
header.style.display = 'none'; header.style.display = 'none';
header.removeAttribute('open'); header.firstElementChild.innerText = '▶';
header.nextElementSibling.classList.add('collapsed');
} }
}); });
}); });
@@ -31,14 +35,30 @@ window.addEventListener('load', () => {
document.getElementById('collapse-all').addEventListener('click', collapseAll); document.getElementById('collapse-all').addEventListener('click', collapseAll);
}); });
const toggleCollapse = (evt) => {
const gameArrow = evt.target.firstElementChild;
const gameInfo = evt.target.nextElementSibling;
if (gameInfo.classList.contains('collapsed')) {
gameArrow.innerText = '▼';
gameInfo.classList.remove('collapsed');
} else {
gameArrow.innerText = '▶';
gameInfo.classList.add('collapsed');
}
};
const expandAll = () => { const expandAll = () => {
document.querySelectorAll('details').forEach((detail) => { document.querySelectorAll('.collapse-toggle').forEach((header) => {
detail.setAttribute('open', '1'); if (header.style.display === 'none') { return; }
header.firstElementChild.innerText = '▼';
header.nextElementSibling.classList.remove('collapsed');
}); });
}; };
const collapseAll = () => { const collapseAll = () => {
document.querySelectorAll('details').forEach((detail) => { document.querySelectorAll('.collapse-toggle').forEach((header) => {
detail.removeAttribute('open'); if (header.style.display === 'none') { return; }
header.firstElementChild.innerText = '▶';
header.nextElementSibling.classList.add('collapsed');
}); });
}; };

View File

@@ -27,7 +27,7 @@ const adjustTableHeight = () => {
* @returns {string} * @returns {string}
*/ */
const secondsToHours = (seconds) => { const secondsToHours = (seconds) => {
let hours = Math.floor(seconds / 3600); let hours = Math.floor(seconds / 3600);
let minutes = Math.floor((seconds - (hours * 3600)) / 60).toString().padStart(2, '0'); let minutes = Math.floor((seconds - (hours * 3600)) / 60).toString().padStart(2, '0');
return `${hours}:${minutes}`; return `${hours}:${minutes}`;
}; };
@@ -38,18 +38,18 @@ window.addEventListener('load', () => {
info: false, info: false,
dom: "t", dom: "t",
stateSave: true, stateSave: true,
stateSaveCallback: function (settings, data) { stateSaveCallback: function(settings, data) {
delete data.search; delete data.search;
localStorage.setItem(`DataTables_${settings.sInstance}_/tracker`, JSON.stringify(data)); localStorage.setItem(`DataTables_${settings.sInstance}_/tracker`, JSON.stringify(data));
}, },
stateLoadCallback: function (settings) { stateLoadCallback: function(settings) {
return JSON.parse(localStorage.getItem(`DataTables_${settings.sInstance}_/tracker`)); return JSON.parse(localStorage.getItem(`DataTables_${settings.sInstance}_/tracker`));
}, },
footerCallback: function (tfoot, data, start, end, display) { footerCallback: function(tfoot, data, start, end, display) {
if (tfoot) { if (tfoot) {
const activityData = this.api().column('lastActivity:name').data().toArray().filter(x => !isNaN(x)); const activityData = this.api().column('lastActivity:name').data().toArray().filter(x => !isNaN(x));
Array.from(tfoot?.children).find(td => td.classList.contains('last-activity')).innerText = Array.from(tfoot?.children).find(td => td.classList.contains('last-activity')).innerText =
(activityData.length) ? secondsToHours(Math.min(...activityData)) : 'None'; (activityData.length) ? secondsToHours(Math.min(...activityData)) : 'None';
} }
}, },
columnDefs: [ columnDefs: [
@@ -123,64 +123,49 @@ window.addEventListener('load', () => {
event.preventDefault(); event.preventDefault();
} }
}); });
const target_second = parseInt(document.getElementById('tracker-wrapper').getAttribute('data-second')) + 3; const tracker = document.getElementById('tracker-wrapper').getAttribute('data-tracker');
console.log("Target second of refresh: " + target_second); const target_second = document.getElementById('tracker-wrapper').getAttribute('data-second') + 3;
function getSleepTimeSeconds() { function getSleepTimeSeconds(){
// -40 % 60 is -40, which is absolutely wrong and should burn // -40 % 60 is -40, which is absolutely wrong and should burn
var sleepSeconds = (((target_second - new Date().getSeconds()) % 60) + 60) % 60; var sleepSeconds = (((target_second - new Date().getSeconds()) % 60) + 60) % 60;
return sleepSeconds || 60; return sleepSeconds || 60;
} }
let update_on_view = false;
const update = () => { const update = () => {
if (document.hidden) { const target = $("<div></div>");
console.log("Document reporting as not visible, not updating Tracker..."); console.log("Updating Tracker...");
update_on_view = true; target.load(location.href, function (response, status) {
} else { if (status === "success") {
update_on_view = false; target.find(".table").each(function (i, new_table) {
const target = $("<div></div>"); const new_trs = $(new_table).find("tbody>tr");
console.log("Updating Tracker..."); const footer_tr = $(new_table).find("tfoot>tr");
target.load(location.href, function (response, status) { const old_table = tables.eq(i);
if (status === "success") { const topscroll = $(old_table.settings()[0].nScrollBody).scrollTop();
target.find(".table").each(function (i, new_table) { const leftscroll = $(old_table.settings()[0].nScrollBody).scrollLeft();
const new_trs = $(new_table).find("tbody>tr"); old_table.clear();
const footer_tr = $(new_table).find("tfoot>tr"); if (footer_tr.length) {
const old_table = tables.eq(i); $(old_table.table).find("tfoot").html(footer_tr);
const topscroll = $(old_table.settings()[0].nScrollBody).scrollTop(); }
const leftscroll = $(old_table.settings()[0].nScrollBody).scrollLeft(); old_table.rows.add(new_trs);
old_table.clear(); old_table.draw();
if (footer_tr.length) { $(old_table.settings()[0].nScrollBody).scrollTop(topscroll);
$(old_table.table).find("tfoot").html(footer_tr); $(old_table.settings()[0].nScrollBody).scrollLeft(leftscroll);
} });
old_table.rows.add(new_trs); $("#multi-stream-link").replaceWith(target.find("#multi-stream-link"));
old_table.draw(); } else {
$(old_table.settings()[0].nScrollBody).scrollTop(topscroll); console.log("Failed to connect to Server, in order to update Table Data.");
$(old_table.settings()[0].nScrollBody).scrollLeft(leftscroll); console.log(response);
}); }
$("#multi-stream-link").replaceWith(target.find("#multi-stream-link")); })
} else { setTimeout(update, getSleepTimeSeconds()*1000);
console.log("Failed to connect to Server, in order to update Table Data.");
console.log(response);
}
})
}
updater = setTimeout(update, getSleepTimeSeconds() * 1000);
} }
let updater = setTimeout(update, getSleepTimeSeconds() * 1000); setTimeout(update, getSleepTimeSeconds()*1000);
window.addEventListener('resize', () => { window.addEventListener('resize', () => {
adjustTableHeight(); adjustTableHeight();
tables.draw(); tables.draw();
}); });
window.addEventListener('visibilitychange', () => {
if (!document.hidden && update_on_view) {
console.log("Page became visible, tracker should be refreshed.");
clearTimeout(updater);
update();
}
});
adjustTableHeight(); adjustTableHeight();
}); });

File diff suppressed because it is too large Load Diff

View File

@@ -1,223 +0,0 @@
let deletedOptions = {};
window.addEventListener('load', () => {
const worldName = document.querySelector('#weighted-options').getAttribute('data-game');
// Generic change listener. Detecting unique qualities and acting on them here reduces initial JS initialisation time
// and handles dynamically created elements
document.addEventListener('change', (evt) => {
// Handle updates to range inputs
if (evt.target.type === 'range') {
// Update span containing range value. All ranges have a corresponding `{rangeId}-value` span
document.getElementById(`${evt.target.id}-value`).innerText = evt.target.value;
// If the changed option was the name of a game, determine whether to show or hide that game's div
if (evt.target.id.startsWith('game||')) {
const gameName = evt.target.id.split('||')[1];
const gameDiv = document.getElementById(`${gameName}-container`);
if (evt.target.value > 0) {
gameDiv.classList.remove('hidden');
} else {
gameDiv.classList.add('hidden');
}
}
}
});
// Generic click listener
document.addEventListener('click', (evt) => {
// Handle creating new rows for Range options
if (evt.target.classList.contains('add-range-option-button')) {
const optionName = evt.target.getAttribute('data-option');
addRangeRow(optionName);
}
// Handle deleting range rows
if (evt.target.classList.contains('range-option-delete')) {
const targetRow = document.querySelector(`tr[data-row="${evt.target.getAttribute('data-target')}"]`);
setDeletedOption(
targetRow.getAttribute('data-option-name'),
targetRow.getAttribute('data-value'),
);
targetRow.parentElement.removeChild(targetRow);
}
});
// Listen for enter presses on inputs intended to add range rows
document.addEventListener('keydown', (evt) => {
if (evt.key === 'Enter') {
evt.preventDefault();
}
if (evt.key === 'Enter' && evt.target.classList.contains('range-option-value')) {
const optionName = evt.target.getAttribute('data-option');
addRangeRow(optionName);
}
});
// Detect form submission
document.getElementById('weighted-options-form').addEventListener('submit', (evt) => {
// Save data to localStorage
const weightedOptions = {};
document.querySelectorAll('input[name]').forEach((input) => {
const keys = input.getAttribute('name').split('||');
// Determine keys
const optionName = keys[0] ?? null;
const subOption = keys[1] ?? null;
// Ensure keys exist
if (!weightedOptions[optionName]) { weightedOptions[optionName] = {}; }
if (subOption && !weightedOptions[optionName][subOption]) {
weightedOptions[optionName][subOption] = null;
}
if (subOption) { return weightedOptions[optionName][subOption] = determineValue(input); }
if (optionName) { return weightedOptions[optionName] = determineValue(input); }
});
localStorage.setItem(`${worldName}-weights`, JSON.stringify(weightedOptions));
localStorage.setItem(`${worldName}-deletedOptions`, JSON.stringify(deletedOptions));
});
// Remove all deleted values as specified by localStorage
deletedOptions = JSON.parse(localStorage.getItem(`${worldName}-deletedOptions`) || '{}');
Object.keys(deletedOptions).forEach((optionName) => {
deletedOptions[optionName].forEach((value) => {
const targetRow = document.querySelector(`tr[data-row="${value}-row"]`);
targetRow.parentElement.removeChild(targetRow);
});
});
// Populate all settings from localStorage on page initialisation
const previousSettingsJson = localStorage.getItem(`${worldName}-weights`);
if (previousSettingsJson) {
const previousSettings = JSON.parse(previousSettingsJson);
Object.keys(previousSettings).forEach((option) => {
if (typeof previousSettings[option] === 'string') {
return document.querySelector(`input[name="${option}"]`).value = previousSettings[option];
}
Object.keys(previousSettings[option]).forEach((value) => {
const input = document.querySelector(`input[name="${option}||${value}"]`);
if (!input?.type) {
return console.error(`Unable to populate option with name ${option}||${value}.`);
}
switch (input.type) {
case 'checkbox':
input.checked = (parseInt(previousSettings[option][value], 10) === 1);
break;
case 'range':
input.value = parseInt(previousSettings[option][value], 10);
break;
case 'number':
input.value = previousSettings[option][value].toString();
break;
default:
console.error(`Found unsupported input type: ${input.type}`);
}
});
});
}
});
const addRangeRow = (optionName) => {
const inputQuery = `input[type=number][data-option="${optionName}"].range-option-value`;
const inputTarget = document.querySelector(inputQuery);
const newValue = inputTarget.value;
if (!/^-?\d+$/.test(newValue)) {
alert('Range values must be a positive or negative integer!');
return;
}
inputTarget.value = '';
const tBody = document.querySelector(`table[data-option="${optionName}"].range-rows tbody`);
const tr = document.createElement('tr');
tr.setAttribute('data-row', `${optionName}-${newValue}-row`);
tr.setAttribute('data-option-name', optionName);
tr.setAttribute('data-value', newValue);
const tdLeft = document.createElement('td');
tdLeft.classList.add('td-left');
const label = document.createElement('label');
label.setAttribute('for', `${optionName}||${newValue}`);
label.innerText = newValue.toString();
tdLeft.appendChild(label);
tr.appendChild(tdLeft);
const tdMiddle = document.createElement('td');
tdMiddle.classList.add('td-middle');
const range = document.createElement('input');
range.setAttribute('type', 'range');
range.setAttribute('min', '0');
range.setAttribute('max', '50');
range.setAttribute('value', '0');
range.setAttribute('id', `${optionName}||${newValue}`);
range.setAttribute('name', `${optionName}||${newValue}`);
tdMiddle.appendChild(range);
tr.appendChild(tdMiddle);
const tdRight = document.createElement('td');
tdRight.classList.add('td-right');
const valueSpan = document.createElement('span');
valueSpan.setAttribute('id', `${optionName}||${newValue}-value`);
valueSpan.innerText = '0';
tdRight.appendChild(valueSpan);
tr.appendChild(tdRight);
const tdDelete = document.createElement('td');
const deleteSpan = document.createElement('span');
deleteSpan.classList.add('range-option-delete');
deleteSpan.classList.add('js-required');
deleteSpan.setAttribute('data-target', `${optionName}-${newValue}-row`);
deleteSpan.innerText = '❌';
tdDelete.appendChild(deleteSpan);
tr.appendChild(tdDelete);
tBody.appendChild(tr);
// Remove this option from the set of deleted options if it exists
unsetDeletedOption(optionName, newValue);
};
/**
* Determines the value of an input element, or returns a 1 or 0 if the element is a checkbox
*
* @param {object} input - The input element.
* @returns {number} The value of the input element.
*/
const determineValue = (input) => {
switch (input.type) {
case 'checkbox':
return (input.checked ? 1 : 0);
case 'range':
return parseInt(input.value, 10);
default:
return input.value;
}
};
/**
* Sets the deleted option value for a given world and option name.
* If the world or option does not exist, it creates the necessary entries.
*
* @param {string} optionName - The name of the option.
* @param {*} value - The value to be set for the deleted option.
* @returns {void}
*/
const setDeletedOption = (optionName, value) => {
deletedOptions[optionName] = deletedOptions[optionName] || [];
deletedOptions[optionName].push(`${optionName}-${value}`);
};
/**
* Removes a specific value from the deletedOptions object.
*
* @param {string} optionName - The name of the option.
* @param {*} value - The value to be removed
* @returns {void}
*/
const unsetDeletedOption = (optionName, value) => {
if (!deletedOptions.hasOwnProperty(optionName)) { return; }
if (deletedOptions[optionName].includes(`${optionName}-${value}`)) {
deletedOptions[optionName].splice(deletedOptions[optionName].indexOf(`${optionName}-${value}`), 1);
}
if (deletedOptions[optionName].length === 0) {
delete deletedOptions[optionName];
}
};

View File

@@ -1,20 +0,0 @@
User-agent: Googlebot
Disallow: /
User-agent: APIs-Google
Disallow: /
User-agent: AdsBot-Google-Mobile
Disallow: /
User-agent: AdsBot-Google-Mobile
Disallow: /
User-agent: Mediapartners-Google
Disallow: /
User-agent: Google-Safety
Disallow: /
User-agent: *
Disallow: /

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 16 KiB

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