Compare commits
1 Commits
fix-links-
...
api-refere
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f33f19f8b2 |
2
.gitattributes
vendored
@@ -1,2 +0,0 @@
|
|||||||
worlds/blasphemous/region_data.py linguist-generated=true
|
|
||||||
worlds/yachtdice/YachtWeights.py linguist-generated=true
|
|
||||||
18
.github/pyright-config.json
vendored
@@ -1,20 +1,8 @@
|
|||||||
{
|
{
|
||||||
"include": [
|
"include": [
|
||||||
"../BizHawkClient.py",
|
"type_check.py",
|
||||||
"../Patch.py",
|
|
||||||
"../test/general/test_groups.py",
|
|
||||||
"../test/general/test_helpers.py",
|
|
||||||
"../test/general/test_memory.py",
|
|
||||||
"../test/general/test_names.py",
|
|
||||||
"../test/multiworld/__init__.py",
|
|
||||||
"../test/multiworld/test_multiworlds.py",
|
|
||||||
"../test/netutils/__init__.py",
|
|
||||||
"../test/programs/__init__.py",
|
|
||||||
"../test/programs/test_multi_server.py",
|
|
||||||
"../test/utils/__init__.py",
|
|
||||||
"../test/webhost/test_descriptions.py",
|
|
||||||
"../worlds/AutoSNIClient.py",
|
"../worlds/AutoSNIClient.py",
|
||||||
"type_check.py"
|
"../Patch.py"
|
||||||
],
|
],
|
||||||
|
|
||||||
"exclude": [
|
"exclude": [
|
||||||
@@ -28,7 +16,7 @@
|
|||||||
"reportMissingImports": true,
|
"reportMissingImports": true,
|
||||||
"reportMissingTypeStubs": true,
|
"reportMissingTypeStubs": true,
|
||||||
|
|
||||||
"pythonVersion": "3.10",
|
"pythonVersion": "3.8",
|
||||||
"pythonPlatform": "Windows",
|
"pythonPlatform": "Windows",
|
||||||
|
|
||||||
"executionEnvironments": [
|
"executionEnvironments": [
|
||||||
|
|||||||
2
.github/workflows/analyze-modified-files.yml
vendored
@@ -53,7 +53,7 @@ jobs:
|
|||||||
- uses: actions/setup-python@v5
|
- uses: actions/setup-python@v5
|
||||||
if: env.diff != ''
|
if: env.diff != ''
|
||||||
with:
|
with:
|
||||||
python-version: '3.10'
|
python-version: 3.8
|
||||||
|
|
||||||
- name: "Install dependencies"
|
- name: "Install dependencies"
|
||||||
if: env.diff != ''
|
if: env.diff != ''
|
||||||
|
|||||||
71
.github/workflows/build.yml
vendored
@@ -24,28 +24,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@v4
|
||||||
- name: Install python
|
- name: Install python
|
||||||
uses: actions/setup-python@v5
|
uses: actions/setup-python@v5
|
||||||
with:
|
with:
|
||||||
python-version: '~3.12.7'
|
python-version: '3.8'
|
||||||
check-latest: true
|
|
||||||
- name: Download run-time dependencies
|
- name: Download run-time dependencies
|
||||||
run: |
|
run: |
|
||||||
Invoke-WebRequest -Uri https://github.com/Ijwu/Enemizer/releases/download/${Env:ENEMIZER_VERSION}/win-x64.zip -OutFile enemizer.zip
|
Invoke-WebRequest -Uri https://github.com/Ijwu/Enemizer/releases/download/${Env:ENEMIZER_VERSION}/win-x64.zip -OutFile enemizer.zip
|
||||||
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"
|
||||||
@@ -55,6 +49,12 @@ jobs:
|
|||||||
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
|
Rename-Item Archipelago "exe.$NAME" # inno_setup.iss expects the original name
|
||||||
|
- name: Store 7z
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: ${{ env.ZIP_NAME }}
|
||||||
|
path: dist/${{ env.ZIP_NAME }}
|
||||||
|
retention-days: 7 # keep for 7 days, should be enough
|
||||||
- name: Build Setup
|
- name: Build Setup
|
||||||
run: |
|
run: |
|
||||||
& "${env:ProgramFiles(x86)}\Inno Setup 6\iscc.exe" inno_setup.iss /DNO_SIGNTOOL
|
& "${env:ProgramFiles(x86)}\Inno Setup 6\iscc.exe" inno_setup.iss /DNO_SIGNTOOL
|
||||||
@@ -65,38 +65,11 @@ jobs:
|
|||||||
$contents = Get-ChildItem -Path setups/*.exe -Force -Recurse
|
$contents = Get-ChildItem -Path setups/*.exe -Force -Recurse
|
||||||
$SETUP_NAME=$contents[0].Name
|
$SETUP_NAME=$contents[0].Name
|
||||||
echo "SETUP_NAME=$SETUP_NAME" >> $Env:GITHUB_ENV
|
echo "SETUP_NAME=$SETUP_NAME" >> $Env:GITHUB_ENV
|
||||||
- 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
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: ${{ env.ZIP_NAME }}
|
|
||||||
path: dist/${{ env.ZIP_NAME }}
|
|
||||||
compression-level: 0 # .7z is incompressible by zip
|
|
||||||
if-no-files-found: error
|
|
||||||
retention-days: 7 # keep for 7 days, should be enough
|
|
||||||
- name: Store Setup
|
- name: Store Setup
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: ${{ env.SETUP_NAME }}
|
name: ${{ env.SETUP_NAME }}
|
||||||
path: setups/${{ 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:
|
||||||
@@ -112,11 +85,10 @@ jobs:
|
|||||||
- name: Get a recent python
|
- name: Get a recent python
|
||||||
uses: actions/setup-python@v5
|
uses: actions/setup-python@v5
|
||||||
with:
|
with:
|
||||||
python-version: '~3.12.7'
|
python-version: '3.11'
|
||||||
check-latest: true
|
|
||||||
- name: Install build-time dependencies
|
- name: Install build-time dependencies
|
||||||
run: |
|
run: |
|
||||||
echo "PYTHON=python3.12" >> $GITHUB_ENV
|
echo "PYTHON=python3.11" >> $GITHUB_ENV
|
||||||
wget -nv https://github.com/AppImage/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
|
||||||
@@ -138,7 +110,7 @@ jobs:
|
|||||||
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 +118,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@v4
|
||||||
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@v4
|
||||||
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
|
||||||
|
|||||||
6
.github/workflows/codeql-analysis.yml
vendored
@@ -47,7 +47,7 @@ jobs:
|
|||||||
|
|
||||||
# Initializes the CodeQL tools for scanning.
|
# Initializes the CodeQL tools for scanning.
|
||||||
- name: Initialize CodeQL
|
- name: Initialize CodeQL
|
||||||
uses: github/codeql-action/init@v3
|
uses: github/codeql-action/init@v2
|
||||||
with:
|
with:
|
||||||
languages: ${{ matrix.language }}
|
languages: ${{ matrix.language }}
|
||||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
# If you wish to specify custom queries, you can do so here or in a config file.
|
||||||
@@ -58,7 +58,7 @@ jobs:
|
|||||||
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
|
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
|
||||||
# If this step fails, then you should remove it and run the build manually (see below)
|
# If this step fails, then you should remove it and run the build manually (see below)
|
||||||
- name: Autobuild
|
- name: Autobuild
|
||||||
uses: github/codeql-action/autobuild@v3
|
uses: github/codeql-action/autobuild@v2
|
||||||
|
|
||||||
# ℹ️ Command-line programs to run using the OS shell.
|
# ℹ️ Command-line programs to run using the OS shell.
|
||||||
# 📚 https://git.io/JvXDl
|
# 📚 https://git.io/JvXDl
|
||||||
@@ -72,4 +72,4 @@ jobs:
|
|||||||
# make release
|
# make release
|
||||||
|
|
||||||
- name: Perform CodeQL Analysis
|
- name: Perform CodeQL Analysis
|
||||||
uses: github/codeql-action/analyze@v3
|
uses: github/codeql-action/analyze@v2
|
||||||
|
|||||||
54
.github/workflows/ctest.yml
vendored
@@ -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@v1
|
|
||||||
if: startsWith(matrix.os,'windows')
|
|
||||||
- uses: Bacondish2023/setup-googletest@v1
|
|
||||||
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
|
|
||||||
7
.github/workflows/release.yml
vendored
@@ -44,11 +44,10 @@ jobs:
|
|||||||
- name: Get a recent python
|
- name: Get a recent python
|
||||||
uses: actions/setup-python@v5
|
uses: actions/setup-python@v5
|
||||||
with:
|
with:
|
||||||
python-version: '~3.12.7'
|
python-version: '3.11'
|
||||||
check-latest: true
|
|
||||||
- name: Install build-time dependencies
|
- name: Install build-time dependencies
|
||||||
run: |
|
run: |
|
||||||
echo "PYTHON=python3.12" >> $GITHUB_ENV
|
echo "PYTHON=python3.11" >> $GITHUB_ENV
|
||||||
wget -nv https://github.com/AppImage/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
|
||||||
@@ -70,7 +69,7 @@ jobs:
|
|||||||
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 -
|
||||||
|
|||||||
6
.github/workflows/scan-build.yml
vendored
@@ -40,10 +40,10 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
wget https://apt.llvm.org/llvm.sh
|
wget https://apt.llvm.org/llvm.sh
|
||||||
chmod +x ./llvm.sh
|
chmod +x ./llvm.sh
|
||||||
sudo ./llvm.sh 19
|
sudo ./llvm.sh 17
|
||||||
- name: Install scan-build command
|
- name: Install scan-build command
|
||||||
run: |
|
run: |
|
||||||
sudo apt install clang-tools-19
|
sudo apt install clang-tools-17
|
||||||
- name: Get a recent python
|
- name: Get a recent python
|
||||||
uses: actions/setup-python@v5
|
uses: actions/setup-python@v5
|
||||||
with:
|
with:
|
||||||
@@ -56,7 +56,7 @@ jobs:
|
|||||||
- name: scan-build
|
- name: scan-build
|
||||||
run: |
|
run: |
|
||||||
source venv/bin/activate
|
source venv/bin/activate
|
||||||
scan-build-19 --status-bugs -o scan-build-reports -disable-checker deadcode.DeadStores python setup.py build -y
|
scan-build-17 --status-bugs -o scan-build-reports -disable-checker deadcode.DeadStores python setup.py build -y
|
||||||
- name: Store report
|
- name: Store report
|
||||||
if: failure()
|
if: failure()
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
|
|||||||
2
.github/workflows/strict-type-check.yml
vendored
@@ -26,7 +26,7 @@ jobs:
|
|||||||
|
|
||||||
- name: "Install dependencies"
|
- name: "Install dependencies"
|
||||||
run: |
|
run: |
|
||||||
python -m pip install --upgrade pip pyright==1.1.392.post0
|
python -m pip install --upgrade pip pyright==1.1.358
|
||||||
python ModuleUpdate.py --append "WebHostLib/requirements.txt" --force --yes
|
python ModuleUpdate.py --append "WebHostLib/requirements.txt" --force --yes
|
||||||
|
|
||||||
- name: "pyright: strict check on specific files"
|
- name: "pyright: strict check on specific files"
|
||||||
|
|||||||
40
.github/workflows/unittests.yml
vendored
@@ -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,15 +33,16 @@ 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:
|
||||||
@@ -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
|
|
||||||
|
|||||||
4
.gitignore
vendored
@@ -62,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__/
|
||||||
@@ -150,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
|
||||||
@@ -178,7 +177,6 @@ dmypy.json
|
|||||||
cython_debug/
|
cython_debug/
|
||||||
|
|
||||||
# Cython intermediates
|
# Cython intermediates
|
||||||
_speedups.c
|
|
||||||
_speedups.cpp
|
_speedups.cpp
|
||||||
_speedups.html
|
_speedups.html
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
|
||||||
@@ -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,7 +112,7 @@ 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"]:
|
if f"adventure_{self.auth}_freeincarnates_used" in args["keys"]:
|
||||||
@@ -415,8 +415,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)
|
||||||
|
|||||||
553
BaseClasses.py
@@ -1,38 +1,37 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import collections
|
import copy
|
||||||
|
import itertools
|
||||||
import functools
|
import functools
|
||||||
import logging
|
import logging
|
||||||
import random
|
import random
|
||||||
import secrets
|
import secrets
|
||||||
|
import typing # this can go away when Python 3.8 support is dropped
|
||||||
from argparse import Namespace
|
from argparse import Namespace
|
||||||
from collections import Counter, deque
|
from collections import Counter, deque
|
||||||
from collections.abc import Collection, MutableSequence
|
from collections.abc import Collection, MutableSequence
|
||||||
from enum import IntEnum, IntFlag
|
from enum import IntEnum, IntFlag
|
||||||
from typing import (AbstractSet, Any, Callable, ClassVar, Dict, Iterable, Iterator, List, Mapping, NamedTuple,
|
from typing import Any, Callable, Dict, Iterable, Iterator, List, Mapping, NamedTuple, Optional, Set, Tuple, \
|
||||||
Optional, Protocol, Set, Tuple, Union, TYPE_CHECKING)
|
TypedDict, Union, Type, ClassVar
|
||||||
|
|
||||||
from typing_extensions import NotRequired, TypedDict
|
|
||||||
|
|
||||||
import NetUtils
|
import NetUtils
|
||||||
import Options
|
import Options
|
||||||
import Utils
|
import Utils
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if typing.TYPE_CHECKING:
|
||||||
from entrance_rando import ERPlacementState
|
|
||||||
from worlds import AutoWorld
|
from worlds import AutoWorld
|
||||||
|
|
||||||
|
|
||||||
class Group(TypedDict):
|
class Group(TypedDict, total=False):
|
||||||
name: str
|
name: str
|
||||||
game: str
|
game: str
|
||||||
world: "AutoWorld.World"
|
world: "AutoWorld.World"
|
||||||
players: AbstractSet[int]
|
players: Set[int]
|
||||||
item_pool: NotRequired[Set[str]]
|
item_pool: Set[str]
|
||||||
replacement_items: NotRequired[Dict[int, Optional[str]]]
|
replacement_items: Dict[int, Optional[str]]
|
||||||
local_items: NotRequired[Set[str]]
|
local_items: Set[str]
|
||||||
non_local_items: NotRequired[Set[str]]
|
non_local_items: Set[str]
|
||||||
link_replacement: NotRequired[bool]
|
link_replacement: bool
|
||||||
|
|
||||||
|
|
||||||
class ThreadBarrierProxy:
|
class ThreadBarrierProxy:
|
||||||
@@ -49,11 +48,6 @@ class ThreadBarrierProxy:
|
|||||||
"Please use multiworld.per_slot_randoms[player] or randomize ahead of output.")
|
"Please use multiworld.per_slot_randoms[player] or randomize ahead of output.")
|
||||||
|
|
||||||
|
|
||||||
class HasNameAndPlayer(Protocol):
|
|
||||||
name: str
|
|
||||||
player: int
|
|
||||||
|
|
||||||
|
|
||||||
class MultiWorld():
|
class MultiWorld():
|
||||||
debug_types = False
|
debug_types = False
|
||||||
player_name: Dict[int, str]
|
player_name: Dict[int, str]
|
||||||
@@ -69,6 +63,7 @@ class MultiWorld():
|
|||||||
state: CollectionState
|
state: CollectionState
|
||||||
|
|
||||||
plando_options: PlandoOptions
|
plando_options: PlandoOptions
|
||||||
|
accessibility: Dict[int, Options.Accessibility]
|
||||||
early_items: Dict[int, Dict[str, int]]
|
early_items: Dict[int, Dict[str, int]]
|
||||||
local_early_items: Dict[int, Dict[str, int]]
|
local_early_items: Dict[int, Dict[str, int]]
|
||||||
local_items: Dict[int, Options.LocalItems]
|
local_items: Dict[int, Options.LocalItems]
|
||||||
@@ -162,7 +157,7 @@ class MultiWorld():
|
|||||||
self.start_inventory_from_pool: Dict[int, Options.StartInventoryPool] = {}
|
self.start_inventory_from_pool: Dict[int, Options.StartInventoryPool] = {}
|
||||||
|
|
||||||
for player in range(1, players + 1):
|
for player in range(1, players + 1):
|
||||||
def set_player_attr(attr: str, val) -> None:
|
def set_player_attr(attr, val):
|
||||||
self.__dict__.setdefault(attr, {})[player] = val
|
self.__dict__.setdefault(attr, {})[player] = val
|
||||||
set_player_attr('plando_items', [])
|
set_player_attr('plando_items', [])
|
||||||
set_player_attr('plando_texts', {})
|
set_player_attr('plando_texts', {})
|
||||||
@@ -171,13 +166,13 @@ class MultiWorld():
|
|||||||
set_player_attr('completion_condition', lambda state: True)
|
set_player_attr('completion_condition', lambda state: True)
|
||||||
self.worlds = {}
|
self.worlds = {}
|
||||||
self.per_slot_randoms = Utils.DeprecateDict("Using per_slot_randoms is now deprecated. Please use the "
|
self.per_slot_randoms = Utils.DeprecateDict("Using per_slot_randoms is now deprecated. Please use the "
|
||||||
"world's random object instead (usually self.random)")
|
"world's random object instead (usually self.random)")
|
||||||
self.plando_options = PlandoOptions.none
|
self.plando_options = PlandoOptions.none
|
||||||
|
|
||||||
def get_all_ids(self) -> Tuple[int, ...]:
|
def get_all_ids(self) -> Tuple[int, ...]:
|
||||||
return self.player_ids + tuple(self.groups)
|
return self.player_ids + tuple(self.groups)
|
||||||
|
|
||||||
def add_group(self, name: str, game: str, players: AbstractSet[int] = frozenset()) -> Tuple[int, Group]:
|
def add_group(self, name: str, game: str, players: Set[int] = frozenset()) -> Tuple[int, Group]:
|
||||||
"""Create a group with name and return the assigned player ID and group.
|
"""Create a group with name and return the assigned player ID and group.
|
||||||
If a group of this name already exists, the set of players is extended instead of creating a new one."""
|
If a group of this name already exists, the set of players is extended instead of creating a new one."""
|
||||||
from worlds import AutoWorld
|
from worlds import AutoWorld
|
||||||
@@ -193,9 +188,7 @@ class MultiWorld():
|
|||||||
self.player_types[new_id] = NetUtils.SlotType.group
|
self.player_types[new_id] = NetUtils.SlotType.group
|
||||||
world_type = AutoWorld.AutoWorldRegister.world_types[game]
|
world_type = AutoWorld.AutoWorldRegister.world_types[game]
|
||||||
self.worlds[new_id] = world_type.create_group(self, new_id, players)
|
self.worlds[new_id] = world_type.create_group(self, new_id, players)
|
||||||
self.worlds[new_id].collect_item = AutoWorld.World.collect_item.__get__(self.worlds[new_id])
|
self.worlds[new_id].collect_item = classmethod(AutoWorld.World.collect_item).__get__(self.worlds[new_id])
|
||||||
self.worlds[new_id].collect = AutoWorld.World.collect.__get__(self.worlds[new_id])
|
|
||||||
self.worlds[new_id].remove = AutoWorld.World.remove.__get__(self.worlds[new_id])
|
|
||||||
self.player_name[new_id] = name
|
self.player_name[new_id] = name
|
||||||
|
|
||||||
new_group = self.groups[new_id] = Group(name=name, game=game, players=players,
|
new_group = self.groups[new_id] = Group(name=name, game=game, players=players,
|
||||||
@@ -203,7 +196,7 @@ class MultiWorld():
|
|||||||
|
|
||||||
return new_id, new_group
|
return new_id, new_group
|
||||||
|
|
||||||
def get_player_groups(self, player: int) -> Set[int]:
|
def get_player_groups(self, player) -> Set[int]:
|
||||||
return {group_id for group_id, group in self.groups.items() if player in group["players"]}
|
return {group_id for group_id, group in self.groups.items() if player in group["players"]}
|
||||||
|
|
||||||
def set_seed(self, seed: Optional[int] = None, secure: bool = False, name: Optional[str] = None):
|
def set_seed(self, seed: Optional[int] = None, secure: bool = False, name: Optional[str] = None):
|
||||||
@@ -230,7 +223,7 @@ class MultiWorld():
|
|||||||
for player in self.player_ids:
|
for player in self.player_ids:
|
||||||
world_type = AutoWorld.AutoWorldRegister.world_types[self.game[player]]
|
world_type = AutoWorld.AutoWorldRegister.world_types[self.game[player]]
|
||||||
self.worlds[player] = world_type(self, player)
|
self.worlds[player] = world_type(self, player)
|
||||||
options_dataclass: type[Options.PerGameCommonOptions] = world_type.options_dataclass
|
options_dataclass: typing.Type[Options.PerGameCommonOptions] = world_type.options_dataclass
|
||||||
self.worlds[player].options = options_dataclass(**{option_key: getattr(args, option_key)[player]
|
self.worlds[player].options = options_dataclass(**{option_key: getattr(args, option_key)[player]
|
||||||
for option_key in options_dataclass.type_hints})
|
for option_key in options_dataclass.type_hints})
|
||||||
|
|
||||||
@@ -266,7 +259,7 @@ class MultiWorld():
|
|||||||
"link_replacement": replacement_prio.index(item_link["link_replacement"]),
|
"link_replacement": replacement_prio.index(item_link["link_replacement"]),
|
||||||
}
|
}
|
||||||
|
|
||||||
for _name, item_link in item_links.items():
|
for name, item_link in item_links.items():
|
||||||
current_item_name_groups = AutoWorld.AutoWorldRegister.world_types[item_link["game"]].item_name_groups
|
current_item_name_groups = AutoWorld.AutoWorldRegister.world_types[item_link["game"]].item_name_groups
|
||||||
pool = set()
|
pool = set()
|
||||||
local_items = set()
|
local_items = set()
|
||||||
@@ -295,88 +288,6 @@ class MultiWorld():
|
|||||||
group["non_local_items"] = item_link["non_local_items"]
|
group["non_local_items"] = item_link["non_local_items"]
|
||||||
group["link_replacement"] = replacement_prio[item_link["link_replacement"]]
|
group["link_replacement"] = replacement_prio[item_link["link_replacement"]]
|
||||||
|
|
||||||
def link_items(self) -> None:
|
|
||||||
"""Called to link together items in the itempool related to the registered item link groups."""
|
|
||||||
from worlds import AutoWorld
|
|
||||||
|
|
||||||
for group_id, group in self.groups.items():
|
|
||||||
def find_common_pool(players: Set[int], shared_pool: Set[str]) -> Tuple[
|
|
||||||
Optional[Dict[int, Dict[str, int]]], Optional[Dict[str, int]]
|
|
||||||
]:
|
|
||||||
classifications: Dict[str, int] = collections.defaultdict(int)
|
|
||||||
counters = {player: {name: 0 for name in shared_pool} for player in players}
|
|
||||||
for item in self.itempool:
|
|
||||||
if item.player in counters and item.name in shared_pool:
|
|
||||||
counters[item.player][item.name] += 1
|
|
||||||
classifications[item.name] |= item.classification
|
|
||||||
|
|
||||||
for player in players.copy():
|
|
||||||
if all([counters[player][item] == 0 for item in shared_pool]):
|
|
||||||
players.remove(player)
|
|
||||||
del (counters[player])
|
|
||||||
|
|
||||||
if not players:
|
|
||||||
return None, None
|
|
||||||
|
|
||||||
for item in shared_pool:
|
|
||||||
count = min(counters[player][item] for player in players)
|
|
||||||
if count:
|
|
||||||
for player in players:
|
|
||||||
counters[player][item] = count
|
|
||||||
else:
|
|
||||||
for player in players:
|
|
||||||
del (counters[player][item])
|
|
||||||
return counters, classifications
|
|
||||||
|
|
||||||
common_item_count, classifications = find_common_pool(group["players"], group["item_pool"])
|
|
||||||
if not common_item_count:
|
|
||||||
continue
|
|
||||||
|
|
||||||
new_itempool: List[Item] = []
|
|
||||||
for item_name, item_count in next(iter(common_item_count.values())).items():
|
|
||||||
for _ in range(item_count):
|
|
||||||
new_item = group["world"].create_item(item_name)
|
|
||||||
# mangle together all original classification bits
|
|
||||||
new_item.classification |= classifications[item_name]
|
|
||||||
new_itempool.append(new_item)
|
|
||||||
|
|
||||||
region = Region(group["world"].origin_region_name, group_id, self, "ItemLink")
|
|
||||||
self.regions.append(region)
|
|
||||||
locations = region.locations
|
|
||||||
# ensure that progression items are linked first, then non-progression
|
|
||||||
self.itempool.sort(key=lambda item: item.advancement)
|
|
||||||
for item in self.itempool:
|
|
||||||
count = common_item_count.get(item.player, {}).get(item.name, 0)
|
|
||||||
if count:
|
|
||||||
loc = Location(group_id, f"Item Link: {item.name} -> {self.player_name[item.player]} {count}",
|
|
||||||
None, region)
|
|
||||||
loc.access_rule = lambda state, item_name = item.name, group_id_ = group_id, count_ = count: \
|
|
||||||
state.has(item_name, group_id_, count_)
|
|
||||||
|
|
||||||
locations.append(loc)
|
|
||||||
loc.place_locked_item(item)
|
|
||||||
common_item_count[item.player][item.name] -= 1
|
|
||||||
else:
|
|
||||||
new_itempool.append(item)
|
|
||||||
|
|
||||||
itemcount = len(self.itempool)
|
|
||||||
self.itempool = new_itempool
|
|
||||||
|
|
||||||
while itemcount > len(self.itempool):
|
|
||||||
items_to_add = []
|
|
||||||
for player in group["players"]:
|
|
||||||
if group["link_replacement"]:
|
|
||||||
item_player = group_id
|
|
||||||
else:
|
|
||||||
item_player = player
|
|
||||||
if group["replacement_items"][player]:
|
|
||||||
items_to_add.append(AutoWorld.call_single(self, "create_item", item_player,
|
|
||||||
group["replacement_items"][player]))
|
|
||||||
else:
|
|
||||||
items_to_add.append(AutoWorld.call_single(self, "create_filler", item_player))
|
|
||||||
self.random.shuffle(items_to_add)
|
|
||||||
self.itempool.extend(items_to_add[:itemcount - len(self.itempool)])
|
|
||||||
|
|
||||||
def secure(self):
|
def secure(self):
|
||||||
self.random = ThreadBarrierProxy(secrets.SystemRandom())
|
self.random = ThreadBarrierProxy(secrets.SystemRandom())
|
||||||
self.is_race = True
|
self.is_race = True
|
||||||
@@ -398,7 +309,7 @@ class MultiWorld():
|
|||||||
return tuple(world for player, world in self.worlds.items() if
|
return tuple(world for player, world in self.worlds.items() if
|
||||||
player not in self.groups and self.game[player] == game_name)
|
player not in self.groups and self.game[player] == game_name)
|
||||||
|
|
||||||
def get_name_string_for_object(self, obj: HasNameAndPlayer) -> str:
|
def get_name_string_for_object(self, obj) -> str:
|
||||||
return obj.name if self.players == 1 else f'{obj.name} ({self.get_player_name(obj.player)})'
|
return obj.name if self.players == 1 else f'{obj.name} ({self.get_player_name(obj.player)})'
|
||||||
|
|
||||||
def get_player_name(self, player: int) -> str:
|
def get_player_name(self, player: int) -> str:
|
||||||
@@ -427,12 +338,12 @@ class MultiWorld():
|
|||||||
def get_location(self, location_name: str, player: int) -> Location:
|
def get_location(self, location_name: str, player: int) -> Location:
|
||||||
return self.regions.location_cache[player][location_name]
|
return self.regions.location_cache[player][location_name]
|
||||||
|
|
||||||
def get_all_state(self, use_cache: bool, allow_partial_entrances: bool = False) -> CollectionState:
|
def get_all_state(self, use_cache: bool) -> CollectionState:
|
||||||
cached = getattr(self, "_all_state", None)
|
cached = getattr(self, "_all_state", None)
|
||||||
if use_cache and cached:
|
if use_cache and cached:
|
||||||
return cached.copy()
|
return cached.copy()
|
||||||
|
|
||||||
ret = CollectionState(self, allow_partial_entrances)
|
ret = CollectionState(self)
|
||||||
|
|
||||||
for item in self.itempool:
|
for item in self.itempool:
|
||||||
self.worlds[item.player].collect(ret, item)
|
self.worlds[item.player].collect(ret, item)
|
||||||
@@ -440,7 +351,7 @@ class MultiWorld():
|
|||||||
subworld = self.worlds[player]
|
subworld = self.worlds[player]
|
||||||
for item in subworld.get_pre_fill_items():
|
for item in subworld.get_pre_fill_items():
|
||||||
subworld.collect(ret, item)
|
subworld.collect(ret, item)
|
||||||
ret.sweep_for_advancements()
|
ret.sweep_for_events()
|
||||||
|
|
||||||
if use_cache:
|
if use_cache:
|
||||||
self._all_state = ret
|
self._all_state = ret
|
||||||
@@ -449,7 +360,7 @@ class MultiWorld():
|
|||||||
def get_items(self) -> List[Item]:
|
def get_items(self) -> List[Item]:
|
||||||
return [loc.item for loc in self.get_filled_locations()] + self.itempool
|
return [loc.item for loc in self.get_filled_locations()] + self.itempool
|
||||||
|
|
||||||
def find_item_locations(self, item: str, player: int, resolve_group_locations: bool = False) -> List[Location]:
|
def find_item_locations(self, item, player: int, resolve_group_locations: bool = False) -> List[Location]:
|
||||||
if resolve_group_locations:
|
if resolve_group_locations:
|
||||||
player_groups = self.get_player_groups(player)
|
player_groups = self.get_player_groups(player)
|
||||||
return [location for location in self.get_locations() if
|
return [location for location in self.get_locations() if
|
||||||
@@ -458,7 +369,7 @@ class MultiWorld():
|
|||||||
return [location for location in self.get_locations() if
|
return [location for location in self.get_locations() if
|
||||||
location.item and location.item.name == item and location.item.player == player]
|
location.item and location.item.name == item and location.item.player == player]
|
||||||
|
|
||||||
def find_item(self, item: str, player: int) -> Location:
|
def find_item(self, item, player: int) -> Location:
|
||||||
return next(location for location in self.get_locations() if
|
return next(location for location in self.get_locations() if
|
||||||
location.item and location.item.name == item and location.item.player == player)
|
location.item and location.item.name == item and location.item.player == player)
|
||||||
|
|
||||||
@@ -551,9 +462,9 @@ class MultiWorld():
|
|||||||
return True
|
return True
|
||||||
state = starting_state.copy()
|
state = starting_state.copy()
|
||||||
else:
|
else:
|
||||||
state = CollectionState(self)
|
if self.has_beaten_game(self.state):
|
||||||
if self.has_beaten_game(state):
|
|
||||||
return True
|
return True
|
||||||
|
state = CollectionState(self)
|
||||||
prog_locations = {location for location in self.get_locations() if location.item
|
prog_locations = {location for location in self.get_locations() if location.item
|
||||||
and location.item.advancement and location not in state.locations_checked}
|
and location.item.advancement and location not in state.locations_checked}
|
||||||
|
|
||||||
@@ -605,49 +516,6 @@ class MultiWorld():
|
|||||||
state.collect(location.item, True, location)
|
state.collect(location.item, True, location)
|
||||||
locations -= sphere
|
locations -= sphere
|
||||||
|
|
||||||
def get_sendable_spheres(self) -> Iterator[Set[Location]]:
|
|
||||||
"""
|
|
||||||
yields a set of multiserver sendable locations (location.item.code: int) for each logical sphere
|
|
||||||
|
|
||||||
If there are unreachable locations, the last sphere of reachable locations is followed by an empty set,
|
|
||||||
and then a set of all of the unreachable locations.
|
|
||||||
"""
|
|
||||||
state = CollectionState(self)
|
|
||||||
locations: Set[Location] = set()
|
|
||||||
events: Set[Location] = set()
|
|
||||||
for location in self.get_filled_locations():
|
|
||||||
if type(location.item.code) is int:
|
|
||||||
locations.add(location)
|
|
||||||
else:
|
|
||||||
events.add(location)
|
|
||||||
|
|
||||||
while locations:
|
|
||||||
sphere: Set[Location] = set()
|
|
||||||
|
|
||||||
# cull events out
|
|
||||||
done_events: Set[Union[Location, None]] = {None}
|
|
||||||
while done_events:
|
|
||||||
done_events = set()
|
|
||||||
for event in events:
|
|
||||||
if event.can_reach(state):
|
|
||||||
state.collect(event.item, True, event)
|
|
||||||
done_events.add(event)
|
|
||||||
events -= done_events
|
|
||||||
|
|
||||||
for location in locations:
|
|
||||||
if location.can_reach(state):
|
|
||||||
sphere.add(location)
|
|
||||||
|
|
||||||
yield sphere
|
|
||||||
if not sphere:
|
|
||||||
if locations:
|
|
||||||
yield locations # unreachable locations
|
|
||||||
break
|
|
||||||
|
|
||||||
for location in sphere:
|
|
||||||
state.collect(location.item, True, location)
|
|
||||||
locations -= sphere
|
|
||||||
|
|
||||||
def fulfills_accessibility(self, state: Optional[CollectionState] = None):
|
def fulfills_accessibility(self, state: Optional[CollectionState] = None):
|
||||||
"""Check if accessibility rules are fulfilled with current or supplied state."""
|
"""Check if accessibility rules are fulfilled with current or supplied state."""
|
||||||
if not state:
|
if not state:
|
||||||
@@ -655,21 +523,26 @@ class MultiWorld():
|
|||||||
players: Dict[str, Set[int]] = {
|
players: Dict[str, Set[int]] = {
|
||||||
"minimal": set(),
|
"minimal": set(),
|
||||||
"items": set(),
|
"items": set(),
|
||||||
"full": set()
|
"locations": set()
|
||||||
}
|
}
|
||||||
for player, world in self.worlds.items():
|
for player, access in self.accessibility.items():
|
||||||
players[world.options.accessibility.current_key].add(player)
|
players[access.current_key].add(player)
|
||||||
|
|
||||||
beatable_fulfilled = False
|
beatable_fulfilled = False
|
||||||
|
|
||||||
def location_condition(location: Location) -> bool:
|
def location_condition(location: Location):
|
||||||
"""Determine if this location has to be accessible, location is already filtered by location_relevant"""
|
"""Determine if this location has to be accessible, location is already filtered by location_relevant"""
|
||||||
return location.player in players["full"] or \
|
if location.player in players["locations"] or (location.item and location.item.player not in
|
||||||
(location.item and location.item.player not in players["minimal"])
|
players["minimal"]):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
def location_relevant(location: Location) -> bool:
|
def location_relevant(location: Location):
|
||||||
"""Determine if this location is relevant to sweep."""
|
"""Determine if this location is relevant to sweep."""
|
||||||
return location.player in players["full"] or location.advancement
|
if location.progress_type != LocationProgressType.EXCLUDED \
|
||||||
|
and (location.player in players["locations"] or location.advancement):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
def all_done() -> bool:
|
def all_done() -> bool:
|
||||||
"""Check if all access rules are fulfilled"""
|
"""Check if all access rules are fulfilled"""
|
||||||
@@ -714,24 +587,22 @@ class CollectionState():
|
|||||||
multiworld: MultiWorld
|
multiworld: MultiWorld
|
||||||
reachable_regions: Dict[int, Set[Region]]
|
reachable_regions: Dict[int, Set[Region]]
|
||||||
blocked_connections: Dict[int, Set[Entrance]]
|
blocked_connections: Dict[int, Set[Entrance]]
|
||||||
advancements: Set[Location]
|
events: Set[Location]
|
||||||
path: Dict[Union[Region, Entrance], PathValue]
|
path: Dict[Union[Region, Entrance], PathValue]
|
||||||
locations_checked: Set[Location]
|
locations_checked: Set[Location]
|
||||||
stale: Dict[int, bool]
|
stale: Dict[int, bool]
|
||||||
allow_partial_entrances: bool
|
|
||||||
additional_init_functions: List[Callable[[CollectionState, MultiWorld], None]] = []
|
additional_init_functions: List[Callable[[CollectionState, MultiWorld], None]] = []
|
||||||
additional_copy_functions: List[Callable[[CollectionState, CollectionState], CollectionState]] = []
|
additional_copy_functions: List[Callable[[CollectionState, CollectionState], CollectionState]] = []
|
||||||
|
|
||||||
def __init__(self, parent: MultiWorld, allow_partial_entrances: bool = False):
|
def __init__(self, parent: MultiWorld):
|
||||||
self.prog_items = {player: Counter() for player in parent.get_all_ids()}
|
self.prog_items = {player: Counter() for player in parent.get_all_ids()}
|
||||||
self.multiworld = parent
|
self.multiworld = parent
|
||||||
self.reachable_regions = {player: set() for player in parent.get_all_ids()}
|
self.reachable_regions = {player: set() for player in parent.get_all_ids()}
|
||||||
self.blocked_connections = {player: set() for player in parent.get_all_ids()}
|
self.blocked_connections = {player: set() for player in parent.get_all_ids()}
|
||||||
self.advancements = set()
|
self.events = set()
|
||||||
self.path = {}
|
self.path = {}
|
||||||
self.locations_checked = set()
|
self.locations_checked = set()
|
||||||
self.stale = {player: True for player in parent.get_all_ids()}
|
self.stale = {player: True for player in parent.get_all_ids()}
|
||||||
self.allow_partial_entrances = allow_partial_entrances
|
|
||||||
for function in self.additional_init_functions:
|
for function in self.additional_init_functions:
|
||||||
function(self, parent)
|
function(self, parent)
|
||||||
for items in parent.precollected_items.values():
|
for items in parent.precollected_items.values():
|
||||||
@@ -740,25 +611,17 @@ class CollectionState():
|
|||||||
|
|
||||||
def update_reachable_regions(self, player: int):
|
def update_reachable_regions(self, player: int):
|
||||||
self.stale[player] = False
|
self.stale[player] = False
|
||||||
world: AutoWorld.World = self.multiworld.worlds[player]
|
|
||||||
reachable_regions = self.reachable_regions[player]
|
reachable_regions = self.reachable_regions[player]
|
||||||
|
blocked_connections = self.blocked_connections[player]
|
||||||
queue = deque(self.blocked_connections[player])
|
queue = deque(self.blocked_connections[player])
|
||||||
start: Region = world.get_region(world.origin_region_name)
|
start = self.multiworld.get_region("Menu", player)
|
||||||
|
|
||||||
# init on first call - this can't be done on construction since the regions don't exist yet
|
# init on first call - this can't be done on construction since the regions don't exist yet
|
||||||
if start not in reachable_regions:
|
if start not in reachable_regions:
|
||||||
reachable_regions.add(start)
|
reachable_regions.add(start)
|
||||||
self.blocked_connections[player].update(start.exits)
|
blocked_connections.update(start.exits)
|
||||||
queue.extend(start.exits)
|
queue.extend(start.exits)
|
||||||
|
|
||||||
if world.explicit_indirect_conditions:
|
|
||||||
self._update_reachable_regions_explicit_indirect_conditions(player, queue)
|
|
||||||
else:
|
|
||||||
self._update_reachable_regions_auto_indirect_conditions(player, queue)
|
|
||||||
|
|
||||||
def _update_reachable_regions_explicit_indirect_conditions(self, player: int, queue: deque):
|
|
||||||
reachable_regions = self.reachable_regions[player]
|
|
||||||
blocked_connections = self.blocked_connections[player]
|
|
||||||
# run BFS on all connections, and keep track of those blocked by missing items
|
# run BFS on all connections, and keep track of those blocked by missing items
|
||||||
while queue:
|
while queue:
|
||||||
connection = queue.popleft()
|
connection = queue.popleft()
|
||||||
@@ -766,9 +629,7 @@ class CollectionState():
|
|||||||
if new_region in reachable_regions:
|
if new_region in reachable_regions:
|
||||||
blocked_connections.remove(connection)
|
blocked_connections.remove(connection)
|
||||||
elif connection.can_reach(self):
|
elif connection.can_reach(self):
|
||||||
if self.allow_partial_entrances and not new_region:
|
assert new_region, f"tried to search through an Entrance \"{connection}\" with no Region"
|
||||||
continue
|
|
||||||
assert new_region, f"tried to search through an Entrance \"{connection}\" with no connected Region"
|
|
||||||
reachable_regions.add(new_region)
|
reachable_regions.add(new_region)
|
||||||
blocked_connections.remove(connection)
|
blocked_connections.remove(connection)
|
||||||
blocked_connections.update(new_region.exits)
|
blocked_connections.update(new_region.exits)
|
||||||
@@ -780,42 +641,16 @@ class CollectionState():
|
|||||||
if new_entrance in blocked_connections and new_entrance not in queue:
|
if new_entrance in blocked_connections and new_entrance not in queue:
|
||||||
queue.append(new_entrance)
|
queue.append(new_entrance)
|
||||||
|
|
||||||
def _update_reachable_regions_auto_indirect_conditions(self, player: int, queue: deque):
|
|
||||||
reachable_regions = self.reachable_regions[player]
|
|
||||||
blocked_connections = self.blocked_connections[player]
|
|
||||||
new_connection: bool = True
|
|
||||||
# run BFS on all connections, and keep track of those blocked by missing items
|
|
||||||
while new_connection:
|
|
||||||
new_connection = False
|
|
||||||
while queue:
|
|
||||||
connection = queue.popleft()
|
|
||||||
new_region = connection.connected_region
|
|
||||||
if new_region in reachable_regions:
|
|
||||||
blocked_connections.remove(connection)
|
|
||||||
elif connection.can_reach(self):
|
|
||||||
if self.allow_partial_entrances and not new_region:
|
|
||||||
continue
|
|
||||||
assert new_region, f"tried to search through an Entrance \"{connection}\" with no connected Region"
|
|
||||||
reachable_regions.add(new_region)
|
|
||||||
blocked_connections.remove(connection)
|
|
||||||
blocked_connections.update(new_region.exits)
|
|
||||||
queue.extend(new_region.exits)
|
|
||||||
self.path[new_region] = (new_region.name, self.path.get(connection, None))
|
|
||||||
new_connection = True
|
|
||||||
# sweep for indirect connections, mostly Entrance.can_reach(unrelated_Region)
|
|
||||||
queue.extend(blocked_connections)
|
|
||||||
|
|
||||||
def copy(self) -> CollectionState:
|
def copy(self) -> CollectionState:
|
||||||
ret = CollectionState(self.multiworld)
|
ret = CollectionState(self.multiworld)
|
||||||
ret.prog_items = {player: counter.copy() for player, counter in self.prog_items.items()}
|
ret.prog_items = copy.deepcopy(self.prog_items)
|
||||||
ret.reachable_regions = {player: region_set.copy() for player, region_set in
|
ret.reachable_regions = {player: copy.copy(self.reachable_regions[player]) for player in
|
||||||
self.reachable_regions.items()}
|
self.reachable_regions}
|
||||||
ret.blocked_connections = {player: entrance_set.copy() for player, entrance_set in
|
ret.blocked_connections = {player: copy.copy(self.blocked_connections[player]) for player in
|
||||||
self.blocked_connections.items()}
|
self.blocked_connections}
|
||||||
ret.advancements = self.advancements.copy()
|
ret.events = copy.copy(self.events)
|
||||||
ret.path = self.path.copy()
|
ret.path = copy.copy(self.path)
|
||||||
ret.locations_checked = self.locations_checked.copy()
|
ret.locations_checked = copy.copy(self.locations_checked)
|
||||||
ret.allow_partial_entrances = self.allow_partial_entrances
|
|
||||||
for function in self.additional_copy_functions:
|
for function in self.additional_copy_functions:
|
||||||
ret = function(self, ret)
|
ret = function(self, ret)
|
||||||
return ret
|
return ret
|
||||||
@@ -845,64 +680,40 @@ class CollectionState():
|
|||||||
def can_reach_region(self, spot: str, player: int) -> bool:
|
def can_reach_region(self, spot: str, player: int) -> bool:
|
||||||
return self.multiworld.get_region(spot, player).can_reach(self)
|
return self.multiworld.get_region(spot, player).can_reach(self)
|
||||||
|
|
||||||
def sweep_for_events(self, locations: Optional[Iterable[Location]] = None) -> None:
|
def sweep_for_events(self, key_only: bool = False, locations: Optional[Iterable[Location]] = None) -> None:
|
||||||
Utils.deprecate("sweep_for_events has been renamed to sweep_for_advancements. The functionality is the same. "
|
|
||||||
"Please switch over to sweep_for_advancements.")
|
|
||||||
return self.sweep_for_advancements(locations)
|
|
||||||
|
|
||||||
def sweep_for_advancements(self, locations: Optional[Iterable[Location]] = None) -> None:
|
|
||||||
if locations is None:
|
if locations is None:
|
||||||
locations = self.multiworld.get_filled_locations()
|
locations = self.multiworld.get_filled_locations()
|
||||||
reachable_advancements = True
|
reachable_events = True
|
||||||
# since the loop has a good chance to run more than once, only filter the advancements once
|
# since the loop has a good chance to run more than once, only filter the events once
|
||||||
locations = {location for location in locations if location.advancement and location not in self.advancements}
|
locations = {location for location in locations if location.advancement and location not in self.events and
|
||||||
|
not key_only or getattr(location.item, "locked_dungeon_item", False)}
|
||||||
while reachable_advancements:
|
while reachable_events:
|
||||||
reachable_advancements = {location for location in locations if location.can_reach(self)}
|
reachable_events = {location for location in locations if location.can_reach(self)}
|
||||||
locations -= reachable_advancements
|
locations -= reachable_events
|
||||||
for advancement in reachable_advancements:
|
for event in reachable_events:
|
||||||
self.advancements.add(advancement)
|
self.events.add(event)
|
||||||
assert isinstance(advancement.item, Item), "tried to collect Event with no Item"
|
assert isinstance(event.item, Item), "tried to collect Event with no Item"
|
||||||
self.collect(advancement.item, True, advancement)
|
self.collect(event.item, True, event)
|
||||||
|
|
||||||
# item name related
|
# item name related
|
||||||
def has(self, item: str, player: int, count: int = 1) -> bool:
|
def has(self, item: str, player: int, count: int = 1) -> bool:
|
||||||
return self.prog_items[player][item] >= count
|
return self.prog_items[player][item] >= count
|
||||||
|
|
||||||
# for loops are specifically used in all/any/count methods, instead of all()/any()/sum(), to avoid the overhead of
|
|
||||||
# creating and iterating generator instances. In `return all(player_prog_items[item] for item in items)`, the
|
|
||||||
# argument to all() would be a new generator instance, for example.
|
|
||||||
def has_all(self, items: Iterable[str], player: int) -> bool:
|
def has_all(self, items: Iterable[str], player: int) -> bool:
|
||||||
"""Returns True if each item name of items is in state at least once."""
|
"""Returns True if each item name of items is in state at least once."""
|
||||||
player_prog_items = self.prog_items[player]
|
return all(self.prog_items[player][item] for item in items)
|
||||||
for item in items:
|
|
||||||
if not player_prog_items[item]:
|
|
||||||
return False
|
|
||||||
return True
|
|
||||||
|
|
||||||
def has_any(self, items: Iterable[str], player: int) -> bool:
|
def has_any(self, items: Iterable[str], player: int) -> bool:
|
||||||
"""Returns True if at least one item name of items is in state at least once."""
|
"""Returns True if at least one item name of items is in state at least once."""
|
||||||
player_prog_items = self.prog_items[player]
|
return any(self.prog_items[player][item] for item in items)
|
||||||
for item in items:
|
|
||||||
if player_prog_items[item]:
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
def has_all_counts(self, item_counts: Mapping[str, int], player: int) -> bool:
|
def has_all_counts(self, item_counts: Mapping[str, int], player: int) -> bool:
|
||||||
"""Returns True if each item name is in the state at least as many times as specified."""
|
"""Returns True if each item name is in the state at least as many times as specified."""
|
||||||
player_prog_items = self.prog_items[player]
|
return all(self.prog_items[player][item] >= count for item, count in item_counts.items())
|
||||||
for item, count in item_counts.items():
|
|
||||||
if player_prog_items[item] < count:
|
|
||||||
return False
|
|
||||||
return True
|
|
||||||
|
|
||||||
def has_any_count(self, item_counts: Mapping[str, int], player: int) -> bool:
|
def has_any_count(self, item_counts: Mapping[str, int], player: int) -> bool:
|
||||||
"""Returns True if at least one item name is in the state at least as many times as specified."""
|
"""Returns True if at least one item name is in the state at least as many times as specified."""
|
||||||
player_prog_items = self.prog_items[player]
|
return any(self.prog_items[player][item] >= count for item, count in item_counts.items())
|
||||||
for item, count in item_counts.items():
|
|
||||||
if player_prog_items[item] >= count:
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
def count(self, item: str, player: int) -> int:
|
def count(self, item: str, player: int) -> int:
|
||||||
return self.prog_items[player][item]
|
return self.prog_items[player][item]
|
||||||
@@ -916,8 +727,8 @@ class CollectionState():
|
|||||||
if found >= count:
|
if found >= count:
|
||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def has_from_list_unique(self, items: Iterable[str], player: int, count: int) -> bool:
|
def has_from_list_exclusive(self, items: Iterable[str], player: int, count: int) -> bool:
|
||||||
"""Returns True if the state contains at least `count` items matching any of the item names from a list.
|
"""Returns True if the state contains at least `count` items matching any of the item names from a list.
|
||||||
Ignores duplicates of the same item."""
|
Ignores duplicates of the same item."""
|
||||||
found: int = 0
|
found: int = 0
|
||||||
@@ -930,20 +741,11 @@ class CollectionState():
|
|||||||
|
|
||||||
def count_from_list(self, items: Iterable[str], player: int) -> int:
|
def count_from_list(self, items: Iterable[str], player: int) -> int:
|
||||||
"""Returns the cumulative count of items from a list present in state."""
|
"""Returns the cumulative count of items from a list present in state."""
|
||||||
player_prog_items = self.prog_items[player]
|
return sum(self.prog_items[player][item_name] for item_name in items)
|
||||||
total = 0
|
|
||||||
for item_name in items:
|
def count_from_list_exclusive(self, items: Iterable[str], player: int) -> int:
|
||||||
total += player_prog_items[item_name]
|
|
||||||
return total
|
|
||||||
|
|
||||||
def count_from_list_unique(self, items: Iterable[str], player: int) -> int:
|
|
||||||
"""Returns the cumulative count of items from a list present in state. Ignores duplicates of the same item."""
|
"""Returns the cumulative count of items from a list present in state. Ignores duplicates of the same item."""
|
||||||
player_prog_items = self.prog_items[player]
|
return sum(self.prog_items[player][item_name] > 0 for item_name in items)
|
||||||
total = 0
|
|
||||||
for item_name in items:
|
|
||||||
if player_prog_items[item_name] > 0:
|
|
||||||
total += 1
|
|
||||||
return total
|
|
||||||
|
|
||||||
# item name group related
|
# item name group related
|
||||||
def has_group(self, item_name_group: str, player: int, count: int = 1) -> bool:
|
def has_group(self, item_name_group: str, player: int, count: int = 1) -> bool:
|
||||||
@@ -956,7 +758,7 @@ class CollectionState():
|
|||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def has_group_unique(self, item_name_group: str, player: int, count: int = 1) -> bool:
|
def has_group_exclusive(self, item_name_group: str, player: int, count: int = 1) -> bool:
|
||||||
"""Returns True if the state contains at least `count` items present in a specified item group.
|
"""Returns True if the state contains at least `count` items present in a specified item group.
|
||||||
Ignores duplicates of the same item.
|
Ignores duplicates of the same item.
|
||||||
"""
|
"""
|
||||||
@@ -976,7 +778,7 @@ class CollectionState():
|
|||||||
for item_name in self.multiworld.worlds[player].item_name_groups[item_name_group]
|
for item_name in self.multiworld.worlds[player].item_name_groups[item_name_group]
|
||||||
)
|
)
|
||||||
|
|
||||||
def count_group_unique(self, item_name_group: str, player: int) -> int:
|
def count_group_exclusive(self, item_name_group: str, player: int) -> int:
|
||||||
"""Returns the cumulative count of items from an item group present in state.
|
"""Returns the cumulative count of items from an item group present in state.
|
||||||
Ignores duplicates of the same item."""
|
Ignores duplicates of the same item."""
|
||||||
player_prog_items = self.prog_items[player]
|
player_prog_items = self.prog_items[player]
|
||||||
@@ -986,16 +788,20 @@ class CollectionState():
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Item related
|
# Item related
|
||||||
def collect(self, item: Item, prevent_sweep: bool = False, location: Optional[Location] = None) -> bool:
|
def collect(self, item: Item, event: bool = False, location: Optional[Location] = None) -> bool:
|
||||||
if location:
|
if location:
|
||||||
self.locations_checked.add(location)
|
self.locations_checked.add(location)
|
||||||
|
|
||||||
changed = self.multiworld.worlds[item.player].collect(self, item)
|
changed = self.multiworld.worlds[item.player].collect(self, item)
|
||||||
|
|
||||||
|
if not changed and event:
|
||||||
|
self.prog_items[item.player][item.name] += 1
|
||||||
|
changed = True
|
||||||
|
|
||||||
self.stale[item.player] = True
|
self.stale[item.player] = True
|
||||||
|
|
||||||
if changed and not prevent_sweep:
|
if changed and not event:
|
||||||
self.sweep_for_advancements()
|
self.sweep_for_events()
|
||||||
|
|
||||||
return changed
|
return changed
|
||||||
|
|
||||||
@@ -1008,11 +814,6 @@ class CollectionState():
|
|||||||
self.stale[item.player] = True
|
self.stale[item.player] = True
|
||||||
|
|
||||||
|
|
||||||
class EntranceType(IntEnum):
|
|
||||||
ONE_WAY = 1
|
|
||||||
TWO_WAY = 2
|
|
||||||
|
|
||||||
|
|
||||||
class Entrance:
|
class Entrance:
|
||||||
access_rule: Callable[[CollectionState], bool] = staticmethod(lambda state: True)
|
access_rule: Callable[[CollectionState], bool] = staticmethod(lambda state: True)
|
||||||
hide_path: bool = False
|
hide_path: bool = False
|
||||||
@@ -1020,24 +821,18 @@ class Entrance:
|
|||||||
name: str
|
name: str
|
||||||
parent_region: Optional[Region]
|
parent_region: Optional[Region]
|
||||||
connected_region: Optional[Region] = None
|
connected_region: Optional[Region] = None
|
||||||
randomization_group: int
|
|
||||||
randomization_type: EntranceType
|
|
||||||
# LttP specific, TODO: should make a LttPEntrance
|
# LttP specific, TODO: should make a LttPEntrance
|
||||||
addresses = None
|
addresses = None
|
||||||
target = None
|
target = None
|
||||||
|
|
||||||
def __init__(self, player: int, name: str = "", parent: Optional[Region] = None,
|
def __init__(self, player: int, name: str = '', parent: Region = None):
|
||||||
randomization_group: int = 0, randomization_type: EntranceType = EntranceType.ONE_WAY) -> None:
|
|
||||||
self.name = name
|
self.name = name
|
||||||
self.parent_region = parent
|
self.parent_region = parent
|
||||||
self.player = player
|
self.player = player
|
||||||
self.randomization_group = randomization_group
|
|
||||||
self.randomization_type = randomization_type
|
|
||||||
|
|
||||||
def can_reach(self, state: CollectionState) -> bool:
|
def can_reach(self, state: CollectionState) -> bool:
|
||||||
assert self.parent_region, f"called can_reach on an Entrance \"{self}\" with no parent_region"
|
|
||||||
if self.parent_region.can_reach(state) and self.access_rule(state):
|
if self.parent_region.can_reach(state) and self.access_rule(state):
|
||||||
if not self.hide_path and self not in state.path:
|
if not self.hide_path and not self in state.path:
|
||||||
state.path[self] = (self.name, state.path.get(self.parent_region, (self.parent_region.name, None)))
|
state.path[self] = (self.name, state.path.get(self.parent_region, (self.parent_region.name, None)))
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -1049,33 +844,10 @@ class Entrance:
|
|||||||
self.addresses = addresses
|
self.addresses = addresses
|
||||||
region.entrances.append(self)
|
region.entrances.append(self)
|
||||||
|
|
||||||
def is_valid_source_transition(self, er_state: "ERPlacementState") -> bool:
|
|
||||||
"""
|
|
||||||
Determines whether this is a valid source transition, that is, whether the entrance
|
|
||||||
randomizer is allowed to pair it to place any other regions. By default, this is the
|
|
||||||
same as a reachability check, but can be modified by Entrance implementations to add
|
|
||||||
other restrictions based on the placement state.
|
|
||||||
|
|
||||||
:param er_state: The current (partial) state of the ongoing entrance randomization
|
|
||||||
"""
|
|
||||||
return self.can_reach(er_state.collection_state)
|
|
||||||
|
|
||||||
def can_connect_to(self, other: Entrance, dead_end: bool, er_state: "ERPlacementState") -> bool:
|
|
||||||
"""
|
|
||||||
Determines whether a given Entrance is a valid target transition, that is, whether
|
|
||||||
the entrance randomizer is allowed to pair this Entrance to that Entrance. By default,
|
|
||||||
only allows connection between entrances of the same type (one ways only go to one ways,
|
|
||||||
two ways always go to two ways) and prevents connecting an exit to itself in coupled mode.
|
|
||||||
|
|
||||||
:param other: The proposed Entrance to connect to
|
|
||||||
:param dead_end: Whether the other entrance considered a dead end by Entrance randomization
|
|
||||||
:param er_state: The current (partial) state of the ongoing entrance randomization
|
|
||||||
"""
|
|
||||||
# the implementation of coupled causes issues for self-loops since the reverse entrance will be the
|
|
||||||
# same as the forward entrance. In uncoupled they are ok.
|
|
||||||
return self.randomization_type == other.randomization_type and (not er_state.coupled or self.name != other.name)
|
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
|
return self.__str__()
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
multiworld = self.parent_region.multiworld if self.parent_region else None
|
multiworld = self.parent_region.multiworld if self.parent_region else None
|
||||||
return multiworld.get_name_string_for_object(self) if multiworld else f'{self.name} (Player {self.player})'
|
return multiworld.get_name_string_for_object(self) if multiworld else f'{self.name} (Player {self.player})'
|
||||||
|
|
||||||
@@ -1088,7 +860,7 @@ class Region:
|
|||||||
entrances: List[Entrance]
|
entrances: List[Entrance]
|
||||||
exits: List[Entrance]
|
exits: List[Entrance]
|
||||||
locations: List[Location]
|
locations: List[Location]
|
||||||
entrance_type: ClassVar[type[Entrance]] = Entrance
|
entrance_type: ClassVar[Type[Entrance]] = Entrance
|
||||||
|
|
||||||
class Register(MutableSequence):
|
class Register(MutableSequence):
|
||||||
region_manager: MultiWorld.RegionManager
|
region_manager: MultiWorld.RegionManager
|
||||||
@@ -1188,7 +960,7 @@ class Region:
|
|||||||
return entrance.parent_region.get_connecting_entrance(is_main_entrance)
|
return entrance.parent_region.get_connecting_entrance(is_main_entrance)
|
||||||
|
|
||||||
def add_locations(self, locations: Dict[str, Optional[int]],
|
def add_locations(self, locations: Dict[str, Optional[int]],
|
||||||
location_type: Optional[type[Location]] = None) -> None:
|
location_type: Optional[Type[Location]] = None) -> None:
|
||||||
"""
|
"""
|
||||||
Adds locations to the Region object, where location_type is your Location class and locations is a dict of
|
Adds locations to the Region object, where location_type is your Location class and locations is a dict of
|
||||||
location names to address.
|
location names to address.
|
||||||
@@ -1201,7 +973,7 @@ class Region:
|
|||||||
self.locations.append(location_type(self.player, location, address, self))
|
self.locations.append(location_type(self.player, location, address, self))
|
||||||
|
|
||||||
def connect(self, connecting_region: Region, name: Optional[str] = None,
|
def connect(self, connecting_region: Region, name: Optional[str] = None,
|
||||||
rule: Optional[Callable[[CollectionState], bool]] = None) -> Entrance:
|
rule: Optional[Callable[[CollectionState], bool]] = None) -> entrance_type:
|
||||||
"""
|
"""
|
||||||
Connects this Region to another Region, placing the provided rule on the connection.
|
Connects this Region to another Region, placing the provided rule on the connection.
|
||||||
|
|
||||||
@@ -1224,18 +996,8 @@ class Region:
|
|||||||
self.exits.append(exit_)
|
self.exits.append(exit_)
|
||||||
return exit_
|
return exit_
|
||||||
|
|
||||||
def create_er_target(self, name: str) -> Entrance:
|
|
||||||
"""
|
|
||||||
Creates and returns an Entrance object as an entrance to this region
|
|
||||||
|
|
||||||
:param name: name of the Entrance being created
|
|
||||||
"""
|
|
||||||
entrance = self.entrance_type(self.player, name)
|
|
||||||
entrance.connect(self)
|
|
||||||
return entrance
|
|
||||||
|
|
||||||
def add_exits(self, exits: Union[Iterable[str], Dict[str, Optional[str]]],
|
def add_exits(self, exits: Union[Iterable[str], Dict[str, Optional[str]]],
|
||||||
rules: Dict[str, Callable[[CollectionState], bool]] = None) -> List[Entrance]:
|
rules: Dict[str, Callable[[CollectionState], bool]] = None) -> None:
|
||||||
"""
|
"""
|
||||||
Connects current region to regions in exit dictionary. Passed region names must exist first.
|
Connects current region to regions in exit dictionary. Passed region names must exist first.
|
||||||
|
|
||||||
@@ -1245,16 +1007,15 @@ class Region:
|
|||||||
"""
|
"""
|
||||||
if not isinstance(exits, Dict):
|
if not isinstance(exits, Dict):
|
||||||
exits = dict.fromkeys(exits)
|
exits = dict.fromkeys(exits)
|
||||||
return [
|
for connecting_region, name in exits.items():
|
||||||
self.connect(
|
self.connect(self.multiworld.get_region(connecting_region, self.player),
|
||||||
self.multiworld.get_region(connecting_region, self.player),
|
name,
|
||||||
name,
|
rules[connecting_region] if rules and connecting_region in rules else None)
|
||||||
rules[connecting_region] if rules and connecting_region in rules else None,
|
|
||||||
)
|
|
||||||
for connecting_region, name in exits.items()
|
|
||||||
]
|
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
|
return self.__str__()
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
return self.multiworld.get_name_string_for_object(self) if self.multiworld else f'{self.name} (Player {self.player})'
|
return self.multiworld.get_name_string_for_object(self) if self.multiworld else f'{self.name} (Player {self.player})'
|
||||||
|
|
||||||
|
|
||||||
@@ -1273,9 +1034,9 @@ class Location:
|
|||||||
locked: bool = False
|
locked: bool = False
|
||||||
show_in_spoiler: bool = True
|
show_in_spoiler: bool = True
|
||||||
progress_type: LocationProgressType = LocationProgressType.DEFAULT
|
progress_type: LocationProgressType = LocationProgressType.DEFAULT
|
||||||
always_allow: Callable[[CollectionState, Item], bool] = staticmethod(lambda state, item: False)
|
always_allow = staticmethod(lambda state, item: False)
|
||||||
access_rule: Callable[[CollectionState], bool] = staticmethod(lambda state: True)
|
access_rule: Callable[[CollectionState], bool] = staticmethod(lambda state: True)
|
||||||
item_rule: Callable[[Item], bool] = staticmethod(lambda item: True)
|
item_rule = staticmethod(lambda item: True)
|
||||||
item: Optional[Item] = None
|
item: Optional[Item] = None
|
||||||
|
|
||||||
def __init__(self, player: int, name: str = '', address: Optional[int] = None, parent: Optional[Region] = None):
|
def __init__(self, player: int, name: str = '', address: Optional[int] = None, parent: Optional[Region] = None):
|
||||||
@@ -1284,20 +1045,16 @@ class Location:
|
|||||||
self.address = address
|
self.address = address
|
||||||
self.parent_region = parent
|
self.parent_region = parent
|
||||||
|
|
||||||
def can_fill(self, state: CollectionState, item: Item, check_access: bool = True) -> bool:
|
def can_fill(self, state: CollectionState, item: Item, check_access=True) -> bool:
|
||||||
return ((
|
return ((self.always_allow(state, item) and item.name not in state.multiworld.worlds[item.player].options.non_local_items)
|
||||||
self.always_allow(state, item)
|
or ((self.progress_type != LocationProgressType.EXCLUDED or not (item.advancement or item.useful))
|
||||||
and item.name not in state.multiworld.worlds[item.player].options.non_local_items
|
and self.item_rule(item)
|
||||||
) or (
|
and (not check_access or self.can_reach(state))))
|
||||||
(self.progress_type != LocationProgressType.EXCLUDED or not (item.advancement or item.useful))
|
|
||||||
and self.item_rule(item)
|
|
||||||
and (not check_access or self.can_reach(state))
|
|
||||||
))
|
|
||||||
|
|
||||||
def can_reach(self, state: CollectionState) -> bool:
|
def can_reach(self, state: CollectionState) -> bool:
|
||||||
# Region.can_reach is just a cache lookup, so placing it first for faster abort on average
|
# self.access_rule computes faster on average, so placing it first for faster abort
|
||||||
assert self.parent_region, f"called can_reach on a Location \"{self}\" with no parent_region"
|
assert self.parent_region, "Can't reach location without region"
|
||||||
return self.parent_region.can_reach(state) and self.access_rule(state)
|
return self.access_rule(state) and self.parent_region.can_reach(state)
|
||||||
|
|
||||||
def place_locked_item(self, item: Item):
|
def place_locked_item(self, item: Item):
|
||||||
if self.item:
|
if self.item:
|
||||||
@@ -1307,6 +1064,9 @@ class Location:
|
|||||||
self.locked = True
|
self.locked = True
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
|
return self.__str__()
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
multiworld = self.parent_region.multiworld if self.parent_region and self.parent_region.multiworld else None
|
multiworld = self.parent_region.multiworld if self.parent_region and self.parent_region.multiworld else None
|
||||||
return multiworld.get_name_string_for_object(self) if multiworld else f'{self.name} (Player {self.player})'
|
return multiworld.get_name_string_for_object(self) if multiworld else f'{self.name} (Player {self.player})'
|
||||||
|
|
||||||
@@ -1328,7 +1088,7 @@ class Location:
|
|||||||
@property
|
@property
|
||||||
def native_item(self) -> bool:
|
def native_item(self) -> bool:
|
||||||
"""Returns True if the item in this location matches game."""
|
"""Returns True if the item in this location matches game."""
|
||||||
return self.item is not None and self.item.game == self.game
|
return self.item and self.item.game == self.game
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def hint_text(self) -> str:
|
def hint_text(self) -> str:
|
||||||
@@ -1336,26 +1096,13 @@ class Location:
|
|||||||
|
|
||||||
|
|
||||||
class ItemClassification(IntFlag):
|
class ItemClassification(IntFlag):
|
||||||
filler = 0b0000
|
filler = 0b0000 # aka trash, as in filler items like ammo, currency etc,
|
||||||
""" aka trash, as in filler items like ammo, currency etc """
|
progression = 0b0001 # Item that is logically relevant
|
||||||
|
useful = 0b0010 # Item that is generally quite useful, but not required for anything logical
|
||||||
progression = 0b0001
|
trap = 0b0100 # detrimental or entirely useless (nothing) item
|
||||||
""" Item that is logically relevant.
|
skip_balancing = 0b1000 # should technically never occur on its own
|
||||||
Protects this item from being placed on excluded or unreachable locations. """
|
# Item that is logically relevant, but progression balancing should not touch.
|
||||||
|
# Typically currency or other counted items.
|
||||||
useful = 0b0010
|
|
||||||
""" Item that is especially useful.
|
|
||||||
Protects this item from being placed on excluded or unreachable locations.
|
|
||||||
When combined with another flag like "progression", it means "an especially useful progression item". """
|
|
||||||
|
|
||||||
trap = 0b0100
|
|
||||||
""" Item that is detrimental in some way. """
|
|
||||||
|
|
||||||
skip_balancing = 0b1000
|
|
||||||
""" should technically never occur on its own
|
|
||||||
Item that is logically relevant, but progression balancing should not touch.
|
|
||||||
Typically currency or other counted items. """
|
|
||||||
|
|
||||||
progression_skip_balancing = 0b1001 # only progression gets balanced
|
progression_skip_balancing = 0b1001 # only progression gets balanced
|
||||||
|
|
||||||
def as_flag(self) -> int:
|
def as_flag(self) -> int:
|
||||||
@@ -1404,14 +1151,6 @@ class Item:
|
|||||||
def trap(self) -> bool:
|
def trap(self) -> bool:
|
||||||
return ItemClassification.trap in self.classification
|
return ItemClassification.trap in self.classification
|
||||||
|
|
||||||
@property
|
|
||||||
def filler(self) -> bool:
|
|
||||||
return not (self.advancement or self.useful or self.trap)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def excludable(self) -> bool:
|
|
||||||
return not (self.advancement or self.useful)
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def flags(self) -> int:
|
def flags(self) -> int:
|
||||||
return self.classification.as_flag()
|
return self.classification.as_flag()
|
||||||
@@ -1432,6 +1171,9 @@ class Item:
|
|||||||
return hash((self.name, self.player))
|
return hash((self.name, self.player))
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
def __repr__(self) -> str:
|
||||||
|
return self.__str__()
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
if self.location and self.location.parent_region and self.location.parent_region.multiworld:
|
if self.location and self.location.parent_region and self.location.parent_region.multiworld:
|
||||||
return self.location.parent_region.multiworld.get_name_string_for_object(self)
|
return self.location.parent_region.multiworld.get_name_string_for_object(self)
|
||||||
return f"{self.name} (Player {self.player})"
|
return f"{self.name} (Player {self.player})"
|
||||||
@@ -1509,9 +1251,9 @@ class Spoiler:
|
|||||||
|
|
||||||
# in the second phase, we cull each sphere such that the game is still beatable,
|
# in the second phase, we cull each sphere such that the game is still beatable,
|
||||||
# reducing each range of influence to the bare minimum required inside it
|
# reducing each range of influence to the bare minimum required inside it
|
||||||
restore_later: Dict[Location, Item] = {}
|
restore_later = {}
|
||||||
for num, sphere in reversed(tuple(enumerate(collection_spheres))):
|
for num, sphere in reversed(tuple(enumerate(collection_spheres))):
|
||||||
to_delete: Set[Location] = set()
|
to_delete = set()
|
||||||
for location in sphere:
|
for location in sphere:
|
||||||
# we remove the item at location and check if game is still beatable
|
# we remove the item at location and check if game is still beatable
|
||||||
logging.debug('Checking if %s (Player %d) is required to beat the game.', location.item.name,
|
logging.debug('Checking if %s (Player %d) is required to beat the game.', location.item.name,
|
||||||
@@ -1529,22 +1271,15 @@ class Spoiler:
|
|||||||
sphere -= to_delete
|
sphere -= to_delete
|
||||||
|
|
||||||
# second phase, sphere 0
|
# second phase, sphere 0
|
||||||
removed_precollected: List[Item] = []
|
removed_precollected = []
|
||||||
|
for item in (i for i in chain.from_iterable(multiworld.precollected_items.values()) if i.advancement):
|
||||||
for precollected_items in multiworld.precollected_items.values():
|
logging.debug('Checking if %s (Player %d) is required to beat the game.', item.name, item.player)
|
||||||
# The list of items is mutated by removing one item at a time to determine if each item is required to beat
|
multiworld.precollected_items[item.player].remove(item)
|
||||||
# the game, and re-adding that item if it was required, so a copy needs to be made before iterating.
|
multiworld.state.remove(item)
|
||||||
for item in precollected_items.copy():
|
if not multiworld.can_beat_game():
|
||||||
if not item.advancement:
|
multiworld.push_precollected(item)
|
||||||
continue
|
else:
|
||||||
logging.debug('Checking if %s (Player %d) is required to beat the game.', item.name, item.player)
|
removed_precollected.append(item)
|
||||||
precollected_items.remove(item)
|
|
||||||
multiworld.state.remove(item)
|
|
||||||
if not multiworld.can_beat_game():
|
|
||||||
# Add the item back into `precollected_items` and collect it into `multiworld.state`.
|
|
||||||
multiworld.push_precollected(item)
|
|
||||||
else:
|
|
||||||
removed_precollected.append(item)
|
|
||||||
|
|
||||||
# we are now down to just the required progress items in collection_spheres. Unfortunately
|
# we are now down to just the required progress items in collection_spheres. Unfortunately
|
||||||
# the previous pruning stage could potentially have made certain items dependant on others
|
# the previous pruning stage could potentially have made certain items dependant on others
|
||||||
@@ -1556,6 +1291,8 @@ class Spoiler:
|
|||||||
state = CollectionState(multiworld)
|
state = CollectionState(multiworld)
|
||||||
collection_spheres = []
|
collection_spheres = []
|
||||||
while required_locations:
|
while required_locations:
|
||||||
|
state.sweep_for_events(key_only=True)
|
||||||
|
|
||||||
sphere = set(filter(state.can_reach, required_locations))
|
sphere = set(filter(state.can_reach, required_locations))
|
||||||
|
|
||||||
for location in sphere:
|
for location in sphere:
|
||||||
@@ -1617,7 +1354,7 @@ class Spoiler:
|
|||||||
# Maybe move the big bomb over to the Event system instead?
|
# Maybe move the big bomb over to the Event system instead?
|
||||||
if any(exit_path == 'Pyramid Fairy' for path in self.paths.values()
|
if any(exit_path == 'Pyramid Fairy' for path in self.paths.values()
|
||||||
for (_, exit_path) in path):
|
for (_, exit_path) in path):
|
||||||
if multiworld.worlds[player].options.mode != 'inverted':
|
if multiworld.mode[player] != 'inverted':
|
||||||
self.paths[str(multiworld.get_region('Big Bomb Shop', player))] = \
|
self.paths[str(multiworld.get_region('Big Bomb Shop', player))] = \
|
||||||
get_path(state, multiworld.get_region('Big Bomb Shop', player))
|
get_path(state, multiworld.get_region('Big Bomb Shop', player))
|
||||||
else:
|
else:
|
||||||
@@ -1683,15 +1420,15 @@ class Spoiler:
|
|||||||
[f" {location}: {item}" for (location, item) in sphere.items()] if isinstance(sphere, dict) else
|
[f" {location}: {item}" for (location, item) in sphere.items()] if isinstance(sphere, dict) else
|
||||||
[f" {item}" for item in sphere])) for (sphere_nr, sphere) in self.playthrough.items()]))
|
[f" {item}" for item in sphere])) for (sphere_nr, sphere) in self.playthrough.items()]))
|
||||||
if self.unreachables:
|
if self.unreachables:
|
||||||
outfile.write('\n\nUnreachable Progression Items:\n\n')
|
outfile.write('\n\nUnreachable Items:\n\n')
|
||||||
outfile.write(
|
outfile.write(
|
||||||
'\n'.join(['%s: %s' % (unreachable.item, unreachable) for unreachable in self.unreachables]))
|
'\n'.join(['%s: %s' % (unreachable.item, unreachable) for unreachable in self.unreachables]))
|
||||||
|
|
||||||
if self.paths:
|
if self.paths:
|
||||||
outfile.write('\n\nPaths:\n\n')
|
outfile.write('\n\nPaths:\n\n')
|
||||||
path_listings: List[str] = []
|
path_listings = []
|
||||||
for location, path in sorted(self.paths.items()):
|
for location, path in sorted(self.paths.items()):
|
||||||
path_lines: List[str] = []
|
path_lines = []
|
||||||
for region, exit in path:
|
for region, exit in path:
|
||||||
if exit is not None:
|
if exit is not None:
|
||||||
path_lines.append("{} -> {}".format(region, exit))
|
path_lines.append("{} -> {}".format(region, exit))
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
259
CommonClient.py
@@ -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()
|
||||||
@@ -23,7 +21,7 @@ if __name__ == "__main__":
|
|||||||
|
|
||||||
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, ClientStatus, Permission, NetworkSlot,
|
||||||
RawJSONtoTextParser, add_json_text, add_json_location, add_json_item, JSONTypes, HintStatus, SlotType)
|
RawJSONtoTextParser, add_json_text, add_json_location, add_json_item, JSONTypes)
|
||||||
from Utils import Version, stream_input, async_start
|
from Utils import Version, stream_input, async_start
|
||||||
from worlds import network_data_package, AutoWorldRegister
|
from worlds import network_data_package, AutoWorldRegister
|
||||||
import os
|
import os
|
||||||
@@ -31,7 +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
|
||||||
@@ -176,96 +161,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
|
||||||
@@ -314,7 +231,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 +271,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 +325,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 +356,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 +366,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 +386,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 +393,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 +425,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 +437,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 +451,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,38 +473,32 @@ 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": [game_name]} for game_name in 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)
|
||||||
@@ -658,7 +536,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 +549,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 +558,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 +585,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.")
|
||||||
@@ -907,7 +768,6 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
|
|||||||
ctx.disconnected_intentionally = True
|
ctx.disconnected_intentionally = True
|
||||||
ctx.event_invalid_game()
|
ctx.event_invalid_game()
|
||||||
elif 'IncompatibleVersion' in errors:
|
elif 'IncompatibleVersion' in errors:
|
||||||
ctx.disconnected_intentionally = True
|
|
||||||
raise Exception('Server reported your client version as incompatible. '
|
raise Exception('Server reported your client version as incompatible. '
|
||||||
'This probably means you have to update.')
|
'This probably means you have to update.')
|
||||||
elif 'InvalidItemsHandling' in errors:
|
elif 'InvalidItemsHandling' in errors:
|
||||||
@@ -927,8 +787,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 +907,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 +916,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 +928,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,11 +955,16 @@ 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.init()
|
||||||
|
|
||||||
asyncio.run(main(args))
|
asyncio.run(main(args))
|
||||||
@@ -1136,4 +973,4 @@ def run_as_textclient(*args):
|
|||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
logging.getLogger().setLevel(logging.INFO) # force log-level to work around log level resetting to WARNING
|
logging.getLogger().setLevel(logging.INFO) # force log-level to work around log level resetting to WARNING
|
||||||
run_as_textclient(*sys.argv[1:]) # default value for parse_args
|
run_as_textclient()
|
||||||
|
|||||||
198
Fill.py
@@ -12,12 +12,7 @@ from worlds.generic.Rules import add_item_rule
|
|||||||
|
|
||||||
|
|
||||||
class FillError(RuntimeError):
|
class FillError(RuntimeError):
|
||||||
def __init__(self, *args: typing.Union[str, typing.Any], **kwargs) -> None:
|
pass
|
||||||
if "multiworld" in kwargs and isinstance(args[0], str):
|
|
||||||
placements = (args[0] + f"\nAll Placements:\n" +
|
|
||||||
f"{[(loc, loc.item) for loc in kwargs['multiworld'].get_filled_locations()]}")
|
|
||||||
args = (placements, *args[1:])
|
|
||||||
super().__init__(*args)
|
|
||||||
|
|
||||||
|
|
||||||
def _log_fill_progress(name: str, placed: int, total_items: int) -> None:
|
def _log_fill_progress(name: str, placed: int, total_items: int) -> None:
|
||||||
@@ -29,20 +24,19 @@ def sweep_from_pool(base_state: CollectionState, itempool: typing.Sequence[Item]
|
|||||||
new_state = base_state.copy()
|
new_state = base_state.copy()
|
||||||
for item in itempool:
|
for item in itempool:
|
||||||
new_state.collect(item, True)
|
new_state.collect(item, True)
|
||||||
new_state.sweep_for_advancements(locations=locations)
|
new_state.sweep_for_events(locations=locations)
|
||||||
return new_state
|
return new_state
|
||||||
|
|
||||||
|
|
||||||
def fill_restrictive(multiworld: MultiWorld, base_state: CollectionState, locations: typing.List[Location],
|
def fill_restrictive(multiworld: MultiWorld, base_state: CollectionState, locations: typing.List[Location],
|
||||||
item_pool: typing.List[Item], single_player_placement: bool = False, lock: bool = False,
|
item_pool: typing.List[Item], single_player_placement: bool = False, lock: bool = False,
|
||||||
swap: bool = True, on_place: typing.Optional[typing.Callable[[Location], None]] = None,
|
swap: bool = True, on_place: typing.Optional[typing.Callable[[Location], None]] = None,
|
||||||
allow_partial: bool = False, allow_excluded: bool = False, one_item_per_player: bool = True,
|
allow_partial: bool = False, allow_excluded: bool = False, name: str = "Unknown") -> None:
|
||||||
name: str = "Unknown") -> None:
|
|
||||||
"""
|
"""
|
||||||
:param multiworld: Multiworld to be filled.
|
:param multiworld: Multiworld to be filled.
|
||||||
:param base_state: State assumed before fill.
|
:param base_state: State assumed before fill.
|
||||||
: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,22 +58,14 @@ def fill_restrictive(multiworld: MultiWorld, base_state: CollectionState, locati
|
|||||||
placed = 0
|
placed = 0
|
||||||
|
|
||||||
while any(reachable_items.values()) and locations:
|
while any(reachable_items.values()) and locations:
|
||||||
if one_item_per_player:
|
# grab one item per player
|
||||||
# grab one item per player
|
items_to_place = [items.pop()
|
||||||
items_to_place = [items.pop()
|
for items in reachable_items.values() if items]
|
||||||
for items in reachable_items.values() if items]
|
|
||||||
else:
|
|
||||||
next_player = multiworld.random.choice([player for player, items in reachable_items.items() if items])
|
|
||||||
items_to_place = []
|
|
||||||
if item_pool:
|
|
||||||
items_to_place.append(reachable_items[next_player].pop())
|
|
||||||
|
|
||||||
for item in items_to_place:
|
for item in items_to_place:
|
||||||
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, multiworld.get_filled_locations(item.player)
|
||||||
if single_player_placement else None)
|
if single_player_placement else None)
|
||||||
@@ -226,7 +212,7 @@ def fill_restrictive(multiworld: MultiWorld, base_state: CollectionState, locati
|
|||||||
f"Unfilled locations:\n"
|
f"Unfilled locations:\n"
|
||||||
f"{', '.join(str(location) for location in locations)}\n"
|
f"{', '.join(str(location) for location in locations)}\n"
|
||||||
f"Already placed {len(placements)}:\n"
|
f"Already placed {len(placements)}:\n"
|
||||||
f"{', '.join(str(place) for place in placements)}", multiworld=multiworld)
|
f"{', '.join(str(place) for place in placements)}")
|
||||||
|
|
||||||
item_pool.extend(unplaced_items)
|
item_pool.extend(unplaced_items)
|
||||||
|
|
||||||
@@ -234,31 +220,18 @@ def fill_restrictive(multiworld: MultiWorld, base_state: CollectionState, locati
|
|||||||
def remaining_fill(multiworld: MultiWorld,
|
def remaining_fill(multiworld: MultiWorld,
|
||||||
locations: typing.List[Location],
|
locations: typing.List[Location],
|
||||||
itempool: typing.List[Item],
|
itempool: typing.List[Item],
|
||||||
name: str = "Remaining",
|
name: str = "Remaining") -> None:
|
||||||
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 +252,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)
|
||||||
@@ -311,21 +284,13 @@ def remaining_fill(multiworld: MultiWorld,
|
|||||||
|
|
||||||
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 {len(unplaced_items)} items. Remaining locations are invalid.\n"
|
||||||
last_batch = []
|
f"Unplaced items:\n"
|
||||||
for item in unplaced_items:
|
f"{', '.join(str(item) for item in unplaced_items)}\n"
|
||||||
logging.debug(f"Moved {item} to start_inventory to prevent fill failure.")
|
f"Unfilled locations:\n"
|
||||||
multiworld.push_precollected(item)
|
f"{', '.join(str(location) for location in locations)}\n"
|
||||||
last_batch.append(multiworld.worlds[item.player].create_filler())
|
f"Already placed {len(placements)}:\n"
|
||||||
remaining_fill(multiworld, locations, unplaced_items, name + " Start Inventory Retry")
|
f"{', '.join(str(place) for place in placements)}")
|
||||||
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)
|
||||||
|
|
||||||
@@ -350,8 +315,8 @@ 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:
|
if location in state.events:
|
||||||
state.advancements.remove(location)
|
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)
|
||||||
@@ -384,7 +349,7 @@ def distribute_early_items(multiworld: MultiWorld,
|
|||||||
early_priority_locations: typing.List[Location] = []
|
early_priority_locations: typing.List[Location] = []
|
||||||
loc_indexes_to_remove: typing.Set[int] = set()
|
loc_indexes_to_remove: typing.Set[int] = set()
|
||||||
base_state = multiworld.state.copy()
|
base_state = multiworld.state.copy()
|
||||||
base_state.sweep_for_advancements(locations=(loc for loc in multiworld.get_filled_locations() if loc.address is None))
|
base_state.sweep_for_events(locations=(loc for loc in multiworld.get_filled_locations() if loc.address is None))
|
||||||
for i, loc in enumerate(fill_locations):
|
for i, loc in enumerate(fill_locations):
|
||||||
if loc.can_reach(base_state):
|
if loc.can_reach(base_state):
|
||||||
if loc.progress_type == LocationProgressType.PRIORITY:
|
if loc.progress_type == LocationProgressType.PRIORITY:
|
||||||
@@ -455,8 +420,7 @@ def distribute_early_items(multiworld: MultiWorld,
|
|||||||
return fill_locations, itempool
|
return fill_locations, itempool
|
||||||
|
|
||||||
|
|
||||||
def distribute_items_restrictive(multiworld: MultiWorld,
|
def distribute_items_restrictive(multiworld: MultiWorld) -> None:
|
||||||
panic_method: typing.Literal["swap", "raise", "start_inventory"] = "swap") -> None:
|
|
||||||
fill_locations = sorted(multiworld.get_unfilled_locations())
|
fill_locations = sorted(multiworld.get_unfilled_locations())
|
||||||
multiworld.random.shuffle(fill_locations)
|
multiworld.random.shuffle(fill_locations)
|
||||||
# get items to distribute
|
# get items to distribute
|
||||||
@@ -496,50 +460,22 @@ 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(multiworld, multiworld.state, prioritylocations, progitempool,
|
||||||
single_player_placement=single_player, swap=False, on_place=mark_for_locking,
|
single_player_placement=multiworld.players == 1, swap=False, on_place=mark_for_locking,
|
||||||
name="Priority", one_item_per_player=True, allow_partial=True)
|
name="Priority")
|
||||||
|
|
||||||
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)
|
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(multiworld, multiworld.state, defaultlocations, progitempool, single_player_placement=multiworld.players == 1,
|
||||||
fill_restrictive(multiworld, multiworld.state, defaultlocations, progitempool, swap=True,
|
name="Progression")
|
||||||
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 progression items. "
|
||||||
f"There are {len(progitempool)} more progression items than there are available locations.\n"
|
f"There are {len(progitempool)} more progression items than there are available locations."
|
||||||
f"Unfilled locations:\n{multiworld.get_unfilled_locations()}.",
|
|
||||||
multiworld=multiworld,
|
|
||||||
)
|
)
|
||||||
accessibility_corrections(multiworld, multiworld.state, defaultlocations)
|
accessibility_corrections(multiworld, multiworld.state, defaultlocations)
|
||||||
|
|
||||||
@@ -550,20 +486,16 @@ def distribute_items_restrictive(multiworld: MultiWorld,
|
|||||||
|
|
||||||
inaccessible_location_rules(multiworld, multiworld.state, defaultlocations)
|
inaccessible_location_rules(multiworld, multiworld.state, defaultlocations)
|
||||||
|
|
||||||
remaining_fill(multiworld, excludedlocations, filleritempool, "Remaining Excluded",
|
remaining_fill(multiworld, excludedlocations, filleritempool, "Remaining Excluded")
|
||||||
move_unplaceable_to_start_inventory=panic_method=="start_inventory")
|
|
||||||
|
|
||||||
if excludedlocations:
|
if excludedlocations:
|
||||||
raise FillError(
|
raise FillError(
|
||||||
f"Not enough filler items for excluded locations. "
|
f"Not enough filler items for excluded locations. "
|
||||||
f"There are {len(excludedlocations)} more excluded locations than excludable items.",
|
f"There are {len(excludedlocations)} more excluded locations than filler or trap items."
|
||||||
multiworld=multiworld,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
restitempool = filleritempool + usefulitempool
|
restitempool = filleritempool + usefulitempool
|
||||||
|
|
||||||
remaining_fill(multiworld, defaultlocations, restitempool,
|
remaining_fill(multiworld, defaultlocations, restitempool)
|
||||||
move_unplaceable_to_start_inventory=panic_method=="start_inventory")
|
|
||||||
|
|
||||||
unplaced = restitempool
|
unplaced = restitempool
|
||||||
unfilled = defaultlocations
|
unfilled = defaultlocations
|
||||||
@@ -577,26 +509,6 @@ def distribute_items_restrictive(multiworld: MultiWorld,
|
|||||||
print_data = {"items": items_counter, "locations": locations_counter}
|
print_data = {"items": items_counter, "locations": locations_counter}
|
||||||
logging.info(f"Per-Player counts: {print_data})")
|
logging.info(f"Per-Player counts: {print_data})")
|
||||||
|
|
||||||
more_locations = locations_counter - items_counter
|
|
||||||
more_items = items_counter - locations_counter
|
|
||||||
for player in multiworld.player_ids:
|
|
||||||
if more_locations[player]:
|
|
||||||
logging.error(
|
|
||||||
f"Player {multiworld.get_player_name(player)} had {more_locations[player]} more locations than items.")
|
|
||||||
elif more_items[player]:
|
|
||||||
logging.warning(
|
|
||||||
f"Player {multiworld.get_player_name(player)} had {more_items[player]} more items than locations.")
|
|
||||||
if unfilled:
|
|
||||||
raise FillError(
|
|
||||||
f"Unable to fill all locations.\n" +
|
|
||||||
f"Unfilled locations({len(unfilled)}): {unfilled}"
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
logging.warning(
|
|
||||||
f"Unable to place all items.\n" +
|
|
||||||
f"Unplaced items({len(unplaced)}): {unplaced}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def flood_items(multiworld: MultiWorld) -> None:
|
def flood_items(multiworld: MultiWorld) -> None:
|
||||||
# get items to distribute
|
# get items to distribute
|
||||||
@@ -605,7 +517,7 @@ def flood_items(multiworld: MultiWorld) -> None:
|
|||||||
progress_done = False
|
progress_done = False
|
||||||
|
|
||||||
# sweep once to pick up preplaced items
|
# sweep once to pick up preplaced items
|
||||||
multiworld.state.sweep_for_advancements()
|
multiworld.state.sweep_for_events()
|
||||||
|
|
||||||
# fill multiworld from top of itempool while we can
|
# fill multiworld from top of itempool while we can
|
||||||
while not progress_done:
|
while not progress_done:
|
||||||
@@ -643,7 +555,7 @@ def flood_items(multiworld: MultiWorld) -> None:
|
|||||||
if candidate_item_to_place is not None:
|
if candidate_item_to_place is not None:
|
||||||
item_to_place = candidate_item_to_place
|
item_to_place = candidate_item_to_place
|
||||||
else:
|
else:
|
||||||
raise FillError('No more progress items left to place.', multiworld=multiworld)
|
raise FillError('No more progress items left to place.')
|
||||||
|
|
||||||
# find item to replace with progress item
|
# find item to replace with progress item
|
||||||
location_list = multiworld.get_reachable_locations()
|
location_list = multiworld.get_reachable_locations()
|
||||||
@@ -700,6 +612,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:
|
||||||
@@ -793,7 +706,7 @@ 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 multiworld.has_beaten_game(balancing_state):
|
||||||
if not multiworld.has_beaten_game(reducing_state):
|
if not multiworld.has_beaten_game(reducing_state):
|
||||||
@@ -876,7 +789,7 @@ def distribute_planned(multiworld: MultiWorld) -> None:
|
|||||||
warn(warning, force)
|
warn(warning, force)
|
||||||
|
|
||||||
swept_state = multiworld.state.copy()
|
swept_state = multiworld.state.copy()
|
||||||
swept_state.sweep_for_advancements()
|
swept_state.sweep_for_events()
|
||||||
reachable = frozenset(multiworld.get_reachable_locations(swept_state))
|
reachable = frozenset(multiworld.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)
|
||||||
@@ -1027,32 +940,15 @@ def distribute_planned(multiworld: MultiWorld) -> None:
|
|||||||
multiworld.random.shuffle(items)
|
multiworld.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 = multiworld.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(multiworld.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,7 +960,6 @@ 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']:
|
||||||
@@ -1072,16 +967,17 @@ def distribute_planned(multiworld: MultiWorld) -> None:
|
|||||||
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 {multiworld.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
|
|
||||||
successful_pairs = sorted(successful_pairs, key=lambda successful_pair: successful_pair[0] or 0, reverse=True)
|
|
||||||
|
|
||||||
for (index, item, location) in successful_pairs:
|
|
||||||
multiworld.push_item(location, item, collect=False)
|
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:
|
||||||
|
multiworld.itempool.remove(item)
|
||||||
|
except ValueError:
|
||||||
|
warn(
|
||||||
|
f"Could not remove {item} from pool for {multiworld.player_name[player]} as it's already missing from it.",
|
||||||
|
placement['force'])
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise Exception(
|
raise Exception(
|
||||||
|
|||||||
182
Generate.py
@@ -1,32 +1,36 @@
|
|||||||
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.EntranceRandomizer import parse_arguments
|
||||||
|
from worlds.alttp.Text import TextTable
|
||||||
|
from worlds.AutoWorld import AutoWorldRegister
|
||||||
|
from worlds.generic import PlandoConnection
|
||||||
|
from worlds import failed_world_loads
|
||||||
|
|
||||||
|
|
||||||
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,
|
||||||
@@ -38,17 +42,15 @@ def mystery_argparse():
|
|||||||
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)
|
||||||
|
|
||||||
@@ -112,18 +111,11 @@ def main(args=None) -> Tuple[argparse.Namespace, int]:
|
|||||||
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
|
||||||
|
|
||||||
@@ -152,9 +144,6 @@ 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
|
||||||
@@ -164,8 +153,6 @@ def main(args=None) -> Tuple[argparse.Namespace, int]:
|
|||||||
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.sameoptions else None)
|
||||||
@@ -213,7 +200,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 +213,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, ...]:
|
||||||
@@ -310,34 +319,18 @@ def update_weights(weights: dict, new_weights: dict, update_type: str, name: str
|
|||||||
logging.debug(f'Applying {new_weights}')
|
logging.debug(f'Applying {new_weights}')
|
||||||
cleaned_weights = {}
|
cleaned_weights = {}
|
||||||
for option in new_weights:
|
for option in new_weights:
|
||||||
option_name = option.lstrip("+-")
|
option_name = option.lstrip("+")
|
||||||
if option.startswith("+") and option_name in weights:
|
if option.startswith("+") and option_name in weights:
|
||||||
cleaned_value = weights[option_name]
|
cleaned_value = weights[option_name]
|
||||||
new_value = new_weights[option]
|
new_value = new_weights[option]
|
||||||
if isinstance(new_value, set):
|
if isinstance(new_value, (set, dict)):
|
||||||
cleaned_value.update(new_value)
|
cleaned_value.update(new_value)
|
||||||
elif isinstance(new_value, list):
|
elif isinstance(new_value, list):
|
||||||
cleaned_value.extend(new_value)
|
cleaned_value.extend(new_value)
|
||||||
elif isinstance(new_value, dict):
|
|
||||||
cleaned_value = dict(Counter(cleaned_value) + Counter(new_value))
|
|
||||||
else:
|
else:
|
||||||
raise Exception(f"Cannot apply merge to non-dict, set, or list type {option_name},"
|
raise Exception(f"Cannot apply merge to non-dict, set, or list type {option_name},"
|
||||||
f" received {type(new_value).__name__}.")
|
f" received {type(new_value).__name__}.")
|
||||||
cleaned_weights[option_name] = cleaned_value
|
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:
|
else:
|
||||||
cleaned_weights[option_name] = new_weights[option]
|
cleaned_weights[option_name] = new_weights[option]
|
||||||
new_options = set(cleaned_weights) - set(weights)
|
new_options = set(cleaned_weights) - set(weights)
|
||||||
@@ -351,8 +344,6 @@ def update_weights(weights: dict, new_weights: dict, update_type: str, name: str
|
|||||||
|
|
||||||
|
|
||||||
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:
|
||||||
@@ -424,25 +415,23 @@ def handle_option(ret: argparse.Namespace, game_weights: dict, option_key: str,
|
|||||||
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))
|
||||||
|
del game_weights[option_key]
|
||||||
else:
|
else:
|
||||||
player_option = option.from_any(option.default) # call the from_any here to support default "random"
|
player_option = option.from_any(option.default) # call the from_any here to support default "random"
|
||||||
setattr(ret, option_key, player_option)
|
setattr(ret, option_key, player_option)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise Options.OptionError(f"Error generating option {option_key} in {ret.game}") from e
|
raise Options.OptionError(f"Error generating option {option_key} in {ret.game}") from e
|
||||||
else:
|
else:
|
||||||
from worlds import AutoWorldRegister
|
|
||||||
player_option.verify(AutoWorldRegister.world_types[ret.game], ret.name, plando_options)
|
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"}
|
valid_trigger_names = set()
|
||||||
if "triggers" in weights:
|
if "triggers" in weights:
|
||||||
weights = roll_triggers(weights, weights["triggers"], valid_keys)
|
weights = roll_triggers(weights, weights["triggers"], valid_trigger_names)
|
||||||
|
|
||||||
requirements = weights.get("requires", {})
|
requirements = weights.get("requires", {})
|
||||||
if requirements:
|
if requirements:
|
||||||
@@ -462,12 +451,7 @@ 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, list(AutoWorldRegister.world_types) + failed_world_loads, limit=1)[0]
|
picks = Utils.get_fuzzy_results(ret.game, list(AutoWorldRegister.world_types) + failed_world_loads, limit=1)[0]
|
||||||
if picks[0] in failed_world_loads:
|
if picks[0] in failed_world_loads:
|
||||||
raise Exception(f"No functional world found to handle game {ret.game}. "
|
raise Exception(f"No functional world found to handle game {ret.game}. "
|
||||||
@@ -482,14 +466,12 @@ 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 any(weight.startswith("+") for weight in game_weights) or \
|
||||||
if weight.startswith("+"):
|
any(weight.startswith("+") for weight in weights):
|
||||||
raise Exception(f"Merge tag cannot be used outside of trigger contexts. Found {weight}")
|
raise Exception(f"Merge tag cannot be used outside of trigger contexts.")
|
||||||
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"], valid_trigger_names)
|
||||||
game_weights = weights[ret.game]
|
game_weights = weights[ret.game]
|
||||||
|
|
||||||
ret.name = get_choice('name', weights)
|
ret.name = get_choice('name', weights)
|
||||||
@@ -498,28 +480,42 @@ 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:
|
|
||||||
ret.plando_items = copy.deepcopy(game_weights.get("plando_items", []))
|
|
||||||
if ret.game == "A Link to the Past":
|
|
||||||
# TODO there are still more LTTP options not on the options system
|
|
||||||
valid_keys |= {"sprite_pool", "sprite", "random_sprite_on_event"}
|
|
||||||
roll_alttp_settings(ret, game_weights)
|
|
||||||
|
|
||||||
# log a warning for options within a game section that aren't determined as valid
|
|
||||||
for option_key in game_weights:
|
for option_key in game_weights:
|
||||||
if option_key in valid_keys:
|
if option_key in {"triggers", *valid_trigger_names}:
|
||||||
continue
|
continue
|
||||||
logging.warning(f"{option_key} is not a valid option name for {ret.game} and is not present in triggers "
|
logging.warning(f"{option_key} is not a valid option name for {ret.game} and is not present in triggers.")
|
||||||
f"for player {ret.name}.")
|
if PlandoOptions.items in plando_options:
|
||||||
|
ret.plando_items = game_weights.get("plando_items", [])
|
||||||
|
if ret.game == "A Link to the Past":
|
||||||
|
roll_alttp_settings(ret, game_weights, plando_options)
|
||||||
|
if PlandoOptions.connections in plando_options:
|
||||||
|
ret.plando_connections = []
|
||||||
|
options = game_weights.get("plando_connections", [])
|
||||||
|
for placement in options:
|
||||||
|
if roll_percentage(get_choice("percentage", placement, 100)):
|
||||||
|
ret.plando_connections.append(PlandoConnection(
|
||||||
|
get_choice("entrance", placement),
|
||||||
|
get_choice("exit", placement),
|
||||||
|
get_choice("direction", placement, "both")
|
||||||
|
))
|
||||||
|
|
||||||
return ret
|
return ret
|
||||||
|
|
||||||
|
|
||||||
def roll_alttp_settings(ret: argparse.Namespace, weights):
|
def roll_alttp_settings(ret: argparse.Namespace, weights, plando_options):
|
||||||
|
|
||||||
|
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.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 +543,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
|
||||||
|
|||||||
@@ -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()
|
|
||||||
2
LICENSE
@@ -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
|
||||||
|
|
||||||
|
|||||||
191
Launcher.py
@@ -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 \
|
||||||
@@ -103,71 +101,13 @@ components.extend([
|
|||||||
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 Options", 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("Unrated/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,28 +160,39 @@ 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, ScrollBox, Widget
|
||||||
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", size_hint_y=None, height=40))
|
||||||
|
self.grid.add_widget(Label(text="Clients", size_hint_y=None, height=40))
|
||||||
|
tool_layout = ScrollBox()
|
||||||
|
tool_layout.layout.orientation = "vertical"
|
||||||
|
self.grid.add_widget(tool_layout)
|
||||||
|
client_layout = ScrollBox()
|
||||||
|
client_layout.layout.orientation = "vertical"
|
||||||
|
self.grid.add_widget(client_layout)
|
||||||
|
|
||||||
def build_button(component: Component) -> Widget:
|
def build_button(component: Component) -> Widget:
|
||||||
"""
|
"""
|
||||||
@@ -258,57 +209,22 @@ def run_gui():
|
|||||||
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(size_hint_y=None, height=40)
|
||||||
box_layout.add_widget(button)
|
box_layout.add_widget(button)
|
||||||
box_layout.add_widget(image)
|
box_layout.add_widget(image)
|
||||||
return box_layout
|
return box_layout
|
||||||
return button
|
return 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]))
|
tool_layout.layout.add_widget(build_button(tool[1]))
|
||||||
# column 2
|
# column 2
|
||||||
if client:
|
if client:
|
||||||
self._client_layout.layout.add_widget(build_button(client[1]))
|
client_layout.layout.add_widget(build_button(client[1]))
|
||||||
|
|
||||||
def build(self):
|
|
||||||
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 args.get("Patch|Game|Component", None) is not None:
|
||||||
if path is not None:
|
file, component = identify(args["Patch|Game|Component"])
|
||||||
if path.startswith("archipelago://"):
|
|
||||||
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())
|
||||||
|
|||||||
@@ -235,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:
|
||||||
@@ -467,8 +467,6 @@ class LinksAwakeningContext(CommonContext):
|
|||||||
|
|
||||||
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()
|
||||||
@@ -560,18 +558,12 @@ 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"]):
|
||||||
@@ -636,7 +628,6 @@ class LinksAwakeningContext(CommonContext):
|
|||||||
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)
|
||||||
await self.magpie.send_gps(self.client.gps_tracker)
|
await self.magpie.send_gps(self.client.gps_tracker)
|
||||||
self.magpie.slot_data = self.slot_data
|
|
||||||
except Exception:
|
except Exception:
|
||||||
# Don't let magpie errors take out the client
|
# Don't let magpie errors take out the client
|
||||||
pass
|
pass
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
191
Main.py
@@ -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
|
||||||
@@ -46,9 +45,6 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
|||||||
multiworld.sprite_pool = args.sprite_pool.copy()
|
multiworld.sprite_pool = args.sprite_pool.copy()
|
||||||
|
|
||||||
multiworld.set_options(args)
|
multiworld.set_options(args)
|
||||||
if args.csv_output:
|
|
||||||
from Options import dump_player_options
|
|
||||||
dump_player_options(multiworld)
|
|
||||||
multiworld.set_item_links()
|
multiworld.set_item_links()
|
||||||
multiworld.state = CollectionState(multiworld)
|
multiworld.state = CollectionState(multiworld)
|
||||||
logger.info('Archipelago Version %s - Seed: %s\n', __version__, multiworld.seed)
|
logger.info('Archipelago Version %s - Seed: %s\n', __version__, multiworld.seed)
|
||||||
@@ -104,7 +100,7 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
|||||||
multiworld.early_items[player][item_name] = max(0, early-count)
|
multiworld.early_items[player][item_name] = max(0, early-count)
|
||||||
remaining_count = count-early
|
remaining_count = count-early
|
||||||
if remaining_count > 0:
|
if remaining_count > 0:
|
||||||
local_early = multiworld.local_early_items[player].get(item_name, 0)
|
local_early = multiworld.early_local_items[player].get(item_name, 0)
|
||||||
if local_early:
|
if local_early:
|
||||||
multiworld.early_items[player][item_name] = max(0, local_early - remaining_count)
|
multiworld.early_items[player][item_name] = max(0, local_early - remaining_count)
|
||||||
del local_early
|
del local_early
|
||||||
@@ -128,19 +124,14 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
|||||||
for player in multiworld.player_ids:
|
for player in multiworld.player_ids:
|
||||||
exclusion_rules(multiworld, player, multiworld.worlds[player].options.exclude_locations.value)
|
exclusion_rules(multiworld, player, multiworld.worlds[player].options.exclude_locations.value)
|
||||||
multiworld.worlds[player].options.priority_locations.value -= multiworld.worlds[player].options.exclude_locations.value
|
multiworld.worlds[player].options.priority_locations.value -= multiworld.worlds[player].options.exclude_locations.value
|
||||||
world_excluded_locations = set()
|
|
||||||
for location_name in multiworld.worlds[player].options.priority_locations.value:
|
for location_name in multiworld.worlds[player].options.priority_locations.value:
|
||||||
try:
|
try:
|
||||||
location = multiworld.get_location(location_name, player)
|
location = multiworld.get_location(location_name, player)
|
||||||
except KeyError:
|
except KeyError as e: # failed to find the given location. Check if it's a legitimate location
|
||||||
continue
|
if location_name not in multiworld.worlds[player].location_name_to_id:
|
||||||
|
raise Exception(f"Unable to prioritize location {location_name} in player {player}'s world.") from e
|
||||||
if location.progress_type != LocationProgressType.EXCLUDED:
|
|
||||||
location.progress_type = LocationProgressType.PRIORITY
|
|
||||||
else:
|
else:
|
||||||
logger.warning(f"Unable to prioritize location \"{location_name}\" in player {player}'s world because the world excluded it.")
|
location.progress_type = LocationProgressType.PRIORITY
|
||||||
world_excluded_locations.add(location_name)
|
|
||||||
multiworld.worlds[player].options.priority_locations.value -= world_excluded_locations
|
|
||||||
|
|
||||||
# Set local and non-local item rules.
|
# Set local and non-local item rules.
|
||||||
if multiworld.players > 1:
|
if multiworld.players > 1:
|
||||||
@@ -148,46 +139,122 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
|||||||
else:
|
else:
|
||||||
multiworld.worlds[1].options.non_local_items.value = set()
|
multiworld.worlds[1].options.non_local_items.value = set()
|
||||||
multiworld.worlds[1].options.local_items.value = set()
|
multiworld.worlds[1].options.local_items.value = set()
|
||||||
|
|
||||||
AutoWorld.call_all(multiworld, "connect_entrances")
|
|
||||||
AutoWorld.call_all(multiworld, "generate_basic")
|
AutoWorld.call_all(multiworld, "generate_basic")
|
||||||
|
|
||||||
# remove starting inventory from pool items.
|
# remove starting inventory from pool items.
|
||||||
# Because some worlds don't actually create items during create_items this has to be as late as possible.
|
# Because some worlds don't actually create items during create_items this has to be as late as possible.
|
||||||
fallback_inventory = StartInventoryPool({})
|
if any(getattr(multiworld.worlds[player].options, "start_inventory_from_pool", None) for player in multiworld.player_ids):
|
||||||
depletion_pool: Dict[int, Dict[str, int]] = {
|
new_items: List[Item] = []
|
||||||
player: getattr(multiworld.worlds[player].options, "start_inventory_from_pool", fallback_inventory).value.copy()
|
depletion_pool: Dict[int, Dict[str, int]] = {
|
||||||
for player in multiworld.player_ids
|
player: getattr(multiworld.worlds[player].options,
|
||||||
}
|
"start_inventory_from_pool",
|
||||||
target_per_player = {
|
StartInventoryPool({})).value.copy()
|
||||||
player: sum(target_items.values()) for player, target_items in depletion_pool.items() if target_items
|
for player in multiworld.player_ids
|
||||||
}
|
}
|
||||||
|
for player, items in depletion_pool.items():
|
||||||
if target_per_player:
|
player_world: AutoWorld.World = multiworld.worlds[player]
|
||||||
new_itempool: List[Item] = []
|
for count in items.values():
|
||||||
|
for _ in range(count):
|
||||||
# Make new itempool with start_inventory_from_pool items removed
|
new_items.append(player_world.create_filler())
|
||||||
for item in multiworld.itempool:
|
target: int = sum(sum(items.values()) for items in depletion_pool.values())
|
||||||
|
for i, item in enumerate(multiworld.itempool):
|
||||||
if depletion_pool[item.player].get(item.name, 0):
|
if depletion_pool[item.player].get(item.name, 0):
|
||||||
|
target -= 1
|
||||||
depletion_pool[item.player][item.name] -= 1
|
depletion_pool[item.player][item.name] -= 1
|
||||||
|
# quick abort if we have found all items
|
||||||
|
if not target:
|
||||||
|
new_items.extend(multiworld.itempool[i+1:])
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
new_items.append(item)
|
||||||
|
|
||||||
|
# leftovers?
|
||||||
|
if target:
|
||||||
|
for player, remaining_items in depletion_pool.items():
|
||||||
|
remaining_items = {name: count for name, count in remaining_items.items() if count}
|
||||||
|
if remaining_items:
|
||||||
|
raise Exception(f"{multiworld.get_player_name(player)}"
|
||||||
|
f" is trying to remove items from their pool that don't exist: {remaining_items}")
|
||||||
|
assert len(multiworld.itempool) == len(new_items), "Item Pool amounts should not change."
|
||||||
|
multiworld.itempool[:] = new_items
|
||||||
|
|
||||||
|
# temporary home for item links, should be moved out of Main
|
||||||
|
for group_id, group in multiworld.groups.items():
|
||||||
|
def find_common_pool(players: Set[int], shared_pool: Set[str]) -> Tuple[
|
||||||
|
Optional[Dict[int, Dict[str, int]]], Optional[Dict[str, int]]
|
||||||
|
]:
|
||||||
|
classifications: Dict[str, int] = collections.defaultdict(int)
|
||||||
|
counters = {player: {name: 0 for name in shared_pool} for player in players}
|
||||||
|
for item in multiworld.itempool:
|
||||||
|
if item.player in counters and item.name in shared_pool:
|
||||||
|
counters[item.player][item.name] += 1
|
||||||
|
classifications[item.name] |= item.classification
|
||||||
|
|
||||||
|
for player in players.copy():
|
||||||
|
if all([counters[player][item] == 0 for item in shared_pool]):
|
||||||
|
players.remove(player)
|
||||||
|
del (counters[player])
|
||||||
|
|
||||||
|
if not players:
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
for item in shared_pool:
|
||||||
|
count = min(counters[player][item] for player in players)
|
||||||
|
if count:
|
||||||
|
for player in players:
|
||||||
|
counters[player][item] = count
|
||||||
|
else:
|
||||||
|
for player in players:
|
||||||
|
del (counters[player][item])
|
||||||
|
return counters, classifications
|
||||||
|
|
||||||
|
common_item_count, classifications = find_common_pool(group["players"], group["item_pool"])
|
||||||
|
if not common_item_count:
|
||||||
|
continue
|
||||||
|
|
||||||
|
new_itempool: List[Item] = []
|
||||||
|
for item_name, item_count in next(iter(common_item_count.values())).items():
|
||||||
|
for _ in range(item_count):
|
||||||
|
new_item = group["world"].create_item(item_name)
|
||||||
|
# mangle together all original classification bits
|
||||||
|
new_item.classification |= classifications[item_name]
|
||||||
|
new_itempool.append(new_item)
|
||||||
|
|
||||||
|
region = Region("Menu", group_id, multiworld, "ItemLink")
|
||||||
|
multiworld.regions.append(region)
|
||||||
|
locations = region.locations
|
||||||
|
for item in multiworld.itempool:
|
||||||
|
count = common_item_count.get(item.player, {}).get(item.name, 0)
|
||||||
|
if count:
|
||||||
|
loc = Location(group_id, f"Item Link: {item.name} -> {multiworld.player_name[item.player]} {count}",
|
||||||
|
None, region)
|
||||||
|
loc.access_rule = lambda state, item_name = item.name, group_id_ = group_id, count_ = count: \
|
||||||
|
state.has(item_name, group_id_, count_)
|
||||||
|
|
||||||
|
locations.append(loc)
|
||||||
|
loc.place_locked_item(item)
|
||||||
|
common_item_count[item.player][item.name] -= 1
|
||||||
else:
|
else:
|
||||||
new_itempool.append(item)
|
new_itempool.append(item)
|
||||||
|
|
||||||
# Create filler in place of the removed items, warn if any items couldn't be found in the multiworld itempool
|
itemcount = len(multiworld.itempool)
|
||||||
for player, target in target_per_player.items():
|
multiworld.itempool = new_itempool
|
||||||
unfound_items = {item: count for item, count in depletion_pool[player].items() if count}
|
|
||||||
|
|
||||||
if unfound_items:
|
while itemcount > len(multiworld.itempool):
|
||||||
player_name = multiworld.get_player_name(player)
|
items_to_add = []
|
||||||
logger.warning(f"{player_name} tried to remove items from their pool that don't exist: {unfound_items}")
|
for player in group["players"]:
|
||||||
|
if group["link_replacement"]:
|
||||||
needed_items = target_per_player[player] - sum(unfound_items.values())
|
item_player = group_id
|
||||||
new_itempool += [multiworld.worlds[player].create_filler() for _ in range(needed_items)]
|
else:
|
||||||
|
item_player = player
|
||||||
assert len(multiworld.itempool) == len(new_itempool), "Item Pool amounts should not change."
|
if group["replacement_items"][player]:
|
||||||
multiworld.itempool[:] = new_itempool
|
items_to_add.append(AutoWorld.call_single(multiworld, "create_item", item_player,
|
||||||
|
group["replacement_items"][player]))
|
||||||
multiworld.link_items()
|
else:
|
||||||
|
items_to_add.append(AutoWorld.call_single(multiworld, "create_filler", item_player))
|
||||||
|
multiworld.random.shuffle(items_to_add)
|
||||||
|
multiworld.itempool.extend(items_to_add[:itemcount - len(multiworld.itempool)])
|
||||||
|
|
||||||
if any(multiworld.item_links.values()):
|
if any(multiworld.item_links.values()):
|
||||||
multiworld._all_state = None
|
multiworld._all_state = None
|
||||||
@@ -205,7 +272,7 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
|||||||
if multiworld.algorithm == 'flood':
|
if multiworld.algorithm == 'flood':
|
||||||
flood_items(multiworld) # different algo, biased towards early game progress items
|
flood_items(multiworld) # different algo, biased towards early game progress items
|
||||||
elif multiworld.algorithm == 'balanced':
|
elif multiworld.algorithm == 'balanced':
|
||||||
distribute_items_restrictive(multiworld, get_settings().generator.panic_method)
|
distribute_items_restrictive(multiworld)
|
||||||
|
|
||||||
AutoWorld.call_all(multiworld, 'post_fill')
|
AutoWorld.call_all(multiworld, 'post_fill')
|
||||||
|
|
||||||
@@ -243,7 +310,6 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
|||||||
|
|
||||||
def write_multidata():
|
def write_multidata():
|
||||||
import NetUtils
|
import NetUtils
|
||||||
from NetUtils import HintStatus
|
|
||||||
slot_data = {}
|
slot_data = {}
|
||||||
client_versions = {}
|
client_versions = {}
|
||||||
games = {}
|
games = {}
|
||||||
@@ -268,10 +334,10 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
|||||||
for slot in multiworld.player_ids:
|
for slot in multiworld.player_ids:
|
||||||
slot_data[slot] = multiworld.worlds[slot].fill_slot_data()
|
slot_data[slot] = multiworld.worlds[slot].fill_slot_data()
|
||||||
|
|
||||||
def precollect_hint(location: Location, auto_status: HintStatus):
|
def precollect_hint(location):
|
||||||
entrance = er_hint_data.get(location.player, {}).get(location.address, "")
|
entrance = er_hint_data.get(location.player, {}).get(location.address, "")
|
||||||
hint = NetUtils.Hint(location.item.player, location.player, location.address,
|
hint = NetUtils.Hint(location.item.player, location.player, location.address,
|
||||||
location.item.code, False, entrance, location.item.flags, auto_status)
|
location.item.code, False, entrance, location.item.flags)
|
||||||
precollected_hints[location.player].add(hint)
|
precollected_hints[location.player].add(hint)
|
||||||
if location.item.player not in multiworld.groups:
|
if location.item.player not in multiworld.groups:
|
||||||
precollected_hints[location.item.player].add(hint)
|
precollected_hints[location.item.player].add(hint)
|
||||||
@@ -284,22 +350,19 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
|||||||
if type(location.address) == int:
|
if type(location.address) == int:
|
||||||
assert location.item.code is not None, "item code None should be event, " \
|
assert location.item.code is not None, "item code None should be event, " \
|
||||||
"location.address should then also be None. Location: " \
|
"location.address should then also be None. Location: " \
|
||||||
f" {location}, Item: {location.item}"
|
f" {location}"
|
||||||
assert location.address not in locations_data[location.player], (
|
assert location.address not in locations_data[location.player], (
|
||||||
f"Locations with duplicate address. {location} and "
|
f"Locations with duplicate address. {location} and "
|
||||||
f"{locations_data[location.player][location.address]}")
|
f"{locations_data[location.player][location.address]}")
|
||||||
locations_data[location.player][location.address] = \
|
locations_data[location.player][location.address] = \
|
||||||
location.item.code, location.item.player, location.item.flags
|
location.item.code, location.item.player, location.item.flags
|
||||||
auto_status = HintStatus.HINT_AVOID if location.item.trap else HintStatus.HINT_PRIORITY
|
|
||||||
if location.name in multiworld.worlds[location.player].options.start_location_hints:
|
if location.name in multiworld.worlds[location.player].options.start_location_hints:
|
||||||
if not location.item.trap: # Unspecified status for location hints, except traps
|
precollect_hint(location)
|
||||||
auto_status = HintStatus.HINT_UNSPECIFIED
|
|
||||||
precollect_hint(location, auto_status)
|
|
||||||
elif location.item.name in multiworld.worlds[location.item.player].options.start_hints:
|
elif location.item.name in multiworld.worlds[location.item.player].options.start_hints:
|
||||||
precollect_hint(location, auto_status)
|
precollect_hint(location)
|
||||||
elif any([location.item.name in multiworld.worlds[player].options.start_hints
|
elif any([location.item.name in multiworld.worlds[player].options.start_hints
|
||||||
for player in multiworld.groups.get(location.item.player, {}).get("players", [])]):
|
for player in multiworld.groups.get(location.item.player, {}).get("players", [])]):
|
||||||
precollect_hint(location, auto_status)
|
precollect_hint(location)
|
||||||
|
|
||||||
# embedded data package
|
# embedded data package
|
||||||
data_package = {
|
data_package = {
|
||||||
@@ -309,16 +372,6 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
|||||||
|
|
||||||
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,
|
||||||
@@ -333,9 +386,7 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
|||||||
"tags": ["AP"],
|
"tags": ["AP"],
|
||||||
"minimum_versions": minimum_versions,
|
"minimum_versions": minimum_versions,
|
||||||
"seed_name": multiworld.seed_name,
|
"seed_name": multiworld.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(multiworld, "modify_multidata", multidata)
|
||||||
|
|
||||||
@@ -348,7 +399,7 @@ 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 multiworld.can_beat_game():
|
||||||
raise FillError("Game appears as unbeatable. Aborting.", multiworld=multiworld)
|
raise Exception("Game appears as unbeatable. Aborting.")
|
||||||
else:
|
else:
|
||||||
logger.warning("Location Accessibility requirements not fulfilled.")
|
logger.warning("Location Accessibility requirements not fulfilled.")
|
||||||
|
|
||||||
|
|||||||
@@ -5,15 +5,8 @@ import multiprocessing
|
|||||||
import warnings
|
import warnings
|
||||||
|
|
||||||
|
|
||||||
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())
|
_skip_update = bool(getattr(sys, "frozen", False) or multiprocessing.parent_process())
|
||||||
@@ -82,13 +75,13 @@ def update(yes: bool = False, force: bool = False) -> None:
|
|||||||
if not update_ran:
|
if not update_ran:
|
||||||
update_ran = True
|
update_ran = True
|
||||||
|
|
||||||
install_pkg_resources(yes=yes)
|
|
||||||
import pkg_resources
|
|
||||||
|
|
||||||
if force:
|
if force:
|
||||||
update_command()
|
update_command()
|
||||||
return
|
return
|
||||||
|
|
||||||
|
install_pkg_resources(yes=yes)
|
||||||
|
import pkg_resources
|
||||||
|
|
||||||
prev = "" # if a line ends in \ we store here and merge later
|
prev = "" # if a line ends in \ we store here and merge later
|
||||||
for req_file in requirements_files:
|
for req_file in requirements_files:
|
||||||
path = os.path.join(os.path.dirname(sys.argv[0]), req_file)
|
path = os.path.join(os.path.dirname(sys.argv[0]), req_file)
|
||||||
|
|||||||
451
MultiServer.py
@@ -3,7 +3,6 @@ from __future__ import annotations
|
|||||||
import argparse
|
import argparse
|
||||||
import asyncio
|
import asyncio
|
||||||
import collections
|
import collections
|
||||||
import contextlib
|
|
||||||
import copy
|
import copy
|
||||||
import datetime
|
import datetime
|
||||||
import functools
|
import functools
|
||||||
@@ -15,7 +14,6 @@ import math
|
|||||||
import operator
|
import operator
|
||||||
import pickle
|
import pickle
|
||||||
import random
|
import random
|
||||||
import shlex
|
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
import typing
|
import typing
|
||||||
@@ -28,11 +26,9 @@ ModuleUpdate.update()
|
|||||||
|
|
||||||
if typing.TYPE_CHECKING:
|
if typing.TYPE_CHECKING:
|
||||||
import ssl
|
import ssl
|
||||||
from NetUtils import ServerConnection
|
|
||||||
|
|
||||||
import colorama
|
|
||||||
import websockets
|
import websockets
|
||||||
from websockets.extensions.permessage_deflate import PerMessageDeflate
|
import colorama
|
||||||
try:
|
try:
|
||||||
# ponyorm is a requirement for webhost, not default server, so may not be importable
|
# ponyorm is a requirement for webhost, not default server, so may not be importable
|
||||||
from pony.orm.dbapiprovider import OperationalError
|
from pony.orm.dbapiprovider import OperationalError
|
||||||
@@ -41,10 +37,9 @@ except ImportError:
|
|||||||
|
|
||||||
import NetUtils
|
import NetUtils
|
||||||
import Utils
|
import Utils
|
||||||
from Utils import version_tuple, restricted_loads, Version, async_start, get_intended_text
|
from Utils import version_tuple, restricted_loads, Version, async_start
|
||||||
from NetUtils import Endpoint, ClientStatus, NetworkItem, decode, encode, NetworkPlayer, Permission, NetworkSlot, \
|
from NetUtils import Endpoint, ClientStatus, NetworkItem, decode, encode, NetworkPlayer, Permission, NetworkSlot, \
|
||||||
SlotType, LocationStore, Hint, HintStatus
|
SlotType, LocationStore
|
||||||
from BaseClasses import ItemClassification
|
|
||||||
|
|
||||||
min_client_version = Version(0, 1, 6)
|
min_client_version = Version(0, 1, 6)
|
||||||
colorama.init()
|
colorama.init()
|
||||||
@@ -71,21 +66,6 @@ def update_dict(dictionary, entries):
|
|||||||
return dictionary
|
return dictionary
|
||||||
|
|
||||||
|
|
||||||
def queue_gc():
|
|
||||||
import gc
|
|
||||||
from threading import Thread
|
|
||||||
|
|
||||||
gc_thread: typing.Optional[Thread] = getattr(queue_gc, "_thread", None)
|
|
||||||
def async_collect():
|
|
||||||
time.sleep(2)
|
|
||||||
setattr(queue_gc, "_thread", None)
|
|
||||||
gc.collect()
|
|
||||||
if not gc_thread:
|
|
||||||
gc_thread = Thread(target=async_collect)
|
|
||||||
setattr(queue_gc, "_thread", gc_thread)
|
|
||||||
gc_thread.start()
|
|
||||||
|
|
||||||
|
|
||||||
# functions callable on storable data on the server by clients
|
# functions callable on storable data on the server by clients
|
||||||
modify_functions = {
|
modify_functions = {
|
||||||
# generic:
|
# generic:
|
||||||
@@ -121,14 +101,13 @@ def get_saving_second(seed_name: str, interval: int = 60) -> int:
|
|||||||
|
|
||||||
class Client(Endpoint):
|
class Client(Endpoint):
|
||||||
version = Version(0, 0, 0)
|
version = Version(0, 0, 0)
|
||||||
tags: typing.List[str]
|
tags: typing.List[str] = []
|
||||||
remote_items: bool
|
remote_items: bool
|
||||||
remote_start_inventory: bool
|
remote_start_inventory: bool
|
||||||
no_items: bool
|
no_items: bool
|
||||||
no_locations: bool
|
no_locations: bool
|
||||||
no_text: bool
|
|
||||||
|
|
||||||
def __init__(self, socket: "ServerConnection", ctx: Context) -> None:
|
def __init__(self, socket: websockets.WebSocketServerProtocol, ctx: Context):
|
||||||
super().__init__(socket)
|
super().__init__(socket)
|
||||||
self.auth = False
|
self.auth = False
|
||||||
self.team = None
|
self.team = None
|
||||||
@@ -178,7 +157,6 @@ class Context:
|
|||||||
"compatibility": int}
|
"compatibility": int}
|
||||||
# team -> slot id -> list of clients authenticated to slot.
|
# team -> slot id -> list of clients authenticated to slot.
|
||||||
clients: typing.Dict[int, typing.Dict[int, typing.List[Client]]]
|
clients: typing.Dict[int, typing.Dict[int, typing.List[Client]]]
|
||||||
endpoints: list[Client]
|
|
||||||
locations: LocationStore # typing.Dict[int, typing.Dict[int, typing.Tuple[int, int, int]]]
|
locations: LocationStore # typing.Dict[int, typing.Dict[int, typing.Tuple[int, int, int]]]
|
||||||
location_checks: typing.Dict[typing.Tuple[int, int], typing.Set[int]]
|
location_checks: typing.Dict[typing.Tuple[int, int], typing.Set[int]]
|
||||||
hints_used: typing.Dict[typing.Tuple[int, int], int]
|
hints_used: typing.Dict[typing.Tuple[int, int], int]
|
||||||
@@ -190,15 +168,13 @@ class Context:
|
|||||||
slot_info: typing.Dict[int, NetworkSlot]
|
slot_info: typing.Dict[int, NetworkSlot]
|
||||||
generator_version = Version(0, 0, 0)
|
generator_version = Version(0, 0, 0)
|
||||||
checksums: typing.Dict[str, str]
|
checksums: typing.Dict[str, str]
|
||||||
item_names: typing.Dict[str, typing.Dict[int, str]]
|
item_names: typing.Dict[int, str] = Utils.KeyedDefaultDict(lambda code: f'Unknown item (ID:{code})')
|
||||||
item_name_groups: typing.Dict[str, typing.Dict[str, typing.Set[str]]]
|
item_name_groups: typing.Dict[str, typing.Dict[str, typing.Set[str]]]
|
||||||
location_names: typing.Dict[str, typing.Dict[int, str]]
|
location_names: typing.Dict[int, str] = Utils.KeyedDefaultDict(lambda code: f'Unknown location (ID:{code})')
|
||||||
location_name_groups: typing.Dict[str, typing.Dict[str, typing.Set[str]]]
|
location_name_groups: typing.Dict[str, typing.Dict[str, typing.Set[str]]]
|
||||||
all_item_and_group_names: typing.Dict[str, typing.Set[str]]
|
all_item_and_group_names: typing.Dict[str, typing.Set[str]]
|
||||||
all_location_and_group_names: typing.Dict[str, typing.Set[str]]
|
all_location_and_group_names: typing.Dict[str, typing.Set[str]]
|
||||||
non_hintable_names: typing.Dict[str, typing.AbstractSet[str]]
|
non_hintable_names: typing.Dict[str, typing.Set[str]]
|
||||||
spheres: typing.List[typing.Dict[int, typing.Set[int]]]
|
|
||||||
""" each sphere is { player: { location_id, ... } } """
|
|
||||||
logger: logging.Logger
|
logger: logging.Logger
|
||||||
|
|
||||||
def __init__(self, host: str, port: int, server_password: str, password: str, location_check_points: int,
|
def __init__(self, host: str, port: int, server_password: str, password: str, location_check_points: int,
|
||||||
@@ -233,7 +209,7 @@ class Context:
|
|||||||
self.hint_cost = hint_cost
|
self.hint_cost = hint_cost
|
||||||
self.location_check_points = location_check_points
|
self.location_check_points = location_check_points
|
||||||
self.hints_used = collections.defaultdict(int)
|
self.hints_used = collections.defaultdict(int)
|
||||||
self.hints: typing.Dict[team_slot, typing.Set[Hint]] = collections.defaultdict(set)
|
self.hints: typing.Dict[team_slot, typing.Set[NetUtils.Hint]] = collections.defaultdict(set)
|
||||||
self.release_mode: str = release_mode
|
self.release_mode: str = release_mode
|
||||||
self.remaining_mode: str = remaining_mode
|
self.remaining_mode: str = remaining_mode
|
||||||
self.collect_mode: str = collect_mode
|
self.collect_mode: str = collect_mode
|
||||||
@@ -250,7 +226,7 @@ class Context:
|
|||||||
self.embedded_blacklist = {"host", "port"}
|
self.embedded_blacklist = {"host", "port"}
|
||||||
self.client_ids: typing.Dict[typing.Tuple[int, int], datetime.datetime] = {}
|
self.client_ids: typing.Dict[typing.Tuple[int, int], datetime.datetime] = {}
|
||||||
self.auto_save_interval = 60 # in seconds
|
self.auto_save_interval = 60 # in seconds
|
||||||
self.auto_saver_thread: typing.Optional[threading.Thread] = None
|
self.auto_saver_thread = None
|
||||||
self.save_dirty = False
|
self.save_dirty = False
|
||||||
self.tags = ['AP']
|
self.tags = ['AP']
|
||||||
self.games: typing.Dict[int, str] = {}
|
self.games: typing.Dict[int, str] = {}
|
||||||
@@ -262,7 +238,6 @@ class Context:
|
|||||||
self.stored_data = {}
|
self.stored_data = {}
|
||||||
self.stored_data_notification_clients = collections.defaultdict(weakref.WeakSet)
|
self.stored_data_notification_clients = collections.defaultdict(weakref.WeakSet)
|
||||||
self.read_data = {}
|
self.read_data = {}
|
||||||
self.spheres = []
|
|
||||||
|
|
||||||
# init empty to satisfy linter, I suppose
|
# init empty to satisfy linter, I suppose
|
||||||
self.gamespackage = {}
|
self.gamespackage = {}
|
||||||
@@ -271,10 +246,6 @@ class Context:
|
|||||||
self.location_name_groups = {}
|
self.location_name_groups = {}
|
||||||
self.all_item_and_group_names = {}
|
self.all_item_and_group_names = {}
|
||||||
self.all_location_and_group_names = {}
|
self.all_location_and_group_names = {}
|
||||||
self.item_names = collections.defaultdict(
|
|
||||||
lambda: Utils.KeyedDefaultDict(lambda code: f'Unknown item (ID:{code})'))
|
|
||||||
self.location_names = collections.defaultdict(
|
|
||||||
lambda: Utils.KeyedDefaultDict(lambda code: f'Unknown location (ID:{code})'))
|
|
||||||
self.non_hintable_names = collections.defaultdict(frozenset)
|
self.non_hintable_names = collections.defaultdict(frozenset)
|
||||||
|
|
||||||
self._load_game_data()
|
self._load_game_data()
|
||||||
@@ -291,31 +262,19 @@ class Context:
|
|||||||
for world_name, world in worlds.AutoWorldRegister.world_types.items():
|
for world_name, world in worlds.AutoWorldRegister.world_types.items():
|
||||||
self.non_hintable_names[world_name] = world.hint_blacklist
|
self.non_hintable_names[world_name] = world.hint_blacklist
|
||||||
|
|
||||||
for game_package in self.gamespackage.values():
|
|
||||||
# remove groups from data sent to clients
|
|
||||||
del game_package["item_name_groups"]
|
|
||||||
del game_package["location_name_groups"]
|
|
||||||
|
|
||||||
def _init_game_data(self):
|
def _init_game_data(self):
|
||||||
for game_name, game_package in self.gamespackage.items():
|
for game_name, game_package in self.gamespackage.items():
|
||||||
if "checksum" in game_package:
|
if "checksum" in game_package:
|
||||||
self.checksums[game_name] = game_package["checksum"]
|
self.checksums[game_name] = game_package["checksum"]
|
||||||
for item_name, item_id in game_package["item_name_to_id"].items():
|
for item_name, item_id in game_package["item_name_to_id"].items():
|
||||||
self.item_names[game_name][item_id] = item_name
|
self.item_names[item_id] = item_name
|
||||||
for location_name, location_id in game_package["location_name_to_id"].items():
|
for location_name, location_id in game_package["location_name_to_id"].items():
|
||||||
self.location_names[game_name][location_id] = location_name
|
self.location_names[location_id] = location_name
|
||||||
self.all_item_and_group_names[game_name] = \
|
self.all_item_and_group_names[game_name] = \
|
||||||
set(game_package["item_name_to_id"]) | set(self.item_name_groups[game_name])
|
set(game_package["item_name_to_id"]) | set(self.item_name_groups[game_name])
|
||||||
self.all_location_and_group_names[game_name] = \
|
self.all_location_and_group_names[game_name] = \
|
||||||
set(game_package["location_name_to_id"]) | set(self.location_name_groups.get(game_name, []))
|
set(game_package["location_name_to_id"]) | set(self.location_name_groups.get(game_name, []))
|
||||||
|
|
||||||
archipelago_item_names = self.item_names["Archipelago"]
|
|
||||||
archipelago_location_names = self.location_names["Archipelago"]
|
|
||||||
for game in [game_name for game_name in self.gamespackage if game_name != "Archipelago"]:
|
|
||||||
# Add Archipelago items and locations to each data package.
|
|
||||||
self.item_names[game].update(archipelago_item_names)
|
|
||||||
self.location_names[game].update(archipelago_location_names)
|
|
||||||
|
|
||||||
def item_names_for_game(self, game: str) -> typing.Optional[typing.Dict[str, int]]:
|
def item_names_for_game(self, game: str) -> typing.Optional[typing.Dict[str, int]]:
|
||||||
return self.gamespackage[game]["item_name_to_id"] if game in self.gamespackage else None
|
return self.gamespackage[game]["item_name_to_id"] if game in self.gamespackage else None
|
||||||
|
|
||||||
@@ -368,28 +327,18 @@ class Context:
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
def broadcast_all(self, msgs: typing.List[dict]):
|
def broadcast_all(self, msgs: typing.List[dict]):
|
||||||
msg_is_text = all(msg["cmd"] == "PrintJSON" for msg in msgs)
|
msgs = self.dumper(msgs)
|
||||||
data = self.dumper(msgs)
|
endpoints = (endpoint for endpoint in self.endpoints if endpoint.auth)
|
||||||
endpoints = (
|
async_start(self.broadcast_send_encoded_msgs(endpoints, msgs))
|
||||||
endpoint
|
|
||||||
for endpoint in self.endpoints
|
|
||||||
if endpoint.auth and not (msg_is_text and endpoint.no_text)
|
|
||||||
)
|
|
||||||
async_start(self.broadcast_send_encoded_msgs(endpoints, data))
|
|
||||||
|
|
||||||
def broadcast_text_all(self, text: str, additional_arguments: dict = {}):
|
def broadcast_text_all(self, text: str, additional_arguments: dict = {}):
|
||||||
self.logger.info("Notice (all): %s" % text)
|
self.logger.info("Notice (all): %s" % text)
|
||||||
self.broadcast_all([{**{"cmd": "PrintJSON", "data": [{ "text": text }]}, **additional_arguments}])
|
self.broadcast_all([{**{"cmd": "PrintJSON", "data": [{ "text": text }]}, **additional_arguments}])
|
||||||
|
|
||||||
def broadcast_team(self, team: int, msgs: typing.List[dict]):
|
def broadcast_team(self, team: int, msgs: typing.List[dict]):
|
||||||
msg_is_text = all(msg["cmd"] == "PrintJSON" for msg in msgs)
|
msgs = self.dumper(msgs)
|
||||||
data = self.dumper(msgs)
|
endpoints = (endpoint for endpoint in itertools.chain.from_iterable(self.clients[team].values()))
|
||||||
endpoints = (
|
async_start(self.broadcast_send_encoded_msgs(endpoints, msgs))
|
||||||
endpoint
|
|
||||||
for endpoint in itertools.chain.from_iterable(self.clients[team].values())
|
|
||||||
if not (msg_is_text and endpoint.no_text)
|
|
||||||
)
|
|
||||||
async_start(self.broadcast_send_encoded_msgs(endpoints, data))
|
|
||||||
|
|
||||||
def broadcast(self, endpoints: typing.Iterable[Client], msgs: typing.List[dict]):
|
def broadcast(self, endpoints: typing.Iterable[Client], msgs: typing.List[dict]):
|
||||||
msgs = self.dumper(msgs)
|
msgs = self.dumper(msgs)
|
||||||
@@ -403,13 +352,13 @@ class Context:
|
|||||||
await on_client_disconnected(self, endpoint)
|
await on_client_disconnected(self, endpoint)
|
||||||
|
|
||||||
def notify_client(self, client: Client, text: str, additional_arguments: dict = {}):
|
def notify_client(self, client: Client, text: str, additional_arguments: dict = {}):
|
||||||
if not client.auth or client.no_text:
|
if not client.auth:
|
||||||
return
|
return
|
||||||
self.logger.info("Notice (Player %s in team %d): %s" % (client.name, client.team + 1, text))
|
self.logger.info("Notice (Player %s in team %d): %s" % (client.name, client.team + 1, text))
|
||||||
async_start(self.send_msgs(client, [{"cmd": "PrintJSON", "data": [{ "text": text }], **additional_arguments}]))
|
async_start(self.send_msgs(client, [{"cmd": "PrintJSON", "data": [{ "text": text }], **additional_arguments}]))
|
||||||
|
|
||||||
def notify_client_multiple(self, client: Client, texts: typing.List[str], additional_arguments: dict = {}):
|
def notify_client_multiple(self, client: Client, texts: typing.List[str], additional_arguments: dict = {}):
|
||||||
if not client.auth or client.no_text:
|
if not client.auth:
|
||||||
return
|
return
|
||||||
async_start(self.send_msgs(client,
|
async_start(self.send_msgs(client,
|
||||||
[{"cmd": "PrintJSON", "data": [{ "text": text }], **additional_arguments}
|
[{"cmd": "PrintJSON", "data": [{ "text": text }], **additional_arguments}
|
||||||
@@ -444,8 +393,6 @@ class Context:
|
|||||||
use_embedded_server_options: bool):
|
use_embedded_server_options: bool):
|
||||||
|
|
||||||
self.read_data = {}
|
self.read_data = {}
|
||||||
# there might be a better place to put this.
|
|
||||||
self.read_data["race_mode"] = lambda: decoded_obj.get("race_mode", 0)
|
|
||||||
mdata_ver = decoded_obj["minimum_versions"]["server"]
|
mdata_ver = decoded_obj["minimum_versions"]["server"]
|
||||||
if mdata_ver > version_tuple:
|
if mdata_ver > version_tuple:
|
||||||
raise RuntimeError(f"Supplied Multidata (.archipelago) requires a server of at least version {mdata_ver},"
|
raise RuntimeError(f"Supplied Multidata (.archipelago) requires a server of at least version {mdata_ver},"
|
||||||
@@ -458,7 +405,7 @@ class Context:
|
|||||||
|
|
||||||
self.slot_info = decoded_obj["slot_info"]
|
self.slot_info = decoded_obj["slot_info"]
|
||||||
self.games = {slot: slot_info.game for slot, slot_info in self.slot_info.items()}
|
self.games = {slot: slot_info.game for slot, slot_info in self.slot_info.items()}
|
||||||
self.groups = {slot: set(slot_info.group_members) for slot, slot_info in self.slot_info.items()
|
self.groups = {slot: slot_info.group_members for slot, slot_info in self.slot_info.items()
|
||||||
if slot_info.type == SlotType.group}
|
if slot_info.type == SlotType.group}
|
||||||
|
|
||||||
self.clients = {0: {}}
|
self.clients = {0: {}}
|
||||||
@@ -519,9 +466,6 @@ class Context:
|
|||||||
for game_name, data in self.location_name_groups.items():
|
for game_name, data in self.location_name_groups.items():
|
||||||
self.read_data[f"location_name_groups_{game_name}"] = lambda lgame=game_name: self.location_name_groups[lgame]
|
self.read_data[f"location_name_groups_{game_name}"] = lambda lgame=game_name: self.location_name_groups[lgame]
|
||||||
|
|
||||||
# sorted access spheres
|
|
||||||
self.spheres = decoded_obj.get("spheres", [])
|
|
||||||
|
|
||||||
# saving
|
# saving
|
||||||
|
|
||||||
def save(self, now=False) -> bool:
|
def save(self, now=False) -> bool:
|
||||||
@@ -564,7 +508,7 @@ class Context:
|
|||||||
self.logger.exception(e)
|
self.logger.exception(e)
|
||||||
self._start_async_saving()
|
self._start_async_saving()
|
||||||
|
|
||||||
def _start_async_saving(self, atexit_save: bool = True):
|
def _start_async_saving(self):
|
||||||
if not self.auto_saver_thread:
|
if not self.auto_saver_thread:
|
||||||
def save_regularly():
|
def save_regularly():
|
||||||
# time.time() is platform dependent, so using the expensive datetime method instead
|
# time.time() is platform dependent, so using the expensive datetime method instead
|
||||||
@@ -585,15 +529,11 @@ class Context:
|
|||||||
self.logger.info(f"Saving failed. Retry in {self.auto_save_interval} seconds.")
|
self.logger.info(f"Saving failed. Retry in {self.auto_save_interval} seconds.")
|
||||||
else:
|
else:
|
||||||
self.save_dirty = False
|
self.save_dirty = False
|
||||||
if not atexit_save: # if atexit is used, that keeps a reference anyway
|
|
||||||
queue_gc()
|
|
||||||
|
|
||||||
self.auto_saver_thread = threading.Thread(target=save_regularly, daemon=True)
|
self.auto_saver_thread = threading.Thread(target=save_regularly, daemon=True)
|
||||||
self.auto_saver_thread.start()
|
self.auto_saver_thread.start()
|
||||||
|
|
||||||
if atexit_save:
|
import atexit
|
||||||
import atexit
|
atexit.register(self._save, True) # make sure we save on exit too
|
||||||
atexit.register(self._save, True) # make sure we save on exit too
|
|
||||||
|
|
||||||
def get_save(self) -> dict:
|
def get_save(self) -> dict:
|
||||||
self.recheck_hints()
|
self.recheck_hints()
|
||||||
@@ -671,44 +611,18 @@ class Context:
|
|||||||
return max(1, int(self.hint_cost * 0.01 * len(self.locations[slot])))
|
return max(1, int(self.hint_cost * 0.01 * len(self.locations[slot])))
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
def recheck_hints(self, team: typing.Optional[int] = None, slot: typing.Optional[int] = None,
|
def recheck_hints(self, team: typing.Optional[int] = None, slot: typing.Optional[int] = None):
|
||||||
changed: typing.Optional[typing.Set[team_slot]] = None) -> None:
|
|
||||||
"""Refreshes the hints for the specified team/slot. Providing 'None' for either team or slot
|
|
||||||
will refresh all teams or all slots respectively. If a set is passed for 'changed', each (team,slot)
|
|
||||||
pair that has at least one hint modified will be added to the set.
|
|
||||||
"""
|
|
||||||
for hint_team, hint_slot in self.hints:
|
for hint_team, hint_slot in self.hints:
|
||||||
if team != hint_team and team is not None:
|
if (team is None or team == hint_team) and (slot is None or slot == hint_slot):
|
||||||
continue # Check specified team only, all if team is None
|
self.hints[hint_team, hint_slot] = {
|
||||||
if slot != hint_slot and slot is not None:
|
hint.re_check(self, hint_team) for hint in
|
||||||
continue # Check specified slot only, all if slot is None
|
self.hints[hint_team, hint_slot]
|
||||||
new_hints: typing.Set[Hint] = set()
|
}
|
||||||
for hint in self.hints[hint_team, hint_slot]:
|
|
||||||
new_hint = hint.re_check(self, hint_team)
|
|
||||||
new_hints.add(new_hint)
|
|
||||||
if hint == new_hint:
|
|
||||||
continue
|
|
||||||
for player in self.slot_set(hint.receiving_player) | {hint.finding_player}:
|
|
||||||
if changed is not None:
|
|
||||||
changed.add((hint_team,player))
|
|
||||||
if slot is not None and slot != player:
|
|
||||||
self.replace_hint(hint_team, player, hint, new_hint)
|
|
||||||
self.hints[hint_team, hint_slot] = new_hints
|
|
||||||
|
|
||||||
def get_rechecked_hints(self, team: int, slot: int):
|
def get_rechecked_hints(self, team: int, slot: int):
|
||||||
self.recheck_hints(team, slot)
|
self.recheck_hints(team, slot)
|
||||||
return self.hints[team, slot]
|
return self.hints[team, slot]
|
||||||
|
|
||||||
def get_sphere(self, player: int, location_id: int) -> int:
|
|
||||||
"""Get sphere of a location, -1 if spheres are not available."""
|
|
||||||
if self.spheres:
|
|
||||||
for i, sphere in enumerate(self.spheres):
|
|
||||||
if location_id in sphere.get(player, set()):
|
|
||||||
return i
|
|
||||||
raise KeyError(f"No Sphere found for location ID {location_id} belonging to player {player}. "
|
|
||||||
f"Location or player may not exist.")
|
|
||||||
return -1
|
|
||||||
|
|
||||||
def get_players_package(self):
|
def get_players_package(self):
|
||||||
return [NetworkPlayer(t, p, self.get_aliased_name(t, p), n) for (t, p), n in self.player_names.items()]
|
return [NetworkPlayer(t, p, self.get_aliased_name(t, p), n) for (t, p), n in self.player_names.items()]
|
||||||
|
|
||||||
@@ -742,7 +656,7 @@ class Context:
|
|||||||
else:
|
else:
|
||||||
return self.player_names[team, slot]
|
return self.player_names[team, slot]
|
||||||
|
|
||||||
def notify_hints(self, team: int, hints: typing.List[Hint], only_new: bool = False,
|
def notify_hints(self, team: int, hints: typing.List[NetUtils.Hint], only_new: bool = False,
|
||||||
recipients: typing.Sequence[int] = None):
|
recipients: typing.Sequence[int] = None):
|
||||||
"""Send and remember hints."""
|
"""Send and remember hints."""
|
||||||
if only_new:
|
if only_new:
|
||||||
@@ -757,8 +671,7 @@ class Context:
|
|||||||
concerns[player].append(data)
|
concerns[player].append(data)
|
||||||
if not hint.local and data not in concerns[hint.finding_player]:
|
if not hint.local and data not in concerns[hint.finding_player]:
|
||||||
concerns[hint.finding_player].append(data)
|
concerns[hint.finding_player].append(data)
|
||||||
|
# remember hints in all cases
|
||||||
# only remember hints that were not already found at the time of creation
|
|
||||||
if not hint.found:
|
if not hint.found:
|
||||||
# since hints are bidirectional, finding player and receiving player,
|
# since hints are bidirectional, finding player and receiving player,
|
||||||
# we can check once if hint already exists
|
# we can check once if hint already exists
|
||||||
@@ -774,24 +687,13 @@ class Context:
|
|||||||
self.on_new_hint(team, slot)
|
self.on_new_hint(team, slot)
|
||||||
for slot, hint_data in concerns.items():
|
for slot, hint_data in concerns.items():
|
||||||
if recipients is None or slot in recipients:
|
if recipients is None or slot in recipients:
|
||||||
clients = filter(lambda c: not c.no_text, self.clients[team].get(slot, []))
|
clients = self.clients[team].get(slot)
|
||||||
if not clients:
|
if not clients:
|
||||||
continue
|
continue
|
||||||
client_hints = [datum[1] for datum in sorted(hint_data, key=lambda x: x[0].finding_player != slot)]
|
client_hints = [datum[1] for datum in sorted(hint_data, key=lambda x: x[0].finding_player != slot)]
|
||||||
for client in clients:
|
for client in clients:
|
||||||
async_start(self.send_msgs(client, client_hints))
|
async_start(self.send_msgs(client, client_hints))
|
||||||
|
|
||||||
def get_hint(self, team: int, finding_player: int, seeked_location: int) -> typing.Optional[Hint]:
|
|
||||||
for hint in self.hints[team, finding_player]:
|
|
||||||
if hint.location == seeked_location and hint.finding_player == finding_player:
|
|
||||||
return hint
|
|
||||||
return None
|
|
||||||
|
|
||||||
def replace_hint(self, team: int, slot: int, old_hint: Hint, new_hint: Hint) -> None:
|
|
||||||
if old_hint in self.hints[team, slot]:
|
|
||||||
self.hints[team, slot].remove(old_hint)
|
|
||||||
self.hints[team, slot].add(new_hint)
|
|
||||||
|
|
||||||
# "events"
|
# "events"
|
||||||
|
|
||||||
def on_goal_achieved(self, client: Client):
|
def on_goal_achieved(self, client: Client):
|
||||||
@@ -833,7 +735,7 @@ def update_aliases(ctx: Context, team: int):
|
|||||||
async_start(ctx.send_encoded_msgs(client, cmd))
|
async_start(ctx.send_encoded_msgs(client, cmd))
|
||||||
|
|
||||||
|
|
||||||
async def server(websocket: "ServerConnection", path: str = "/", ctx: Context = None) -> None:
|
async def server(websocket, path: str = "/", ctx: Context = None):
|
||||||
client = Client(websocket, ctx)
|
client = Client(websocket, ctx)
|
||||||
ctx.endpoints.append(client)
|
ctx.endpoints.append(client)
|
||||||
|
|
||||||
@@ -863,7 +765,10 @@ async def on_client_connected(ctx: Context, client: Client):
|
|||||||
for slot, connected_clients in clients.items():
|
for slot, connected_clients in clients.items():
|
||||||
if connected_clients:
|
if connected_clients:
|
||||||
name = ctx.player_names[team, slot]
|
name = ctx.player_names[team, slot]
|
||||||
players.append(NetworkPlayer(team, slot, ctx.name_aliases.get((team, slot), name), name))
|
players.append(
|
||||||
|
NetworkPlayer(team, slot,
|
||||||
|
ctx.name_aliases.get((team, slot), name), name)
|
||||||
|
)
|
||||||
games = {ctx.games[x] for x in range(1, len(ctx.games) + 1)}
|
games = {ctx.games[x] for x in range(1, len(ctx.games) + 1)}
|
||||||
games.add("Archipelago")
|
games.add("Archipelago")
|
||||||
await ctx.send_msgs(client, [{
|
await ctx.send_msgs(client, [{
|
||||||
@@ -878,6 +783,8 @@ async def on_client_connected(ctx: Context, client: Client):
|
|||||||
'permissions': get_permissions(ctx),
|
'permissions': get_permissions(ctx),
|
||||||
'hint_cost': ctx.hint_cost,
|
'hint_cost': ctx.hint_cost,
|
||||||
'location_check_points': ctx.location_check_points,
|
'location_check_points': ctx.location_check_points,
|
||||||
|
'datapackage_versions': {game: game_data["version"] for game, game_data
|
||||||
|
in ctx.gamespackage.items() if game in games},
|
||||||
'datapackage_checksums': {game: game_data["checksum"] for game, game_data
|
'datapackage_checksums': {game: game_data["checksum"] for game, game_data
|
||||||
in ctx.gamespackage.items() if game in games and "checksum" in game_data},
|
in ctx.gamespackage.items() if game in games and "checksum" in game_data},
|
||||||
'seed_name': ctx.seed_name,
|
'seed_name': ctx.seed_name,
|
||||||
@@ -924,10 +831,6 @@ async def on_client_joined(ctx: Context, client: Client):
|
|||||||
"If your client supports it, "
|
"If your client supports it, "
|
||||||
"you may have additional local commands you can list with /help.",
|
"you may have additional local commands you can list with /help.",
|
||||||
{"type": "Tutorial"})
|
{"type": "Tutorial"})
|
||||||
if not any(isinstance(extension, PerMessageDeflate) for extension in client.socket.extensions):
|
|
||||||
ctx.notify_client(client, "Warning: your client does not support compressed websocket connections! "
|
|
||||||
"It may stop working in the future. If you are a player, please report this to the "
|
|
||||||
"client's developer.")
|
|
||||||
ctx.client_connection_timers[client.team, client.slot] = datetime.datetime.now(datetime.timezone.utc)
|
ctx.client_connection_timers[client.team, client.slot] = datetime.datetime.now(datetime.timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
@@ -994,13 +897,9 @@ def get_status_string(ctx: Context, team: int, tag: str):
|
|||||||
tagged = len([client for client in ctx.clients[team][slot] if tag in client.tags])
|
tagged = len([client for client in ctx.clients[team][slot] if tag in client.tags])
|
||||||
completion_text = f"({len(ctx.location_checks[team, slot])}/{len(ctx.locations[slot])})"
|
completion_text = f"({len(ctx.location_checks[team, slot])}/{len(ctx.locations[slot])})"
|
||||||
tag_text = f" {tagged} of which are tagged {tag}" if connected and tag else ""
|
tag_text = f" {tagged} of which are tagged {tag}" if connected and tag else ""
|
||||||
status_text = (
|
goal_text = " and has finished." if ctx.client_game_state[team, slot] == ClientStatus.CLIENT_GOAL else "."
|
||||||
" and has finished." if ctx.client_game_state[team, slot] == ClientStatus.CLIENT_GOAL else
|
|
||||||
" and is ready." if ctx.client_game_state[team, slot] == ClientStatus.CLIENT_READY else
|
|
||||||
"."
|
|
||||||
)
|
|
||||||
text += f"\n{ctx.get_aliased_name(team, slot)} has {connected} connection{'' if connected == 1 else 's'}" \
|
text += f"\n{ctx.get_aliased_name(team, slot)} has {connected} connection{'' if connected == 1 else 's'}" \
|
||||||
f"{tag_text}{status_text} {completion_text}"
|
f"{tag_text}{goal_text} {completion_text}"
|
||||||
return text
|
return text
|
||||||
|
|
||||||
|
|
||||||
@@ -1064,7 +963,7 @@ def collect_player(ctx: Context, team: int, slot: int, is_group: bool = False):
|
|||||||
collect_player(ctx, team, group, True)
|
collect_player(ctx, team, group, True)
|
||||||
|
|
||||||
|
|
||||||
def get_remaining(ctx: Context, team: int, slot: int) -> typing.List[typing.Tuple[int, int]]:
|
def get_remaining(ctx: Context, team: int, slot: int) -> typing.List[int]:
|
||||||
return ctx.locations.get_remaining(ctx.location_checks, team, slot)
|
return ctx.locations.get_remaining(ctx.location_checks, team, slot)
|
||||||
|
|
||||||
|
|
||||||
@@ -1078,37 +977,21 @@ def send_items_to(ctx: Context, team: int, target_slot: int, *items: NetworkItem
|
|||||||
|
|
||||||
def register_location_checks(ctx: Context, team: int, slot: int, locations: typing.Iterable[int],
|
def register_location_checks(ctx: Context, team: int, slot: int, locations: typing.Iterable[int],
|
||||||
count_activity: bool = True):
|
count_activity: bool = True):
|
||||||
slot_locations = ctx.locations[slot]
|
|
||||||
new_locations = set(locations) - ctx.location_checks[team, slot]
|
new_locations = set(locations) - ctx.location_checks[team, slot]
|
||||||
new_locations.intersection_update(slot_locations) # ignore location IDs unknown to this multidata
|
new_locations.intersection_update(ctx.locations[slot]) # ignore location IDs unknown to this multidata
|
||||||
if new_locations:
|
if new_locations:
|
||||||
if count_activity:
|
if count_activity:
|
||||||
ctx.client_activity_timers[team, slot] = datetime.datetime.now(datetime.timezone.utc)
|
ctx.client_activity_timers[team, slot] = datetime.datetime.now(datetime.timezone.utc)
|
||||||
|
|
||||||
sortable: list[tuple[int, int, int, int]] = []
|
|
||||||
for location in new_locations:
|
for location in new_locations:
|
||||||
# extract all fields to avoid runtime overhead in LocationStore
|
item_id, target_player, flags = ctx.locations[slot][location]
|
||||||
item_id, target_player, flags = slot_locations[location]
|
|
||||||
# sort/group by receiver and item
|
|
||||||
sortable.append((target_player, item_id, location, flags))
|
|
||||||
|
|
||||||
info_texts: list[dict[str, typing.Any]] = []
|
|
||||||
for target_player, item_id, location, flags in sorted(sortable):
|
|
||||||
new_item = NetworkItem(item_id, location, slot, flags)
|
new_item = NetworkItem(item_id, location, slot, flags)
|
||||||
send_items_to(ctx, team, target_player, new_item)
|
send_items_to(ctx, team, target_player, new_item)
|
||||||
|
|
||||||
ctx.logger.info('(Team #%d) %s sent %s to %s (%s)' % (
|
ctx.logger.info('(Team #%d) %s sent %s to %s (%s)' % (
|
||||||
team + 1, ctx.player_names[(team, slot)], ctx.item_names[ctx.slot_info[target_player].game][item_id],
|
team + 1, ctx.player_names[(team, slot)], ctx.item_names[item_id],
|
||||||
ctx.player_names[(team, target_player)], ctx.location_names[ctx.slot_info[slot].game][location]))
|
ctx.player_names[(team, target_player)], ctx.location_names[location]))
|
||||||
if len(info_texts) >= 140:
|
info_text = json_format_send_event(new_item, target_player)
|
||||||
# split into chunks that are close to compression window of 64K but not too big on the wire
|
ctx.broadcast_team(team, [info_text])
|
||||||
# (roughly 1300-2600 bytes after compression depending on repetitiveness)
|
|
||||||
ctx.broadcast_team(team, info_texts)
|
|
||||||
info_texts.clear()
|
|
||||||
info_texts.append(json_format_send_event(new_item, target_player))
|
|
||||||
ctx.broadcast_team(team, info_texts)
|
|
||||||
del info_texts
|
|
||||||
del sortable
|
|
||||||
|
|
||||||
ctx.location_checks[team, slot] |= new_locations
|
ctx.location_checks[team, slot] |= new_locations
|
||||||
send_new_items(ctx)
|
send_new_items(ctx)
|
||||||
@@ -1117,15 +1000,14 @@ def register_location_checks(ctx: Context, team: int, slot: int, locations: typi
|
|||||||
"hint_points": get_slot_points(ctx, team, slot),
|
"hint_points": get_slot_points(ctx, team, slot),
|
||||||
"checked_locations": new_locations, # send back new checks only
|
"checked_locations": new_locations, # send back new checks only
|
||||||
}])
|
}])
|
||||||
updated_slots: typing.Set[tuple[int, int]] = set()
|
old_hints = ctx.hints[team, slot].copy()
|
||||||
ctx.recheck_hints(team, slot, updated_slots)
|
ctx.recheck_hints(team, slot)
|
||||||
for hint_team, hint_slot in updated_slots:
|
if old_hints != ctx.hints[team, slot]:
|
||||||
ctx.on_changed_hints(hint_team, hint_slot)
|
ctx.on_changed_hints(team, slot)
|
||||||
ctx.save()
|
ctx.save()
|
||||||
|
|
||||||
|
|
||||||
def collect_hints(ctx: Context, team: int, slot: int, item: typing.Union[int, str], auto_status: HintStatus) \
|
def collect_hints(ctx: Context, team: int, slot: int, item: typing.Union[int, str]) -> typing.List[NetUtils.Hint]:
|
||||||
-> typing.List[Hint]:
|
|
||||||
hints = []
|
hints = []
|
||||||
slots: typing.Set[int] = {slot}
|
slots: typing.Set[int] = {slot}
|
||||||
for group_id, group in ctx.groups.items():
|
for group_id, group in ctx.groups.items():
|
||||||
@@ -1135,67 +1017,39 @@ def collect_hints(ctx: Context, team: int, slot: int, item: typing.Union[int, st
|
|||||||
seeked_item_id = item if isinstance(item, int) else ctx.item_names_for_game(ctx.games[slot])[item]
|
seeked_item_id = item if isinstance(item, int) else ctx.item_names_for_game(ctx.games[slot])[item]
|
||||||
for finding_player, location_id, item_id, receiving_player, item_flags \
|
for finding_player, location_id, item_id, receiving_player, item_flags \
|
||||||
in ctx.locations.find_item(slots, seeked_item_id):
|
in ctx.locations.find_item(slots, seeked_item_id):
|
||||||
prev_hint = ctx.get_hint(team, finding_player, location_id)
|
found = location_id in ctx.location_checks[team, finding_player]
|
||||||
if prev_hint:
|
entrance = ctx.er_hint_data.get(finding_player, {}).get(location_id, "")
|
||||||
hints.append(prev_hint)
|
hints.append(NetUtils.Hint(receiving_player, finding_player, location_id, item_id, found, entrance,
|
||||||
else:
|
item_flags))
|
||||||
found = location_id in ctx.location_checks[team, finding_player]
|
|
||||||
entrance = ctx.er_hint_data.get(finding_player, {}).get(location_id, "")
|
|
||||||
new_status = auto_status
|
|
||||||
if found:
|
|
||||||
new_status = HintStatus.HINT_FOUND
|
|
||||||
elif item_flags & ItemClassification.trap:
|
|
||||||
new_status = HintStatus.HINT_AVOID
|
|
||||||
hints.append(Hint(receiving_player, finding_player, location_id, item_id, found, entrance,
|
|
||||||
item_flags, new_status))
|
|
||||||
|
|
||||||
return hints
|
return hints
|
||||||
|
|
||||||
|
|
||||||
def collect_hint_location_name(ctx: Context, team: int, slot: int, location: str, auto_status: HintStatus) \
|
def collect_hint_location_name(ctx: Context, team: int, slot: int, location: str) -> typing.List[NetUtils.Hint]:
|
||||||
-> typing.List[Hint]:
|
|
||||||
seeked_location: int = ctx.location_names_for_game(ctx.games[slot])[location]
|
seeked_location: int = ctx.location_names_for_game(ctx.games[slot])[location]
|
||||||
return collect_hint_location_id(ctx, team, slot, seeked_location, auto_status)
|
return collect_hint_location_id(ctx, team, slot, seeked_location)
|
||||||
|
|
||||||
|
|
||||||
def collect_hint_location_id(ctx: Context, team: int, slot: int, seeked_location: int, auto_status: HintStatus) \
|
def collect_hint_location_id(ctx: Context, team: int, slot: int, seeked_location: int) -> typing.List[NetUtils.Hint]:
|
||||||
-> typing.List[Hint]:
|
|
||||||
prev_hint = ctx.get_hint(team, slot, seeked_location)
|
|
||||||
if prev_hint:
|
|
||||||
return [prev_hint]
|
|
||||||
result = ctx.locations[slot].get(seeked_location, (None, None, None))
|
result = ctx.locations[slot].get(seeked_location, (None, None, None))
|
||||||
if any(result):
|
if any(result):
|
||||||
item_id, receiving_player, item_flags = result
|
item_id, receiving_player, item_flags = result
|
||||||
|
|
||||||
found = seeked_location in ctx.location_checks[team, slot]
|
found = seeked_location in ctx.location_checks[team, slot]
|
||||||
entrance = ctx.er_hint_data.get(slot, {}).get(seeked_location, "")
|
entrance = ctx.er_hint_data.get(slot, {}).get(seeked_location, "")
|
||||||
new_status = auto_status
|
return [NetUtils.Hint(receiving_player, slot, seeked_location, item_id, found, entrance, item_flags)]
|
||||||
if found:
|
|
||||||
new_status = HintStatus.HINT_FOUND
|
|
||||||
elif item_flags & ItemClassification.trap:
|
|
||||||
new_status = HintStatus.HINT_AVOID
|
|
||||||
return [Hint(receiving_player, slot, seeked_location, item_id, found, entrance, item_flags,
|
|
||||||
new_status)]
|
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
||||||
status_names: typing.Dict[HintStatus, str] = {
|
def format_hint(ctx: Context, team: int, hint: NetUtils.Hint) -> str:
|
||||||
HintStatus.HINT_FOUND: "(found)",
|
|
||||||
HintStatus.HINT_UNSPECIFIED: "(unspecified)",
|
|
||||||
HintStatus.HINT_NO_PRIORITY: "(no priority)",
|
|
||||||
HintStatus.HINT_AVOID: "(avoid)",
|
|
||||||
HintStatus.HINT_PRIORITY: "(priority)",
|
|
||||||
}
|
|
||||||
def format_hint(ctx: Context, team: int, hint: Hint) -> str:
|
|
||||||
text = f"[Hint]: {ctx.player_names[team, hint.receiving_player]}'s " \
|
text = f"[Hint]: {ctx.player_names[team, hint.receiving_player]}'s " \
|
||||||
f"{ctx.item_names[ctx.slot_info[hint.receiving_player].game][hint.item]} is " \
|
f"{ctx.item_names[hint.item]} is " \
|
||||||
f"at {ctx.location_names[ctx.slot_info[hint.finding_player].game][hint.location]} " \
|
f"at {ctx.location_names[hint.location]} " \
|
||||||
f"in {ctx.player_names[team, hint.finding_player]}'s World"
|
f"in {ctx.player_names[team, hint.finding_player]}'s World"
|
||||||
|
|
||||||
if hint.entrance:
|
if hint.entrance:
|
||||||
text += f" at {hint.entrance}"
|
text += f" at {hint.entrance}"
|
||||||
|
return text + (". (found)" if hint.found else ".")
|
||||||
return text + ". " + status_names.get(hint.status, "(unknown)")
|
|
||||||
|
|
||||||
|
|
||||||
def json_format_send_event(net_item: NetworkItem, receiving_player: int):
|
def json_format_send_event(net_item: NetworkItem, receiving_player: int):
|
||||||
@@ -1219,6 +1073,28 @@ def json_format_send_event(net_item: NetworkItem, receiving_player: int):
|
|||||||
"item": net_item}
|
"item": net_item}
|
||||||
|
|
||||||
|
|
||||||
|
def get_intended_text(input_text: str, possible_answers) -> typing.Tuple[str, bool, str]:
|
||||||
|
picks = Utils.get_fuzzy_results(input_text, possible_answers, limit=2)
|
||||||
|
if len(picks) > 1:
|
||||||
|
dif = picks[0][1] - picks[1][1]
|
||||||
|
if picks[0][1] == 100:
|
||||||
|
return picks[0][0], True, "Perfect Match"
|
||||||
|
elif picks[0][1] < 75:
|
||||||
|
return picks[0][0], False, f"Didn't find something that closely matches '{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)"
|
||||||
|
|
||||||
|
|
||||||
class CommandMeta(type):
|
class CommandMeta(type):
|
||||||
def __new__(cls, name, bases, attrs):
|
def __new__(cls, name, bases, attrs):
|
||||||
commands = attrs["commands"] = {}
|
commands = attrs["commands"] = {}
|
||||||
@@ -1250,10 +1126,7 @@ class CommandProcessor(metaclass=CommandMeta):
|
|||||||
if not raw:
|
if not raw:
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
try:
|
command = raw.split()
|
||||||
command = shlex.split(raw, comments=False)
|
|
||||||
except ValueError: # most likely: "ValueError: No closing quotation"
|
|
||||||
command = raw.split()
|
|
||||||
basecommand = command[0]
|
basecommand = command[0]
|
||||||
if basecommand[0] == self.marker:
|
if basecommand[0] == self.marker:
|
||||||
method = self.commands.get(basecommand[1:].lower(), None)
|
method = self.commands.get(basecommand[1:].lower(), None)
|
||||||
@@ -1324,10 +1197,6 @@ class CommonCommandProcessor(CommandProcessor):
|
|||||||
timer = int(seconds, 10)
|
timer = int(seconds, 10)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
timer = 10
|
timer = 10
|
||||||
else:
|
|
||||||
if timer > 60 * 60:
|
|
||||||
raise ValueError(f"{timer} is invalid. Maximum is 1 hour.")
|
|
||||||
|
|
||||||
async_start(countdown(self.ctx, timer))
|
async_start(countdown(self.ctx, timer))
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -1475,10 +1344,10 @@ class ClientMessageProcessor(CommonCommandProcessor):
|
|||||||
def _cmd_remaining(self) -> bool:
|
def _cmd_remaining(self) -> bool:
|
||||||
"""List remaining items in your game, but not their location or recipient"""
|
"""List remaining items in your game, but not their location or recipient"""
|
||||||
if self.ctx.remaining_mode == "enabled":
|
if self.ctx.remaining_mode == "enabled":
|
||||||
rest_locations = get_remaining(self.ctx, self.client.team, self.client.slot)
|
remaining_item_ids = get_remaining(self.ctx, self.client.team, self.client.slot)
|
||||||
if rest_locations:
|
if remaining_item_ids:
|
||||||
self.output("Remaining items: " + ", ".join(self.ctx.item_names[self.ctx.games[slot]][item_id]
|
self.output("Remaining items: " + ", ".join(self.ctx.item_names[item_id]
|
||||||
for slot, item_id in rest_locations))
|
for item_id in remaining_item_ids))
|
||||||
else:
|
else:
|
||||||
self.output("No remaining items found.")
|
self.output("No remaining items found.")
|
||||||
return True
|
return True
|
||||||
@@ -1488,10 +1357,10 @@ class ClientMessageProcessor(CommonCommandProcessor):
|
|||||||
return False
|
return False
|
||||||
else: # is goal
|
else: # is goal
|
||||||
if self.ctx.client_game_state[self.client.team, self.client.slot] == ClientStatus.CLIENT_GOAL:
|
if self.ctx.client_game_state[self.client.team, self.client.slot] == ClientStatus.CLIENT_GOAL:
|
||||||
rest_locations = get_remaining(self.ctx, self.client.team, self.client.slot)
|
remaining_item_ids = get_remaining(self.ctx, self.client.team, self.client.slot)
|
||||||
if rest_locations:
|
if remaining_item_ids:
|
||||||
self.output("Remaining items: " + ", ".join(self.ctx.item_names[self.ctx.games[slot]][item_id]
|
self.output("Remaining items: " + ", ".join(self.ctx.item_names[item_id]
|
||||||
for slot, item_id in rest_locations))
|
for item_id in remaining_item_ids))
|
||||||
else:
|
else:
|
||||||
self.output("No remaining items found.")
|
self.output("No remaining items found.")
|
||||||
return True
|
return True
|
||||||
@@ -1508,8 +1377,7 @@ class ClientMessageProcessor(CommonCommandProcessor):
|
|||||||
locations = get_missing_checks(self.ctx, self.client.team, self.client.slot)
|
locations = get_missing_checks(self.ctx, self.client.team, self.client.slot)
|
||||||
|
|
||||||
if locations:
|
if locations:
|
||||||
game = self.ctx.slot_info[self.client.slot].game
|
names = [self.ctx.location_names[location] for location in locations]
|
||||||
names = [self.ctx.location_names[game][location] for location in locations]
|
|
||||||
if filter_text:
|
if filter_text:
|
||||||
location_groups = self.ctx.location_name_groups[self.ctx.games[self.client.slot]]
|
location_groups = self.ctx.location_name_groups[self.ctx.games[self.client.slot]]
|
||||||
if filter_text in location_groups: # location group name
|
if filter_text in location_groups: # location group name
|
||||||
@@ -1534,8 +1402,7 @@ class ClientMessageProcessor(CommonCommandProcessor):
|
|||||||
locations = get_checked_checks(self.ctx, self.client.team, self.client.slot)
|
locations = get_checked_checks(self.ctx, self.client.team, self.client.slot)
|
||||||
|
|
||||||
if locations:
|
if locations:
|
||||||
game = self.ctx.slot_info[self.client.slot].game
|
names = [self.ctx.location_names[location] for location in locations]
|
||||||
names = [self.ctx.location_names[game][location] for location in locations]
|
|
||||||
if filter_text:
|
if filter_text:
|
||||||
location_groups = self.ctx.location_name_groups[self.ctx.games[self.client.slot]]
|
location_groups = self.ctx.location_name_groups[self.ctx.games[self.client.slot]]
|
||||||
if filter_text in location_groups: # location group name
|
if filter_text in location_groups: # location group name
|
||||||
@@ -1599,7 +1466,7 @@ class ClientMessageProcessor(CommonCommandProcessor):
|
|||||||
def get_hints(self, input_text: str, for_location: bool = False) -> bool:
|
def get_hints(self, input_text: str, for_location: bool = False) -> bool:
|
||||||
points_available = get_client_points(self.ctx, self.client)
|
points_available = get_client_points(self.ctx, self.client)
|
||||||
cost = self.ctx.get_hint_cost(self.client.slot)
|
cost = self.ctx.get_hint_cost(self.client.slot)
|
||||||
auto_status = HintStatus.HINT_UNSPECIFIED if for_location else HintStatus.HINT_PRIORITY
|
|
||||||
if not input_text:
|
if not input_text:
|
||||||
hints = {hint.re_check(self.ctx, self.client.team) for hint in
|
hints = {hint.re_check(self.ctx, self.client.team) for hint in
|
||||||
self.ctx.hints[self.client.team, self.client.slot]}
|
self.ctx.hints[self.client.team, self.client.slot]}
|
||||||
@@ -1616,18 +1483,18 @@ class ClientMessageProcessor(CommonCommandProcessor):
|
|||||||
elif input_text.isnumeric():
|
elif input_text.isnumeric():
|
||||||
game = self.ctx.games[self.client.slot]
|
game = self.ctx.games[self.client.slot]
|
||||||
hint_id = int(input_text)
|
hint_id = int(input_text)
|
||||||
hint_name = self.ctx.item_names[game][hint_id] \
|
hint_name = self.ctx.item_names[hint_id] \
|
||||||
if not for_location and hint_id in self.ctx.item_names[game] \
|
if not for_location and hint_id in self.ctx.item_names \
|
||||||
else self.ctx.location_names[game][hint_id] \
|
else self.ctx.location_names[hint_id] \
|
||||||
if for_location and hint_id in self.ctx.location_names[game] \
|
if for_location and hint_id in self.ctx.location_names \
|
||||||
else None
|
else None
|
||||||
if hint_name in self.ctx.non_hintable_names[game]:
|
if hint_name in self.ctx.non_hintable_names[game]:
|
||||||
self.output(f"Sorry, \"{hint_name}\" is marked as non-hintable.")
|
self.output(f"Sorry, \"{hint_name}\" is marked as non-hintable.")
|
||||||
hints = []
|
hints = []
|
||||||
elif not for_location:
|
elif not for_location:
|
||||||
hints = collect_hints(self.ctx, self.client.team, self.client.slot, hint_id, auto_status)
|
hints = collect_hints(self.ctx, self.client.team, self.client.slot, hint_id)
|
||||||
else:
|
else:
|
||||||
hints = collect_hint_location_id(self.ctx, self.client.team, self.client.slot, hint_id, auto_status)
|
hints = collect_hint_location_id(self.ctx, self.client.team, self.client.slot, hint_id)
|
||||||
|
|
||||||
else:
|
else:
|
||||||
game = self.ctx.games[self.client.slot]
|
game = self.ctx.games[self.client.slot]
|
||||||
@@ -1647,16 +1514,16 @@ class ClientMessageProcessor(CommonCommandProcessor):
|
|||||||
hints = []
|
hints = []
|
||||||
for item_name in self.ctx.item_name_groups[game][hint_name]:
|
for item_name in self.ctx.item_name_groups[game][hint_name]:
|
||||||
if item_name in self.ctx.item_names_for_game(game): # ensure item has an ID
|
if item_name in self.ctx.item_names_for_game(game): # ensure item has an ID
|
||||||
hints.extend(collect_hints(self.ctx, self.client.team, self.client.slot, item_name, auto_status))
|
hints.extend(collect_hints(self.ctx, self.client.team, self.client.slot, item_name))
|
||||||
elif not for_location and hint_name in self.ctx.item_names_for_game(game): # item name
|
elif not for_location and hint_name in self.ctx.item_names_for_game(game): # item name
|
||||||
hints = collect_hints(self.ctx, self.client.team, self.client.slot, hint_name, auto_status)
|
hints = collect_hints(self.ctx, self.client.team, self.client.slot, hint_name)
|
||||||
elif hint_name in self.ctx.location_name_groups[game]: # location group name
|
elif hint_name in self.ctx.location_name_groups[game]: # location group name
|
||||||
hints = []
|
hints = []
|
||||||
for loc_name in self.ctx.location_name_groups[game][hint_name]:
|
for loc_name in self.ctx.location_name_groups[game][hint_name]:
|
||||||
if loc_name in self.ctx.location_names_for_game(game):
|
if loc_name in self.ctx.location_names_for_game(game):
|
||||||
hints.extend(collect_hint_location_name(self.ctx, self.client.team, self.client.slot, loc_name, auto_status))
|
hints.extend(collect_hint_location_name(self.ctx, self.client.team, self.client.slot, loc_name))
|
||||||
else: # location name
|
else: # location name
|
||||||
hints = collect_hint_location_name(self.ctx, self.client.team, self.client.slot, hint_name, auto_status)
|
hints = collect_hint_location_name(self.ctx, self.client.team, self.client.slot, hint_name)
|
||||||
|
|
||||||
else:
|
else:
|
||||||
self.output(response)
|
self.output(response)
|
||||||
@@ -1681,9 +1548,6 @@ class ClientMessageProcessor(CommonCommandProcessor):
|
|||||||
self.ctx.random.shuffle(not_found_hints)
|
self.ctx.random.shuffle(not_found_hints)
|
||||||
# By popular vote, make hints prefer non-local placements
|
# By popular vote, make hints prefer non-local placements
|
||||||
not_found_hints.sort(key=lambda hint: int(hint.receiving_player != hint.finding_player))
|
not_found_hints.sort(key=lambda hint: int(hint.receiving_player != hint.finding_player))
|
||||||
# By another popular vote, prefer early sphere
|
|
||||||
not_found_hints.sort(key=lambda hint: self.ctx.get_sphere(hint.finding_player, hint.location),
|
|
||||||
reverse=True)
|
|
||||||
|
|
||||||
hints = found_hints + old_hints
|
hints = found_hints + old_hints
|
||||||
while can_pay > 0:
|
while can_pay > 0:
|
||||||
@@ -1693,10 +1557,10 @@ class ClientMessageProcessor(CommonCommandProcessor):
|
|||||||
hints.append(hint)
|
hints.append(hint)
|
||||||
can_pay -= 1
|
can_pay -= 1
|
||||||
self.ctx.hints_used[self.client.team, self.client.slot] += 1
|
self.ctx.hints_used[self.client.team, self.client.slot] += 1
|
||||||
|
points_available = get_client_points(self.ctx, self.client)
|
||||||
|
|
||||||
self.ctx.notify_hints(self.client.team, hints)
|
self.ctx.notify_hints(self.client.team, hints)
|
||||||
if not_found_hints:
|
if not_found_hints:
|
||||||
points_available = get_client_points(self.ctx, self.client)
|
|
||||||
if hints and cost and int((points_available // cost) == 0):
|
if hints and cost and int((points_available // cost) == 0):
|
||||||
self.output(
|
self.output(
|
||||||
f"There may be more hintables, however, you cannot afford to pay for any more. "
|
f"There may be more hintables, however, you cannot afford to pay for any more. "
|
||||||
@@ -1821,9 +1685,7 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
|
|||||||
ctx.clients[team][slot].append(client)
|
ctx.clients[team][slot].append(client)
|
||||||
client.version = args['version']
|
client.version = args['version']
|
||||||
client.tags = args['tags']
|
client.tags = args['tags']
|
||||||
client.no_locations = "TextOnly" in client.tags or "Tracker" in client.tags
|
client.no_locations = 'TextOnly' in client.tags or 'Tracker' in client.tags
|
||||||
# set NoText for old PopTracker clients that predate the tag to save traffic
|
|
||||||
client.no_text = "NoText" in client.tags or ("PopTracker" in client.tags and client.version < (0, 5, 1))
|
|
||||||
connected_packet = {
|
connected_packet = {
|
||||||
"cmd": "Connected",
|
"cmd": "Connected",
|
||||||
"team": client.team, "slot": client.slot,
|
"team": client.team, "slot": client.slot,
|
||||||
@@ -1896,9 +1758,6 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
|
|||||||
client.tags = args["tags"]
|
client.tags = args["tags"]
|
||||||
if set(old_tags) != set(client.tags):
|
if set(old_tags) != set(client.tags):
|
||||||
client.no_locations = 'TextOnly' in client.tags or 'Tracker' in client.tags
|
client.no_locations = 'TextOnly' in client.tags or 'Tracker' in client.tags
|
||||||
client.no_text = "NoText" in client.tags or (
|
|
||||||
"PopTracker" in client.tags and client.version < (0, 5, 1)
|
|
||||||
)
|
|
||||||
ctx.broadcast_text_all(
|
ctx.broadcast_text_all(
|
||||||
f"{ctx.get_aliased_name(client.team, client.slot)} (Team #{client.team + 1}) has changed tags "
|
f"{ctx.get_aliased_name(client.team, client.slot)} (Team #{client.team + 1}) has changed tags "
|
||||||
f"from {old_tags} to {client.tags}.",
|
f"from {old_tags} to {client.tags}.",
|
||||||
@@ -1927,63 +1786,19 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
|
|||||||
for location in args["locations"]:
|
for location in args["locations"]:
|
||||||
if type(location) is not int:
|
if type(location) is not int:
|
||||||
await ctx.send_msgs(client,
|
await ctx.send_msgs(client,
|
||||||
[{'cmd': 'InvalidPacket', "type": "arguments",
|
[{'cmd': 'InvalidPacket', "type": "arguments", "text": 'LocationScouts',
|
||||||
"text": 'Locations has to be a list of integers',
|
|
||||||
"original_cmd": cmd}])
|
"original_cmd": cmd}])
|
||||||
return
|
return
|
||||||
|
|
||||||
target_item, target_player, flags = ctx.locations[client.slot][location]
|
target_item, target_player, flags = ctx.locations[client.slot][location]
|
||||||
if create_as_hint:
|
if create_as_hint:
|
||||||
hints.extend(collect_hint_location_id(ctx, client.team, client.slot, location,
|
hints.extend(collect_hint_location_id(ctx, client.team, client.slot, location))
|
||||||
HintStatus.HINT_UNSPECIFIED))
|
|
||||||
locs.append(NetworkItem(target_item, location, target_player, flags))
|
locs.append(NetworkItem(target_item, location, target_player, flags))
|
||||||
ctx.notify_hints(client.team, hints, only_new=create_as_hint == 2)
|
ctx.notify_hints(client.team, hints, only_new=create_as_hint == 2)
|
||||||
if locs and create_as_hint:
|
if locs and create_as_hint:
|
||||||
ctx.save()
|
ctx.save()
|
||||||
await ctx.send_msgs(client, [{'cmd': 'LocationInfo', 'locations': locs}])
|
await ctx.send_msgs(client, [{'cmd': 'LocationInfo', 'locations': locs}])
|
||||||
|
|
||||||
elif cmd == 'UpdateHint':
|
|
||||||
location = args["location"]
|
|
||||||
player = args["player"]
|
|
||||||
status = args["status"]
|
|
||||||
if not isinstance(player, int) or not isinstance(location, int) \
|
|
||||||
or (status is not None and not isinstance(status, int)):
|
|
||||||
await ctx.send_msgs(client,
|
|
||||||
[{'cmd': 'InvalidPacket', "type": "arguments", "text": 'UpdateHint',
|
|
||||||
"original_cmd": cmd}])
|
|
||||||
return
|
|
||||||
hint = ctx.get_hint(client.team, player, location)
|
|
||||||
if not hint:
|
|
||||||
return # Ignored safely
|
|
||||||
if client.slot not in ctx.slot_set(hint.receiving_player):
|
|
||||||
await ctx.send_msgs(client,
|
|
||||||
[{'cmd': 'InvalidPacket', "type": "arguments", "text": 'UpdateHint: No Permission',
|
|
||||||
"original_cmd": cmd}])
|
|
||||||
return
|
|
||||||
new_hint = hint
|
|
||||||
if status is None:
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
status = HintStatus(status)
|
|
||||||
except ValueError:
|
|
||||||
await ctx.send_msgs(client,
|
|
||||||
[{'cmd': 'InvalidPacket', "type": "arguments",
|
|
||||||
"text": 'UpdateHint: Invalid Status', "original_cmd": cmd}])
|
|
||||||
return
|
|
||||||
if status == HintStatus.HINT_FOUND:
|
|
||||||
await ctx.send_msgs(client,
|
|
||||||
[{'cmd': 'InvalidPacket', "type": "arguments",
|
|
||||||
"text": 'UpdateHint: Cannot manually update status to "HINT_FOUND"', "original_cmd": cmd}])
|
|
||||||
return
|
|
||||||
new_hint = new_hint.re_prioritize(ctx, status)
|
|
||||||
if hint == new_hint:
|
|
||||||
return
|
|
||||||
ctx.replace_hint(client.team, hint.finding_player, hint, new_hint)
|
|
||||||
ctx.replace_hint(client.team, hint.receiving_player, hint, new_hint)
|
|
||||||
ctx.save()
|
|
||||||
ctx.on_changed_hints(client.team, hint.finding_player)
|
|
||||||
ctx.on_changed_hints(client.team, hint.receiving_player)
|
|
||||||
|
|
||||||
elif cmd == 'StatusUpdate':
|
elif cmd == 'StatusUpdate':
|
||||||
update_client_status(ctx, client, args["status"])
|
update_client_status(ctx, client, args["status"])
|
||||||
|
|
||||||
@@ -2031,7 +1846,6 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
|
|||||||
args["cmd"] = "SetReply"
|
args["cmd"] = "SetReply"
|
||||||
value = ctx.stored_data.get(args["key"], args.get("default", 0))
|
value = ctx.stored_data.get(args["key"], args.get("default", 0))
|
||||||
args["original_value"] = copy.copy(value)
|
args["original_value"] = copy.copy(value)
|
||||||
args["slot"] = client.slot
|
|
||||||
for operation in args["operations"]:
|
for operation in args["operations"]:
|
||||||
func = modify_functions[operation["operation"]]
|
func = modify_functions[operation["operation"]]
|
||||||
value = func(value, operation["value"])
|
value = func(value, operation["value"])
|
||||||
@@ -2106,10 +1920,10 @@ class ServerCommandProcessor(CommonCommandProcessor):
|
|||||||
|
|
||||||
def _cmd_exit(self) -> bool:
|
def _cmd_exit(self) -> bool:
|
||||||
"""Shutdown the server"""
|
"""Shutdown the server"""
|
||||||
try:
|
self.ctx.server.ws_server.close()
|
||||||
self.ctx.server.ws_server.close()
|
if self.ctx.shutdown_task:
|
||||||
finally:
|
self.ctx.shutdown_task.cancel()
|
||||||
self.ctx.exit_event.set()
|
self.ctx.exit_event.set()
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@mark_raw
|
@mark_raw
|
||||||
@@ -2216,8 +2030,6 @@ class ServerCommandProcessor(CommonCommandProcessor):
|
|||||||
item_name, usable, response = get_intended_text(item_name, names)
|
item_name, usable, response = get_intended_text(item_name, names)
|
||||||
if usable:
|
if usable:
|
||||||
amount: int = int(amount)
|
amount: int = int(amount)
|
||||||
if amount > 100:
|
|
||||||
raise ValueError(f"{amount} is invalid. Maximum is 100.")
|
|
||||||
new_items = [NetworkItem(names[item_name], -1, 0) for _ in range(int(amount))]
|
new_items = [NetworkItem(names[item_name], -1, 0) for _ in range(int(amount))]
|
||||||
send_items_to(self.ctx, team, slot, *new_items)
|
send_items_to(self.ctx, team, slot, *new_items)
|
||||||
|
|
||||||
@@ -2289,9 +2101,9 @@ class ServerCommandProcessor(CommonCommandProcessor):
|
|||||||
hints = []
|
hints = []
|
||||||
for item_name_from_group in self.ctx.item_name_groups[game][item]:
|
for item_name_from_group in self.ctx.item_name_groups[game][item]:
|
||||||
if item_name_from_group in self.ctx.item_names_for_game(game): # ensure item has an ID
|
if item_name_from_group in self.ctx.item_names_for_game(game): # ensure item has an ID
|
||||||
hints.extend(collect_hints(self.ctx, team, slot, item_name_from_group, HintStatus.HINT_PRIORITY))
|
hints.extend(collect_hints(self.ctx, team, slot, item_name_from_group))
|
||||||
else: # item name or id
|
else: # item name or id
|
||||||
hints = collect_hints(self.ctx, team, slot, item, HintStatus.HINT_PRIORITY)
|
hints = collect_hints(self.ctx, team, slot, item)
|
||||||
|
|
||||||
if hints:
|
if hints:
|
||||||
self.ctx.notify_hints(team, hints)
|
self.ctx.notify_hints(team, hints)
|
||||||
@@ -2325,17 +2137,14 @@ class ServerCommandProcessor(CommonCommandProcessor):
|
|||||||
|
|
||||||
if usable:
|
if usable:
|
||||||
if isinstance(location, int):
|
if isinstance(location, int):
|
||||||
hints = collect_hint_location_id(self.ctx, team, slot, location,
|
hints = collect_hint_location_id(self.ctx, team, slot, location)
|
||||||
HintStatus.HINT_UNSPECIFIED)
|
|
||||||
elif game in self.ctx.location_name_groups and location in self.ctx.location_name_groups[game]:
|
elif game in self.ctx.location_name_groups and location in self.ctx.location_name_groups[game]:
|
||||||
hints = []
|
hints = []
|
||||||
for loc_name_from_group in self.ctx.location_name_groups[game][location]:
|
for loc_name_from_group in self.ctx.location_name_groups[game][location]:
|
||||||
if loc_name_from_group in self.ctx.location_names_for_game(game):
|
if loc_name_from_group in self.ctx.location_names_for_game(game):
|
||||||
hints.extend(collect_hint_location_name(self.ctx, team, slot, loc_name_from_group,
|
hints.extend(collect_hint_location_name(self.ctx, team, slot, loc_name_from_group))
|
||||||
HintStatus.HINT_UNSPECIFIED))
|
|
||||||
else:
|
else:
|
||||||
hints = collect_hint_location_name(self.ctx, team, slot, location,
|
hints = collect_hint_location_name(self.ctx, team, slot, location)
|
||||||
HintStatus.HINT_UNSPECIFIED)
|
|
||||||
if hints:
|
if hints:
|
||||||
self.ctx.notify_hints(team, hints)
|
self.ctx.notify_hints(team, hints)
|
||||||
else:
|
else:
|
||||||
@@ -2425,8 +2234,6 @@ def parse_args() -> argparse.Namespace:
|
|||||||
parser.add_argument('--cert_key', help="Path to SSL Certificate Key file")
|
parser.add_argument('--cert_key', help="Path to SSL Certificate Key file")
|
||||||
parser.add_argument('--loglevel', default=defaults["loglevel"],
|
parser.add_argument('--loglevel', default=defaults["loglevel"],
|
||||||
choices=['debug', 'info', 'warning', 'error', 'critical'])
|
choices=['debug', 'info', 'warning', 'error', 'critical'])
|
||||||
parser.add_argument('--logtime', help="Add timestamps to STDOUT",
|
|
||||||
default=defaults["logtime"], action='store_true')
|
|
||||||
parser.add_argument('--location_check_points', default=defaults["location_check_points"], type=int)
|
parser.add_argument('--location_check_points', default=defaults["location_check_points"], type=int)
|
||||||
parser.add_argument('--hint_cost', default=defaults["hint_cost"], type=int)
|
parser.add_argument('--hint_cost', default=defaults["hint_cost"], type=int)
|
||||||
parser.add_argument('--disable_item_cheat', default=defaults["disable_item_cheat"], action='store_true')
|
parser.add_argument('--disable_item_cheat', default=defaults["disable_item_cheat"], action='store_true')
|
||||||
@@ -2473,8 +2280,7 @@ def parse_args() -> argparse.Namespace:
|
|||||||
|
|
||||||
|
|
||||||
async def auto_shutdown(ctx, to_cancel=None):
|
async def auto_shutdown(ctx, to_cancel=None):
|
||||||
with contextlib.suppress(asyncio.TimeoutError):
|
await asyncio.sleep(ctx.auto_shutdown)
|
||||||
await asyncio.wait_for(ctx.exit_event.wait(), ctx.auto_shutdown)
|
|
||||||
|
|
||||||
def inactivity_shutdown():
|
def inactivity_shutdown():
|
||||||
ctx.server.ws_server.close()
|
ctx.server.ws_server.close()
|
||||||
@@ -2494,8 +2300,7 @@ async def auto_shutdown(ctx, to_cancel=None):
|
|||||||
if seconds < 0:
|
if seconds < 0:
|
||||||
inactivity_shutdown()
|
inactivity_shutdown()
|
||||||
else:
|
else:
|
||||||
with contextlib.suppress(asyncio.TimeoutError):
|
await asyncio.sleep(seconds)
|
||||||
await asyncio.wait_for(ctx.exit_event.wait(), seconds)
|
|
||||||
|
|
||||||
|
|
||||||
def load_server_cert(path: str, cert_key: typing.Optional[str]) -> "ssl.SSLContext":
|
def load_server_cert(path: str, cert_key: typing.Optional[str]) -> "ssl.SSLContext":
|
||||||
@@ -2507,9 +2312,7 @@ def load_server_cert(path: str, cert_key: typing.Optional[str]) -> "ssl.SSLConte
|
|||||||
|
|
||||||
|
|
||||||
async def main(args: argparse.Namespace):
|
async def main(args: argparse.Namespace):
|
||||||
Utils.init_logging(name="Server",
|
Utils.init_logging("Server", loglevel=args.loglevel.lower())
|
||||||
loglevel=args.loglevel.lower(),
|
|
||||||
add_timestamp=args.logtime)
|
|
||||||
|
|
||||||
ctx = Context(args.host, args.port, args.server_password, args.password, args.location_check_points,
|
ctx = Context(args.host, args.port, args.server_password, args.password, args.location_check_points,
|
||||||
args.hint_cost, not args.disable_item_cheat, args.release_mode, args.collect_mode,
|
args.hint_cost, not args.disable_item_cheat, args.release_mode, args.collect_mode,
|
||||||
|
|||||||
84
NetUtils.py
@@ -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):
|
||||||
@@ -313,27 +294,6 @@ def add_json_location(parts: list, location_id: int, player: int = 0, **kwargs)
|
|||||||
parts.append({"text": str(location_id), "player": player, "type": JSONTypes.location_id, **kwargs})
|
parts.append({"text": str(location_id), "player": player, "type": JSONTypes.location_id, **kwargs})
|
||||||
|
|
||||||
|
|
||||||
status_names: typing.Dict[HintStatus, str] = {
|
|
||||||
HintStatus.HINT_FOUND: "(found)",
|
|
||||||
HintStatus.HINT_UNSPECIFIED: "(unspecified)",
|
|
||||||
HintStatus.HINT_NO_PRIORITY: "(no priority)",
|
|
||||||
HintStatus.HINT_AVOID: "(avoid)",
|
|
||||||
HintStatus.HINT_PRIORITY: "(priority)",
|
|
||||||
}
|
|
||||||
status_colors: typing.Dict[HintStatus, str] = {
|
|
||||||
HintStatus.HINT_FOUND: "green",
|
|
||||||
HintStatus.HINT_UNSPECIFIED: "white",
|
|
||||||
HintStatus.HINT_NO_PRIORITY: "slateblue",
|
|
||||||
HintStatus.HINT_AVOID: "salmon",
|
|
||||||
HintStatus.HINT_PRIORITY: "plum",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def add_json_hint_status(parts: list, hint_status: HintStatus, text: typing.Optional[str] = None, **kwargs):
|
|
||||||
parts.append({"text": text if text != None else status_names.get(hint_status, "(unknown)"),
|
|
||||||
"hint_status": hint_status, "type": JSONTypes.hint_status, **kwargs})
|
|
||||||
|
|
||||||
|
|
||||||
class Hint(typing.NamedTuple):
|
class Hint(typing.NamedTuple):
|
||||||
receiving_player: int
|
receiving_player: int
|
||||||
finding_player: int
|
finding_player: int
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import tkinter as tk
|
import tkinter as tk
|
||||||
import argparse
|
import argparse
|
||||||
import logging
|
import logging
|
||||||
|
import random
|
||||||
import os
|
import os
|
||||||
import zipfile
|
import zipfile
|
||||||
from itertools import chain
|
from itertools import chain
|
||||||
@@ -196,6 +197,7 @@ def set_icon(window):
|
|||||||
def adjust(args):
|
def adjust(args):
|
||||||
# Create a fake multiworld and OOTWorld to use as a base
|
# Create a fake multiworld and OOTWorld to use as a base
|
||||||
multiworld = MultiWorld(1)
|
multiworld = MultiWorld(1)
|
||||||
|
multiworld.per_slot_randoms = {1: random}
|
||||||
ootworld = OOTWorld(multiworld, 1)
|
ootworld = OOTWorld(multiworld, 1)
|
||||||
# Set options in the fake OOTWorld
|
# Set options in the fake OOTWorld
|
||||||
for name, option in chain(cosmetic_options.items(), sfx_options.items()):
|
for name, option in chain(cosmetic_options.items(), sfx_options.items()):
|
||||||
|
|||||||
551
Options.py
@@ -8,17 +8,15 @@ import numbers
|
|||||||
import random
|
import random
|
||||||
import typing
|
import typing
|
||||||
import enum
|
import enum
|
||||||
from collections import defaultdict
|
|
||||||
from copy import deepcopy
|
from copy import deepcopy
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
from schema import And, Optional, Or, Schema
|
from schema import And, Optional, Or, Schema
|
||||||
from typing_extensions import Self
|
|
||||||
|
|
||||||
from Utils import get_file_safe_name, get_fuzzy_results, is_iterable_except_str, output_path
|
from Utils import get_fuzzy_results, is_iterable_except_str
|
||||||
|
|
||||||
if typing.TYPE_CHECKING:
|
if typing.TYPE_CHECKING:
|
||||||
from BaseClasses import MultiWorld, PlandoOptions
|
from BaseClasses import PlandoOptions
|
||||||
from worlds.AutoWorld import World
|
from worlds.AutoWorld import World
|
||||||
import pathlib
|
import pathlib
|
||||||
|
|
||||||
@@ -54,8 +52,8 @@ class AssembleOptions(abc.ABCMeta):
|
|||||||
attrs["name_lookup"].update({option_id: name for name, option_id in new_options.items()})
|
attrs["name_lookup"].update({option_id: name for name, option_id in new_options.items()})
|
||||||
options.update(new_options)
|
options.update(new_options)
|
||||||
# apply aliases, without name_lookup
|
# apply aliases, without name_lookup
|
||||||
aliases = attrs["aliases"] = {name[6:].lower(): option_id for name, option_id in attrs.items() if
|
aliases = {name[6:].lower(): option_id for name, option_id in attrs.items() if
|
||||||
name.startswith("alias_")}
|
name.startswith("alias_")}
|
||||||
|
|
||||||
assert (
|
assert (
|
||||||
name in {"Option", "VerifyKeys"} or # base abstract classes don't need default
|
name in {"Option", "VerifyKeys"} or # base abstract classes don't need default
|
||||||
@@ -127,28 +125,10 @@ class Option(typing.Generic[T], metaclass=AssembleOptions):
|
|||||||
# can be weighted between selections
|
# can be weighted between selections
|
||||||
supports_weighting = True
|
supports_weighting = True
|
||||||
|
|
||||||
rich_text_doc: typing.Optional[bool] = None
|
|
||||||
"""Whether the WebHost should render the Option's docstring as rich text.
|
|
||||||
|
|
||||||
If this is True, the Option's docstring is interpreted as reStructuredText_,
|
|
||||||
the standard Python markup format. In the WebHost, it's rendered to HTML so
|
|
||||||
that lists, emphasis, and other rich text features are displayed properly.
|
|
||||||
|
|
||||||
If this is False, the docstring is instead interpreted as plain text, and
|
|
||||||
displayed as-is on the WebHost with whitespace preserved.
|
|
||||||
|
|
||||||
If this is None, it inherits the value of `WebWorld.rich_text_options_doc`. For
|
|
||||||
backwards compatibility, this defaults to False, but worlds are encouraged to
|
|
||||||
set it to True and use reStructuredText for their Option documentation.
|
|
||||||
|
|
||||||
.. _reStructuredText: https://docutils.sourceforge.io/rst.html
|
|
||||||
"""
|
|
||||||
|
|
||||||
# filled by AssembleOptions:
|
# filled by AssembleOptions:
|
||||||
name_lookup: typing.ClassVar[typing.Dict[T, str]] # type: ignore
|
name_lookup: typing.ClassVar[typing.Dict[T, str]] # type: ignore
|
||||||
# https://github.com/python/typing/discussions/1460 the reason for this type: ignore
|
# https://github.com/python/typing/discussions/1460 the reason for this type: ignore
|
||||||
options: typing.ClassVar[typing.Dict[str, int]]
|
options: typing.ClassVar[typing.Dict[str, int]]
|
||||||
aliases: typing.ClassVar[typing.Dict[str, int]]
|
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
def __repr__(self) -> str:
|
||||||
return f"{self.__class__.__name__}({self.current_option_name})"
|
return f"{self.__class__.__name__}({self.current_option_name})"
|
||||||
@@ -496,7 +476,7 @@ class TextChoice(Choice):
|
|||||||
|
|
||||||
def __init__(self, value: typing.Union[str, int]):
|
def __init__(self, value: typing.Union[str, int]):
|
||||||
assert isinstance(value, str) or isinstance(value, int), \
|
assert isinstance(value, str) or isinstance(value, int), \
|
||||||
f"'{value}' is not a valid option for '{self.__class__.__name__}'"
|
f"{value} is not a valid option for {self.__class__.__name__}"
|
||||||
self.value = value
|
self.value = value
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -617,17 +597,17 @@ class PlandoBosses(TextChoice, metaclass=BossMeta):
|
|||||||
used_locations.append(location)
|
used_locations.append(location)
|
||||||
used_bosses.append(boss)
|
used_bosses.append(boss)
|
||||||
if not cls.valid_boss_name(boss):
|
if not cls.valid_boss_name(boss):
|
||||||
raise ValueError(f"'{boss.title()}' is not a valid boss name.")
|
raise ValueError(f"{boss.title()} is not a valid boss name.")
|
||||||
if not cls.valid_location_name(location):
|
if not cls.valid_location_name(location):
|
||||||
raise ValueError(f"'{location.title()}' is not a valid boss location name.")
|
raise ValueError(f"{location.title()} is not a valid boss location name.")
|
||||||
if not cls.can_place_boss(boss, location):
|
if not cls.can_place_boss(boss, location):
|
||||||
raise ValueError(f"'{location.title()}' is not a valid location for {boss.title()} to be placed.")
|
raise ValueError(f"{location.title()} is not a valid location for {boss.title()} to be placed.")
|
||||||
else:
|
else:
|
||||||
if cls.duplicate_bosses:
|
if cls.duplicate_bosses:
|
||||||
if not cls.valid_boss_name(option):
|
if not cls.valid_boss_name(option):
|
||||||
raise ValueError(f"'{option}' is not a valid boss name.")
|
raise ValueError(f"{option} is not a valid boss name.")
|
||||||
else:
|
else:
|
||||||
raise ValueError(f"'{option.title()}' is not formatted correctly.")
|
raise ValueError(f"{option.title()} is not formatted correctly.")
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def can_place_boss(cls, boss: str, location: str) -> bool:
|
def can_place_boss(cls, boss: str, location: str) -> bool:
|
||||||
@@ -689,9 +669,9 @@ class Range(NumericOption):
|
|||||||
@classmethod
|
@classmethod
|
||||||
def weighted_range(cls, text) -> Range:
|
def weighted_range(cls, text) -> Range:
|
||||||
if text == "random-low":
|
if text == "random-low":
|
||||||
return cls(cls.triangular(cls.range_start, cls.range_end, 0.0))
|
return cls(cls.triangular(cls.range_start, cls.range_end, cls.range_start))
|
||||||
elif text == "random-high":
|
elif text == "random-high":
|
||||||
return cls(cls.triangular(cls.range_start, cls.range_end, 1.0))
|
return cls(cls.triangular(cls.range_start, cls.range_end, cls.range_end))
|
||||||
elif text == "random-middle":
|
elif text == "random-middle":
|
||||||
return cls(cls.triangular(cls.range_start, cls.range_end))
|
return cls(cls.triangular(cls.range_start, cls.range_end))
|
||||||
elif text.startswith("random-range-"):
|
elif text.startswith("random-range-"):
|
||||||
@@ -717,11 +697,11 @@ class Range(NumericOption):
|
|||||||
f"{random_range[0]}-{random_range[1]} is outside allowed range "
|
f"{random_range[0]}-{random_range[1]} is outside allowed range "
|
||||||
f"{cls.range_start}-{cls.range_end} for option {cls.__name__}")
|
f"{cls.range_start}-{cls.range_end} for option {cls.__name__}")
|
||||||
if text.startswith("random-range-low"):
|
if text.startswith("random-range-low"):
|
||||||
return cls(cls.triangular(random_range[0], random_range[1], 0.0))
|
return cls(cls.triangular(random_range[0], random_range[1], random_range[0]))
|
||||||
elif text.startswith("random-range-middle"):
|
elif text.startswith("random-range-middle"):
|
||||||
return cls(cls.triangular(random_range[0], random_range[1]))
|
return cls(cls.triangular(random_range[0], random_range[1]))
|
||||||
elif text.startswith("random-range-high"):
|
elif text.startswith("random-range-high"):
|
||||||
return cls(cls.triangular(random_range[0], random_range[1], 1.0))
|
return cls(cls.triangular(random_range[0], random_range[1], random_range[1]))
|
||||||
else:
|
else:
|
||||||
return cls(random.randint(random_range[0], random_range[1]))
|
return cls(random.randint(random_range[0], random_range[1]))
|
||||||
|
|
||||||
@@ -739,16 +719,8 @@ class Range(NumericOption):
|
|||||||
return str(self.value)
|
return str(self.value)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def triangular(lower: int, end: int, tri: float = 0.5) -> int:
|
def triangular(lower: int, end: int, tri: typing.Optional[int] = None) -> int:
|
||||||
"""
|
return int(round(random.triangular(lower, end, tri), 0))
|
||||||
Integer triangular distribution for `lower` inclusive to `end` inclusive.
|
|
||||||
|
|
||||||
Expects `lower <= end` and `0.0 <= tri <= 1.0`. The result of other inputs is undefined.
|
|
||||||
"""
|
|
||||||
# Use the continuous range [lower, end + 1) to produce an integer result in [lower, end].
|
|
||||||
# random.triangular is actually [a, b] and not [a, b), so there is a very small chance of getting exactly b even
|
|
||||||
# when a != b, so ensure the result is never more than `end`.
|
|
||||||
return min(end, math.floor(random.triangular(0.0, 1.0, tri) * (end - lower + 1) + lower))
|
|
||||||
|
|
||||||
|
|
||||||
class NamedRange(Range):
|
class NamedRange(Range):
|
||||||
@@ -762,12 +734,6 @@ class NamedRange(Range):
|
|||||||
elif value > self.range_end and value not in self.special_range_names.values():
|
elif value > self.range_end and value not in self.special_range_names.values():
|
||||||
raise Exception(f"{value} is higher than maximum {self.range_end} for option {self.__class__.__name__} " +
|
raise Exception(f"{value} is higher than maximum {self.range_end} for option {self.__class__.__name__} " +
|
||||||
f"and is also not one of the supported named special values: {self.special_range_names}")
|
f"and is also not one of the supported named special values: {self.special_range_names}")
|
||||||
|
|
||||||
# See docstring
|
|
||||||
for key in self.special_range_names:
|
|
||||||
if key != key.lower():
|
|
||||||
raise Exception(f"{self.__class__.__name__} has an invalid special_range_names key: {key}. "
|
|
||||||
f"NamedRange keys must use only lowercase letters, and ideally should be snake_case.")
|
|
||||||
self.value = value
|
self.value = value
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -780,7 +746,6 @@ class NamedRange(Range):
|
|||||||
|
|
||||||
class FreezeValidKeys(AssembleOptions):
|
class FreezeValidKeys(AssembleOptions):
|
||||||
def __new__(mcs, name, bases, attrs):
|
def __new__(mcs, name, bases, attrs):
|
||||||
assert not "_valid_keys" in attrs, "'_valid_keys' gets set by FreezeValidKeys, define 'valid_keys' instead."
|
|
||||||
if "valid_keys" in attrs:
|
if "valid_keys" in attrs:
|
||||||
attrs["_valid_keys"] = frozenset(attrs["valid_keys"])
|
attrs["_valid_keys"] = frozenset(attrs["valid_keys"])
|
||||||
return super(FreezeValidKeys, mcs).__new__(mcs, name, bases, attrs)
|
return super(FreezeValidKeys, mcs).__new__(mcs, name, bases, attrs)
|
||||||
@@ -795,22 +760,17 @@ class VerifyKeys(metaclass=FreezeValidKeys):
|
|||||||
verify_location_name: bool = False
|
verify_location_name: bool = False
|
||||||
value: typing.Any
|
value: typing.Any
|
||||||
|
|
||||||
def verify_keys(self) -> None:
|
@classmethod
|
||||||
if self.valid_keys:
|
def verify_keys(cls, data: typing.Iterable[str]) -> None:
|
||||||
data = set(self.value)
|
if cls.valid_keys:
|
||||||
dataset = set(word.casefold() for word in data) if self.valid_keys_casefold else set(data)
|
data = set(data)
|
||||||
extra = dataset - self._valid_keys
|
dataset = set(word.casefold() for word in data) if cls.valid_keys_casefold else set(data)
|
||||||
|
extra = dataset - cls._valid_keys
|
||||||
if extra:
|
if extra:
|
||||||
raise OptionError(
|
raise Exception(f"Found unexpected key {', '.join(extra)} in {cls}. "
|
||||||
f"Found unexpected key {', '.join(extra)} in {getattr(self, 'display_name', self)}. "
|
f"Allowed keys: {cls._valid_keys}.")
|
||||||
f"Allowed keys: {self._valid_keys}."
|
|
||||||
)
|
|
||||||
|
|
||||||
def verify(self, world: typing.Type[World], player_name: str, plando_options: "PlandoOptions") -> None:
|
def verify(self, world: typing.Type[World], player_name: str, plando_options: "PlandoOptions") -> None:
|
||||||
try:
|
|
||||||
self.verify_keys()
|
|
||||||
except OptionError as validation_error:
|
|
||||||
raise OptionError(f"Player {player_name} has invalid option keys:\n{validation_error}")
|
|
||||||
if self.convert_name_groups and self.verify_item_name:
|
if self.convert_name_groups and self.verify_item_name:
|
||||||
new_value = type(self.value)() # empty container of whatever value is
|
new_value = type(self.value)() # empty container of whatever value is
|
||||||
for item_name in self.value:
|
for item_name in self.value:
|
||||||
@@ -825,21 +785,18 @@ class VerifyKeys(metaclass=FreezeValidKeys):
|
|||||||
for item_name in self.value:
|
for item_name in self.value:
|
||||||
if item_name not in world.item_names:
|
if item_name not in world.item_names:
|
||||||
picks = get_fuzzy_results(item_name, world.item_names, limit=1)
|
picks = get_fuzzy_results(item_name, world.item_names, limit=1)
|
||||||
raise Exception(f"Item '{item_name}' from option '{self}' "
|
raise Exception(f"Item {item_name} from option {self} "
|
||||||
f"is not a valid item name from '{world.game}'. "
|
f"is not a valid item name from {world.game}. "
|
||||||
f"Did you mean '{picks[0][0]}' ({picks[0][1]}% sure)")
|
f"Did you mean '{picks[0][0]}' ({picks[0][1]}% sure)")
|
||||||
elif self.verify_location_name:
|
elif self.verify_location_name:
|
||||||
for location_name in self.value:
|
for location_name in self.value:
|
||||||
if location_name not in world.location_names:
|
if location_name not in world.location_names:
|
||||||
picks = get_fuzzy_results(location_name, world.location_names, limit=1)
|
picks = get_fuzzy_results(location_name, world.location_names, limit=1)
|
||||||
raise Exception(f"Location '{location_name}' from option '{self}' "
|
raise Exception(f"Location {location_name} from option {self} "
|
||||||
f"is not a valid location name from '{world.game}'. "
|
f"is not a valid location name from {world.game}. "
|
||||||
f"Did you mean '{picks[0][0]}' ({picks[0][1]}% sure)")
|
f"Did you mean '{picks[0][0]}' ({picks[0][1]}% sure)")
|
||||||
|
|
||||||
def __iter__(self) -> typing.Iterator[typing.Any]:
|
|
||||||
return self.value.__iter__()
|
|
||||||
|
|
||||||
|
|
||||||
class OptionDict(Option[typing.Dict[str, typing.Any]], VerifyKeys, typing.Mapping[str, typing.Any]):
|
class OptionDict(Option[typing.Dict[str, typing.Any]], VerifyKeys, typing.Mapping[str, typing.Any]):
|
||||||
default = {}
|
default = {}
|
||||||
supports_weighting = False
|
supports_weighting = False
|
||||||
@@ -850,6 +807,7 @@ class OptionDict(Option[typing.Dict[str, typing.Any]], VerifyKeys, typing.Mappin
|
|||||||
@classmethod
|
@classmethod
|
||||||
def from_any(cls, data: typing.Dict[str, typing.Any]) -> OptionDict:
|
def from_any(cls, data: typing.Dict[str, typing.Any]) -> OptionDict:
|
||||||
if type(data) == dict:
|
if type(data) == dict:
|
||||||
|
cls.verify_keys(data)
|
||||||
return cls(data)
|
return cls(data)
|
||||||
else:
|
else:
|
||||||
raise NotImplementedError(f"Cannot Convert from non-dictionary, got {type(data)}")
|
raise NotImplementedError(f"Cannot Convert from non-dictionary, got {type(data)}")
|
||||||
@@ -871,8 +829,6 @@ class ItemDict(OptionDict):
|
|||||||
verify_item_name = True
|
verify_item_name = True
|
||||||
|
|
||||||
def __init__(self, value: typing.Dict[str, int]):
|
def __init__(self, value: typing.Dict[str, int]):
|
||||||
if any(item_count is None for item_count in value.values()):
|
|
||||||
raise Exception("Items must have counts associated with them. Please provide positive integer values in the format \"item\": count .")
|
|
||||||
if any(item_count < 1 for item_count in value.values()):
|
if any(item_count < 1 for item_count in value.values()):
|
||||||
raise Exception("Cannot have non-positive item counts.")
|
raise Exception("Cannot have non-positive item counts.")
|
||||||
super(ItemDict, self).__init__(value)
|
super(ItemDict, self).__init__(value)
|
||||||
@@ -897,6 +853,7 @@ class OptionList(Option[typing.List[typing.Any]], VerifyKeys):
|
|||||||
@classmethod
|
@classmethod
|
||||||
def from_any(cls, data: typing.Any):
|
def from_any(cls, data: typing.Any):
|
||||||
if is_iterable_except_str(data):
|
if is_iterable_except_str(data):
|
||||||
|
cls.verify_keys(data)
|
||||||
return cls(data)
|
return cls(data)
|
||||||
return cls.from_text(str(data))
|
return cls.from_text(str(data))
|
||||||
|
|
||||||
@@ -922,6 +879,7 @@ class OptionSet(Option[typing.Set[str]], VerifyKeys):
|
|||||||
@classmethod
|
@classmethod
|
||||||
def from_any(cls, data: typing.Any):
|
def from_any(cls, data: typing.Any):
|
||||||
if is_iterable_except_str(data):
|
if is_iterable_except_str(data):
|
||||||
|
cls.verify_keys(data)
|
||||||
return cls(data)
|
return cls(data)
|
||||||
return cls.from_text(str(data))
|
return cls.from_text(str(data))
|
||||||
|
|
||||||
@@ -937,297 +895,26 @@ class ItemSet(OptionSet):
|
|||||||
convert_name_groups = True
|
convert_name_groups = True
|
||||||
|
|
||||||
|
|
||||||
class PlandoText(typing.NamedTuple):
|
|
||||||
at: str
|
|
||||||
text: typing.List[str]
|
|
||||||
percentage: int = 100
|
|
||||||
|
|
||||||
|
|
||||||
PlandoTextsFromAnyType = typing.Union[
|
|
||||||
typing.Iterable[typing.Union[typing.Mapping[str, typing.Any], PlandoText, typing.Any]], typing.Any
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
class PlandoTexts(Option[typing.List[PlandoText]], VerifyKeys):
|
|
||||||
default = ()
|
|
||||||
supports_weighting = False
|
|
||||||
display_name = "Plando Texts"
|
|
||||||
|
|
||||||
def __init__(self, value: typing.Iterable[PlandoText]) -> None:
|
|
||||||
self.value = list(deepcopy(value))
|
|
||||||
super().__init__()
|
|
||||||
|
|
||||||
def verify(self, world: typing.Type[World], player_name: str, plando_options: "PlandoOptions") -> None:
|
|
||||||
from BaseClasses import PlandoOptions
|
|
||||||
if self.value and not (PlandoOptions.texts & plando_options):
|
|
||||||
# plando is disabled but plando options were given so overwrite the options
|
|
||||||
self.value = []
|
|
||||||
logging.warning(f"The plando texts module is turned off, "
|
|
||||||
f"so text for {player_name} will be ignored.")
|
|
||||||
else:
|
|
||||||
super().verify(world, player_name, plando_options)
|
|
||||||
|
|
||||||
def verify_keys(self) -> None:
|
|
||||||
if self.valid_keys:
|
|
||||||
data = set(text.at for text in self)
|
|
||||||
dataset = set(word.casefold() for word in data) if self.valid_keys_casefold else set(data)
|
|
||||||
extra = dataset - self._valid_keys
|
|
||||||
if extra:
|
|
||||||
raise OptionError(
|
|
||||||
f"Invalid \"at\" placement {', '.join(extra)} in {getattr(self, 'display_name', self)}. "
|
|
||||||
f"Allowed placements: {self._valid_keys}."
|
|
||||||
)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_any(cls, data: PlandoTextsFromAnyType) -> Self:
|
|
||||||
texts: typing.List[PlandoText] = []
|
|
||||||
if isinstance(data, typing.Iterable):
|
|
||||||
for text in data:
|
|
||||||
if isinstance(text, typing.Mapping):
|
|
||||||
if random.random() < float(text.get("percentage", 100)/100):
|
|
||||||
at = text.get("at", None)
|
|
||||||
if at is not None:
|
|
||||||
if isinstance(at, dict):
|
|
||||||
if at:
|
|
||||||
at = random.choices(list(at.keys()),
|
|
||||||
weights=list(at.values()), k=1)[0]
|
|
||||||
else:
|
|
||||||
raise OptionError("\"at\" must be a valid string or weighted list of strings!")
|
|
||||||
given_text = text.get("text", [])
|
|
||||||
if isinstance(given_text, dict):
|
|
||||||
if not given_text:
|
|
||||||
given_text = []
|
|
||||||
else:
|
|
||||||
given_text = random.choices(list(given_text.keys()),
|
|
||||||
weights=list(given_text.values()), k=1)
|
|
||||||
if isinstance(given_text, str):
|
|
||||||
given_text = [given_text]
|
|
||||||
texts.append(PlandoText(
|
|
||||||
at,
|
|
||||||
given_text,
|
|
||||||
text.get("percentage", 100)
|
|
||||||
))
|
|
||||||
else:
|
|
||||||
raise OptionError("\"at\" must be a valid string or weighted list of strings!")
|
|
||||||
elif isinstance(text, PlandoText):
|
|
||||||
if random.random() < float(text.percentage/100):
|
|
||||||
texts.append(text)
|
|
||||||
else:
|
|
||||||
raise Exception(f"Cannot create plando text from non-dictionary type, got {type(text)}")
|
|
||||||
return cls(texts)
|
|
||||||
else:
|
|
||||||
raise NotImplementedError(f"Cannot Convert from non-list, got {type(data)}")
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def get_option_name(cls, value: typing.List[PlandoText]) -> str:
|
|
||||||
return str({text.at: " ".join(text.text) for text in value})
|
|
||||||
|
|
||||||
def __iter__(self) -> typing.Iterator[PlandoText]:
|
|
||||||
yield from self.value
|
|
||||||
|
|
||||||
def __getitem__(self, index: typing.SupportsIndex) -> PlandoText:
|
|
||||||
return self.value.__getitem__(index)
|
|
||||||
|
|
||||||
def __len__(self) -> int:
|
|
||||||
return self.value.__len__()
|
|
||||||
|
|
||||||
|
|
||||||
class ConnectionsMeta(AssembleOptions):
|
|
||||||
def __new__(mcs, name: str, bases: tuple[type, ...], attrs: dict[str, typing.Any]):
|
|
||||||
if name != "PlandoConnections":
|
|
||||||
assert "entrances" in attrs, f"Please define valid entrances for {name}"
|
|
||||||
attrs["entrances"] = frozenset((connection.lower() for connection in attrs["entrances"]))
|
|
||||||
assert "exits" in attrs, f"Please define valid exits for {name}"
|
|
||||||
attrs["exits"] = frozenset((connection.lower() for connection in attrs["exits"]))
|
|
||||||
if "__doc__" not in attrs:
|
|
||||||
attrs["__doc__"] = PlandoConnections.__doc__
|
|
||||||
cls = super().__new__(mcs, name, bases, attrs)
|
|
||||||
return cls
|
|
||||||
|
|
||||||
|
|
||||||
class PlandoConnection(typing.NamedTuple):
|
|
||||||
class Direction:
|
|
||||||
entrance = "entrance"
|
|
||||||
exit = "exit"
|
|
||||||
both = "both"
|
|
||||||
|
|
||||||
entrance: str
|
|
||||||
exit: str
|
|
||||||
direction: typing.Literal["entrance", "exit", "both"] # TODO: convert Direction to StrEnum once 3.8 is dropped
|
|
||||||
percentage: int = 100
|
|
||||||
|
|
||||||
|
|
||||||
PlandoConFromAnyType = typing.Union[
|
|
||||||
typing.Iterable[typing.Union[typing.Mapping[str, typing.Any], PlandoConnection, typing.Any]], typing.Any
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
class PlandoConnections(Option[typing.List[PlandoConnection]], metaclass=ConnectionsMeta):
|
|
||||||
"""Generic connections plando. Format is:
|
|
||||||
- entrance: "Entrance Name"
|
|
||||||
exit: "Exit Name"
|
|
||||||
direction: "Direction"
|
|
||||||
percentage: 100
|
|
||||||
Direction must be one of 'entrance', 'exit', or 'both', and defaults to 'both' if omitted.
|
|
||||||
Percentage is an integer from 1 to 100, and defaults to 100 when omitted."""
|
|
||||||
|
|
||||||
display_name = "Plando Connections"
|
|
||||||
|
|
||||||
default = ()
|
|
||||||
supports_weighting = False
|
|
||||||
|
|
||||||
entrances: typing.ClassVar[typing.AbstractSet[str]]
|
|
||||||
exits: typing.ClassVar[typing.AbstractSet[str]]
|
|
||||||
|
|
||||||
duplicate_exits: bool = False
|
|
||||||
"""Whether or not exits should be allowed to be duplicate."""
|
|
||||||
|
|
||||||
def __init__(self, value: typing.Iterable[PlandoConnection]):
|
|
||||||
self.value = list(deepcopy(value))
|
|
||||||
super(PlandoConnections, self).__init__()
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def validate_entrance_name(cls, entrance: str) -> bool:
|
|
||||||
return entrance.lower() in cls.entrances
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def validate_exit_name(cls, exit: str) -> bool:
|
|
||||||
return exit.lower() in cls.exits
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def can_connect(cls, entrance: str, exit: str) -> bool:
|
|
||||||
"""Checks that a given entrance can connect to a given exit.
|
|
||||||
By default, this will always return true unless overridden."""
|
|
||||||
return True
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def validate_plando_connections(cls, connections: typing.Iterable[PlandoConnection]) -> None:
|
|
||||||
used_entrances: typing.List[str] = []
|
|
||||||
used_exits: typing.List[str] = []
|
|
||||||
for connection in connections:
|
|
||||||
entrance = connection.entrance
|
|
||||||
exit = connection.exit
|
|
||||||
direction = connection.direction
|
|
||||||
if direction not in (PlandoConnection.Direction.entrance,
|
|
||||||
PlandoConnection.Direction.exit,
|
|
||||||
PlandoConnection.Direction.both):
|
|
||||||
raise ValueError(f"Unknown direction: {direction}")
|
|
||||||
if entrance in used_entrances:
|
|
||||||
raise ValueError(f"Duplicate Entrance {entrance} not allowed.")
|
|
||||||
if not cls.duplicate_exits and exit in used_exits:
|
|
||||||
raise ValueError(f"Duplicate Exit {exit} not allowed.")
|
|
||||||
used_entrances.append(entrance)
|
|
||||||
used_exits.append(exit)
|
|
||||||
if not cls.validate_entrance_name(entrance):
|
|
||||||
raise ValueError(f"'{entrance.title()}' is not a valid entrance.")
|
|
||||||
if not cls.validate_exit_name(exit):
|
|
||||||
raise ValueError(f"'{exit.title()}' is not a valid exit.")
|
|
||||||
if not cls.can_connect(entrance, exit):
|
|
||||||
raise ValueError(f"Connection between '{entrance.title()}' and '{exit.title()}' is invalid.")
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_any(cls, data: PlandoConFromAnyType) -> Self:
|
|
||||||
if not isinstance(data, typing.Iterable):
|
|
||||||
raise Exception(f"Cannot create plando connections from non-List value, got {type(data)}.")
|
|
||||||
|
|
||||||
value: typing.List[PlandoConnection] = []
|
|
||||||
for connection in data:
|
|
||||||
if isinstance(connection, typing.Mapping):
|
|
||||||
percentage = connection.get("percentage", 100)
|
|
||||||
if random.random() < float(percentage / 100):
|
|
||||||
entrance = connection.get("entrance", None)
|
|
||||||
if is_iterable_except_str(entrance):
|
|
||||||
entrance = random.choice(sorted(entrance))
|
|
||||||
exit = connection.get("exit", None)
|
|
||||||
if is_iterable_except_str(exit):
|
|
||||||
exit = random.choice(sorted(exit))
|
|
||||||
direction = connection.get("direction", "both")
|
|
||||||
|
|
||||||
if not entrance or not exit:
|
|
||||||
raise Exception("Plando connection must have an entrance and an exit.")
|
|
||||||
value.append(PlandoConnection(
|
|
||||||
entrance,
|
|
||||||
exit,
|
|
||||||
direction,
|
|
||||||
percentage
|
|
||||||
))
|
|
||||||
elif isinstance(connection, PlandoConnection):
|
|
||||||
if random.random() < float(connection.percentage / 100):
|
|
||||||
value.append(connection)
|
|
||||||
else:
|
|
||||||
raise Exception(f"Cannot create connection from non-Dict type, got {type(connection)}.")
|
|
||||||
cls.validate_plando_connections(value)
|
|
||||||
return cls(value)
|
|
||||||
|
|
||||||
def verify(self, world: typing.Type[World], player_name: str, plando_options: "PlandoOptions") -> None:
|
|
||||||
from BaseClasses import PlandoOptions
|
|
||||||
if self.value and not (PlandoOptions.connections & plando_options):
|
|
||||||
# plando is disabled but plando options were given so overwrite the options
|
|
||||||
self.value = []
|
|
||||||
logging.warning(f"The plando connections module is turned off, "
|
|
||||||
f"so connections for {player_name} will be ignored.")
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def get_option_name(cls, value: typing.List[PlandoConnection]) -> str:
|
|
||||||
return ", ".join(["%s %s %s" % (connection.entrance,
|
|
||||||
"<=>" if connection.direction == PlandoConnection.Direction.both else
|
|
||||||
"<=" if connection.direction == PlandoConnection.Direction.exit else
|
|
||||||
"=>",
|
|
||||||
connection.exit) for connection in value])
|
|
||||||
|
|
||||||
def __getitem__(self, index: typing.SupportsIndex) -> PlandoConnection:
|
|
||||||
return self.value.__getitem__(index)
|
|
||||||
|
|
||||||
def __iter__(self) -> typing.Iterator[PlandoConnection]:
|
|
||||||
yield from self.value
|
|
||||||
|
|
||||||
def __len__(self) -> int:
|
|
||||||
return len(self.value)
|
|
||||||
|
|
||||||
|
|
||||||
class Accessibility(Choice):
|
class Accessibility(Choice):
|
||||||
"""
|
"""Set rules for reachability of your items/locations.
|
||||||
Set rules for reachability of your items/locations.
|
Locations: ensure everything can be reached and acquired.
|
||||||
|
Items: ensure all logically relevant items can be acquired.
|
||||||
**Full:** ensure everything can be reached and acquired.
|
Minimal: ensure what is needed to reach your goal can be acquired."""
|
||||||
|
|
||||||
**Minimal:** ensure what is needed to reach your goal can be acquired.
|
|
||||||
"""
|
|
||||||
display_name = "Accessibility"
|
display_name = "Accessibility"
|
||||||
rich_text_doc = True
|
option_locations = 0
|
||||||
option_full = 0
|
option_items = 1
|
||||||
option_minimal = 2
|
option_minimal = 2
|
||||||
alias_none = 2
|
alias_none = 2
|
||||||
alias_locations = 0
|
|
||||||
alias_items = 0
|
|
||||||
default = 0
|
|
||||||
|
|
||||||
|
|
||||||
class ItemsAccessibility(Accessibility):
|
|
||||||
"""
|
|
||||||
Set rules for reachability of your items/locations.
|
|
||||||
|
|
||||||
**Full:** ensure everything can be reached and acquired.
|
|
||||||
|
|
||||||
**Minimal:** ensure what is needed to reach your goal can be acquired.
|
|
||||||
|
|
||||||
**Items:** ensure all logically relevant items can be acquired. Some items, such as keys, may be self-locking, and
|
|
||||||
some locations may be inaccessible.
|
|
||||||
"""
|
|
||||||
option_items = 1
|
|
||||||
default = 1
|
default = 1
|
||||||
|
|
||||||
|
|
||||||
class ProgressionBalancing(NamedRange):
|
class ProgressionBalancing(NamedRange):
|
||||||
"""A system that can move progression earlier, to try and prevent the player from getting stuck and bored early.
|
"""A system that can move progression earlier, to try and prevent the player from getting stuck and bored early.
|
||||||
|
A lower setting means more getting stuck. A higher setting means less getting stuck."""
|
||||||
A lower setting means more getting stuck. A higher setting means less getting stuck.
|
|
||||||
"""
|
|
||||||
default = 50
|
default = 50
|
||||||
range_start = 0
|
range_start = 0
|
||||||
range_end = 99
|
range_end = 99
|
||||||
display_name = "Progression Balancing"
|
display_name = "Progression Balancing"
|
||||||
rich_text_doc = True
|
|
||||||
special_range_names = {
|
special_range_names = {
|
||||||
"disabled": 0,
|
"disabled": 0,
|
||||||
"normal": 50,
|
"normal": 50,
|
||||||
@@ -1257,18 +944,13 @@ class CommonOptions(metaclass=OptionsMetaProperty):
|
|||||||
progression_balancing: ProgressionBalancing
|
progression_balancing: ProgressionBalancing
|
||||||
accessibility: Accessibility
|
accessibility: Accessibility
|
||||||
|
|
||||||
def as_dict(self,
|
def as_dict(self, *option_names: str, casing: str = "snake") -> typing.Dict[str, typing.Any]:
|
||||||
*option_names: str,
|
|
||||||
casing: typing.Literal["snake", "camel", "pascal", "kebab"] = "snake",
|
|
||||||
toggles_as_bools: bool = False) -> typing.Dict[str, typing.Any]:
|
|
||||||
"""
|
"""
|
||||||
Returns a dictionary of [str, Option.value]
|
Returns a dictionary of [str, Option.value]
|
||||||
|
|
||||||
:param option_names: names of the options to return
|
:param option_names: names of the options to return
|
||||||
:param casing: case of the keys to return. Supports `snake`, `camel`, `pascal`, `kebab`
|
:param casing: case of the keys to return. Supports `snake`, `camel`, `pascal`, `kebab`
|
||||||
:param toggles_as_bools: whether toggle options should be output as bools instead of strings
|
|
||||||
"""
|
"""
|
||||||
assert option_names, "options.as_dict() was used without any option names."
|
|
||||||
option_results = {}
|
option_results = {}
|
||||||
for option_name in option_names:
|
for option_name in option_names:
|
||||||
if option_name in type(self).type_hints:
|
if option_name in type(self).type_hints:
|
||||||
@@ -1288,8 +970,6 @@ class CommonOptions(metaclass=OptionsMetaProperty):
|
|||||||
value = getattr(self, option_name).value
|
value = getattr(self, option_name).value
|
||||||
if isinstance(value, set):
|
if isinstance(value, set):
|
||||||
value = sorted(value)
|
value = sorted(value)
|
||||||
elif toggles_as_bools and issubclass(type(self).type_hints[option_name], Toggle):
|
|
||||||
value = bool(value)
|
|
||||||
option_results[display_name] = value
|
option_results[display_name] = value
|
||||||
else:
|
else:
|
||||||
raise ValueError(f"{option_name} not found in {tuple(type(self).type_hints)}")
|
raise ValueError(f"{option_name} not found in {tuple(type(self).type_hints)}")
|
||||||
@@ -1299,36 +979,29 @@ class CommonOptions(metaclass=OptionsMetaProperty):
|
|||||||
class LocalItems(ItemSet):
|
class LocalItems(ItemSet):
|
||||||
"""Forces these items to be in their native world."""
|
"""Forces these items to be in their native world."""
|
||||||
display_name = "Local Items"
|
display_name = "Local Items"
|
||||||
rich_text_doc = True
|
|
||||||
|
|
||||||
|
|
||||||
class NonLocalItems(ItemSet):
|
class NonLocalItems(ItemSet):
|
||||||
"""Forces these items to be outside their native world."""
|
"""Forces these items to be outside their native world."""
|
||||||
display_name = "Non-local Items"
|
display_name = "Not Local Items"
|
||||||
rich_text_doc = True
|
|
||||||
|
|
||||||
|
|
||||||
class StartInventory(ItemDict):
|
class StartInventory(ItemDict):
|
||||||
"""Start with these items."""
|
"""Start with these items."""
|
||||||
verify_item_name = True
|
verify_item_name = True
|
||||||
display_name = "Start Inventory"
|
display_name = "Start Inventory"
|
||||||
rich_text_doc = True
|
|
||||||
|
|
||||||
|
|
||||||
class StartInventoryPool(StartInventory):
|
class StartInventoryPool(StartInventory):
|
||||||
"""Start with these items and don't place them in the world.
|
"""Start with these items and don't place them in the world.
|
||||||
|
The game decides what the replacement items will be."""
|
||||||
The game decides what the replacement items will be.
|
|
||||||
"""
|
|
||||||
verify_item_name = True
|
verify_item_name = True
|
||||||
display_name = "Start Inventory from Pool"
|
display_name = "Start Inventory from Pool"
|
||||||
rich_text_doc = True
|
|
||||||
|
|
||||||
|
|
||||||
class StartHints(ItemSet):
|
class StartHints(ItemSet):
|
||||||
"""Start with these item's locations prefilled into the ``!hint`` command."""
|
"""Start with these item's locations prefilled into the !hint command."""
|
||||||
display_name = "Start Hints"
|
display_name = "Start Hints"
|
||||||
rich_text_doc = True
|
|
||||||
|
|
||||||
|
|
||||||
class LocationSet(OptionSet):
|
class LocationSet(OptionSet):
|
||||||
@@ -1337,33 +1010,28 @@ class LocationSet(OptionSet):
|
|||||||
|
|
||||||
|
|
||||||
class StartLocationHints(LocationSet):
|
class StartLocationHints(LocationSet):
|
||||||
"""Start with these locations and their item prefilled into the ``!hint`` command."""
|
"""Start with these locations and their item prefilled into the !hint command"""
|
||||||
display_name = "Start Location Hints"
|
display_name = "Start Location Hints"
|
||||||
rich_text_doc = True
|
|
||||||
|
|
||||||
|
|
||||||
class ExcludeLocations(LocationSet):
|
class ExcludeLocations(LocationSet):
|
||||||
"""Prevent these locations from having an important item."""
|
"""Prevent these locations from having an important item"""
|
||||||
display_name = "Excluded Locations"
|
display_name = "Excluded Locations"
|
||||||
rich_text_doc = True
|
|
||||||
|
|
||||||
|
|
||||||
class PriorityLocations(LocationSet):
|
class PriorityLocations(LocationSet):
|
||||||
"""Prevent these locations from having an unimportant item."""
|
"""Prevent these locations from having an unimportant item"""
|
||||||
display_name = "Priority Locations"
|
display_name = "Priority Locations"
|
||||||
rich_text_doc = True
|
|
||||||
|
|
||||||
|
|
||||||
class DeathLink(Toggle):
|
class DeathLink(Toggle):
|
||||||
"""When you die, everyone who enabled death link dies. Of course, the reverse is true too."""
|
"""When you die, everyone dies. Of course the reverse is true too."""
|
||||||
display_name = "Death Link"
|
display_name = "Death Link"
|
||||||
rich_text_doc = True
|
|
||||||
|
|
||||||
|
|
||||||
class ItemLinks(OptionList):
|
class ItemLinks(OptionList):
|
||||||
"""Share part of your item pool with other players."""
|
"""Share part of your item pool with other players."""
|
||||||
display_name = "Item Links"
|
display_name = "Item Links"
|
||||||
rich_text_doc = True
|
|
||||||
default = []
|
default = []
|
||||||
schema = Schema([
|
schema = Schema([
|
||||||
{
|
{
|
||||||
@@ -1378,8 +1046,7 @@ class ItemLinks(OptionList):
|
|||||||
])
|
])
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def verify_items(items: typing.List[str], item_link: str, pool_name: str, world,
|
def verify_items(items: typing.List[str], item_link: str, pool_name: str, world, allow_item_groups: bool = True) -> typing.Set:
|
||||||
allow_item_groups: bool = True) -> typing.Set:
|
|
||||||
pool = set()
|
pool = set()
|
||||||
for item_name in items:
|
for item_name in items:
|
||||||
if item_name not in world.item_names and (not allow_item_groups or item_name not in world.item_name_groups):
|
if item_name not in world.item_names and (not allow_item_groups or item_name not in world.item_name_groups):
|
||||||
@@ -1387,8 +1054,8 @@ class ItemLinks(OptionList):
|
|||||||
picks_group = get_fuzzy_results(item_name, world.item_name_groups.keys(), limit=1)
|
picks_group = get_fuzzy_results(item_name, world.item_name_groups.keys(), limit=1)
|
||||||
picks_group = f" or '{picks_group[0][0]}' ({picks_group[0][1]}% sure)" if allow_item_groups else ""
|
picks_group = f" or '{picks_group[0][0]}' ({picks_group[0][1]}% sure)" if allow_item_groups else ""
|
||||||
|
|
||||||
raise Exception(f"Item '{item_name}' from item link '{item_link}' "
|
raise Exception(f"Item {item_name} from item link {item_link} "
|
||||||
f"is not a valid item from '{world.game}' for '{pool_name}'. "
|
f"is not a valid item from {world.game} for {pool_name}. "
|
||||||
f"Did you mean '{picks[0][0]}' ({picks[0][1]}% sure){picks_group}")
|
f"Did you mean '{picks[0][0]}' ({picks[0][1]}% sure){picks_group}")
|
||||||
if allow_item_groups:
|
if allow_item_groups:
|
||||||
pool |= world.item_name_groups.get(item_name, {item_name})
|
pool |= world.item_name_groups.get(item_name, {item_name})
|
||||||
@@ -1430,7 +1097,6 @@ class ItemLinks(OptionList):
|
|||||||
|
|
||||||
class Removed(FreeText):
|
class Removed(FreeText):
|
||||||
"""This Option has been Removed."""
|
"""This Option has been Removed."""
|
||||||
rich_text_doc = True
|
|
||||||
default = ""
|
default = ""
|
||||||
visibility = Visibility.none
|
visibility = Visibility.none
|
||||||
|
|
||||||
@@ -1463,45 +1129,9 @@ class OptionGroup(typing.NamedTuple):
|
|||||||
"""Name of the group to categorize these options in for display on the WebHost and in generated YAMLS."""
|
"""Name of the group to categorize these options in for display on the WebHost and in generated YAMLS."""
|
||||||
options: typing.List[typing.Type[Option[typing.Any]]]
|
options: typing.List[typing.Type[Option[typing.Any]]]
|
||||||
"""Options to be in the defined group."""
|
"""Options to be in the defined group."""
|
||||||
start_collapsed: bool = False
|
|
||||||
"""Whether the group will start collapsed on the WebHost options pages."""
|
|
||||||
|
|
||||||
|
|
||||||
item_and_loc_options = [LocalItems, NonLocalItems, StartInventory, StartInventoryPool, StartHints,
|
def generate_yaml_templates(target_folder: typing.Union[str, "pathlib.Path"], generate_hidden: bool = True):
|
||||||
StartLocationHints, ExcludeLocations, PriorityLocations, ItemLinks]
|
|
||||||
"""
|
|
||||||
Options that are always populated in "Item & Location Options" Option Group. Cannot be moved to another group.
|
|
||||||
If desired, a custom "Item & Location Options" Option Group can be defined, but only for adding additional options to
|
|
||||||
it.
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
def get_option_groups(world: typing.Type[World], visibility_level: Visibility = Visibility.template) -> typing.Dict[
|
|
||||||
str, typing.Dict[str, typing.Type[Option[typing.Any]]]]:
|
|
||||||
"""Generates and returns a dictionary for the option groups of a specified world."""
|
|
||||||
option_to_name = {option: option_name for option_name, option in world.options_dataclass.type_hints.items()}
|
|
||||||
|
|
||||||
ordered_groups = {group.name: group.options for group in world.web.option_groups}
|
|
||||||
|
|
||||||
# add a default option group for uncategorized options to get thrown into
|
|
||||||
if "Game Options" not in ordered_groups:
|
|
||||||
grouped_options = set(option for group in ordered_groups.values() for option in group)
|
|
||||||
ungrouped_options = [option for option in option_to_name if option not in grouped_options]
|
|
||||||
# only add the game options group if we have ungrouped options
|
|
||||||
if ungrouped_options:
|
|
||||||
ordered_groups = {**{"Game Options": ungrouped_options}, **ordered_groups}
|
|
||||||
|
|
||||||
return {
|
|
||||||
group: {
|
|
||||||
option_to_name[option]: option
|
|
||||||
for option in group_options
|
|
||||||
if (visibility_level in option.visibility and option in option_to_name)
|
|
||||||
}
|
|
||||||
for group, group_options in ordered_groups.items()
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def generate_yaml_templates(target_folder: typing.Union[str, "pathlib.Path"], generate_hidden: bool = True) -> None:
|
|
||||||
import os
|
import os
|
||||||
|
|
||||||
import yaml
|
import yaml
|
||||||
@@ -1537,61 +1167,56 @@ def generate_yaml_templates(target_folder: typing.Union[str, "pathlib.Path"], ge
|
|||||||
|
|
||||||
return data, notes
|
return data, notes
|
||||||
|
|
||||||
def yaml_dump_scalar(scalar) -> str:
|
|
||||||
# yaml dump may add end of document marker and newlines.
|
|
||||||
return yaml.dump(scalar).replace("...\n", "").strip()
|
|
||||||
|
|
||||||
for game_name, world in AutoWorldRegister.world_types.items():
|
for game_name, world in AutoWorldRegister.world_types.items():
|
||||||
if not world.hidden or generate_hidden:
|
if not world.hidden or generate_hidden:
|
||||||
option_groups = get_option_groups(world)
|
|
||||||
|
option_groups = {option: option_group.name
|
||||||
|
for option_group in world.web.option_groups
|
||||||
|
for option in option_group.options}
|
||||||
|
ordered_groups = ["Game Options"]
|
||||||
|
[ordered_groups.append(group) for group in option_groups.values() if group not in ordered_groups]
|
||||||
|
grouped_options = {group: {} for group in ordered_groups}
|
||||||
|
for option_name, option in world.options_dataclass.type_hints.items():
|
||||||
|
if option.visibility >= Visibility.template:
|
||||||
|
grouped_options[option_groups.get(option, "Game Options")][option_name] = option
|
||||||
|
|
||||||
with open(local_path("data", "options.yaml")) as f:
|
with open(local_path("data", "options.yaml")) as f:
|
||||||
file_data = f.read()
|
file_data = f.read()
|
||||||
res = Template(file_data).render(
|
res = Template(file_data).render(
|
||||||
option_groups=option_groups,
|
option_groups=grouped_options,
|
||||||
__version__=__version__, game=game_name, yaml_dump=yaml_dump_scalar,
|
__version__=__version__, game=game_name, yaml_dump=yaml.dump,
|
||||||
dictify_range=dictify_range,
|
dictify_range=dictify_range,
|
||||||
)
|
)
|
||||||
|
|
||||||
del file_data
|
del file_data
|
||||||
|
|
||||||
with open(os.path.join(target_folder, get_file_safe_name(game_name) + ".yaml"), "w", encoding="utf-8-sig") as f:
|
with open(os.path.join(target_folder, game_name + ".yaml"), "w", encoding="utf-8-sig") as f:
|
||||||
f.write(res)
|
f.write(res)
|
||||||
|
|
||||||
|
|
||||||
def dump_player_options(multiworld: MultiWorld) -> None:
|
if __name__ == "__main__":
|
||||||
from csv import DictWriter
|
|
||||||
|
|
||||||
game_players = defaultdict(list)
|
from worlds.alttp.Options import Logic
|
||||||
for player, game in multiworld.game.items():
|
import argparse
|
||||||
game_players[game].append(player)
|
|
||||||
game_players = dict(sorted(game_players.items()))
|
|
||||||
|
|
||||||
output = []
|
map_shuffle = Toggle
|
||||||
per_game_option_names = [
|
compass_shuffle = Toggle
|
||||||
getattr(option, "display_name", option_key)
|
key_shuffle = Toggle
|
||||||
for option_key, option in PerGameCommonOptions.type_hints.items()
|
big_key_shuffle = Toggle
|
||||||
]
|
hints = Toggle
|
||||||
all_option_names = per_game_option_names.copy()
|
test = argparse.Namespace()
|
||||||
for game, players in game_players.items():
|
test.logic = Logic.from_text("no_logic")
|
||||||
game_option_names = per_game_option_names.copy()
|
test.map_shuffle = map_shuffle.from_text("ON")
|
||||||
for player in players:
|
test.hints = hints.from_text('OFF')
|
||||||
world = multiworld.worlds[player]
|
try:
|
||||||
player_output = {
|
test.logic = Logic.from_text("overworld_glitches_typo")
|
||||||
"Game": multiworld.game[player],
|
except KeyError as e:
|
||||||
"Name": multiworld.get_player_name(player),
|
print(e)
|
||||||
}
|
try:
|
||||||
output.append(player_output)
|
test.logic_owg = Logic.from_text("owg")
|
||||||
for option_key, option in world.options_dataclass.type_hints.items():
|
except KeyError as e:
|
||||||
if option.visibility == Visibility.none:
|
print(e)
|
||||||
continue
|
if test.map_shuffle:
|
||||||
display_name = getattr(option, "display_name", option_key)
|
print("map_shuffle is on")
|
||||||
player_output[display_name] = getattr(world.options, option_key).current_option_name
|
print(f"Hints are {bool(test.hints)}")
|
||||||
if display_name not in game_option_names:
|
print(test)
|
||||||
all_option_names.append(display_name)
|
|
||||||
game_option_names.append(display_name)
|
|
||||||
|
|
||||||
with open(output_path(f"generate_{multiworld.seed_name}.csv"), mode="w", newline="") as file:
|
|
||||||
fields = ["Game", "Name", *all_option_names]
|
|
||||||
writer = DictWriter(file, fields)
|
|
||||||
writer.writeheader()
|
|
||||||
writer.writerows(output)
|
|
||||||
|
|||||||
55
README.md
@@ -1,10 +1,8 @@
|
|||||||
# [Archipelago](https://archipelago.gg)  | [Install](https://github.com/ArchipelagoMW/Archipelago/releases)
|
# [Archipelago](https://archipelago.gg)  | [Install](https://github.com/ArchipelagoMW/Archipelago/releases)
|
||||||
|
|
||||||
Archipelago provides a generic framework for developing multiworld capability for game randomizers. In all cases,
|
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
|
||||||
@@ -69,17 +67,7 @@ Currently, the following games are supported:
|
|||||||
* Yoshi's Island
|
* Yoshi's Island
|
||||||
* Mario & Luigi: Superstar Saga
|
* Mario & Luigi: Superstar Saga
|
||||||
* Bomb Rush Cyberfunk
|
* Bomb Rush Cyberfunk
|
||||||
* Aquaria
|
|
||||||
* Yu-Gi-Oh! Ultimate Masters: World Championship Tournament 2006
|
* 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
|
|
||||||
|
|
||||||
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
|
||||||
@@ -87,57 +75,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, or AppImage for Linux-based systems.
|
||||||
|
|
||||||
For most people, all you need to do is head over to
|
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).
|
||||||
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).
|
|
||||||
|
|||||||
19
SNIClient.py
@@ -243,9 +243,6 @@ class SNIContext(CommonContext):
|
|||||||
# Once the games handled by SNIClient gets made to be remote items,
|
# Once the games handled by SNIClient gets made to be remote items,
|
||||||
# this will no longer be needed.
|
# this will no longer be needed.
|
||||||
async_start(self.send_msgs([{"cmd": "LocationScouts", "locations": list(new_locations)}]))
|
async_start(self.send_msgs([{"cmd": "LocationScouts", "locations": list(new_locations)}]))
|
||||||
|
|
||||||
if self.client_handler is not None:
|
|
||||||
self.client_handler.on_package(self, cmd, args)
|
|
||||||
|
|
||||||
def run_gui(self) -> None:
|
def run_gui(self) -> None:
|
||||||
from kvui import GameManager
|
from kvui import GameManager
|
||||||
@@ -636,13 +633,7 @@ async def game_watcher(ctx: SNIContext) -> None:
|
|||||||
if not ctx.client_handler:
|
if not ctx.client_handler:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
try:
|
rom_validated = await ctx.client_handler.validate_rom(ctx)
|
||||||
rom_validated = await ctx.client_handler.validate_rom(ctx)
|
|
||||||
except Exception as e:
|
|
||||||
snes_logger.error(f"An error occurred, see logs for details: {e}")
|
|
||||||
text_file_logger = logging.getLogger()
|
|
||||||
text_file_logger.exception(e)
|
|
||||||
rom_validated = False
|
|
||||||
|
|
||||||
if not rom_validated or (ctx.auth and ctx.auth != ctx.rom):
|
if not rom_validated or (ctx.auth and ctx.auth != ctx.rom):
|
||||||
snes_logger.warning("ROM change detected, please reconnect to the multiworld server")
|
snes_logger.warning("ROM change detected, please reconnect to the multiworld server")
|
||||||
@@ -658,13 +649,7 @@ 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:
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
208
Utils.py
@@ -18,8 +18,8 @@ import warnings
|
|||||||
|
|
||||||
from argparse import Namespace
|
from argparse import Namespace
|
||||||
from settings import Settings, get_settings
|
from settings import Settings, get_settings
|
||||||
from time import sleep
|
from typing import BinaryIO, Coroutine, Optional, Set, Dict, Any, Union
|
||||||
from typing import BinaryIO, Coroutine, Optional, Set, Dict, Any, Union, TypeGuard
|
from typing_extensions import TypeGuard
|
||||||
from yaml import load, load_all, dump
|
from yaml import load, load_all, dump
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -31,7 +31,6 @@ if typing.TYPE_CHECKING:
|
|||||||
import tkinter
|
import tkinter
|
||||||
import pathlib
|
import pathlib
|
||||||
from BaseClasses import Region
|
from BaseClasses import Region
|
||||||
import multiprocessing
|
|
||||||
|
|
||||||
|
|
||||||
def tuplize_version(version: str) -> Version:
|
def tuplize_version(version: str) -> Version:
|
||||||
@@ -47,7 +46,7 @@ class Version(typing.NamedTuple):
|
|||||||
return ".".join(str(item) for item in self)
|
return ".".join(str(item) for item in self)
|
||||||
|
|
||||||
|
|
||||||
__version__ = "0.6.0"
|
__version__ = "0.4.6"
|
||||||
version_tuple = tuplize_version(__version__)
|
version_tuple = tuplize_version(__version__)
|
||||||
|
|
||||||
is_linux = sys.platform.startswith("linux")
|
is_linux = sys.platform.startswith("linux")
|
||||||
@@ -102,7 +101,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 +152,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
|
||||||
@@ -216,11 +209,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])
|
||||||
|
|
||||||
|
|
||||||
@@ -308,21 +300,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 +323,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 +365,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 +383,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 +407,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,13 +431,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):
|
||||||
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()
|
||||||
|
|
||||||
@@ -467,15 +455,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
|
||||||
@@ -492,9 +471,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")
|
||||||
@@ -514,22 +493,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.
|
||||||
@@ -541,8 +516,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
|
||||||
@@ -565,13 +539,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:
|
||||||
@@ -581,8 +554,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)
|
||||||
@@ -601,7 +572,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:
|
||||||
@@ -624,7 +595,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
|
||||||
|
|
||||||
@@ -632,68 +603,21 @@ 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}.")
|
logging.info(f"Opening file input dialog for {title}.")
|
||||||
|
|
||||||
@@ -722,13 +646,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:
|
||||||
@@ -738,12 +655,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
|
||||||
@@ -767,16 +678,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:
|
||||||
@@ -789,6 +693,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()
|
||||||
@@ -824,7 +734,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)):
|
||||||
@@ -867,26 +777,28 @@ def async_start(co: Coroutine[None, None, typing.Any], name: Optional[str] = Non
|
|||||||
task.add_done_callback(_faf_tasks.discard)
|
task.add_done_callback(_faf_tasks.discard)
|
||||||
|
|
||||||
|
|
||||||
def deprecate(message: str, add_stacklevels: int = 0):
|
def deprecate(message: str):
|
||||||
if __debug__:
|
if __debug__:
|
||||||
raise Exception(message)
|
raise Exception(message)
|
||||||
warnings.warn(message, stacklevel=2 + add_stacklevels)
|
import warnings
|
||||||
|
warnings.warn(message)
|
||||||
|
|
||||||
|
|
||||||
class DeprecateDict(dict):
|
class DeprecateDict(dict):
|
||||||
log_message: str
|
log_message: str
|
||||||
should_error: bool
|
should_error: bool
|
||||||
|
|
||||||
def __init__(self, message: str, error: bool = False) -> None:
|
def __init__(self, message, error: bool = False) -> None:
|
||||||
self.log_message = message
|
self.log_message = message
|
||||||
self.should_error = error
|
self.should_error = error
|
||||||
super().__init__()
|
super().__init__()
|
||||||
|
|
||||||
def __getitem__(self, item: Any) -> Any:
|
def __getitem__(self, item: Any) -> Any:
|
||||||
if self.should_error:
|
if self.should_error:
|
||||||
deprecate(self.log_message, add_stacklevels=1)
|
deprecate(self.log_message)
|
||||||
elif __debug__:
|
elif __debug__:
|
||||||
warnings.warn(self.log_message, stacklevel=2)
|
import warnings
|
||||||
|
warnings.warn(self.log_message)
|
||||||
return super().__getitem__(item)
|
return super().__getitem__(item)
|
||||||
|
|
||||||
|
|
||||||
@@ -940,7 +852,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.)
|
||||||
@@ -956,22 +868,16 @@ def visualize_regions(root_region: Region, file_name: str, *,
|
|||||||
Items without ID will be shown in italics.
|
Items without ID will be shown in italics.
|
||||||
:param show_other_regions: (default True) If enabled, regions that can't be reached by traversing exits are shown.
|
:param show_other_regions: (default True) If enabled, regions that can't be reached by traversing exits are shown.
|
||||||
:param linetype_ortho: (default True) If enabled, orthogonal straight line parts will be used; otherwise polylines.
|
:param linetype_ortho: (default True) If enabled, orthogonal straight line parts will be used; otherwise polylines.
|
||||||
:param regions_to_highlight: Regions that will be highlighted in green if they are reachable.
|
|
||||||
|
|
||||||
Example usage in World code:
|
Example usage in World code:
|
||||||
from Utils import visualize_regions
|
from Utils import visualize_regions
|
||||||
state = self.multiworld.get_all_state(False)
|
visualize_regions(self.multiworld.get_region("Menu", self.player), "my_world.puml")
|
||||||
state.update_reachable_regions(self.player)
|
|
||||||
visualize_regions(self.get_region("Menu"), "my_world.puml", show_entrance_names=True,
|
|
||||||
regions_to_highlight=state.reachable_regions[self.player])
|
|
||||||
|
|
||||||
Example usage in Main code:
|
Example usage in Main code:
|
||||||
from Utils import visualize_regions
|
from Utils import visualize_regions
|
||||||
for player in multiworld.player_ids:
|
for player in multiworld.player_ids:
|
||||||
visualize_regions(multiworld.get_region("Menu", player), f"{multiworld.get_out_file_name_base(player)}.puml")
|
visualize_regions(multiworld.get_region("Menu", player), f"{multiworld.get_out_file_name_base(player)}.puml")
|
||||||
"""
|
"""
|
||||||
if regions_to_highlight is None:
|
|
||||||
regions_to_highlight = set()
|
|
||||||
assert root_region.multiworld, "The multiworld attribute of root_region has to be filled"
|
assert root_region.multiworld, "The multiworld attribute of root_region has to be filled"
|
||||||
from BaseClasses import Entrance, Item, Location, LocationProgressType, MultiWorld, Region
|
from BaseClasses import Entrance, Item, Location, LocationProgressType, MultiWorld, Region
|
||||||
from collections import deque
|
from collections import deque
|
||||||
@@ -1024,7 +930,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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
31
WebHost.py
@@ -1,4 +1,3 @@
|
|||||||
import argparse
|
|
||||||
import os
|
import os
|
||||||
import multiprocessing
|
import multiprocessing
|
||||||
import logging
|
import logging
|
||||||
@@ -12,19 +11,15 @@ 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
|
||||||
|
|
||||||
@@ -33,15 +28,6 @@ def get_app() -> "Flask":
|
|||||||
import yaml
|
import yaml
|
||||||
app.config.from_file(configpath, yaml.safe_load)
|
app.config.from_file(configpath, yaml.safe_load)
|
||||||
logging.info(f"Updated config from {configpath}")
|
logging.info(f"Updated config from {configpath}")
|
||||||
# inside get_app() so it's usable in systems like gunicorn, which do not run WebHost.py, but import it.
|
|
||||||
parser = argparse.ArgumentParser(allow_abbrev=False)
|
|
||||||
parser.add_argument('--config_override', default=None,
|
|
||||||
help="Path to yaml config file that overrules config.yaml.")
|
|
||||||
args = parser.parse_known_args()[0]
|
|
||||||
if args.config_override:
|
|
||||||
import yaml
|
|
||||||
app.config.from_file(os.path.abspath(args.config_override), yaml.safe_load)
|
|
||||||
logging.info(f"Updated config from {args.config_override}")
|
|
||||||
if not app.config["HOST_ADDRESS"]:
|
if not app.config["HOST_ADDRESS"]:
|
||||||
logging.info("Getting public IP, as HOST_ADDRESS is empty.")
|
logging.info("Getting public IP, as HOST_ADDRESS is empty.")
|
||||||
app.config["HOST_ADDRESS"] = Utils.get_public_ipv4()
|
app.config["HOST_ADDRESS"] = Utils.get_public_ipv4()
|
||||||
@@ -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
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ from flask_compress import Compress
|
|||||||
from pony.flask import Pony
|
from pony.flask import Pony
|
||||||
from werkzeug.routing import BaseConverter
|
from werkzeug.routing import BaseConverter
|
||||||
|
|
||||||
from Utils import title_sorted, get_file_safe_name
|
from Utils import title_sorted
|
||||||
|
|
||||||
UPLOAD_FOLDER = os.path.relpath('uploads')
|
UPLOAD_FOLDER = os.path.relpath('uploads')
|
||||||
LOGS_FOLDER = os.path.relpath('logs')
|
LOGS_FOLDER = os.path.relpath('logs')
|
||||||
@@ -20,7 +20,6 @@ Pony(app)
|
|||||||
|
|
||||||
app.jinja_env.filters['any'] = any
|
app.jinja_env.filters['any'] = any
|
||||||
app.jinja_env.filters['all'] = all
|
app.jinja_env.filters['all'] = all
|
||||||
app.jinja_env.filters['get_file_safe_name'] = get_file_safe_name
|
|
||||||
|
|
||||||
app.config["SELFHOST"] = True # application process is in charge of running the websites
|
app.config["SELFHOST"] = True # application process is in charge of running the websites
|
||||||
app.config["GENERATORS"] = 8 # maximum concurrent world gens
|
app.config["GENERATORS"] = 8 # maximum concurrent world gens
|
||||||
@@ -39,8 +38,6 @@ app.config["SECRET_KEY"] = bytes(socket.gethostname(), encoding="utf-8")
|
|||||||
app.config["JOB_THRESHOLD"] = 1
|
app.config["JOB_THRESHOLD"] = 1
|
||||||
# after what time in seconds should generation be aborted, freeing the queue slot. Can be set to None to disable.
|
# after what time in seconds should generation be aborted, freeing the queue slot. Can be set to None to disable.
|
||||||
app.config["JOB_TIME"] = 600
|
app.config["JOB_TIME"] = 600
|
||||||
# memory limit for generator processes in bytes
|
|
||||||
app.config["GENERATOR_MEMORY_LIMIT"] = 4294967296
|
|
||||||
app.config['SESSION_PERMANENT'] = True
|
app.config['SESSION_PERMANENT'] = True
|
||||||
|
|
||||||
# waitress uses one thread for I/O, these are for processing of views that then get sent
|
# waitress uses one thread for I/O, these are for processing of views that then get sent
|
||||||
@@ -87,6 +84,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, robots, options
|
||||||
|
|
||||||
app.register_blueprint(api.api_endpoints)
|
app.register_blueprint(api.api_endpoints)
|
||||||
|
|||||||
@@ -1,15 +1,78 @@
|
|||||||
"""API endpoints package."""
|
"""API endpoints package."""
|
||||||
from typing import List, Tuple
|
from typing import List, Tuple
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
from flask import Blueprint
|
from flask import Blueprint, abort, url_for
|
||||||
|
|
||||||
from ..models import Seed, Slot
|
import worlds.Files
|
||||||
|
from .. 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)
|
||||||
|
|
||||||
|
def supports_apdeltapatch(game: str):
|
||||||
|
return game in worlds.Files.AutoPatchRegister.patch_types
|
||||||
|
downloads = []
|
||||||
|
for slot in sorted(room.seed.slots):
|
||||||
|
if slot.data and not supports_apdeltapatch(slot.game):
|
||||||
|
slot_download = {
|
||||||
|
"slot": slot.player_id,
|
||||||
|
"download": url_for("download_slot_file", room_id=room.id, player_id=slot.player_id)
|
||||||
|
}
|
||||||
|
downloads.append(slot_download)
|
||||||
|
elif slot.data:
|
||||||
|
slot_download = {
|
||||||
|
"slot": slot.player_id,
|
||||||
|
"download": url_for("download_patch", patch_id=slot.id, room_id=room.id)
|
||||||
|
}
|
||||||
|
downloads.append(slot_download)
|
||||||
|
return {
|
||||||
|
"tracker": room.tracker,
|
||||||
|
"players": get_players(room.seed),
|
||||||
|
"last_port": room.last_port,
|
||||||
|
"last_activity": room.last_activity,
|
||||||
|
"timeout": room.timeout,
|
||||||
|
"downloads": downloads,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@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
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -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,
|
|
||||||
}
|
|
||||||
@@ -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)
|
||||||
@@ -3,27 +3,16 @@ from __future__ import annotations
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import multiprocessing
|
import multiprocessing
|
||||||
|
import time
|
||||||
import typing
|
import typing
|
||||||
from datetime import timedelta, datetime
|
|
||||||
from threading import Event, Thread
|
|
||||||
from typing import Any
|
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
from datetime import timedelta, datetime
|
||||||
|
|
||||||
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 stop():
|
|
||||||
"""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):
|
||||||
logging.info(f"Generation finished for seed {seed_id}")
|
logging.info(f"Generation finished for seed {seed_id}")
|
||||||
@@ -54,21 +43,7 @@ def launch_generator(pool: multiprocessing.pool.Pool, generation: Generation):
|
|||||||
generation.state = STATE_STARTED
|
generation.state = STATE_STARTED
|
||||||
|
|
||||||
|
|
||||||
def init_generator(config: dict[str, Any]) -> None:
|
def init_db(pony_config: dict):
|
||||||
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()
|
||||||
|
|
||||||
@@ -88,7 +63,6 @@ def cleanup():
|
|||||||
|
|
||||||
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()
|
cleanup()
|
||||||
@@ -98,30 +72,31 @@ def autohost(config: dict):
|
|||||||
hosters.append(hoster)
|
hosters.append(hoster)
|
||||||
hoster.start()
|
hoster.start()
|
||||||
|
|
||||||
while not stop_event.wait(0.1):
|
while 1:
|
||||||
|
time.sleep(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.
|
# we have to filter twice, as the per-room timeout can't currently be PonyORM transpiled.
|
||||||
if room.last_activity >= datetime.utcnow() - timedelta(seconds=room.timeout + 5):
|
if room.last_activity >= datetime.utcnow() - timedelta(seconds=room.timeout):
|
||||||
hosters[room.id.int % len(hosters)].start_room(room.id)
|
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 +112,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,7 +124,8 @@ 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] = {}
|
||||||
|
|||||||
@@ -105,9 +105,8 @@ 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__:
|
if e.__cause__:
|
||||||
results[filename] = f"Failed to generate options in {filename}: {e} - {e.__cause__}"
|
results[filename] = f"Failed to generate options in {filename}: {e} - {e.__cause__}"
|
||||||
|
|||||||
@@ -72,17 +72,8 @@ class WebHostContext(Context):
|
|||||||
self.video = {}
|
self.video = {}
|
||||||
self.tags = ["AP", "WebHost"]
|
self.tags = ["AP", "WebHost"]
|
||||||
|
|
||||||
def __del__(self):
|
|
||||||
try:
|
|
||||||
import psutil
|
|
||||||
from Utils import format_SI_prefix
|
|
||||||
self.logger.debug(f"Context destroyed, Mem: {format_SI_prefix(psutil.Process().memory_info().rss, 1024)}iB")
|
|
||||||
except ImportError:
|
|
||||||
self.logger.debug("Context destroyed")
|
|
||||||
|
|
||||||
def _load_game_data(self):
|
def _load_game_data(self):
|
||||||
for key, value in self.static_server_data.items():
|
for key, value in self.static_server_data.items():
|
||||||
# NOTE: attributes are mutable and shared, so they will have to be copied before being modified
|
|
||||||
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 +101,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 +122,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,28 +148,17 @@ 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
|
||||||
|
|
||||||
|
|
||||||
@@ -254,105 +212,68 @@ def run_server_process(name: str, ponyconfig: dict, static_server_data: dict,
|
|||||||
loop = asyncio.get_event_loop()
|
loop = asyncio.get_event_loop()
|
||||||
|
|
||||||
async def start_room(room_id):
|
async def start_room(room_id):
|
||||||
with Locker(f"RoomLocker {room_id}"):
|
try:
|
||||||
|
logger = set_up_logging(room_id)
|
||||||
|
ctx = WebHostContext(static_server_data, logger)
|
||||||
|
ctx.load(room_id)
|
||||||
|
ctx.init_save()
|
||||||
try:
|
try:
|
||||||
logger = set_up_logging(room_id)
|
ctx.server = websockets.serve(functools.partial(server, ctx=ctx), ctx.host, ctx.port, ssl=ssl_context)
|
||||||
ctx = WebHostContext(static_server_data, logger)
|
|
||||||
ctx.load(room_id)
|
|
||||||
ctx.init_save()
|
|
||||||
assert ctx.server is None
|
|
||||||
try:
|
|
||||||
ctx.server = websockets.serve(
|
|
||||||
functools.partial(server, ctx=ctx), ctx.host, ctx.port, ssl=ssl_context)
|
|
||||||
|
|
||||||
await ctx.server
|
await ctx.server
|
||||||
except OSError: # likely port in use
|
except OSError: # likely port in use
|
||||||
ctx.server = websockets.serve(
|
ctx.server = websockets.serve(functools.partial(server, ctx=ctx), ctx.host, 0, ssl=ssl_context)
|
||||||
functools.partial(server, ctx=ctx), ctx.host, 0, ssl=ssl_context)
|
|
||||||
|
|
||||||
await ctx.server
|
await ctx.server
|
||||||
port = 0
|
port = 0
|
||||||
for wssocket in ctx.server.ws_server.sockets:
|
for wssocket in ctx.server.ws_server.sockets:
|
||||||
socketname = wssocket.getsockname()
|
socketname = wssocket.getsockname()
|
||||||
if wssocket.family == socket.AF_INET6:
|
if wssocket.family == socket.AF_INET6:
|
||||||
# Prefer IPv4, as most users seem to not have working ipv6 support
|
# Prefer IPv4, as most users seem to not have working ipv6 support
|
||||||
if not port:
|
if not port:
|
||||||
port = socketname[1]
|
|
||||||
elif wssocket.family == socket.AF_INET:
|
|
||||||
port = socketname[1]
|
port = socketname[1]
|
||||||
if port:
|
elif wssocket.family == socket.AF_INET:
|
||||||
ctx.logger.info(f'Hosting game at {host}:{port}')
|
port = socketname[1]
|
||||||
with db_session:
|
if port:
|
||||||
room = Room.get(id=ctx.room_id)
|
ctx.logger.info(f'Hosting game at {host}:{port}')
|
||||||
room.last_port = port
|
|
||||||
else:
|
|
||||||
ctx.logger.exception("Could not determine port. Likely hosting failure.")
|
|
||||||
with db_session:
|
with db_session:
|
||||||
ctx.auto_shutdown = Room.get(id=room_id).timeout
|
room = Room.get(id=ctx.room_id)
|
||||||
if ctx.saving:
|
room.last_port = port
|
||||||
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:
|
else:
|
||||||
if ctx.saving:
|
ctx.logger.exception("Could not determine port. Likely hosting failure.")
|
||||||
ctx._save()
|
with db_session:
|
||||||
setattr(asyncio.current_task(), "save", None)
|
ctx.auto_shutdown = Room.get(id=room_id).timeout
|
||||||
finally:
|
ctx.shutdown_task = asyncio.create_task(auto_shutdown(ctx, []))
|
||||||
try:
|
await ctx.shutdown_task
|
||||||
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
|
# ensure auto launch is on the same page in regard to room activity.
|
||||||
# NOTE: async saving should probably be an async task and could be merged with shutdown_task
|
with db_session:
|
||||||
with (db_session):
|
room: Room = Room.get(id=ctx.room_id)
|
||||||
# ensure the Room does not spin up again on its own, minute of safety buffer
|
room.last_activity = datetime.datetime.utcnow() - datetime.timedelta(seconds=room.timeout + 60)
|
||||||
room = Room.get(id=room_id)
|
|
||||||
room.last_activity = datetime.datetime.utcnow() - \
|
except (KeyboardInterrupt, SystemExit):
|
||||||
datetime.timedelta(minutes=1, seconds=room.timeout)
|
with db_session:
|
||||||
logging.info(f"Shutting down room {room_id} on {name}.")
|
room = Room.get(id=room_id)
|
||||||
finally:
|
# ensure the Room does not spin up again on its own, minute of safety buffer
|
||||||
await asyncio.sleep(5)
|
room.last_activity = datetime.datetime.utcnow() - datetime.timedelta(minutes=1, seconds=room.timeout)
|
||||||
rooms_shutting_down.put(room_id)
|
except Exception:
|
||||||
|
with db_session:
|
||||||
|
room = Room.get(id=room_id)
|
||||||
|
room.last_port = -1
|
||||||
|
# ensure the Room does not spin up again on its own, minute of safety buffer
|
||||||
|
room.last_activity = datetime.datetime.utcnow() - datetime.timedelta(minutes=1, seconds=room.timeout)
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
rooms_shutting_down.put(room_id)
|
||||||
|
|
||||||
class Starter(threading.Thread):
|
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):
|
def run(self):
|
||||||
while 1:
|
while 1:
|
||||||
next_room = rooms_to_run.get(block=True, timeout=None)
|
next_room = rooms_to_run.get(block=True, timeout=None)
|
||||||
gc.collect()
|
asyncio.run_coroutine_threadsafe(start_room(next_room), loop)
|
||||||
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}.")
|
logging.info(f"Starting room {next_room} on {name}.")
|
||||||
del task # delete reference to task object
|
|
||||||
|
|
||||||
starter = Starter()
|
starter = Starter()
|
||||||
starter.daemon = True
|
starter.daemon = True
|
||||||
starter.start()
|
starter.start()
|
||||||
try:
|
loop.run_forever()
|
||||||
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()
|
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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):
|
||||||
@@ -63,40 +69,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 +97,49 @@ def new_room(seed: UUID):
|
|||||||
return redirect(url_for("host_room", room=room.id))
|
return redirect(url_for("host_room", room=room.id))
|
||||||
|
|
||||||
|
|
||||||
def _read_log(log: IO[Any], offset: int = 0) -> Iterator[bytes]:
|
def _read_log(path: str):
|
||||||
marker = log.read(3) # skip optional BOM
|
if os.path.exists(path):
|
||||||
if marker != b'\xEF\xBB\xBF':
|
with open(path, encoding="utf-8-sig") as log:
|
||||||
log.seek(0, os.SEEK_SET)
|
yield from log
|
||||||
log.seek(offset, os.SEEK_CUR)
|
else:
|
||||||
yield from log
|
yield f"Logfile {path} does not exist. " \
|
||||||
log.close() # free file handle as soon as possible
|
f"Likely a crash during spinup of multiworld instance or it is still spinning up."
|
||||||
|
|
||||||
|
|
||||||
@app.route('/log/<suuid:room>')
|
@app.route('/log/<suuid:room>')
|
||||||
def display_log(room: UUID) -> Union[str, Response, Tuple[str, int]]:
|
def display_log(room: UUID):
|
||||||
room = Room.get(id=room)
|
room = Room.get(id=room)
|
||||||
if room is None:
|
if room is None:
|
||||||
return abort(404)
|
return abort(404)
|
||||||
if room.owner == session["_id"]:
|
if room.owner == session["_id"]:
|
||||||
file_path = os.path.join("logs", str(room.id) + ".txt")
|
file_path = os.path.join("logs", str(room.id) + ".txt")
|
||||||
try:
|
if os.path.exists(file_path):
|
||||||
log = open(file_path, "rb")
|
return Response(_read_log(file_path), mimetype="text/plain;charset=UTF-8")
|
||||||
range_header = request.headers.get("Range")
|
return "Log File does not exist."
|
||||||
if range_header:
|
|
||||||
range_type, range_values = range_header.split('=')
|
|
||||||
start, end = map(str.strip, range_values.split('-', 1))
|
|
||||||
if range_type != "bytes" or end != "":
|
|
||||||
return "Unsupported range", 500
|
|
||||||
# NOTE: we skip Content-Range in the response here, which isn't great but works for our JS
|
|
||||||
return Response(_read_log(log, int(start)), mimetype="text/plain", status=206)
|
|
||||||
return Response(_read_log(log), mimetype="text/plain")
|
|
||||||
except FileNotFoundError:
|
|
||||||
return Response(f"Logfile {file_path} does not exist. "
|
|
||||||
f"Likely a crash during spinup of multiworld instance or it is still spinning up.",
|
|
||||||
mimetype="text/plain")
|
|
||||||
|
|
||||||
return "Access Denied", 403
|
return "Access Denied", 403
|
||||||
|
|
||||||
|
|
||||||
@app.post("/room/<suuid:room>")
|
@app.route('/room/<suuid:room>', methods=['GET', 'POST'])
|
||||||
def host_room_command(room: UUID):
|
|
||||||
room: Room = Room.get(id=room)
|
|
||||||
if room is None:
|
|
||||||
return abort(404)
|
|
||||||
|
|
||||||
if room.owner == session["_id"]:
|
|
||||||
cmd = request.form["cmd"]
|
|
||||||
if cmd:
|
|
||||||
Command(room=room, commandtext=cmd)
|
|
||||||
commit()
|
|
||||||
return redirect(url_for("host_room", room=room.id))
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/room/<suuid:room>")
|
|
||||||
def host_room(room: UUID):
|
def host_room(room: UUID):
|
||||||
room: Room = Room.get(id=room)
|
room: Room = Room.get(id=room)
|
||||||
if room is None:
|
if room is None:
|
||||||
return abort(404)
|
return abort(404)
|
||||||
|
if request.method == "POST":
|
||||||
|
if room.owner == session["_id"]:
|
||||||
|
cmd = request.form["cmd"]
|
||||||
|
if cmd:
|
||||||
|
Command(room=room, commandtext=cmd)
|
||||||
|
commit()
|
||||||
|
return redirect(url_for("host_room", room=room.id))
|
||||||
|
|
||||||
now = datetime.datetime.utcnow()
|
now = datetime.datetime.utcnow()
|
||||||
# indicate that the page should reload to get the assigned port
|
# indicate that the page should reload to get the assigned port
|
||||||
should_refresh = ((not room.last_port and now - room.creation_time < datetime.timedelta(seconds=3))
|
should_refresh = not room.last_port and now - room.creation_time < datetime.timedelta(seconds=3)
|
||||||
or room.last_activity < now - datetime.timedelta(seconds=room.timeout))
|
|
||||||
with db_session:
|
with db_session:
|
||||||
room.last_activity = now # will trigger a spinup, if it's not already running
|
room.last_activity = now # will trigger a spinup, if it's not already running
|
||||||
|
|
||||||
browser_tokens = "Mozilla", "Chrome", "Safari"
|
return render_template("hostRoom.html", room=room, should_refresh=should_refresh)
|
||||||
automated = ("update" in request.args
|
|
||||||
or "Discordbot" in request.user_agent.string
|
|
||||||
or not any(browser_token in request.user_agent.string for browser_token in browser_tokens))
|
|
||||||
|
|
||||||
def get_log(max_size: int = 0 if automated else 1024000) -> str:
|
|
||||||
if max_size == 0:
|
|
||||||
return "…"
|
|
||||||
try:
|
|
||||||
with open(os.path.join("logs", str(room.id) + ".txt"), "rb") as log:
|
|
||||||
raw_size = 0
|
|
||||||
fragments: List[str] = []
|
|
||||||
for block in _read_log(log):
|
|
||||||
if raw_size + len(block) > max_size:
|
|
||||||
fragments.append("…")
|
|
||||||
break
|
|
||||||
raw_size += len(block)
|
|
||||||
fragments.append(block.decode("utf-8"))
|
|
||||||
return "".join(fragments)
|
|
||||||
except FileNotFoundError:
|
|
||||||
return ""
|
|
||||||
|
|
||||||
return render_template("hostRoom.html", room=room, should_refresh=should_refresh, get_log=get_log)
|
|
||||||
|
|
||||||
|
|
||||||
@app.route('/favicon.ico')
|
@app.route('/favicon.ico')
|
||||||
|
|||||||
@@ -1,61 +1,87 @@
|
|||||||
import collections.abc
|
import collections.abc
|
||||||
import json
|
|
||||||
import os
|
import os
|
||||||
from textwrap import dedent
|
|
||||||
from typing import Dict, Union
|
|
||||||
from docutils.core import publish_parts
|
|
||||||
|
|
||||||
import yaml
|
import yaml
|
||||||
from flask import redirect, render_template, request, Response
|
import requests
|
||||||
|
import json
|
||||||
|
import flask
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
import Options
|
import Options
|
||||||
from Utils import local_path
|
from Options import Visibility
|
||||||
|
from flask import redirect, render_template, request, Response
|
||||||
from worlds.AutoWorld import AutoWorldRegister
|
from worlds.AutoWorld import AutoWorldRegister
|
||||||
|
from Utils import local_path
|
||||||
|
from textwrap import dedent
|
||||||
from . import app, cache
|
from . import app, cache
|
||||||
from .generate import get_meta
|
|
||||||
|
|
||||||
|
|
||||||
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_world_theme(game_name: str) -> str:
|
def get_world_theme(game_name: str):
|
||||||
if game_name in AutoWorldRegister.world_types:
|
if game_name in AutoWorldRegister.world_types:
|
||||||
return AutoWorldRegister.world_types[game_name].web.theme
|
return AutoWorldRegister.world_types[game_name].web.theme
|
||||||
return 'grass'
|
return 'grass'
|
||||||
|
|
||||||
|
|
||||||
def render_options_page(template: str, world_name: str, is_complex: bool = False) -> Union[Response, str]:
|
def render_options_page(template: str, world_name: str, is_complex: bool = False):
|
||||||
world = AutoWorldRegister.world_types[world_name]
|
world = AutoWorldRegister.world_types[world_name]
|
||||||
if world.hidden or world.web.options_page is False:
|
if world.hidden or world.web.options_page is False:
|
||||||
return redirect("games")
|
return redirect("games")
|
||||||
visibility_flag = Options.Visibility.complex_ui if is_complex else Options.Visibility.simple_ui
|
|
||||||
|
|
||||||
start_collapsed = {"Game Options": False}
|
option_groups = {option: option_group.name
|
||||||
for group in world.web.option_groups:
|
for option_group in world.web.option_groups
|
||||||
start_collapsed[group.name] = group.start_collapsed
|
for option in option_group.options}
|
||||||
|
ordered_groups = ["Game Options", *[group.name for group in world.web.option_groups]]
|
||||||
|
grouped_options = {group: {} for group in ordered_groups}
|
||||||
|
for option_name, option in world.options_dataclass.type_hints.items():
|
||||||
|
# Exclude settings from options pages if their visibility is disabled
|
||||||
|
if not is_complex and option.visibility < Visibility.simple_ui:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if is_complex and option.visibility < Visibility.complex_ui:
|
||||||
|
continue
|
||||||
|
|
||||||
|
grouped_options[option_groups.get(option, "Game Options")][option_name] = option
|
||||||
|
|
||||||
return render_template(
|
return render_template(
|
||||||
template,
|
template,
|
||||||
world_name=world_name,
|
world_name=world_name,
|
||||||
world=world,
|
world=world,
|
||||||
option_groups=Options.get_option_groups(world, visibility_level=visibility_flag),
|
option_groups=grouped_options,
|
||||||
start_collapsed=start_collapsed,
|
|
||||||
issubclass=issubclass,
|
issubclass=issubclass,
|
||||||
Options=Options,
|
Options=Options,
|
||||||
theme=get_world_theme(world_name),
|
theme=get_world_theme(world_name),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def generate_game(options: Dict[str, Union[dict, str]]) -> Union[Response, str]:
|
def generate_game(player_name: str, formatted_options: dict):
|
||||||
from .generate import start_generation
|
payload = {
|
||||||
return start_generation(options, get_meta({}))
|
"race": 0,
|
||||||
|
"hint_cost": 10,
|
||||||
|
"forfeit_mode": "auto",
|
||||||
|
"remaining_mode": "disabled",
|
||||||
|
"collect_mode": "goal",
|
||||||
|
"weights": {
|
||||||
|
player_name: formatted_options,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
url = urlparse(request.base_url)
|
||||||
|
port_string = f":{url.port}" if url.port else ""
|
||||||
|
r = requests.post(f"{url.scheme}://{url.hostname}{port_string}/api/generate", json=payload)
|
||||||
|
if 200 <= r.status_code <= 299:
|
||||||
|
response_data = r.json()
|
||||||
|
return redirect(response_data["url"])
|
||||||
|
else:
|
||||||
|
return r.text
|
||||||
|
|
||||||
|
|
||||||
def send_yaml(player_name: str, formatted_options: dict) -> Response:
|
def send_yaml(player_name: str, formatted_options: dict):
|
||||||
response = Response(yaml.dump(formatted_options, sort_keys=False))
|
response = Response(yaml.dump(formatted_options, sort_keys=False))
|
||||||
response.headers["Content-Type"] = "text/yaml"
|
response.headers["Content-Type"] = "text/yaml"
|
||||||
response.headers["Content-Disposition"] = f"attachment; filename={player_name}.yaml"
|
response.headers["Content-Disposition"] = f"attachment; filename={player_name}.yaml"
|
||||||
@@ -63,26 +89,10 @@ def send_yaml(player_name: str, formatted_options: dict) -> Response:
|
|||||||
|
|
||||||
|
|
||||||
@app.template_filter("dedent")
|
@app.template_filter("dedent")
|
||||||
def filter_dedent(text: str) -> str:
|
def filter_dedent(text: str):
|
||||||
return dedent(text).strip("\n ")
|
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")
|
@app.template_test("ordered")
|
||||||
def test_ordered(obj):
|
def test_ordered(obj):
|
||||||
return isinstance(obj, collections.abc.Sequence)
|
return isinstance(obj, collections.abc.Sequence)
|
||||||
@@ -92,34 +102,10 @@ def test_ordered(obj):
|
|||||||
@cache.cached()
|
@cache.cached()
|
||||||
def option_presets(game: str) -> Response:
|
def option_presets(game: str) -> Response:
|
||||||
world = AutoWorldRegister.world_types[game]
|
world = AutoWorldRegister.world_types[game]
|
||||||
|
|
||||||
presets = {}
|
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 world.web.options_presets:
|
||||||
if isinstance(option, Options.NamedRange) and isinstance(preset_option, str):
|
presets = presets | world.web.options_presets
|
||||||
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):
|
class SetEncoder(json.JSONEncoder):
|
||||||
def default(self, obj):
|
def default(self, obj):
|
||||||
@@ -129,7 +115,7 @@ def option_presets(game: str) -> Response:
|
|||||||
return json.JSONEncoder.default(self, obj)
|
return json.JSONEncoder.default(self, obj)
|
||||||
|
|
||||||
json_data = json.dumps(presets, cls=SetEncoder)
|
json_data = json.dumps(presets, cls=SetEncoder)
|
||||||
response = Response(json_data)
|
response = flask.Response(json_data)
|
||||||
response.headers["Content-Type"] = "application/json"
|
response.headers["Content-Type"] = "application/json"
|
||||||
return response
|
return response
|
||||||
|
|
||||||
@@ -187,7 +173,7 @@ def generate_weighted_yaml(game: str):
|
|||||||
}
|
}
|
||||||
|
|
||||||
if intent_generate:
|
if intent_generate:
|
||||||
return generate_game({player_name: formatted_options})
|
return generate_game(player_name, formatted_options)
|
||||||
|
|
||||||
else:
|
else:
|
||||||
return send_yaml(player_name, formatted_options)
|
return send_yaml(player_name, formatted_options)
|
||||||
@@ -214,9 +200,9 @@ def generate_yaml(game: str):
|
|||||||
else:
|
else:
|
||||||
options[key] = val
|
options[key] = val
|
||||||
|
|
||||||
|
# Detect and build ItemDict options from their name pattern
|
||||||
for key, val in options.copy().items():
|
for key, val in options.copy().items():
|
||||||
key_parts = key.rsplit("||", 2)
|
key_parts = key.rsplit("||", 2)
|
||||||
# Detect and build ItemDict options from their name pattern
|
|
||||||
if key_parts[-1] == "qty":
|
if key_parts[-1] == "qty":
|
||||||
if key_parts[0] not in options:
|
if key_parts[0] not in options:
|
||||||
options[key_parts[0]] = {}
|
options[key_parts[0]] = {}
|
||||||
@@ -224,20 +210,6 @@ def generate_yaml(game: str):
|
|||||||
options[key_parts[0]][key_parts[1]] = int(val)
|
options[key_parts[0]][key_parts[1]] = int(val)
|
||||||
del options[key]
|
del options[key]
|
||||||
|
|
||||||
# Detect keys which end with -custom, indicating a TextChoice with a possible custom value
|
|
||||||
elif key_parts[-1].endswith("-custom"):
|
|
||||||
if val:
|
|
||||||
options[key_parts[-1][:-7]] = val
|
|
||||||
|
|
||||||
del options[key]
|
|
||||||
|
|
||||||
# Detect keys which end with -range, indicating a NamedRange with a possible custom value
|
|
||||||
elif key_parts[-1].endswith("-range"):
|
|
||||||
if options[key_parts[-1][:-6]] == "custom":
|
|
||||||
options[key_parts[-1][:-6]] = val
|
|
||||||
|
|
||||||
del options[key]
|
|
||||||
|
|
||||||
# Detect random-* keys and set their options accordingly
|
# Detect random-* keys and set their options accordingly
|
||||||
for key, val in options.copy().items():
|
for key, val in options.copy().items():
|
||||||
if key.startswith("random-"):
|
if key.startswith("random-"):
|
||||||
@@ -275,7 +247,7 @@ def generate_yaml(game: str):
|
|||||||
}
|
}
|
||||||
|
|
||||||
if intent_generate:
|
if intent_generate:
|
||||||
return generate_game({player_name: formatted_options})
|
return generate_game(player_name, formatted_options)
|
||||||
|
|
||||||
else:
|
else:
|
||||||
return send_yaml(player_name, formatted_options)
|
return send_yaml(player_name, formatted_options)
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
flask>=3.0.3
|
flask>=3.0.0
|
||||||
werkzeug>=3.0.6
|
pony>=0.7.17
|
||||||
pony>=0.7.19
|
waitress>=2.1.2
|
||||||
waitress>=3.0.0
|
Flask-Caching>=2.1.0
|
||||||
Flask-Caching>=2.3.0
|
Flask-Compress>=1.14
|
||||||
Flask-Compress>=1.15
|
Flask-Limiter>=3.5.0
|
||||||
Flask-Limiter>=3.8.0
|
bokeh>=3.1.1; python_version <= '3.8'
|
||||||
bokeh>=3.5.2
|
bokeh>=3.3.2; python_version >= '3.9'
|
||||||
markupsafe>=2.1.5
|
markupsafe>=2.1.3
|
||||||
Markdown>=3.7
|
|
||||||
mdx-breakless-lists>=1.0.1
|
|
||||||
|
|||||||
@@ -8,8 +8,7 @@ from . import cache
|
|||||||
def robots():
|
def robots():
|
||||||
# If this host is not official, do not allow search engine crawling
|
# If this host is not official, do not allow search engine crawling
|
||||||
if not app.config["ASSET_RIGHTS"]:
|
if not app.config["ASSET_RIGHTS"]:
|
||||||
# filename changed in case the path is intercepted and served by an outside service
|
return app.send_static_file('robots.txt')
|
||||||
return app.send_static_file('robots_file.txt')
|
|
||||||
|
|
||||||
# Send 404 if the host has affirmed this to be the official WebHost
|
# Send 404 if the host has affirmed this to be the official WebHost
|
||||||
abort(404)
|
abort(404)
|
||||||
|
|||||||
@@ -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,
|
|
||||||
)
|
|
||||||
51
WebHostLib/static/assets/faq.js
Normal 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>`;
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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.
|
||||||
51
WebHostLib/static/assets/glossary.js
Normal 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>`;
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -288,11 +288,6 @@ const applyPresets = (presetName) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
namedRangeSelect.value = trueValue;
|
namedRangeSelect.value = trueValue;
|
||||||
// It is also possible for a preset to use an unnamed value. If this happens, set the dropdown to "Custom"
|
|
||||||
if (namedRangeSelect.selectedIndex == -1)
|
|
||||||
{
|
|
||||||
namedRangeSelect.value = "custom";
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle options whose presets are "random"
|
// Handle options whose presets are "random"
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 136 KiB |
|
Before Width: | Height: | Size: 97 KiB |
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 78 KiB |
|
Before Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 71 KiB |
|
Before Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 5.4 KiB |
|
Before Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 3.1 KiB After Width: | Height: | Size: 4.8 KiB |
|
Before Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 57 KiB |
|
Before Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 4.1 KiB |
|
Before Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 7.8 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 4.7 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 9.5 KiB |
|
Before Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 6.7 KiB |
|
Before Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 5.9 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 3.1 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 4.7 KiB After Width: | Height: | Size: 8.6 KiB |
|
Before Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 8.2 KiB |
|
Before Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 9.8 KiB |