Compare commits
9 Commits
NewSoupVi-
...
use_sphinx
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
95779c76ed | ||
|
|
b8b6b1c2da | ||
|
|
309651a644 | ||
|
|
dee8a2aaa9 | ||
|
|
edcfd66658 | ||
|
|
b0119a6a80 | ||
|
|
e35d1f98eb | ||
|
|
727f86c1f1 | ||
|
|
f3e5acbbc4 |
@@ -1,5 +0,0 @@
|
||||
[report]
|
||||
exclude_lines =
|
||||
pragma: no cover
|
||||
if TYPE_CHECKING:
|
||||
if typing.TYPE_CHECKING:
|
||||
2
.gitattributes
vendored
@@ -1,2 +0,0 @@
|
||||
worlds/blasphemous/region_data.py linguist-generated=true
|
||||
worlds/yachtdice/YachtWeights.py linguist-generated=true
|
||||
31
.github/labeler.yml
vendored
@@ -1,31 +0,0 @@
|
||||
'is: documentation':
|
||||
- changed-files:
|
||||
- all-globs-to-all-files: '{**/docs/**,**/README.md}'
|
||||
|
||||
'affects: webhost':
|
||||
- changed-files:
|
||||
- all-globs-to-any-file: 'WebHost.py'
|
||||
- all-globs-to-any-file: 'WebHostLib/**/*'
|
||||
|
||||
'affects: core':
|
||||
- changed-files:
|
||||
- all-globs-to-any-file:
|
||||
- '!*Client.py'
|
||||
- '!README.md'
|
||||
- '!LICENSE'
|
||||
- '!*.yml'
|
||||
- '!.gitignore'
|
||||
- '!**/docs/**'
|
||||
- '!typings/kivy/**'
|
||||
- '!test/**'
|
||||
- '!data/**'
|
||||
- '!.run/**'
|
||||
- '!.github/**'
|
||||
- '!worlds_disabled/**'
|
||||
- '!worlds/**'
|
||||
- '!WebHost.py'
|
||||
- '!WebHostLib/**'
|
||||
- any-glob-to-any-file: # exceptions to the above rules of "stuff that isn't core"
|
||||
- 'worlds/generic/**/*.py'
|
||||
- 'worlds/*.py'
|
||||
- 'CommonClient.py'
|
||||
39
.github/pyright-config.json
vendored
@@ -1,39 +0,0 @@
|
||||
{
|
||||
"include": [
|
||||
"../BizHawkClient.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",
|
||||
"type_check.py"
|
||||
],
|
||||
|
||||
"exclude": [
|
||||
"**/__pycache__"
|
||||
],
|
||||
|
||||
"stubPath": "../typings",
|
||||
|
||||
"typeCheckingMode": "strict",
|
||||
"reportImplicitOverride": "error",
|
||||
"reportMissingImports": true,
|
||||
"reportMissingTypeStubs": true,
|
||||
|
||||
"pythonVersion": "3.10",
|
||||
"pythonPlatform": "Windows",
|
||||
|
||||
"executionEnvironments": [
|
||||
{
|
||||
"root": ".."
|
||||
}
|
||||
]
|
||||
}
|
||||
15
.github/type_check.py
vendored
@@ -1,15 +0,0 @@
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
|
||||
config = Path(__file__).parent / "pyright-config.json"
|
||||
|
||||
command = ("pyright", "-p", str(config))
|
||||
print(" ".join(command))
|
||||
|
||||
try:
|
||||
result = subprocess.run(command)
|
||||
except FileNotFoundError as e:
|
||||
print(f"{e} - Is pyright installed?")
|
||||
exit(1)
|
||||
|
||||
exit(result.returncode)
|
||||
80
.github/workflows/analyze-modified-files.yml
vendored
@@ -1,80 +0,0 @@
|
||||
name: Analyze modified files
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- "**.py"
|
||||
push:
|
||||
paths:
|
||||
- "**.py"
|
||||
|
||||
env:
|
||||
BASE: ${{ github.event.pull_request.base.sha }}
|
||||
HEAD: ${{ github.event.pull_request.head.sha }}
|
||||
BEFORE: ${{ github.event.before }}
|
||||
AFTER: ${{ github.event.after }}
|
||||
|
||||
jobs:
|
||||
flake8-or-mypy:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
task: [flake8, mypy]
|
||||
|
||||
name: ${{ matrix.task }}
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: "Determine modified files (pull_request)"
|
||||
if: github.event_name == 'pull_request'
|
||||
run: |
|
||||
git fetch origin $BASE $HEAD
|
||||
DIFF=$(git diff --diff-filter=d --name-only $BASE...$HEAD -- "*.py")
|
||||
echo "modified files:"
|
||||
echo "$DIFF"
|
||||
echo "diff=${DIFF//$'\n'/$' '}" >> $GITHUB_ENV
|
||||
|
||||
- name: "Determine modified files (push)"
|
||||
if: github.event_name == 'push' && github.event.before != '0000000000000000000000000000000000000000'
|
||||
run: |
|
||||
git fetch origin $BEFORE $AFTER
|
||||
DIFF=$(git diff --diff-filter=d --name-only $BEFORE..$AFTER -- "*.py")
|
||||
echo "modified files:"
|
||||
echo "$DIFF"
|
||||
echo "diff=${DIFF//$'\n'/$' '}" >> $GITHUB_ENV
|
||||
|
||||
- name: "Treat all files as modified (new branch)"
|
||||
if: github.event_name == 'push' && github.event.before == '0000000000000000000000000000000000000000'
|
||||
run: |
|
||||
echo "diff=." >> $GITHUB_ENV
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
if: env.diff != ''
|
||||
with:
|
||||
python-version: '3.10'
|
||||
|
||||
- name: "Install dependencies"
|
||||
if: env.diff != ''
|
||||
run: |
|
||||
python -m pip install --upgrade pip ${{ matrix.task }}
|
||||
python ModuleUpdate.py --append "WebHostLib/requirements.txt" --force --yes
|
||||
|
||||
- name: "flake8: Stop the build if there are Python syntax errors or undefined names"
|
||||
continue-on-error: false
|
||||
if: env.diff != '' && matrix.task == 'flake8'
|
||||
run: |
|
||||
flake8 --count --select=E9,F63,F7,F82 --show-source --statistics ${{ env.diff }}
|
||||
|
||||
- name: "flake8: Lint modified files"
|
||||
continue-on-error: true
|
||||
if: env.diff != '' && matrix.task == 'flake8'
|
||||
run: |
|
||||
flake8 --count --max-complexity=14 --max-doc-length=120 --max-line-length=120 --statistics ${{ env.diff }}
|
||||
|
||||
- name: "mypy: Type check modified files"
|
||||
continue-on-error: true
|
||||
if: env.diff != '' && matrix.task == 'mypy'
|
||||
run: |
|
||||
mypy --follow-imports=silent --install-types --non-interactive --strict ${{ env.diff }}
|
||||
136
.github/workflows/build.yml
vendored
@@ -2,121 +2,66 @@
|
||||
|
||||
name: Build
|
||||
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- '.github/workflows/build.yml'
|
||||
- 'setup.py'
|
||||
- 'requirements.txt'
|
||||
- '*.iss'
|
||||
pull_request:
|
||||
paths:
|
||||
- '.github/workflows/build.yml'
|
||||
- 'setup.py'
|
||||
- 'requirements.txt'
|
||||
- '*.iss'
|
||||
workflow_dispatch:
|
||||
on: workflow_dispatch
|
||||
|
||||
env:
|
||||
SNI_VERSION: v0.0.84
|
||||
ENEMIZER_VERSION: 7.1
|
||||
APPIMAGETOOL_VERSION: 13
|
||||
|
||||
jobs:
|
||||
# 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
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v2
|
||||
- name: Install python
|
||||
uses: actions/setup-python@v5
|
||||
uses: actions/setup-python@v3
|
||||
with:
|
||||
python-version: '~3.12.7'
|
||||
check-latest: true
|
||||
python-version: '3.8'
|
||||
- name: Download run-time dependencies
|
||||
run: |
|
||||
Invoke-WebRequest -Uri https://github.com/alttpo/sni/releases/download/${Env:SNI_VERSION}/sni-${Env:SNI_VERSION}-windows-amd64.zip -OutFile sni.zip
|
||||
Expand-Archive -Path sni.zip -DestinationPath SNI -Force
|
||||
Invoke-WebRequest -Uri https://github.com/Ijwu/Enemizer/releases/download/${Env:ENEMIZER_VERSION}/win-x64.zip -OutFile enemizer.zip
|
||||
Expand-Archive -Path enemizer.zip -DestinationPath EnemizerCLI -Force
|
||||
choco install innosetup --version=6.2.2 --allow-downgrade
|
||||
- name: Build
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
python setup.py build_exe --yes
|
||||
if ( $? -eq $false ) {
|
||||
Write-Error "setup.py failed!"
|
||||
exit 1
|
||||
}
|
||||
$NAME="$(ls build | Select-String -Pattern 'exe')".Split('.',2)[1]
|
||||
python -m pip install --upgrade pip setuptools
|
||||
pip install -r requirements.txt
|
||||
python setup.py build --yes
|
||||
$NAME="$(ls build)".Split('.',2)[1]
|
||||
$ZIP_NAME="Archipelago_$NAME.7z"
|
||||
echo "$NAME -> $ZIP_NAME"
|
||||
echo "ZIP_NAME=$ZIP_NAME" >> $Env:GITHUB_ENV
|
||||
New-Item -Path dist -ItemType Directory -Force
|
||||
cd build
|
||||
Rename-Item "exe.$NAME" Archipelago
|
||||
Rename-Item exe.$NAME Archipelago
|
||||
7z a -mx=9 -mhe=on -ms "../dist/$ZIP_NAME" Archipelago
|
||||
Rename-Item Archipelago "exe.$NAME" # inno_setup.iss expects the original name
|
||||
- name: Build Setup
|
||||
run: |
|
||||
& "${env:ProgramFiles(x86)}\Inno Setup 6\iscc.exe" inno_setup.iss /DNO_SIGNTOOL
|
||||
if ( $? -eq $false ) {
|
||||
Write-Error "Building setup failed!"
|
||||
exit 1
|
||||
}
|
||||
$contents = Get-ChildItem -Path setups/*.exe -Force -Recurse
|
||||
$SETUP_NAME=$contents[0].Name
|
||||
echo "SETUP_NAME=$SETUP_NAME" >> $Env:GITHUB_ENV
|
||||
- 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
|
||||
uses: actions/upload-artifact@v2
|
||||
with:
|
||||
name: ${{ env.ZIP_NAME }}
|
||||
path: dist/${{ env.ZIP_NAME }}
|
||||
compression-level: 0 # .7z is incompressible by zip
|
||||
if-no-files-found: error
|
||||
retention-days: 7 # keep for 7 days, should be enough
|
||||
- name: Store Setup
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ env.SETUP_NAME }}
|
||||
path: setups/${{ env.SETUP_NAME }}
|
||||
if-no-files-found: error
|
||||
retention-days: 7 # keep for 7 days, should be enough
|
||||
|
||||
build-ubuntu2004:
|
||||
runs-on: ubuntu-20.04
|
||||
build-ubuntu1804:
|
||||
runs-on: ubuntu-18.04
|
||||
steps:
|
||||
# - copy code below to release.yml -
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v2
|
||||
- name: Install base dependencies
|
||||
run: |
|
||||
sudo apt update
|
||||
sudo apt -y install build-essential p7zip xz-utils wget libglib2.0-0
|
||||
sudo apt -y install python3-gi libgirepository1.0-dev # should pull dependencies for gi installation below
|
||||
- name: Get a recent python
|
||||
uses: actions/setup-python@v5
|
||||
uses: actions/setup-python@v3
|
||||
with:
|
||||
python-version: '~3.12.7'
|
||||
check-latest: true
|
||||
python-version: '3.9'
|
||||
- name: Install build-time dependencies
|
||||
run: |
|
||||
echo "PYTHON=python3.12" >> $GITHUB_ENV
|
||||
echo "PYTHON=python3.9" >> $GITHUB_ENV
|
||||
wget -nv https://github.com/AppImage/AppImageKit/releases/download/$APPIMAGETOOL_VERSION/appimagetool-x86_64.AppImage
|
||||
chmod a+rx appimagetool-x86_64.AppImage
|
||||
./appimagetool-x86_64.AppImage --appimage-extract
|
||||
@@ -124,58 +69,37 @@ jobs:
|
||||
chmod a+rx appimagetool
|
||||
- name: Download run-time dependencies
|
||||
run: |
|
||||
wget -nv https://github.com/alttpo/sni/releases/download/$SNI_VERSION/sni-$SNI_VERSION-manylinux2014-amd64.tar.xz
|
||||
tar xf sni-*.tar.xz
|
||||
rm sni-*.tar.xz
|
||||
mv sni-* SNI
|
||||
wget -nv https://github.com/Ijwu/Enemizer/releases/download/$ENEMIZER_VERSION/ubuntu.16.04-x64.7z
|
||||
7za x -oEnemizerCLI/ ubuntu.16.04-x64.7z
|
||||
- name: Build
|
||||
run: |
|
||||
# pygobject is an optional dependency for kivy that's not in requirements
|
||||
# charset-normalizer was somehow incomplete in the github runner
|
||||
"${{ env.PYTHON }}" -m pip install --upgrade pip virtualenv PyGObject setuptools
|
||||
"${{ env.PYTHON }}" -m venv venv
|
||||
source venv/bin/activate
|
||||
"${{ env.PYTHON }}" -m pip install --upgrade pip PyGObject charset-normalizer
|
||||
python setup.py build_exe --yes bdist_appimage --yes
|
||||
pip install -r requirements.txt
|
||||
python setup.py build --yes bdist_appimage --yes
|
||||
echo -e "setup.py build output:\n `ls build`"
|
||||
echo -e "setup.py dist output:\n `ls dist`"
|
||||
cd dist && export APPIMAGE_NAME="`ls *.AppImage`" && cd ..
|
||||
export TAR_NAME="${APPIMAGE_NAME%.AppImage}.tar.gz"
|
||||
(cd build && DIR_NAME="`ls | grep exe`" && mv "$DIR_NAME" Archipelago && tar -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 "TAR_NAME=$TAR_NAME" >> $GITHUB_ENV
|
||||
# - copy code above to release.yml -
|
||||
- name: Build Again
|
||||
run: |
|
||||
source venv/bin/activate
|
||||
python setup.py build_exe --yes
|
||||
- name: Check build loads expected worlds
|
||||
shell: bash
|
||||
run: |
|
||||
cd build/exe*
|
||||
mv Players/Templates/meta.yaml .
|
||||
ls -1 Players/Templates | sort > setup-player-templates.txt
|
||||
rm -R Players/Templates
|
||||
timeout 30 ./ArchipelagoLauncher "Generate Template Options" || true
|
||||
ls -1 Players/Templates | sort > generated-player-templates.txt
|
||||
cmp setup-player-templates.txt generated-player-templates.txt \
|
||||
|| diff setup-player-templates.txt generated-player-templates.txt
|
||||
mv meta.yaml Players/Templates/
|
||||
- name: Test Generate
|
||||
shell: bash
|
||||
run: |
|
||||
cd build/exe*
|
||||
cp Players/Templates/Clique.yaml Players/
|
||||
timeout 30 ./ArchipelagoGenerate
|
||||
- name: Store AppImage
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v2
|
||||
with:
|
||||
name: ${{ env.APPIMAGE_NAME }}
|
||||
path: dist/${{ env.APPIMAGE_NAME }}
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
- name: Store .tar.gz
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v2
|
||||
with:
|
||||
name: ${{ env.TAR_NAME }}
|
||||
path: dist/${{ env.TAR_NAME }}
|
||||
compression-level: 0 # .gz is incompressible by zip
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
|
||||
16
.github/workflows/codeql-analysis.yml
vendored
@@ -14,17 +14,9 @@ name: "CodeQL"
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
paths:
|
||||
- '**.py'
|
||||
- '**.js'
|
||||
- '.github/workflows/codeql-analysis.yml'
|
||||
pull_request:
|
||||
# The branches below must be a subset of the branches above
|
||||
branches: [ main ]
|
||||
paths:
|
||||
- '**.py'
|
||||
- '**.js'
|
||||
- '.github/workflows/codeql-analysis.yml'
|
||||
schedule:
|
||||
- cron: '44 8 * * 1'
|
||||
|
||||
@@ -43,11 +35,11 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v2
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v3
|
||||
uses: github/codeql-action/init@v1
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
||||
@@ -58,7 +50,7 @@ jobs:
|
||||
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
|
||||
# If this step fails, then you should remove it and run the build manually (see below)
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@v3
|
||||
uses: github/codeql-action/autobuild@v1
|
||||
|
||||
# ℹ️ Command-line programs to run using the OS shell.
|
||||
# 📚 https://git.io/JvXDl
|
||||
@@ -72,4 +64,4 @@ jobs:
|
||||
# make release
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v3
|
||||
uses: github/codeql-action/analyze@v1
|
||||
|
||||
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'
|
||||
- '.github/workflows/ctest.yml'
|
||||
pull_request:
|
||||
paths:
|
||||
- '**.cc?'
|
||||
- '**.cpp'
|
||||
- '**.cxx'
|
||||
- '**.hh?'
|
||||
- '**.hpp'
|
||||
- '**.hxx'
|
||||
- '**.CMakeLists'
|
||||
- '.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
|
||||
46
.github/workflows/label-pull-requests.yml
vendored
@@ -1,46 +0,0 @@
|
||||
name: Label Pull Request
|
||||
on:
|
||||
pull_request_target:
|
||||
types: ['opened', 'reopened', 'synchronize', 'ready_for_review', 'converted_to_draft', 'closed']
|
||||
branches: ['main']
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
labeler:
|
||||
name: 'Apply content-based labels'
|
||||
if: github.event.action == 'opened' || github.event.action == 'reopened' || github.event.action == 'synchronize'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/labeler@v5
|
||||
with:
|
||||
sync-labels: false
|
||||
peer_review:
|
||||
name: 'Apply peer review label'
|
||||
needs: labeler
|
||||
if: >-
|
||||
(github.event.action == 'opened' || github.event.action == 'reopened' ||
|
||||
github.event.action == 'ready_for_review') && !github.event.pull_request.draft
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: 'Add label'
|
||||
run: "gh pr edit \"$PR_URL\" --add-label 'waiting-on: peer-review'"
|
||||
env:
|
||||
PR_URL: ${{ github.event.pull_request.html_url }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
unblock_draft_prs:
|
||||
name: 'Remove waiting-on labels'
|
||||
needs: labeler
|
||||
if: github.event.action == 'converted_to_draft' || github.event.action == 'closed'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: 'Remove labels'
|
||||
run: |-
|
||||
gh pr edit "$PR_URL" --remove-label 'waiting-on: peer-review' \
|
||||
--remove-label 'waiting-on: core-review' \
|
||||
--remove-label 'waiting-on: world-maintainer' \
|
||||
--remove-label 'waiting-on: author'
|
||||
env:
|
||||
PR_URL: ${{ github.event.pull_request.html_url }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
29
.github/workflows/lint.yml
vendored
Normal file
@@ -0,0 +1,29 @@
|
||||
# This workflow will install Python dependencies, run tests and lint with a single version of Python
|
||||
# For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions
|
||||
|
||||
name: lint
|
||||
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Set up Python 3.9
|
||||
uses: actions/setup-python@v1
|
||||
with:
|
||||
python-version: 3.9
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip wheel
|
||||
pip install flake8 pytest pytest-subtests
|
||||
if [ -f requirements.txt ]; then pip install -r requirements.txt; fi
|
||||
- name: Lint with flake8
|
||||
run: |
|
||||
# stop the build if there are Python syntax errors or undefined names
|
||||
flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics
|
||||
# exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide
|
||||
flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics
|
||||
30
.github/workflows/release.yml
vendored
@@ -8,6 +8,7 @@ on:
|
||||
- '*.*.*'
|
||||
|
||||
env:
|
||||
SNI_VERSION: v0.0.84
|
||||
ENEMIZER_VERSION: 7.1
|
||||
APPIMAGETOOL_VERSION: 13
|
||||
|
||||
@@ -18,7 +19,7 @@ jobs:
|
||||
- name: Set env
|
||||
run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV # tag x.y.z will become "Archipelago x.y.z"
|
||||
- name: Create Release
|
||||
uses: softprops/action-gh-release@975c1b265e11dd76618af1c374e7981f9a6ff44a
|
||||
uses: softprops/action-gh-release@b7e450da2a4b4cb4bfbae528f788167786cfcedf
|
||||
with:
|
||||
draft: true # don't publish right away, especially since windows build is added by hand
|
||||
prerelease: false
|
||||
@@ -29,26 +30,25 @@ jobs:
|
||||
# build-release-windows: # this is done by hand because of signing
|
||||
# build-release-macos: # LF volunteer
|
||||
|
||||
build-release-ubuntu2004:
|
||||
runs-on: ubuntu-20.04
|
||||
build-release-ubuntu1804:
|
||||
runs-on: ubuntu-18.04
|
||||
steps:
|
||||
- name: Set env
|
||||
run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV
|
||||
# - code below copied from build.yml -
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v2
|
||||
- name: Install base dependencies
|
||||
run: |
|
||||
sudo apt update
|
||||
sudo apt -y install build-essential p7zip xz-utils wget libglib2.0-0
|
||||
sudo apt -y install python3-gi libgirepository1.0-dev # should pull dependencies for gi installation below
|
||||
- name: Get a recent python
|
||||
uses: actions/setup-python@v5
|
||||
uses: actions/setup-python@v3
|
||||
with:
|
||||
python-version: '~3.12.7'
|
||||
check-latest: true
|
||||
python-version: '3.9'
|
||||
- name: Install build-time dependencies
|
||||
run: |
|
||||
echo "PYTHON=python3.12" >> $GITHUB_ENV
|
||||
echo "PYTHON=python3.9" >> $GITHUB_ENV
|
||||
wget -nv https://github.com/AppImage/AppImageKit/releases/download/$APPIMAGETOOL_VERSION/appimagetool-x86_64.AppImage
|
||||
chmod a+rx appimagetool-x86_64.AppImage
|
||||
./appimagetool-x86_64.AppImage --appimage-extract
|
||||
@@ -56,26 +56,30 @@ jobs:
|
||||
chmod a+rx appimagetool
|
||||
- name: Download run-time dependencies
|
||||
run: |
|
||||
wget -nv https://github.com/alttpo/sni/releases/download/$SNI_VERSION/sni-$SNI_VERSION-manylinux2014-amd64.tar.xz
|
||||
tar xf sni-*.tar.xz
|
||||
rm sni-*.tar.xz
|
||||
mv sni-* SNI
|
||||
wget -nv https://github.com/Ijwu/Enemizer/releases/download/$ENEMIZER_VERSION/ubuntu.16.04-x64.7z
|
||||
7za x -oEnemizerCLI/ ubuntu.16.04-x64.7z
|
||||
- name: Build
|
||||
run: |
|
||||
# pygobject is an optional dependency for kivy that's not in requirements
|
||||
# charset-normalizer was somehow incomplete in the github runner
|
||||
"${{ env.PYTHON }}" -m pip install --upgrade pip virtualenv PyGObject setuptools
|
||||
"${{ env.PYTHON }}" -m venv venv
|
||||
source venv/bin/activate
|
||||
"${{ env.PYTHON }}" -m pip install --upgrade pip PyGObject charset-normalizer
|
||||
python setup.py build_exe --yes bdist_appimage --yes
|
||||
pip install -r requirements.txt
|
||||
python setup.py build --yes bdist_appimage --yes
|
||||
echo -e "setup.py build output:\n `ls build`"
|
||||
echo -e "setup.py dist output:\n `ls dist`"
|
||||
cd dist && export APPIMAGE_NAME="`ls *.AppImage`" && cd ..
|
||||
export TAR_NAME="${APPIMAGE_NAME%.AppImage}.tar.gz"
|
||||
(cd build && DIR_NAME="`ls | grep exe`" && mv "$DIR_NAME" Archipelago && tar -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 "TAR_NAME=$TAR_NAME" >> $GITHUB_ENV
|
||||
# - code above copied from build.yml -
|
||||
- name: Add to Release
|
||||
uses: softprops/action-gh-release@975c1b265e11dd76618af1c374e7981f9a6ff44a
|
||||
uses: softprops/action-gh-release@b7e450da2a4b4cb4bfbae528f788167786cfcedf
|
||||
with:
|
||||
draft: true # see above
|
||||
prerelease: false
|
||||
|
||||
65
.github/workflows/scan-build.yml
vendored
@@ -1,65 +0,0 @@
|
||||
name: Native Code Static Analysis
|
||||
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- '**.c'
|
||||
- '**.cc'
|
||||
- '**.cpp'
|
||||
- '**.cxx'
|
||||
- '**.h'
|
||||
- '**.hh'
|
||||
- '**.hpp'
|
||||
- '**.pyx'
|
||||
- 'setup.py'
|
||||
- 'requirements.txt'
|
||||
- '.github/workflows/scan-build.yml'
|
||||
pull_request:
|
||||
paths:
|
||||
- '**.c'
|
||||
- '**.cc'
|
||||
- '**.cpp'
|
||||
- '**.cxx'
|
||||
- '**.h'
|
||||
- '**.hh'
|
||||
- '**.hpp'
|
||||
- '**.pyx'
|
||||
- 'setup.py'
|
||||
- 'requirements.txt'
|
||||
- '.github/workflows/scan-build.yml'
|
||||
|
||||
jobs:
|
||||
scan-build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
- name: Install newer Clang
|
||||
run: |
|
||||
wget https://apt.llvm.org/llvm.sh
|
||||
chmod +x ./llvm.sh
|
||||
sudo ./llvm.sh 19
|
||||
- name: Install scan-build command
|
||||
run: |
|
||||
sudo apt install clang-tools-19
|
||||
- name: Get a recent python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m venv venv
|
||||
source venv/bin/activate
|
||||
python -m pip install --upgrade pip -r requirements.txt
|
||||
- name: scan-build
|
||||
run: |
|
||||
source venv/bin/activate
|
||||
scan-build-19 --status-bugs -o scan-build-reports -disable-checker deadcode.DeadStores python setup.py build -y
|
||||
- name: Store report
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: scan-build-reports
|
||||
path: scan-build-reports
|
||||
33
.github/workflows/strict-type-check.yml
vendored
@@ -1,33 +0,0 @@
|
||||
name: type check
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- "**.py"
|
||||
- ".github/pyright-config.json"
|
||||
- ".github/workflows/strict-type-check.yml"
|
||||
- "**.pyi"
|
||||
push:
|
||||
paths:
|
||||
- "**.py"
|
||||
- ".github/pyright-config.json"
|
||||
- ".github/workflows/strict-type-check.yml"
|
||||
- "**.pyi"
|
||||
|
||||
jobs:
|
||||
pyright:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
- name: "Install dependencies"
|
||||
run: |
|
||||
python -m pip install --upgrade pip pyright==1.1.392.post0
|
||||
python ModuleUpdate.py --append "WebHostLib/requirements.txt" --force --yes
|
||||
|
||||
- name: "pyright: strict check on specific files"
|
||||
run: python .github/type_check.py
|
||||
72
.github/workflows/unittests.yml
vendored
@@ -3,28 +3,10 @@
|
||||
|
||||
name: unittests
|
||||
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- '**'
|
||||
- '!docs/**'
|
||||
- '!setup.py'
|
||||
- '!*.iss'
|
||||
- '!.gitignore'
|
||||
- '!.github/workflows/**'
|
||||
- '.github/workflows/unittests.yml'
|
||||
pull_request:
|
||||
paths:
|
||||
- '**'
|
||||
- '!docs/**'
|
||||
- '!setup.py'
|
||||
- '!*.iss'
|
||||
- '!.gitignore'
|
||||
- '!.github/workflows/**'
|
||||
- '.github/workflows/unittests.yml'
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
unit:
|
||||
build:
|
||||
runs-on: ${{ matrix.os }}
|
||||
name: Test Python ${{ matrix.python.version }} ${{ matrix.os }}
|
||||
|
||||
@@ -33,58 +15,26 @@ jobs:
|
||||
matrix:
|
||||
os: [ubuntu-latest]
|
||||
python:
|
||||
- {version: '3.8'}
|
||||
- {version: '3.9'}
|
||||
- {version: '3.10'}
|
||||
- {version: '3.11'}
|
||||
- {version: '3.12'}
|
||||
include:
|
||||
- python: {version: '3.10'} # old compat
|
||||
- python: {version: '3.8'} # win7 compat
|
||||
os: windows-latest
|
||||
- python: {version: '3.12'} # current
|
||||
- python: {version: '3.10'} # current
|
||||
os: windows-latest
|
||||
- python: {version: '3.12'} # current
|
||||
os: macos-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v2
|
||||
- name: Set up Python ${{ matrix.python.version }}
|
||||
uses: actions/setup-python@v5
|
||||
uses: actions/setup-python@v1
|
||||
with:
|
||||
python-version: ${{ matrix.python.version }}
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install pytest pytest-subtests pytest-xdist
|
||||
python -m pip install --upgrade pip wheel
|
||||
pip install flake8 pytest pytest-subtests
|
||||
python ModuleUpdate.py --yes --force --append "WebHostLib/requirements.txt"
|
||||
python Launcher.py --update_settings # make sure host.yaml exists for tests
|
||||
- name: Unittests
|
||||
run: |
|
||||
pytest -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
|
||||
pytest test
|
||||
|
||||
35
.gitignore
vendored
@@ -4,23 +4,15 @@
|
||||
*_Spoiler.txt
|
||||
*.bmbp
|
||||
*.apbp
|
||||
*.apl2ac
|
||||
*.apm3
|
||||
*.apmc
|
||||
*.apz5
|
||||
*.aptloz
|
||||
*.apemerald
|
||||
*.pyc
|
||||
*.pyd
|
||||
*.sfc
|
||||
*.z64
|
||||
*.n64
|
||||
*.nes
|
||||
*.smc
|
||||
*.sms
|
||||
*.gb
|
||||
*.gbc
|
||||
*.gba
|
||||
*.wixobj
|
||||
*.lck
|
||||
*.db3
|
||||
@@ -28,22 +20,16 @@
|
||||
*multisave
|
||||
*.archipelago
|
||||
*.apsave
|
||||
*.BIN
|
||||
*.puml
|
||||
|
||||
setups
|
||||
docs/sphinx/_build/
|
||||
build
|
||||
bundle/components.wxs
|
||||
dist
|
||||
/prof/
|
||||
README.html
|
||||
.vs/
|
||||
EnemizerCLI/
|
||||
/Players/
|
||||
/SNI/
|
||||
/sni-*/
|
||||
/appimagetool*
|
||||
/host.yaml
|
||||
/options.yaml
|
||||
/config.yaml
|
||||
/logs/
|
||||
@@ -59,10 +45,7 @@ Output Logs/
|
||||
/freeze_requirements.txt
|
||||
/Archipelago.zip
|
||||
/setup.ini
|
||||
/installdelete.iss
|
||||
/data/user.kv
|
||||
/datapackage
|
||||
/custom_worlds
|
||||
|
||||
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
@@ -143,15 +126,12 @@ ipython_config.py
|
||||
|
||||
# Environments
|
||||
.env
|
||||
.venv*
|
||||
.venv
|
||||
env/
|
||||
venv/
|
||||
/venv*/
|
||||
ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
*.code-workspace
|
||||
shell.nix
|
||||
|
||||
# Spyder project settings
|
||||
.spyderproject
|
||||
@@ -177,23 +157,14 @@ dmypy.json
|
||||
# Cython debug symbols
|
||||
cython_debug/
|
||||
|
||||
# Cython intermediates
|
||||
_speedups.c
|
||||
_speedups.cpp
|
||||
_speedups.html
|
||||
|
||||
# minecraft server stuff
|
||||
jdk*/
|
||||
minecraft*/
|
||||
minecraft_versions.json
|
||||
!worlds/minecraft/
|
||||
|
||||
# pyenv
|
||||
.python-version
|
||||
|
||||
#undertale stuff
|
||||
/Undertale/
|
||||
|
||||
# OS General Files
|
||||
.DS_Store
|
||||
.AppleDouble
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
<component name="ProjectRunConfigurationManager">
|
||||
<configuration default="false" name="Archipelago Unittests" type="tests" factoryName="Unittests">
|
||||
<module name="Archipelago" />
|
||||
<option name="INTERPRETER_OPTIONS" value="" />
|
||||
<option name="PARENT_ENVS" value="true" />
|
||||
<option name="SDK_HOME" value="" />
|
||||
<option name="WORKING_DIRECTORY" value="$PROJECT_DIR$" />
|
||||
<option name="IS_MODULE_SDK" value="true" />
|
||||
<option name="ADD_CONTENT_ROOTS" value="true" />
|
||||
<option name="ADD_SOURCE_ROOTS" value="true" />
|
||||
<EXTENSION ID="PythonCoverageRunConfigurationExtension" runner="coverage.py" />
|
||||
<option name="_new_pattern" value="""" />
|
||||
<option name="_new_additionalArguments" value="""" />
|
||||
<option name="_new_target" value=""$PROJECT_DIR$/test"" />
|
||||
<option name="_new_targetType" value=""PATH"" />
|
||||
<method v="2" />
|
||||
</configuration>
|
||||
</component>
|
||||
@@ -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()
|
||||
@@ -1,517 +0,0 @@
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import time
|
||||
import os
|
||||
import bsdiff4
|
||||
import subprocess
|
||||
import zipfile
|
||||
from asyncio import StreamReader, StreamWriter, CancelledError
|
||||
from typing import List
|
||||
|
||||
|
||||
import Utils
|
||||
from NetUtils import ClientStatus
|
||||
from Utils import async_start
|
||||
from CommonClient import CommonContext, server_loop, gui_enabled, ClientCommandProcessor, logger, \
|
||||
get_base_parser
|
||||
from worlds.adventure import AdventureDeltaPatch
|
||||
|
||||
from worlds.adventure.Locations import base_location_id
|
||||
from worlds.adventure.Rom import AdventureForeignItemInfo, AdventureAutoCollectLocation, BatNoTouchLocation
|
||||
from worlds.adventure.Items import base_adventure_item_id, standard_item_max, item_table
|
||||
from worlds.adventure.Offsets import static_item_element_size, connector_port_offset
|
||||
|
||||
SYSTEM_MESSAGE_ID = 0
|
||||
|
||||
CONNECTION_TIMING_OUT_STATUS = \
|
||||
"Connection timing out. Please restart your emulator, then restart connector_adventure.lua"
|
||||
CONNECTION_REFUSED_STATUS = \
|
||||
"Connection Refused. Please start your emulator and make sure connector_adventure.lua is running"
|
||||
CONNECTION_RESET_STATUS = \
|
||||
"Connection was reset. Please restart your emulator, then restart connector_adventure.lua"
|
||||
CONNECTION_TENTATIVE_STATUS = "Initial Connection Made"
|
||||
CONNECTION_CONNECTED_STATUS = "Connected"
|
||||
CONNECTION_INITIAL_STATUS = "Connection has not been initiated"
|
||||
|
||||
SCRIPT_VERSION = 1
|
||||
|
||||
|
||||
class AdventureCommandProcessor(ClientCommandProcessor):
|
||||
def __init__(self, ctx: CommonContext):
|
||||
super().__init__(ctx)
|
||||
|
||||
def _cmd_2600(self):
|
||||
"""Check 2600 Connection State"""
|
||||
if isinstance(self.ctx, AdventureContext):
|
||||
logger.info(f"2600 Status: {self.ctx.atari_status}")
|
||||
|
||||
def _cmd_aconnect(self):
|
||||
"""Discard current atari 2600 connection state"""
|
||||
if isinstance(self.ctx, AdventureContext):
|
||||
self.ctx.atari_sync_task.cancel()
|
||||
|
||||
|
||||
class AdventureContext(CommonContext):
|
||||
command_processor = AdventureCommandProcessor
|
||||
game = 'Adventure'
|
||||
lua_connector_port: int = 17242
|
||||
|
||||
def __init__(self, server_address, password):
|
||||
super().__init__(server_address, password)
|
||||
self.freeincarnates_used: int = -1
|
||||
self.freeincarnate_pending: int = 0
|
||||
self.foreign_items: [AdventureForeignItemInfo] = []
|
||||
self.autocollect_items: [AdventureAutoCollectLocation] = []
|
||||
self.atari_streams: (StreamReader, StreamWriter) = None
|
||||
self.atari_sync_task = None
|
||||
self.messages = {}
|
||||
self.locations_array = None
|
||||
self.atari_status = CONNECTION_INITIAL_STATUS
|
||||
self.awaiting_rom = False
|
||||
self.display_msgs = True
|
||||
self.deathlink_pending = False
|
||||
self.set_deathlink = False
|
||||
self.client_compatibility_mode = 0
|
||||
self.items_handling = 0b111
|
||||
self.checked_locations_sent: bool = False
|
||||
self.port_offset = 0
|
||||
self.bat_no_touch_locations: [BatNoTouchLocation] = []
|
||||
self.local_item_locations = {}
|
||||
self.dragon_speed_info = {}
|
||||
|
||||
options = Utils.get_settings()
|
||||
self.display_msgs = options["adventure_options"]["display_msgs"]
|
||||
|
||||
async def server_auth(self, password_requested: bool = False):
|
||||
if password_requested and not self.password:
|
||||
await super(AdventureContext, self).server_auth(password_requested)
|
||||
if not self.auth:
|
||||
self.auth = self.player_name
|
||||
if not self.auth:
|
||||
self.awaiting_rom = True
|
||||
logger.info('Awaiting connection to adventure_connector to get Player information')
|
||||
return
|
||||
|
||||
await self.send_connect()
|
||||
|
||||
def _set_message(self, msg: str, msg_id: int):
|
||||
if self.display_msgs:
|
||||
self.messages[(time.time(), msg_id)] = msg
|
||||
|
||||
def on_package(self, cmd: str, args: dict):
|
||||
if cmd == 'Connected':
|
||||
self.locations_array = None
|
||||
if Utils.get_settings()["adventure_options"].get("death_link", False):
|
||||
self.set_deathlink = True
|
||||
async_start(self.get_freeincarnates_used())
|
||||
elif cmd == "RoomInfo":
|
||||
self.seed_name = args['seed_name']
|
||||
elif cmd == 'Print':
|
||||
msg = args['text']
|
||||
if ': !' not in msg:
|
||||
self._set_message(msg, SYSTEM_MESSAGE_ID)
|
||||
elif cmd == "ReceivedItems":
|
||||
msg = f"Received {', '.join([self.item_names.lookup_in_game(item.item) for item in args['items']])}"
|
||||
self._set_message(msg, SYSTEM_MESSAGE_ID)
|
||||
elif cmd == "Retrieved":
|
||||
if f"adventure_{self.auth}_freeincarnates_used" in args["keys"]:
|
||||
self.freeincarnates_used = args["keys"][f"adventure_{self.auth}_freeincarnates_used"]
|
||||
if self.freeincarnates_used is None:
|
||||
self.freeincarnates_used = 0
|
||||
self.freeincarnates_used += self.freeincarnate_pending
|
||||
self.send_pending_freeincarnates()
|
||||
elif cmd == "SetReply":
|
||||
if args["key"] == f"adventure_{self.auth}_freeincarnates_used":
|
||||
self.freeincarnates_used = args["value"]
|
||||
if self.freeincarnates_used is None:
|
||||
self.freeincarnates_used = 0
|
||||
self.freeincarnates_used += self.freeincarnate_pending
|
||||
self.send_pending_freeincarnates()
|
||||
|
||||
def on_deathlink(self, data: dict):
|
||||
self.deathlink_pending = True
|
||||
super().on_deathlink(data)
|
||||
|
||||
def run_gui(self):
|
||||
from kvui import GameManager
|
||||
|
||||
class AdventureManager(GameManager):
|
||||
logging_pairs = [
|
||||
("Client", "Archipelago")
|
||||
]
|
||||
base_title = "Archipelago Adventure Client"
|
||||
|
||||
self.ui = AdventureManager(self)
|
||||
self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI")
|
||||
|
||||
async def get_freeincarnates_used(self):
|
||||
if self.server and not self.server.socket.closed:
|
||||
await self.send_msgs([{"cmd": "SetNotify", "keys": [f"adventure_{self.auth}_freeincarnates_used"]}])
|
||||
await self.send_msgs([{"cmd": "Get", "keys": [f"adventure_{self.auth}_freeincarnates_used"]}])
|
||||
|
||||
def send_pending_freeincarnates(self):
|
||||
if self.freeincarnate_pending > 0:
|
||||
async_start(self.send_pending_freeincarnates_impl(self.freeincarnate_pending))
|
||||
self.freeincarnate_pending = 0
|
||||
|
||||
async def send_pending_freeincarnates_impl(self, send_val: int) -> None:
|
||||
await self.send_msgs([{"cmd": "Set", "key": f"adventure_{self.auth}_freeincarnates_used",
|
||||
"default": 0, "want_reply": False,
|
||||
"operations": [{"operation": "add", "value": send_val}]}])
|
||||
|
||||
async def used_freeincarnate(self) -> None:
|
||||
if self.server and not self.server.socket.closed:
|
||||
await self.send_msgs([{"cmd": "Set", "key": f"adventure_{self.auth}_freeincarnates_used",
|
||||
"default": 0, "want_reply": True,
|
||||
"operations": [{"operation": "add", "value": 1}]}])
|
||||
else:
|
||||
self.freeincarnate_pending = self.freeincarnate_pending + 1
|
||||
|
||||
|
||||
def convert_item_id(ap_item_id: int):
|
||||
static_item_index = ap_item_id - base_adventure_item_id
|
||||
return static_item_index * static_item_element_size
|
||||
|
||||
|
||||
def get_payload(ctx: AdventureContext):
|
||||
current_time = time.time()
|
||||
items = []
|
||||
dragon_speed_update = {}
|
||||
diff_a_locked = ctx.diff_a_mode > 0
|
||||
diff_b_locked = ctx.diff_b_mode > 0
|
||||
freeincarnate_count = 0
|
||||
for item in ctx.items_received:
|
||||
item_id_str = str(item.item)
|
||||
if base_adventure_item_id < item.item <= standard_item_max:
|
||||
items.append(convert_item_id(item.item))
|
||||
elif item_id_str in ctx.dragon_speed_info:
|
||||
if item.item in dragon_speed_update:
|
||||
last_index = len(ctx.dragon_speed_info[item_id_str]) - 1
|
||||
dragon_speed_update[item.item] = ctx.dragon_speed_info[item_id_str][last_index]
|
||||
else:
|
||||
dragon_speed_update[item.item] = ctx.dragon_speed_info[item_id_str][0]
|
||||
elif item.item == item_table["Left Difficulty Switch"].id:
|
||||
diff_a_locked = False
|
||||
elif item.item == item_table["Right Difficulty Switch"].id:
|
||||
diff_b_locked = False
|
||||
elif item.item == item_table["Freeincarnate"].id:
|
||||
freeincarnate_count = freeincarnate_count + 1
|
||||
freeincarnates_available = 0
|
||||
|
||||
if ctx.freeincarnates_used >= 0:
|
||||
freeincarnates_available = freeincarnate_count - (ctx.freeincarnates_used + ctx.freeincarnate_pending)
|
||||
ret = json.dumps(
|
||||
{
|
||||
"items": items,
|
||||
"messages": {f'{key[0]}:{key[1]}': value for key, value in ctx.messages.items()
|
||||
if key[0] > current_time - 10},
|
||||
"deathlink": ctx.deathlink_pending,
|
||||
"dragon_speeds": dragon_speed_update,
|
||||
"difficulty_a_locked": diff_a_locked,
|
||||
"difficulty_b_locked": diff_b_locked,
|
||||
"freeincarnates_available": freeincarnates_available,
|
||||
"bat_logic": ctx.bat_logic
|
||||
}
|
||||
)
|
||||
ctx.deathlink_pending = False
|
||||
return ret
|
||||
|
||||
|
||||
async def parse_locations(data: List, ctx: AdventureContext):
|
||||
locations = data
|
||||
|
||||
# for loc_name, loc_data in location_table.items():
|
||||
|
||||
# if flags["EventFlag"][280] & 1 and not ctx.finished_game:
|
||||
# await ctx.send_msgs([
|
||||
# {"cmd": "StatusUpdate",
|
||||
# "status": 30}
|
||||
# ])
|
||||
# ctx.finished_game = True
|
||||
if locations == ctx.locations_array:
|
||||
return
|
||||
ctx.locations_array = locations
|
||||
if locations is not None:
|
||||
await ctx.send_msgs([{"cmd": "LocationChecks", "locations": locations}])
|
||||
|
||||
|
||||
def send_ap_foreign_items(adventure_context):
|
||||
foreign_item_json_list = []
|
||||
autocollect_item_json_list = []
|
||||
bat_no_touch_locations_json_list = []
|
||||
for fi in adventure_context.foreign_items:
|
||||
foreign_item_json_list.append(fi.get_dict())
|
||||
for fi in adventure_context.autocollect_items:
|
||||
autocollect_item_json_list.append(fi.get_dict())
|
||||
for ntl in adventure_context.bat_no_touch_locations:
|
||||
bat_no_touch_locations_json_list.append(ntl.get_dict())
|
||||
payload = json.dumps(
|
||||
{
|
||||
"foreign_items": foreign_item_json_list,
|
||||
"autocollect_items": autocollect_item_json_list,
|
||||
"local_item_locations": adventure_context.local_item_locations,
|
||||
"bat_no_touch_locations": bat_no_touch_locations_json_list
|
||||
}
|
||||
)
|
||||
print("sending foreign items")
|
||||
msg = payload.encode()
|
||||
(reader, writer) = adventure_context.atari_streams
|
||||
writer.write(msg)
|
||||
writer.write(b'\n')
|
||||
|
||||
|
||||
def send_checked_locations_if_needed(adventure_context):
|
||||
if not adventure_context.checked_locations_sent and adventure_context.checked_locations is not None:
|
||||
if len(adventure_context.checked_locations) == 0:
|
||||
return
|
||||
checked_short_ids = []
|
||||
for location in adventure_context.checked_locations:
|
||||
checked_short_ids.append(location - base_location_id)
|
||||
print("Sending checked locations")
|
||||
payload = json.dumps(
|
||||
{
|
||||
"checked_locations": checked_short_ids,
|
||||
}
|
||||
)
|
||||
msg = payload.encode()
|
||||
(reader, writer) = adventure_context.atari_streams
|
||||
writer.write(msg)
|
||||
writer.write(b'\n')
|
||||
adventure_context.checked_locations_sent = True
|
||||
|
||||
|
||||
async def atari_sync_task(ctx: AdventureContext):
|
||||
logger.info("Starting Atari 2600 connector. Use /2600 for status information")
|
||||
while not ctx.exit_event.is_set():
|
||||
try:
|
||||
error_status = None
|
||||
if ctx.atari_streams:
|
||||
(reader, writer) = ctx.atari_streams
|
||||
msg = get_payload(ctx).encode()
|
||||
writer.write(msg)
|
||||
writer.write(b'\n')
|
||||
try:
|
||||
await asyncio.wait_for(writer.drain(), timeout=1.5)
|
||||
try:
|
||||
# Data will return a dict with 1+ fields
|
||||
# 1. A keepalive response of the Players Name (always)
|
||||
# 2. romhash field with sha256 hash of the ROM memory region
|
||||
# 3. locations, messages, and deathLink
|
||||
# 4. freeincarnate, to indicate a freeincarnate was used
|
||||
data = await asyncio.wait_for(reader.readline(), timeout=5)
|
||||
data_decoded = json.loads(data.decode())
|
||||
if 'scriptVersion' not in data_decoded or data_decoded['scriptVersion'] != SCRIPT_VERSION:
|
||||
msg = "You are connecting with an incompatible Lua script version. Ensure your connector " \
|
||||
"Lua and AdventureClient are from the same Archipelago installation."
|
||||
logger.info(msg, extra={'compact_gui': True})
|
||||
ctx.gui_error('Error', msg)
|
||||
error_status = CONNECTION_RESET_STATUS
|
||||
if ctx.seed_name and bytes(ctx.seed_name, encoding='ASCII') != ctx.seed_name_from_data:
|
||||
msg = "The server is running a different multiworld than your client is. " \
|
||||
"(invalid seed_name)"
|
||||
logger.info(msg, extra={'compact_gui': True})
|
||||
ctx.gui_error('Error', msg)
|
||||
error_status = CONNECTION_RESET_STATUS
|
||||
if 'romhash' in data_decoded:
|
||||
if ctx.rom_hash.upper() != data_decoded['romhash'].upper():
|
||||
msg = "The rom hash does not match the client rom hash data"
|
||||
print("got " + data_decoded['romhash'])
|
||||
print("expected " + str(ctx.rom_hash))
|
||||
logger.info(msg, extra={'compact_gui': True})
|
||||
ctx.gui_error('Error', msg)
|
||||
error_status = CONNECTION_RESET_STATUS
|
||||
if ctx.auth is None:
|
||||
ctx.auth = ctx.player_name
|
||||
if ctx.awaiting_rom:
|
||||
await ctx.server_auth(False)
|
||||
if 'locations' in data_decoded and ctx.game and ctx.atari_status == CONNECTION_CONNECTED_STATUS \
|
||||
and not error_status and ctx.auth:
|
||||
# Not just a keep alive ping, parse
|
||||
async_start(parse_locations(data_decoded['locations'], ctx))
|
||||
if 'deathLink' in data_decoded and data_decoded['deathLink'] > 0 and 'DeathLink' in ctx.tags:
|
||||
dragon_name = "a dragon"
|
||||
if data_decoded['deathLink'] == 1:
|
||||
dragon_name = "Rhindle"
|
||||
elif data_decoded['deathLink'] == 2:
|
||||
dragon_name = "Yorgle"
|
||||
elif data_decoded['deathLink'] == 3:
|
||||
dragon_name = "Grundle"
|
||||
print (ctx.auth + " has been eaten by " + dragon_name )
|
||||
await ctx.send_death(ctx.auth + " has been eaten by " + dragon_name)
|
||||
# TODO - also if player reincarnates with a dragon onscreen ' dies to avoid being eaten by '
|
||||
if 'victory' in data_decoded and not ctx.finished_game:
|
||||
await ctx.send_msgs([{"cmd": "StatusUpdate", "status": ClientStatus.CLIENT_GOAL}])
|
||||
ctx.finished_game = True
|
||||
if 'freeincarnate' in data_decoded:
|
||||
await ctx.used_freeincarnate()
|
||||
if ctx.set_deathlink:
|
||||
await ctx.update_death_link(True)
|
||||
send_checked_locations_if_needed(ctx)
|
||||
except asyncio.TimeoutError:
|
||||
logger.debug("Read Timed Out, Reconnecting")
|
||||
error_status = CONNECTION_TIMING_OUT_STATUS
|
||||
writer.close()
|
||||
ctx.atari_streams = None
|
||||
except ConnectionResetError as e:
|
||||
logger.debug("Read failed due to Connection Lost, Reconnecting")
|
||||
error_status = CONNECTION_RESET_STATUS
|
||||
writer.close()
|
||||
ctx.atari_streams = None
|
||||
except TimeoutError:
|
||||
logger.debug("Connection Timed Out, Reconnecting")
|
||||
error_status = CONNECTION_TIMING_OUT_STATUS
|
||||
writer.close()
|
||||
ctx.atari_streams = None
|
||||
except ConnectionResetError:
|
||||
logger.debug("Connection Lost, Reconnecting")
|
||||
error_status = CONNECTION_RESET_STATUS
|
||||
writer.close()
|
||||
ctx.atari_streams = None
|
||||
except CancelledError:
|
||||
logger.debug("Connection Cancelled, Reconnecting")
|
||||
error_status = CONNECTION_RESET_STATUS
|
||||
writer.close()
|
||||
ctx.atari_streams = None
|
||||
pass
|
||||
except Exception as e:
|
||||
print("unknown exception " + e)
|
||||
raise
|
||||
if ctx.atari_status == CONNECTION_TENTATIVE_STATUS:
|
||||
if not error_status:
|
||||
logger.info("Successfully Connected to 2600")
|
||||
ctx.atari_status = CONNECTION_CONNECTED_STATUS
|
||||
ctx.checked_locations_sent = False
|
||||
send_ap_foreign_items(ctx)
|
||||
send_checked_locations_if_needed(ctx)
|
||||
else:
|
||||
ctx.atari_status = f"Was tentatively connected but error occurred: {error_status}"
|
||||
elif error_status:
|
||||
ctx.atari_status = error_status
|
||||
logger.info("Lost connection to 2600 and attempting to reconnect. Use /2600 for status updates")
|
||||
else:
|
||||
try:
|
||||
port = ctx.lua_connector_port + ctx.port_offset
|
||||
logger.debug(f"Attempting to connect to 2600 on port {port}")
|
||||
print(f"Attempting to connect to 2600 on port {port}")
|
||||
ctx.atari_streams = await asyncio.wait_for(
|
||||
asyncio.open_connection("localhost",
|
||||
port),
|
||||
timeout=10)
|
||||
ctx.atari_status = CONNECTION_TENTATIVE_STATUS
|
||||
except TimeoutError:
|
||||
logger.debug("Connection Timed Out, Trying Again")
|
||||
ctx.atari_status = CONNECTION_TIMING_OUT_STATUS
|
||||
continue
|
||||
except ConnectionRefusedError:
|
||||
logger.debug("Connection Refused, Trying Again")
|
||||
ctx.atari_status = CONNECTION_REFUSED_STATUS
|
||||
continue
|
||||
except CancelledError:
|
||||
pass
|
||||
except CancelledError:
|
||||
pass
|
||||
print("exiting atari sync task")
|
||||
|
||||
|
||||
async def run_game(romfile):
|
||||
auto_start = Utils.get_settings()["adventure_options"].get("rom_start", True)
|
||||
rom_args = Utils.get_settings()["adventure_options"].get("rom_args")
|
||||
if auto_start is True:
|
||||
import webbrowser
|
||||
webbrowser.open(romfile)
|
||||
elif os.path.isfile(auto_start):
|
||||
open_args = [auto_start, romfile]
|
||||
if rom_args is not None:
|
||||
open_args.insert(1, rom_args)
|
||||
subprocess.Popen(open_args,
|
||||
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
|
||||
|
||||
async def patch_and_run_game(patch_file, ctx):
|
||||
base_name = os.path.splitext(patch_file)[0]
|
||||
comp_path = base_name + '.a26'
|
||||
try:
|
||||
base_rom = AdventureDeltaPatch.get_source_data()
|
||||
except Exception as msg:
|
||||
logger.info(msg, extra={'compact_gui': True})
|
||||
ctx.gui_error('Error', msg)
|
||||
|
||||
with open(Utils.local_path("data", "adventure_basepatch.bsdiff4"), "rb") as file:
|
||||
basepatch = bytes(file.read())
|
||||
|
||||
base_patched_rom_data = bsdiff4.patch(base_rom, basepatch)
|
||||
|
||||
with zipfile.ZipFile(patch_file, 'r') as patch_archive:
|
||||
if not AdventureDeltaPatch.check_version(patch_archive):
|
||||
logger.error("apadvn version doesn't match this client. Make sure your generator and client are the same")
|
||||
raise Exception("apadvn version doesn't match this client.")
|
||||
|
||||
ctx.foreign_items = AdventureDeltaPatch.read_foreign_items(patch_archive)
|
||||
ctx.autocollect_items = AdventureDeltaPatch.read_autocollect_items(patch_archive)
|
||||
ctx.local_item_locations = AdventureDeltaPatch.read_local_item_locations(patch_archive)
|
||||
ctx.dragon_speed_info = AdventureDeltaPatch.read_dragon_speed_info(patch_archive)
|
||||
ctx.seed_name_from_data, ctx.player_name = AdventureDeltaPatch.read_rom_info(patch_archive)
|
||||
ctx.diff_a_mode, ctx.diff_b_mode = AdventureDeltaPatch.read_difficulty_switch_info(patch_archive)
|
||||
ctx.bat_logic = AdventureDeltaPatch.read_bat_logic(patch_archive)
|
||||
ctx.bat_no_touch_locations = AdventureDeltaPatch.read_bat_no_touch(patch_archive)
|
||||
ctx.rom_deltas = AdventureDeltaPatch.read_rom_deltas(patch_archive)
|
||||
ctx.auth = ctx.player_name
|
||||
|
||||
patched_rom_data = AdventureDeltaPatch.apply_rom_deltas(base_patched_rom_data, ctx.rom_deltas)
|
||||
rom_hash = hashlib.sha256()
|
||||
rom_hash.update(patched_rom_data)
|
||||
ctx.rom_hash = rom_hash.hexdigest()
|
||||
ctx.port_offset = patched_rom_data[connector_port_offset]
|
||||
|
||||
with open(comp_path, "wb") as patched_rom_file:
|
||||
patched_rom_file.write(patched_rom_data)
|
||||
|
||||
async_start(run_game(comp_path))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
||||
Utils.init_logging("AdventureClient")
|
||||
|
||||
async def main():
|
||||
parser = get_base_parser()
|
||||
parser.add_argument('patch_file', default="", type=str, nargs="?",
|
||||
help='Path to an ADVNTURE.BIN rom file')
|
||||
parser.add_argument('port', default=17242, type=int, nargs="?",
|
||||
help='port for adventure_connector connection')
|
||||
args = parser.parse_args()
|
||||
|
||||
ctx = AdventureContext(args.connect, args.password)
|
||||
ctx.server_task = asyncio.create_task(server_loop(ctx), name="ServerLoop")
|
||||
if gui_enabled:
|
||||
ctx.run_gui()
|
||||
ctx.run_cli()
|
||||
ctx.atari_sync_task = asyncio.create_task(atari_sync_task(ctx), name="Adventure Sync")
|
||||
|
||||
if args.patch_file:
|
||||
ext = args.patch_file.split(".")[len(args.patch_file.split(".")) - 1].lower()
|
||||
if ext == "apadvn":
|
||||
logger.info("apadvn file supplied, beginning patching process...")
|
||||
async_start(patch_and_run_game(args.patch_file, ctx))
|
||||
else:
|
||||
logger.warning(f"Unknown patch file extension {ext}")
|
||||
if args.port is int:
|
||||
ctx.lua_connector_port = args.port
|
||||
|
||||
await ctx.exit_event.wait()
|
||||
ctx.server_address = None
|
||||
|
||||
await ctx.shutdown()
|
||||
|
||||
if ctx.atari_sync_task:
|
||||
await ctx.atari_sync_task
|
||||
print("finished atari_sync_task (main)")
|
||||
|
||||
|
||||
import colorama
|
||||
|
||||
colorama.init()
|
||||
|
||||
asyncio.run(main())
|
||||
colorama.deinit()
|
||||
1955
BaseClasses.py
@@ -1,10 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import ModuleUpdate
|
||||
ModuleUpdate.update()
|
||||
|
||||
from worlds._bizhawk.context import launch
|
||||
|
||||
if __name__ == "__main__":
|
||||
launch(*sys.argv[1:])
|
||||
615
CommonClient.py
66
FF1Client.py
@@ -1,5 +1,4 @@
|
||||
import asyncio
|
||||
import copy
|
||||
import json
|
||||
import time
|
||||
from asyncio import StreamReader, StreamWriter
|
||||
@@ -7,15 +6,14 @@ from typing import List
|
||||
|
||||
|
||||
import Utils
|
||||
from Utils import async_start
|
||||
from CommonClient import CommonContext, server_loop, gui_enabled, ClientCommandProcessor, logger, \
|
||||
from CommonClient import CommonContext, server_loop, gui_enabled, console_loop, ClientCommandProcessor, logger, \
|
||||
get_base_parser
|
||||
|
||||
SYSTEM_MESSAGE_ID = 0
|
||||
|
||||
CONNECTION_TIMING_OUT_STATUS = "Connection timing out. Please restart your emulator, then restart connector_ff1.lua"
|
||||
CONNECTION_REFUSED_STATUS = "Connection Refused. Please start your emulator and make sure connector_ff1.lua is running"
|
||||
CONNECTION_RESET_STATUS = "Connection was reset. Please restart your emulator, then restart connector_ff1.lua"
|
||||
CONNECTION_TIMING_OUT_STATUS = "Connection timing out. Please restart your emulator, then restart ff1_connector.lua"
|
||||
CONNECTION_REFUSED_STATUS = "Connection Refused. Please start your emulator and make sure ff1_connector.lua is running"
|
||||
CONNECTION_RESET_STATUS = "Connection was reset. Please restart your emulator, then restart ff1_connector.lua"
|
||||
CONNECTION_TENTATIVE_STATUS = "Initial Connection Made"
|
||||
CONNECTION_CONNECTED_STATUS = "Connected"
|
||||
CONNECTION_INITIAL_STATUS = "Connection has not been initiated"
|
||||
@@ -33,7 +31,7 @@ class FF1CommandProcessor(ClientCommandProcessor):
|
||||
logger.info(f"NES Status: {self.ctx.nes_status}")
|
||||
|
||||
def _cmd_toggle_msgs(self):
|
||||
"""Toggle displaying messages in EmuHawk"""
|
||||
"""Toggle displaying messages in bizhawk"""
|
||||
global DISPLAY_MSGS
|
||||
DISPLAY_MSGS = not DISPLAY_MSGS
|
||||
logger.info(f"Messages are now {'enabled' if DISPLAY_MSGS else 'disabled'}")
|
||||
@@ -66,37 +64,41 @@ class FF1Context(CommonContext):
|
||||
|
||||
def _set_message(self, msg: str, msg_id: int):
|
||||
if DISPLAY_MSGS:
|
||||
self.messages[time.time(), msg_id] = msg
|
||||
self.messages[(time.time(), msg_id)] = msg
|
||||
|
||||
def on_package(self, cmd: str, args: dict):
|
||||
if cmd == 'Connected':
|
||||
async_start(parse_locations(self.locations_array, self, True))
|
||||
asyncio.create_task(parse_locations(self.locations_array, self, True))
|
||||
elif cmd == 'Print':
|
||||
msg = args['text']
|
||||
if ': !' not in msg:
|
||||
self._set_message(msg, SYSTEM_MESSAGE_ID)
|
||||
|
||||
def on_print_json(self, args: dict):
|
||||
if self.ui:
|
||||
self.ui.print_json(copy.deepcopy(args["data"]))
|
||||
else:
|
||||
text = self.jsontotextparser(copy.deepcopy(args["data"]))
|
||||
logger.info(text)
|
||||
relevant = args.get("type", None) in {"Hint", "ItemSend"}
|
||||
if relevant:
|
||||
item = args["item"]
|
||||
# goes to this world
|
||||
if self.slot_concerns_self(args["receiving"]):
|
||||
relevant = True
|
||||
# found in this world
|
||||
elif self.slot_concerns_self(item.player):
|
||||
relevant = True
|
||||
# not related
|
||||
else:
|
||||
relevant = False
|
||||
if relevant:
|
||||
item = args["item"]
|
||||
msg = self.raw_text_parser(copy.deepcopy(args["data"]))
|
||||
elif cmd == "ReceivedItems":
|
||||
msg = f"Received {', '.join([self.item_names[item.item] for item in args['items']])}"
|
||||
self._set_message(msg, SYSTEM_MESSAGE_ID)
|
||||
elif cmd == 'PrintJSON':
|
||||
print_type = args['type']
|
||||
item = args['item']
|
||||
receiving_player_id = args['receiving']
|
||||
receiving_player_name = self.player_names[receiving_player_id]
|
||||
sending_player_id = item.player
|
||||
sending_player_name = self.player_names[item.player]
|
||||
if print_type == 'Hint':
|
||||
msg = f"Hint: Your {self.item_names[item.item]} is at" \
|
||||
f" {self.player_names[item.player]}'s {self.location_names[item.location]}"
|
||||
self._set_message(msg, item.item)
|
||||
elif print_type == 'ItemSend' and receiving_player_id != self.slot:
|
||||
if sending_player_id == self.slot:
|
||||
if receiving_player_id == self.slot:
|
||||
msg = f"You found your own {self.item_names[item.item]}"
|
||||
else:
|
||||
msg = f"You sent {self.item_names[item.item]} to {receiving_player_name}"
|
||||
else:
|
||||
if receiving_player_id == sending_player_id:
|
||||
msg = f"{sending_player_name} found their {self.item_names[item.item]}"
|
||||
else:
|
||||
msg = f"{sending_player_name} sent {self.item_names[item.item]} to " \
|
||||
f"{receiving_player_name}"
|
||||
self._set_message(msg, item.item)
|
||||
|
||||
def run_gui(self):
|
||||
@@ -181,7 +183,7 @@ async def nes_sync_task(ctx: FF1Context):
|
||||
# print(data_decoded)
|
||||
if ctx.game is not None and 'locations' in data_decoded:
|
||||
# Not just a keep alive ping, parse
|
||||
async_start(parse_locations(data_decoded['locations'], ctx, False))
|
||||
asyncio.create_task(parse_locations(data_decoded['locations'], ctx, False))
|
||||
if not ctx.auth:
|
||||
ctx.auth = ''.join([chr(i) for i in data_decoded['playerName'] if i != 0])
|
||||
if ctx.auth == '':
|
||||
|
||||
@@ -1,12 +1,433 @@
|
||||
from __future__ import annotations
|
||||
import os
|
||||
import logging
|
||||
import json
|
||||
import string
|
||||
import copy
|
||||
import subprocess
|
||||
import time
|
||||
import random
|
||||
|
||||
import ModuleUpdate
|
||||
ModuleUpdate.update()
|
||||
|
||||
from worlds.factorio.Client import check_stdin, launch
|
||||
import factorio_rcon
|
||||
import colorama
|
||||
import asyncio
|
||||
from queue import Queue
|
||||
import Utils
|
||||
|
||||
if __name__ == "__main__":
|
||||
Utils.init_logging("FactorioClient", exception_logger="Client")
|
||||
check_stdin()
|
||||
launch()
|
||||
|
||||
from CommonClient import CommonContext, server_loop, ClientCommandProcessor, logger, gui_enabled, get_base_parser
|
||||
from MultiServer import mark_raw
|
||||
from NetUtils import NetworkItem, ClientStatus, JSONtoTextParser, JSONMessagePart
|
||||
|
||||
from worlds.factorio import Factorio
|
||||
|
||||
|
||||
class FactorioCommandProcessor(ClientCommandProcessor):
|
||||
ctx: FactorioContext
|
||||
|
||||
@mark_raw
|
||||
def _cmd_factorio(self, text: str) -> bool:
|
||||
"""Send the following command to the bound Factorio Server."""
|
||||
if self.ctx.rcon_client:
|
||||
# TODO: Print the command non-silently only for race seeds, or otherwise block anything but /factorio /save in race seeds.
|
||||
self.ctx.print_to_game(f"/factorio {text}")
|
||||
result = self.ctx.rcon_client.send_command(text)
|
||||
if result:
|
||||
self.output(result)
|
||||
return True
|
||||
return False
|
||||
|
||||
def _cmd_resync(self):
|
||||
"""Manually trigger a resync."""
|
||||
self.ctx.awaiting_bridge = True
|
||||
|
||||
|
||||
class FactorioContext(CommonContext):
|
||||
command_processor = FactorioCommandProcessor
|
||||
game = "Factorio"
|
||||
items_handling = 0b111 # full remote
|
||||
|
||||
# updated by spinup server
|
||||
mod_version: Utils.Version = Utils.Version(0, 0, 0)
|
||||
|
||||
def __init__(self, server_address, password):
|
||||
super(FactorioContext, self).__init__(server_address, password)
|
||||
self.send_index: int = 0
|
||||
self.rcon_client = None
|
||||
self.awaiting_bridge = False
|
||||
self.write_data_path = None
|
||||
self.death_link_tick: int = 0 # last send death link on Factorio layer
|
||||
self.factorio_json_text_parser = FactorioJSONtoTextParser(self)
|
||||
self.energy_link_increment = 0
|
||||
self.last_deplete = 0
|
||||
|
||||
async def server_auth(self, password_requested: bool = False):
|
||||
if password_requested and not self.password:
|
||||
await super(FactorioContext, self).server_auth(password_requested)
|
||||
|
||||
if self.rcon_client:
|
||||
await get_info(self, self.rcon_client) # retrieve current auth code
|
||||
else:
|
||||
raise Exception("Cannot connect to a server with unknown own identity, "
|
||||
"bridge to Factorio first.")
|
||||
|
||||
await self.send_connect()
|
||||
|
||||
def on_print(self, args: dict):
|
||||
super(FactorioContext, self).on_print(args)
|
||||
if self.rcon_client:
|
||||
self.print_to_game(args['text'])
|
||||
|
||||
def on_print_json(self, args: dict):
|
||||
if self.rcon_client:
|
||||
text = self.factorio_json_text_parser(copy.deepcopy(args["data"]))
|
||||
self.print_to_game(text)
|
||||
super(FactorioContext, self).on_print_json(args)
|
||||
|
||||
@property
|
||||
def savegame_name(self) -> str:
|
||||
return f"AP_{self.seed_name}_{self.auth}_Save.zip"
|
||||
|
||||
def print_to_game(self, text):
|
||||
self.rcon_client.send_command(f"/ap-print [font=default-large-bold]Archipelago:[/font] "
|
||||
f"{text}")
|
||||
|
||||
def on_deathlink(self, data: dict):
|
||||
if self.rcon_client:
|
||||
self.rcon_client.send_command(f"/ap-deathlink {data['source']}")
|
||||
super(FactorioContext, self).on_deathlink(data)
|
||||
|
||||
def on_package(self, cmd: str, args: dict):
|
||||
if cmd in {"Connected", "RoomUpdate"}:
|
||||
# catch up sync anything that is already cleared.
|
||||
if "checked_locations" in args and args["checked_locations"]:
|
||||
self.rcon_client.send_commands({item_name: f'/ap-get-technology ap-{item_name}-\t-1' for
|
||||
item_name in args["checked_locations"]})
|
||||
if cmd == "Connected" and self.energy_link_increment:
|
||||
asyncio.create_task(self.send_msgs([{
|
||||
"cmd": "SetNotify", "keys": ["EnergyLink"]
|
||||
}]))
|
||||
elif cmd == "SetReply":
|
||||
if args["key"] == "EnergyLink":
|
||||
if self.energy_link_increment and args.get("last_deplete", -1) == self.last_deplete:
|
||||
# it's our deplete request
|
||||
gained = int(args["original_value"] - args["value"])
|
||||
gained_text = Utils.format_SI_prefix(gained) + "J"
|
||||
if gained:
|
||||
logger.debug(f"EnergyLink: Received {gained_text}. "
|
||||
f"{Utils.format_SI_prefix(args['value'])}J remaining.")
|
||||
self.rcon_client.send_command(f"/ap-energylink {gained}")
|
||||
|
||||
def run_gui(self):
|
||||
from kvui import GameManager
|
||||
|
||||
class FactorioManager(GameManager):
|
||||
logging_pairs = [
|
||||
("Client", "Archipelago"),
|
||||
("FactorioServer", "Factorio Server Log"),
|
||||
("FactorioWatcher", "Bridge Data Log"),
|
||||
]
|
||||
base_title = "Archipelago Factorio Client"
|
||||
|
||||
self.ui = FactorioManager(self)
|
||||
self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI")
|
||||
|
||||
|
||||
async def game_watcher(ctx: FactorioContext):
|
||||
bridge_logger = logging.getLogger("FactorioWatcher")
|
||||
from worlds.factorio.Technologies import lookup_id_to_name
|
||||
next_bridge = time.perf_counter() + 1
|
||||
try:
|
||||
while not ctx.exit_event.is_set():
|
||||
# TODO: restore on-demand refresh
|
||||
if ctx.rcon_client and time.perf_counter() > next_bridge:
|
||||
next_bridge = time.perf_counter() + 1
|
||||
ctx.awaiting_bridge = False
|
||||
data = json.loads(ctx.rcon_client.send_command("/ap-sync"))
|
||||
if not ctx.auth:
|
||||
pass # auth failed, wait for new attempt
|
||||
elif data["slot_name"] != ctx.auth:
|
||||
bridge_logger.warning(f"Connected World is not the expected one {data['slot_name']} != {ctx.auth}")
|
||||
elif data["seed_name"] != ctx.seed_name:
|
||||
bridge_logger.warning(
|
||||
f"Connected Multiworld is not the expected one {data['seed_name']} != {ctx.seed_name}")
|
||||
else:
|
||||
data = data["info"]
|
||||
research_data = data["research_done"]
|
||||
research_data = {int(tech_name.split("-")[1]) for tech_name in research_data}
|
||||
victory = data["victory"]
|
||||
await ctx.update_death_link(data["death_link"])
|
||||
|
||||
if not ctx.finished_game and victory:
|
||||
await ctx.send_msgs([{"cmd": "StatusUpdate", "status": ClientStatus.CLIENT_GOAL}])
|
||||
ctx.finished_game = True
|
||||
|
||||
if ctx.locations_checked != research_data:
|
||||
bridge_logger.debug(
|
||||
f"New researches done: "
|
||||
f"{[lookup_id_to_name[rid] for rid in research_data - ctx.locations_checked]}")
|
||||
ctx.locations_checked = research_data
|
||||
await ctx.send_msgs([{"cmd": 'LocationChecks', "locations": tuple(research_data)}])
|
||||
death_link_tick = data.get("death_link_tick", 0)
|
||||
if death_link_tick != ctx.death_link_tick:
|
||||
ctx.death_link_tick = death_link_tick
|
||||
if "DeathLink" in ctx.tags:
|
||||
asyncio.create_task(ctx.send_death())
|
||||
if ctx.energy_link_increment:
|
||||
in_world_bridges = data["energy_bridges"]
|
||||
if in_world_bridges:
|
||||
in_world_energy = data["energy"]
|
||||
if in_world_energy < (ctx.energy_link_increment * in_world_bridges):
|
||||
# attempt to refill
|
||||
ctx.last_deplete = time.time()
|
||||
asyncio.create_task(ctx.send_msgs([{
|
||||
"cmd": "Set", "key": "EnergyLink", "operations":
|
||||
[{"operation": "add", "value": -ctx.energy_link_increment * in_world_bridges},
|
||||
{"operation": "max", "value": 0}],
|
||||
"last_deplete": ctx.last_deplete
|
||||
}]))
|
||||
# Above Capacity - (len(Bridges) * ENERGY_INCREMENT)
|
||||
elif in_world_energy > (in_world_bridges * ctx.energy_link_increment * 5) - \
|
||||
ctx.energy_link_increment*in_world_bridges:
|
||||
value = ctx.energy_link_increment * in_world_bridges
|
||||
asyncio.create_task(ctx.send_msgs([{
|
||||
"cmd": "Set", "key": "EnergyLink", "operations":
|
||||
[{"operation": "add", "value": value}]
|
||||
}]))
|
||||
ctx.rcon_client.send_command(
|
||||
f"/ap-energylink -{value}")
|
||||
logger.debug(f"EnergyLink: Sent {Utils.format_SI_prefix(value)}J")
|
||||
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
except Exception as e:
|
||||
logging.exception(e)
|
||||
logging.error("Aborted Factorio Server Bridge")
|
||||
|
||||
|
||||
def stream_factorio_output(pipe, queue, process):
|
||||
def queuer():
|
||||
while process.poll() is None:
|
||||
text = pipe.readline().strip()
|
||||
if text:
|
||||
queue.put_nowait(text)
|
||||
|
||||
from threading import Thread
|
||||
|
||||
thread = Thread(target=queuer, name="Factorio Output Queue", daemon=True)
|
||||
thread.start()
|
||||
return thread
|
||||
|
||||
|
||||
async def factorio_server_watcher(ctx: FactorioContext):
|
||||
savegame_name = os.path.abspath(ctx.savegame_name)
|
||||
if not os.path.exists(savegame_name):
|
||||
logger.info(f"Creating savegame {savegame_name}")
|
||||
subprocess.run((
|
||||
executable, "--create", savegame_name, "--preset", "archipelago"
|
||||
))
|
||||
factorio_process = subprocess.Popen((executable, "--start-server", ctx.savegame_name,
|
||||
*(str(elem) for elem in server_args)),
|
||||
stderr=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
stdin=subprocess.DEVNULL,
|
||||
encoding="utf-8")
|
||||
factorio_server_logger.info("Started Factorio Server")
|
||||
factorio_queue = Queue()
|
||||
stream_factorio_output(factorio_process.stdout, factorio_queue, factorio_process)
|
||||
stream_factorio_output(factorio_process.stderr, factorio_queue, factorio_process)
|
||||
try:
|
||||
while not ctx.exit_event.is_set():
|
||||
if factorio_process.poll():
|
||||
factorio_server_logger.info("Factorio server has exited.")
|
||||
ctx.exit_event.set()
|
||||
|
||||
while not factorio_queue.empty():
|
||||
msg = factorio_queue.get()
|
||||
factorio_queue.task_done()
|
||||
|
||||
if not ctx.rcon_client and "Starting RCON interface at IP ADDR:" in msg:
|
||||
ctx.rcon_client = factorio_rcon.RCONClient("localhost", rcon_port, rcon_password)
|
||||
if not ctx.server:
|
||||
logger.info("Established bridge to Factorio Server. "
|
||||
"Ready to connect to Archipelago via /connect")
|
||||
|
||||
if not ctx.awaiting_bridge and "Archipelago Bridge Data available for game tick " in msg:
|
||||
ctx.awaiting_bridge = True
|
||||
factorio_server_logger.debug(msg)
|
||||
else:
|
||||
factorio_server_logger.info(msg)
|
||||
if ctx.rcon_client:
|
||||
commands = {}
|
||||
while ctx.send_index < len(ctx.items_received):
|
||||
transfer_item: NetworkItem = ctx.items_received[ctx.send_index]
|
||||
item_id = transfer_item.item
|
||||
player_name = ctx.player_names[transfer_item.player]
|
||||
if item_id not in Factorio.item_id_to_name:
|
||||
factorio_server_logger.error(f"Cannot send unknown item ID: {item_id}")
|
||||
else:
|
||||
item_name = Factorio.item_id_to_name[item_id]
|
||||
factorio_server_logger.info(f"Sending {item_name} to Nauvis from {player_name}.")
|
||||
commands[ctx.send_index] = f'/ap-get-technology {item_name}\t{ctx.send_index}\t{player_name}'
|
||||
ctx.send_index += 1
|
||||
if commands:
|
||||
ctx.rcon_client.send_commands(commands)
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
except Exception as e:
|
||||
logging.exception(e)
|
||||
logging.error("Aborted Factorio Server Bridge")
|
||||
ctx.rcon_client = None
|
||||
ctx.exit_event.set()
|
||||
|
||||
finally:
|
||||
factorio_process.terminate()
|
||||
factorio_process.wait(5)
|
||||
|
||||
|
||||
async def get_info(ctx: FactorioContext, rcon_client: factorio_rcon.RCONClient):
|
||||
info = json.loads(rcon_client.send_command("/ap-rcon-info"))
|
||||
ctx.auth = info["slot_name"]
|
||||
ctx.seed_name = info["seed_name"]
|
||||
# 0.2.0 addition, not present earlier
|
||||
death_link = bool(info.get("death_link", False))
|
||||
ctx.energy_link_increment = info.get("energy_link", 0)
|
||||
logger.debug(f"Energy Link Increment: {ctx.energy_link_increment}")
|
||||
if ctx.energy_link_increment and ctx.ui:
|
||||
ctx.ui.enable_energy_link()
|
||||
await ctx.update_death_link(death_link)
|
||||
|
||||
|
||||
async def factorio_spinup_server(ctx: FactorioContext) -> bool:
|
||||
savegame_name = os.path.abspath("Archipelago.zip")
|
||||
if not os.path.exists(savegame_name):
|
||||
logger.info(f"Creating savegame {savegame_name}")
|
||||
subprocess.run((
|
||||
executable, "--create", savegame_name
|
||||
))
|
||||
factorio_process = subprocess.Popen(
|
||||
(executable, "--start-server", savegame_name, *(str(elem) for elem in server_args)),
|
||||
stderr=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
stdin=subprocess.DEVNULL,
|
||||
encoding="utf-8")
|
||||
factorio_server_logger.info("Started Information Exchange Factorio Server")
|
||||
factorio_queue = Queue()
|
||||
stream_factorio_output(factorio_process.stdout, factorio_queue, factorio_process)
|
||||
stream_factorio_output(factorio_process.stderr, factorio_queue, factorio_process)
|
||||
rcon_client = None
|
||||
try:
|
||||
while not ctx.auth:
|
||||
while not factorio_queue.empty():
|
||||
msg = factorio_queue.get()
|
||||
factorio_server_logger.info(msg)
|
||||
if "Loading mod AP-" in msg and msg.endswith("(data.lua)"):
|
||||
parts = msg.split()
|
||||
ctx.mod_version = Utils.Version(*(int(number) for number in parts[-2].split(".")))
|
||||
elif "Write data path: " in msg:
|
||||
ctx.write_data_path = Utils.get_text_between(msg, "Write data path: ", " [")
|
||||
if "AppData" in ctx.write_data_path:
|
||||
logger.warning("It appears your mods are loaded from Appdata, "
|
||||
"this can lead to problems with multiple Factorio instances. "
|
||||
"If this is the case, you will get a file locked error running Factorio.")
|
||||
if not rcon_client and "Starting RCON interface at IP ADDR:" in msg:
|
||||
rcon_client = factorio_rcon.RCONClient("localhost", rcon_port, rcon_password)
|
||||
if ctx.mod_version == ctx.__class__.mod_version:
|
||||
raise Exception("No Archipelago mod was loaded. Aborting.")
|
||||
await get_info(ctx, rcon_client)
|
||||
await asyncio.sleep(0.01)
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(e, extra={"compact_gui": True})
|
||||
msg = "Aborted Factorio Server Bridge"
|
||||
logger.error(msg)
|
||||
ctx.gui_error(msg, e)
|
||||
ctx.exit_event.set()
|
||||
|
||||
else:
|
||||
logger.info(
|
||||
f"Got World Information from AP Mod {tuple(ctx.mod_version)} for seed {ctx.seed_name} in slot {ctx.auth}")
|
||||
return True
|
||||
finally:
|
||||
factorio_process.terminate()
|
||||
factorio_process.wait(5)
|
||||
return False
|
||||
|
||||
|
||||
async def main(args):
|
||||
ctx = FactorioContext(args.connect, args.password)
|
||||
ctx.server_task = asyncio.create_task(server_loop(ctx), name="ServerLoop")
|
||||
|
||||
if gui_enabled:
|
||||
ctx.run_gui()
|
||||
ctx.run_cli()
|
||||
|
||||
factorio_server_task = asyncio.create_task(factorio_spinup_server(ctx), name="FactorioSpinupServer")
|
||||
successful_launch = await factorio_server_task
|
||||
if successful_launch:
|
||||
factorio_server_task = asyncio.create_task(factorio_server_watcher(ctx), name="FactorioServer")
|
||||
progression_watcher = asyncio.create_task(
|
||||
game_watcher(ctx), name="FactorioProgressionWatcher")
|
||||
|
||||
await ctx.exit_event.wait()
|
||||
ctx.server_address = None
|
||||
|
||||
await progression_watcher
|
||||
await factorio_server_task
|
||||
|
||||
await ctx.shutdown()
|
||||
|
||||
|
||||
class FactorioJSONtoTextParser(JSONtoTextParser):
|
||||
def _handle_color(self, node: JSONMessagePart):
|
||||
colors = node["color"].split(";")
|
||||
for color in colors:
|
||||
if color in self.color_codes:
|
||||
node["text"] = f"[color=#{self.color_codes[color]}]{node['text']}[/color]"
|
||||
return self._handle_text(node)
|
||||
return self._handle_text(node)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
parser = get_base_parser(description="Optional arguments to FactorioClient follow. "
|
||||
"Remaining arguments get passed into bound Factorio instance."
|
||||
"Refer to Factorio --help for those.")
|
||||
parser.add_argument('--rcon-port', default='24242', type=int, help='Port to use to communicate with Factorio')
|
||||
parser.add_argument('--rcon-password', help='Password to authenticate with RCON.')
|
||||
parser.add_argument('--server-settings', help='Factorio server settings configuration file.')
|
||||
|
||||
args, rest = parser.parse_known_args()
|
||||
colorama.init()
|
||||
rcon_port = args.rcon_port
|
||||
rcon_password = args.rcon_password if args.rcon_password else ''.join(
|
||||
random.choice(string.ascii_letters) for x in range(32))
|
||||
|
||||
factorio_server_logger = logging.getLogger("FactorioServer")
|
||||
options = Utils.get_options()
|
||||
executable = options["factorio_options"]["executable"]
|
||||
server_settings = args.server_settings if args.server_settings else options["factorio_options"].get("server_settings", None)
|
||||
if server_settings:
|
||||
server_settings = os.path.abspath(server_settings)
|
||||
|
||||
if not os.path.exists(os.path.dirname(executable)):
|
||||
raise FileNotFoundError(f"Path {os.path.dirname(executable)} does not exist or could not be accessed.")
|
||||
if os.path.isdir(executable): # user entered a path to a directory, let's find the executable therein
|
||||
executable = os.path.join(executable, "factorio")
|
||||
if not os.path.isfile(executable):
|
||||
if os.path.isfile(executable + ".exe"):
|
||||
executable = executable + ".exe"
|
||||
else:
|
||||
raise FileNotFoundError(f"Path {executable} is not an executable file.")
|
||||
|
||||
if server_settings and os.path.isfile(server_settings):
|
||||
server_args = ("--rcon-port", rcon_port, "--rcon-password", rcon_password, "--server-settings", server_settings, *rest)
|
||||
else:
|
||||
server_args = ("--rcon-port", rcon_port, "--rcon-password", rcon_password, *rest)
|
||||
|
||||
asyncio.run(main(args))
|
||||
colorama.deinit()
|
||||
|
||||
609
Generate.py
@@ -1,88 +1,120 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import copy
|
||||
import logging
|
||||
import os
|
||||
import random
|
||||
import string
|
||||
import sys
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from collections import Counter
|
||||
from typing import Any, Dict, Tuple, Union
|
||||
from itertools import chain
|
||||
import urllib.parse
|
||||
from typing import Set, Dict, Tuple, Callable, Any, Union
|
||||
import os
|
||||
from collections import Counter, ChainMap
|
||||
import string
|
||||
import enum
|
||||
|
||||
import ModuleUpdate
|
||||
|
||||
ModuleUpdate.update()
|
||||
|
||||
import Utils
|
||||
from worlds.alttp import Options as LttPOptions
|
||||
from worlds.generic import PlandoConnection
|
||||
from Utils import parse_yamls, version_tuple, __version__, tuplize_version, get_options, local_path, user_path
|
||||
from worlds.alttp.EntranceRandomizer import parse_arguments
|
||||
from Main import main as ERmain
|
||||
from BaseClasses import seeddigits, get_seed
|
||||
import Options
|
||||
from BaseClasses import seeddigits, get_seed, PlandoOptions
|
||||
from Utils import parse_yamls, version_tuple, __version__, tuplize_version
|
||||
from worlds.alttp import Bosses
|
||||
from worlds.alttp.Text import TextTable
|
||||
from worlds.AutoWorld import AutoWorldRegister
|
||||
import copy
|
||||
|
||||
|
||||
class PlandoSettings(enum.IntFlag):
|
||||
items = 0b0001
|
||||
connections = 0b0010
|
||||
texts = 0b0100
|
||||
bosses = 0b1000
|
||||
|
||||
@classmethod
|
||||
def from_option_string(cls, option_string: str) -> PlandoSettings:
|
||||
result = cls(0)
|
||||
for part in option_string.split(","):
|
||||
part = part.strip().lower()
|
||||
if part:
|
||||
result = cls._handle_part(part, result)
|
||||
return result
|
||||
|
||||
@classmethod
|
||||
def from_set(cls, option_set: Set[str]) -> PlandoSettings:
|
||||
result = cls(0)
|
||||
for part in option_set:
|
||||
result = cls._handle_part(part, result)
|
||||
return result
|
||||
|
||||
@classmethod
|
||||
def _handle_part(cls, part: str, base: PlandoSettings) -> PlandoSettings:
|
||||
try:
|
||||
part = cls[part]
|
||||
except Exception as e:
|
||||
raise KeyError(f"{part} is not a recognized name for a plando module. "
|
||||
f"Known options: {', '.join(flag.name for flag in cls)}") from e
|
||||
else:
|
||||
return base | part
|
||||
|
||||
def __str__(self) -> str:
|
||||
if self.value:
|
||||
return ", ".join(flag.name for flag in PlandoSettings if self.value & flag.value)
|
||||
return "Off"
|
||||
|
||||
|
||||
def mystery_argparse():
|
||||
from settings import get_settings
|
||||
settings = get_settings()
|
||||
defaults = settings.generator
|
||||
options = get_options()
|
||||
defaults = options["generator"]
|
||||
|
||||
def resolve_path(path: str, resolver: Callable[[str], str]) -> str:
|
||||
return path if os.path.isabs(path) else resolver(path)
|
||||
|
||||
parser = argparse.ArgumentParser(description="CMD Generation Interface, defaults come from host.yaml.")
|
||||
parser.add_argument('--weights_file_path', default=defaults.weights_file_path,
|
||||
help='Path to the weights file to use for rolling game options, urls are also valid')
|
||||
parser.add_argument('--sameoptions', help='Rolls options per weights file rather than per player',
|
||||
parser.add_argument('--weights_file_path', default=defaults["weights_file_path"],
|
||||
help='Path to the weights file to use for rolling game settings, urls are also valid')
|
||||
parser.add_argument('--samesettings', help='Rolls settings per weights file rather than per player',
|
||||
action='store_true')
|
||||
parser.add_argument('--player_files_path', default=defaults.player_files_path,
|
||||
parser.add_argument('--player_files_path', default=resolve_path(defaults["player_files_path"], user_path),
|
||||
help="Input directory for player files.")
|
||||
parser.add_argument('--seed', help='Define seed number to generate.', type=int)
|
||||
parser.add_argument('--multi', default=defaults.players, type=lambda value: max(int(value), 1))
|
||||
parser.add_argument('--spoiler', type=int, default=defaults.spoiler)
|
||||
parser.add_argument('--outputpath', default=settings.general_options.output_path,
|
||||
parser.add_argument('--multi', default=defaults["players"], type=lambda value: max(int(value), 1))
|
||||
parser.add_argument('--spoiler', type=int, default=defaults["spoiler"])
|
||||
parser.add_argument('--outputpath', default=resolve_path(options["general_options"]["output_path"], user_path),
|
||||
help="Path to output folder. Absolute or relative to cwd.") # absolute or relative to cwd
|
||||
parser.add_argument('--race', action='store_true', default=defaults.race)
|
||||
parser.add_argument('--meta_file_path', default=defaults.meta_file_path)
|
||||
parser.add_argument('--log_level', default=defaults.loglevel, help='Sets log level')
|
||||
parser.add_argument('--log_time', help="Add timestamps to STDOUT",
|
||||
default=defaults.logtime, action='store_true')
|
||||
parser.add_argument("--csv_output", action="store_true",
|
||||
help="Output rolled player options to csv (made for async multiworld).")
|
||||
parser.add_argument("--plando", default=defaults.plando_options,
|
||||
help="List of options that can be set manually. Can be combined, for example \"bosses, items\"")
|
||||
parser.add_argument("--skip_prog_balancing", action="store_true",
|
||||
help="Skip progression balancing step during generation.")
|
||||
parser.add_argument("--skip_output", action="store_true",
|
||||
help="Skips generation assertion and output stages and skips multidata and spoiler output. "
|
||||
"Intended for debugging and testing purposes.")
|
||||
parser.add_argument('--race', action='store_true', default=defaults["race"])
|
||||
parser.add_argument('--meta_file_path', default=defaults["meta_file_path"])
|
||||
parser.add_argument('--log_level', default='info', help='Sets log level')
|
||||
parser.add_argument('--yaml_output', default=0, type=lambda value: max(int(value), 0),
|
||||
help='Output rolled mystery results to yaml up to specified number (made for async multiworld)')
|
||||
parser.add_argument('--plando', default=defaults["plando_options"],
|
||||
help='List of options that can be set manually. Can be combined, for example "bosses, items"')
|
||||
args = parser.parse_args()
|
||||
if not os.path.isabs(args.weights_file_path):
|
||||
args.weights_file_path = os.path.join(args.player_files_path, args.weights_file_path)
|
||||
if not os.path.isabs(args.meta_file_path):
|
||||
args.meta_file_path = os.path.join(args.player_files_path, args.meta_file_path)
|
||||
args.plando: PlandoOptions = PlandoOptions.from_option_string(args.plando)
|
||||
return args
|
||||
args.plando: PlandoSettings = PlandoSettings.from_option_string(args.plando)
|
||||
return args, options
|
||||
|
||||
|
||||
def get_seed_name(random_source) -> str:
|
||||
return f"{random_source.randint(0, pow(10, seeddigits) - 1)}".zfill(seeddigits)
|
||||
|
||||
|
||||
def main(args=None) -> Tuple[argparse.Namespace, int]:
|
||||
# __name__ == "__main__" check so unittests that already imported worlds don't trip this.
|
||||
if __name__ == "__main__" and "worlds" in sys.modules:
|
||||
raise Exception("Worlds system should not be loaded before logging init.")
|
||||
|
||||
def main(args=None, callback=ERmain):
|
||||
if not args:
|
||||
args = mystery_argparse()
|
||||
args, options = mystery_argparse()
|
||||
|
||||
seed = get_seed(args.seed)
|
||||
|
||||
Utils.init_logging(f"Generate_{seed}", loglevel=args.log_level, add_timestamp=args.log_time)
|
||||
random.seed(seed)
|
||||
seed_name = get_seed_name(random)
|
||||
|
||||
if args.race:
|
||||
logging.info("Race mode enabled. Using non-deterministic random source.")
|
||||
random.seed() # reset to time-based random source
|
||||
|
||||
weights_cache: Dict[str, Tuple[Any, ...]] = {}
|
||||
@@ -90,85 +122,64 @@ def main(args=None) -> Tuple[argparse.Namespace, int]:
|
||||
try:
|
||||
weights_cache[args.weights_file_path] = read_weights_yamls(args.weights_file_path)
|
||||
except Exception as e:
|
||||
raise ValueError(f"File {args.weights_file_path} is invalid. Please fix your yaml.") from e
|
||||
logging.info(f"Weights: {args.weights_file_path} >> "
|
||||
f"{get_choice('description', weights_cache[args.weights_file_path][-1], 'No description specified')}")
|
||||
raise ValueError(f"File {args.weights_file_path} is destroyed. Please fix your yaml.") from e
|
||||
print(f"Weights: {args.weights_file_path} >> "
|
||||
f"{get_choice('description', weights_cache[args.weights_file_path][-1], 'No description specified')}")
|
||||
|
||||
if args.meta_file_path and os.path.exists(args.meta_file_path):
|
||||
try:
|
||||
meta_weights = read_weights_yamls(args.meta_file_path)[-1]
|
||||
except Exception as e:
|
||||
raise ValueError(f"File {args.meta_file_path} is invalid. Please fix your yaml.") from e
|
||||
logging.info(f"Meta: {args.meta_file_path} >> {get_choice('meta_description', meta_weights)}")
|
||||
raise ValueError(f"File {args.meta_file_path} is destroyed. Please fix your yaml.") from e
|
||||
print(f"Meta: {args.meta_file_path} >> {get_choice('meta_description', meta_weights)}")
|
||||
try: # meta description allows us to verify that the file named meta.yaml is intentionally a meta file
|
||||
del(meta_weights["meta_description"])
|
||||
except Exception as e:
|
||||
raise ValueError("No meta description found for meta.yaml. Unable to verify.") from e
|
||||
if args.sameoptions:
|
||||
raise Exception("Cannot mix --sameoptions with --meta")
|
||||
if args.samesettings:
|
||||
raise Exception("Cannot mix --samesettings with --meta")
|
||||
else:
|
||||
meta_weights = None
|
||||
player_id = 1
|
||||
player_files = {}
|
||||
for file in os.scandir(args.player_files_path):
|
||||
fname = file.name
|
||||
if file.is_file() and not fname.startswith(".") and not fname.lower().endswith(".ini") and \
|
||||
if file.is_file() and not file.name.startswith(".") and \
|
||||
os.path.join(args.player_files_path, fname) not in {args.meta_file_path, args.weights_file_path}:
|
||||
path = os.path.join(args.player_files_path, fname)
|
||||
try:
|
||||
weights_for_file = []
|
||||
for doc_idx, yaml in enumerate(read_weights_yamls(path)):
|
||||
if yaml is None:
|
||||
logging.warning(f"Ignoring empty yaml document #{doc_idx + 1} in {fname}")
|
||||
else:
|
||||
weights_for_file.append(yaml)
|
||||
weights_cache[fname] = tuple(weights_for_file)
|
||||
|
||||
weights_cache[fname] = read_weights_yamls(path)
|
||||
except Exception as e:
|
||||
raise ValueError(f"File {fname} is invalid. Please fix your yaml.") from e
|
||||
raise ValueError(f"File {fname} is destroyed. Please fix your yaml.") from e
|
||||
|
||||
# sort dict for consistent results across platforms:
|
||||
weights_cache = {key: value for key, value in sorted(weights_cache.items(), key=lambda k: k[0].casefold())}
|
||||
weights_cache = {key: value for key, value in sorted(weights_cache.items())}
|
||||
for filename, yaml_data in weights_cache.items():
|
||||
if filename not in {args.meta_file_path, args.weights_file_path}:
|
||||
for yaml in yaml_data:
|
||||
logging.info(f"P{player_id} Weights: {filename} >> "
|
||||
f"{get_choice('description', yaml, 'No description specified')}")
|
||||
player_files[player_id] = filename
|
||||
player_id += 1
|
||||
for yaml in yaml_data:
|
||||
print(f"P{player_id} Weights: {filename} >> "
|
||||
f"{get_choice('description', yaml, 'No description specified')}")
|
||||
player_files[player_id] = filename
|
||||
player_id += 1
|
||||
|
||||
args.multi = max(player_id - 1, args.multi)
|
||||
|
||||
if args.multi == 0:
|
||||
raise ValueError(
|
||||
"No individual player files found and number of players is 0. "
|
||||
"Provide individual player files or specify the number of players via host.yaml or --multi."
|
||||
)
|
||||
|
||||
logging.info(f"Generating for {args.multi} player{'s' if args.multi > 1 else ''}, "
|
||||
f"{seed_name} Seed {seed} with plando: {args.plando}")
|
||||
print(f"Generating for {args.multi} player{'s' if args.multi > 1 else ''}, {seed_name} Seed {seed} with plando: "
|
||||
f"{args.plando}")
|
||||
|
||||
if not weights_cache:
|
||||
raise Exception(f"No weights found. "
|
||||
f"Provide a general weights file ({args.weights_file_path}) or individual player files. "
|
||||
raise Exception(f"No weights found. Provide a general weights file ({args.weights_file_path}) or individual player files. "
|
||||
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.seed = seed
|
||||
erargs.plando_options = args.plando
|
||||
erargs.glitch_triforce = options["generator"]["glitch_triforce_room"]
|
||||
erargs.spoiler = args.spoiler
|
||||
erargs.race = args.race
|
||||
erargs.outputname = seed_name
|
||||
erargs.outputpath = args.outputpath
|
||||
erargs.skip_prog_balancing = args.skip_prog_balancing
|
||||
erargs.skip_output = args.skip_output
|
||||
erargs.name = {}
|
||||
erargs.csv_output = args.csv_output
|
||||
|
||||
Utils.init_logging(f"Generate_{seed}", loglevel=args.log_level)
|
||||
|
||||
settings_cache: Dict[str, Tuple[argparse.Namespace, ...]] = \
|
||||
{fname: (tuple(roll_settings(yaml, args.plando) for yaml in yamls) if args.sameoptions else None)
|
||||
{fname: (tuple(roll_settings(yaml, args.plando) for yaml in yamls) if args.samesettings else None)
|
||||
for fname, yamls in weights_cache.items()}
|
||||
|
||||
if meta_weights:
|
||||
@@ -180,8 +191,7 @@ def main(args=None) -> Tuple[argparse.Namespace, int]:
|
||||
for yaml in weights_cache[path]:
|
||||
if category_name is None:
|
||||
for category in yaml:
|
||||
if category in AutoWorldRegister.world_types and \
|
||||
key in Options.CommonOptions.type_hints:
|
||||
if category in AutoWorldRegister.world_types and key in Options.common_options:
|
||||
yaml[category][key] = option
|
||||
elif category_name not in yaml:
|
||||
logging.warning(f"Meta: Category {category_name} is not present in {path}.")
|
||||
@@ -192,7 +202,7 @@ def main(args=None) -> Tuple[argparse.Namespace, int]:
|
||||
for player in range(1, args.multi + 1):
|
||||
player_path_cache[player] = player_files.get(player, args.weights_file_path)
|
||||
name_counter = Counter()
|
||||
erargs.player_options = {}
|
||||
erargs.player_settings = {}
|
||||
|
||||
player = 1
|
||||
while player <= args.multi:
|
||||
@@ -213,20 +223,42 @@ 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
|
||||
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] = handle_name(erargs.name[player], player, name_counter)
|
||||
|
||||
|
||||
player += 1
|
||||
except Exception as e:
|
||||
raise ValueError(f"File {path} is invalid. Please fix your yaml.") from e
|
||||
raise ValueError(f"File {path} is destroyed. Please fix your yaml.") from e
|
||||
else:
|
||||
raise RuntimeError(f'No weights specified for player {player}')
|
||||
|
||||
if len(set(name.lower() for name in erargs.name.values())) != len(erargs.name):
|
||||
raise Exception(f"Names have to be unique. Names: {Counter(name.lower() for name in erargs.name.values())}")
|
||||
if len(set(erargs.name.values())) != len(erargs.name):
|
||||
raise Exception(f"Names have to be unique. Names: {Counter(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)
|
||||
|
||||
callback(erargs, seed)
|
||||
|
||||
|
||||
def read_weights_yamls(path) -> Tuple[Any, ...]:
|
||||
@@ -285,84 +317,91 @@ class SafeDict(dict):
|
||||
|
||||
|
||||
def handle_name(name: str, player: int, name_counter: Counter):
|
||||
name_counter[name.lower()] += 1
|
||||
number = name_counter[name.lower()]
|
||||
name_counter[name] += 1
|
||||
new_name = "%".join([x.replace("%number%", "{number}").replace("%player%", "{player}") for x in name.split("%%")])
|
||||
new_name = string.Formatter().vformat(new_name, (), SafeDict(number=number,
|
||||
NUMBER=(number if number > 1 else ''),
|
||||
new_name = string.Formatter().vformat(new_name, (), SafeDict(number=name_counter[name],
|
||||
NUMBER=(name_counter[name] if name_counter[
|
||||
name] > 1 else ''),
|
||||
player=player,
|
||||
PLAYER=(player if player > 1 else '')))
|
||||
# Run .strip twice for edge case where after the initial .slice new_name has a leading whitespace.
|
||||
# Could cause issues for some clients that cannot handle the additional whitespace.
|
||||
new_name = new_name.strip()[:16].strip()
|
||||
new_name = new_name.strip()[:16]
|
||||
if new_name == "Archipelago":
|
||||
raise Exception(f"You cannot name yourself \"{new_name}\"")
|
||||
return new_name
|
||||
|
||||
|
||||
def prefer_int(input_data: str) -> Union[str, int]:
|
||||
try:
|
||||
return int(input_data)
|
||||
except:
|
||||
return input_data
|
||||
|
||||
|
||||
available_boss_names: Set[str] = {boss.lower() for boss in Bosses.boss_table if boss not in
|
||||
{'Agahnim', 'Agahnim2', 'Ganon'}}
|
||||
available_boss_locations: Set[str] = {f"{loc.lower()}{f' {level}' if level else ''}" for loc, level in
|
||||
Bosses.boss_location_table}
|
||||
|
||||
boss_shuffle_options = {None: 'none',
|
||||
'none': 'none',
|
||||
'basic': 'basic',
|
||||
'full': 'full',
|
||||
'chaos': 'chaos',
|
||||
'singularity': 'singularity'
|
||||
}
|
||||
|
||||
goals = {
|
||||
'ganon': 'ganon',
|
||||
'crystals': 'crystals',
|
||||
'bosses': 'bosses',
|
||||
'pedestal': 'pedestal',
|
||||
'ganon_pedestal': 'ganonpedestal',
|
||||
'triforce_hunt': 'triforcehunt',
|
||||
'local_triforce_hunt': 'localtriforcehunt',
|
||||
'ganon_triforce_hunt': 'ganontriforcehunt',
|
||||
'local_ganon_triforce_hunt': 'localganontriforcehunt',
|
||||
'ice_rod_hunt': 'icerodhunt',
|
||||
}
|
||||
|
||||
|
||||
def roll_percentage(percentage: Union[int, float]) -> bool:
|
||||
"""Roll a percentage chance.
|
||||
percentage is expected to be in range [0, 100]"""
|
||||
return random.random() < (float(percentage) / 100)
|
||||
|
||||
|
||||
def update_weights(weights: dict, new_weights: dict, update_type: str, name: str) -> dict:
|
||||
def update_weights(weights: dict, new_weights: dict, type: str, name: str) -> dict:
|
||||
logging.debug(f'Applying {new_weights}')
|
||||
cleaned_weights = {}
|
||||
for option in new_weights:
|
||||
option_name = option.lstrip("+-")
|
||||
if option.startswith("+") and option_name in weights:
|
||||
cleaned_value = weights[option_name]
|
||||
new_value = new_weights[option]
|
||||
if isinstance(new_value, set):
|
||||
cleaned_value.update(new_value)
|
||||
elif isinstance(new_value, list):
|
||||
cleaned_value.extend(new_value)
|
||||
elif isinstance(new_value, dict):
|
||||
cleaned_value = dict(Counter(cleaned_value) + Counter(new_value))
|
||||
else:
|
||||
raise Exception(f"Cannot apply merge to non-dict, set, or list type {option_name},"
|
||||
f" received {type(new_value).__name__}.")
|
||||
cleaned_weights[option_name] = cleaned_value
|
||||
elif option.startswith("-") and option_name in weights:
|
||||
cleaned_value = weights[option_name]
|
||||
new_value = new_weights[option]
|
||||
if isinstance(new_value, set):
|
||||
cleaned_value.difference_update(new_value)
|
||||
elif isinstance(new_value, list):
|
||||
for element in new_value:
|
||||
cleaned_value.remove(element)
|
||||
elif isinstance(new_value, dict):
|
||||
cleaned_value = dict(Counter(cleaned_value) - Counter(new_value))
|
||||
else:
|
||||
raise Exception(f"Cannot apply remove to non-dict, set, or list type {option_name},"
|
||||
f" received {type(new_value).__name__}.")
|
||||
cleaned_weights[option_name] = cleaned_value
|
||||
else:
|
||||
cleaned_weights[option_name] = new_weights[option]
|
||||
new_options = set(cleaned_weights) - set(weights)
|
||||
weights.update(cleaned_weights)
|
||||
new_options = set(new_weights) - set(weights)
|
||||
weights.update(new_weights)
|
||||
if new_options:
|
||||
for new_option in new_options:
|
||||
logging.warning(f'{update_type} Suboption "{new_option}" of "{name}" did not '
|
||||
logging.warning(f'{type} Suboption "{new_option}" of "{name}" did not '
|
||||
f'overwrite a root option. '
|
||||
f'This is probably in error.')
|
||||
return weights
|
||||
|
||||
|
||||
def roll_meta_option(option_key, game: str, category_dict: Dict) -> Any:
|
||||
from worlds import AutoWorldRegister
|
||||
|
||||
if not game:
|
||||
return get_choice(option_key, category_dict)
|
||||
if game in AutoWorldRegister.world_types:
|
||||
game_world = AutoWorldRegister.world_types[game]
|
||||
options = game_world.options_dataclass.type_hints
|
||||
options = ChainMap(game_world.option_definitions, Options.per_game_common_options)
|
||||
if option_key in options:
|
||||
if options[option_key].supports_weighting:
|
||||
return get_choice(option_key, category_dict)
|
||||
return category_dict[option_key]
|
||||
raise Options.OptionError(f"Error generating meta option {option_key} for {game}.")
|
||||
return options[option_key]
|
||||
if game == "A Link to the Past": # TODO wow i hate this
|
||||
if option_key in {"glitches_required", "dark_room_logic", "entrance_shuffle", "goals", "triforce_pieces_mode",
|
||||
"triforce_pieces_percentage", "triforce_pieces_available", "triforce_pieces_extra",
|
||||
"triforce_pieces_required", "shop_shuffle", "mode", "item_pool", "item_functionality",
|
||||
"boss_shuffle", "enemy_damage", "enemy_health", "timer", "countdown_start_time",
|
||||
"red_clock_time", "blue_clock_time", "green_clock_time", "dungeon_counters", "shuffle_prizes",
|
||||
"misery_mire_medallion", "turtle_rock_medallion", "sprite_pool", "sprite",
|
||||
"random_sprite_on_event"}:
|
||||
return get_choice(option_key, category_dict)
|
||||
raise Exception(f"Error generating meta option {option_key} for {game}.")
|
||||
|
||||
|
||||
def roll_linked_options(weights: dict) -> dict:
|
||||
@@ -382,12 +421,12 @@ def roll_linked_options(weights: dict) -> dict:
|
||||
else:
|
||||
logging.debug(f"linked option {option_set['name']} skipped.")
|
||||
except Exception as e:
|
||||
raise ValueError(f"Linked option {option_set['name']} is invalid. "
|
||||
raise ValueError(f"Linked option {option_set['name']} is destroyed. "
|
||||
f"Please fix your linked option.") from e
|
||||
return weights
|
||||
|
||||
|
||||
def roll_triggers(weights: dict, triggers: list, valid_keys: set) -> dict:
|
||||
def roll_triggers(weights: dict, triggers: list) -> dict:
|
||||
weights = copy.deepcopy(weights) # make sure we don't write back to other weights sets in same_settings
|
||||
weights["_Generator_Version"] = Utils.__version__
|
||||
for i, option_set in enumerate(triggers):
|
||||
@@ -410,39 +449,71 @@ def roll_triggers(weights: dict, triggers: list, valid_keys: set) -> dict:
|
||||
if category_name:
|
||||
currently_targeted_weights = currently_targeted_weights[category_name]
|
||||
update_weights(currently_targeted_weights, category_options, "Triggered", option_set["option_name"])
|
||||
valid_keys.add(key)
|
||||
|
||||
except Exception as e:
|
||||
raise ValueError(f"Your trigger number {i + 1} is invalid. "
|
||||
raise ValueError(f"Your trigger number {i + 1} is destroyed. "
|
||||
f"Please fix your triggers.") from e
|
||||
return weights
|
||||
|
||||
|
||||
def handle_option(ret: argparse.Namespace, game_weights: dict, option_key: str, option: type(Options.Option), plando_options: PlandoOptions):
|
||||
try:
|
||||
if option_key in game_weights:
|
||||
def get_plando_bosses(boss_shuffle: str, plando_options: Set[str]) -> str:
|
||||
if boss_shuffle in boss_shuffle_options:
|
||||
return boss_shuffle_options[boss_shuffle]
|
||||
elif PlandoSettings.bosses in plando_options:
|
||||
options = boss_shuffle.lower().split(";")
|
||||
remainder_shuffle = "none" # vanilla
|
||||
bosses = []
|
||||
for boss in options:
|
||||
if boss in boss_shuffle_options:
|
||||
remainder_shuffle = boss_shuffle_options[boss]
|
||||
elif "-" in boss:
|
||||
loc, boss_name = boss.split("-")
|
||||
if boss_name not in available_boss_names:
|
||||
raise ValueError(f"Unknown Boss name {boss_name}")
|
||||
if loc not in available_boss_locations:
|
||||
raise ValueError(f"Unknown Boss Location {loc}")
|
||||
level = ''
|
||||
if loc.split(" ")[-1] in {"top", "middle", "bottom"}:
|
||||
# split off level
|
||||
loc = loc.split(" ")
|
||||
level = f" {loc[-1]}"
|
||||
loc = " ".join(loc[:-1])
|
||||
loc = loc.title().replace("Of", "of")
|
||||
if not Bosses.can_place_boss(boss_name.title(), loc, level):
|
||||
raise ValueError(f"Cannot place {boss_name} at {loc}{level}")
|
||||
bosses.append(boss)
|
||||
elif boss not in available_boss_names:
|
||||
raise ValueError(f"Unknown Boss name or Boss shuffle option {boss}.")
|
||||
else:
|
||||
bosses.append(boss)
|
||||
return ";".join(bosses + [remainder_shuffle])
|
||||
else:
|
||||
raise Exception(f"Boss Shuffle {boss_shuffle} is unknown and boss plando is turned off.")
|
||||
|
||||
|
||||
def handle_option(ret: argparse.Namespace, game_weights: dict, option_key: str, option: type(Options.Option)):
|
||||
if option_key in game_weights:
|
||||
try:
|
||||
if not option.supports_weighting:
|
||||
player_option = option.from_any(game_weights[option_key])
|
||||
else:
|
||||
player_option = option.from_any(get_choice(option_key, game_weights))
|
||||
setattr(ret, option_key, player_option)
|
||||
except Exception as e:
|
||||
raise Exception(f"Error generating option {option_key} in {ret.game}") from e
|
||||
else:
|
||||
player_option = option.from_any(option.default) # call the from_any here to support default "random"
|
||||
setattr(ret, option_key, player_option)
|
||||
except Exception as e:
|
||||
raise Options.OptionError(f"Error generating option {option_key} in {ret.game}") from e
|
||||
if hasattr(player_option, "verify"):
|
||||
player_option.verify(AutoWorldRegister.world_types[ret.game])
|
||||
else:
|
||||
from worlds import AutoWorldRegister
|
||||
player_option.verify(AutoWorldRegister.world_types[ret.game], ret.name, plando_options)
|
||||
setattr(ret, option_key, option(option.default))
|
||||
|
||||
|
||||
def roll_settings(weights: dict, plando_options: PlandoOptions = PlandoOptions.bosses):
|
||||
from worlds import AutoWorldRegister
|
||||
|
||||
def roll_settings(weights: dict, plando_options: PlandoSettings = PlandoSettings.bosses):
|
||||
if "linked_options" in weights:
|
||||
weights = roll_linked_options(weights)
|
||||
|
||||
valid_keys = {"triggers"}
|
||||
if "triggers" in weights:
|
||||
weights = roll_triggers(weights, weights["triggers"], valid_keys)
|
||||
weights = roll_triggers(weights, weights["triggers"])
|
||||
|
||||
requirements = weights.get("requires", {})
|
||||
if requirements:
|
||||
@@ -450,76 +521,183 @@ def roll_settings(weights: dict, plando_options: PlandoOptions = PlandoOptions.b
|
||||
if tuplize_version(version) > version_tuple:
|
||||
raise Exception(f"Settings reports required version of generator is at least {version}, "
|
||||
f"however generator is of version {__version__}")
|
||||
required_plando_options = PlandoOptions.from_option_string(requirements.get("plando", ""))
|
||||
required_plando_options = PlandoSettings.from_option_string(requirements.get("plando", ""))
|
||||
if required_plando_options not in plando_options:
|
||||
if required_plando_options:
|
||||
raise Exception(f"Settings reports required plando module {str(required_plando_options)}, "
|
||||
f"which is not enabled.")
|
||||
|
||||
ret = argparse.Namespace()
|
||||
for option_key in Options.PerGameCommonOptions.type_hints:
|
||||
if option_key in weights and option_key not in Options.CommonOptions.type_hints:
|
||||
for option_key in Options.per_game_common_options:
|
||||
if option_key in weights and option_key not in Options.common_options:
|
||||
raise Exception(f"Option {option_key} has to be in a game's section, not on its own.")
|
||||
|
||||
ret.game = get_choice("game", weights)
|
||||
if not isinstance(ret.game, str):
|
||||
if ret.game is None:
|
||||
raise Exception('"game" not specified')
|
||||
raise Exception(f"Invalid game: {ret.game}")
|
||||
if ret.game not in AutoWorldRegister.world_types:
|
||||
from worlds import failed_world_loads
|
||||
picks = Utils.get_fuzzy_results(ret.game, list(AutoWorldRegister.world_types) + failed_world_loads, limit=1)[0]
|
||||
if picks[0] in failed_world_loads:
|
||||
raise Exception(f"No functional world found to handle game {ret.game}. "
|
||||
f"Did you mean '{picks[0]}' ({picks[1]}% sure)? "
|
||||
f"If so, it appears the world failed to initialize correctly.")
|
||||
raise Exception(f"No world found to handle game {ret.game}. Did you mean '{picks[0]}' ({picks[1]}% sure)? "
|
||||
f"Check your spelling or installation of that world.")
|
||||
|
||||
if ret.game not in weights:
|
||||
raise Exception(f"No game options for selected game \"{ret.game}\" found.")
|
||||
|
||||
world_type = AutoWorldRegister.world_types[ret.game]
|
||||
game_weights = weights[ret.game]
|
||||
|
||||
for weight in chain(game_weights, weights):
|
||||
if weight.startswith("+"):
|
||||
raise Exception(f"Merge tag cannot be used outside of trigger contexts. Found {weight}")
|
||||
if weight.startswith("-"):
|
||||
raise Exception(f"Remove tag cannot be used outside of trigger contexts. Found {weight}")
|
||||
|
||||
if "triggers" in game_weights:
|
||||
weights = roll_triggers(weights, game_weights["triggers"], valid_keys)
|
||||
weights = roll_triggers(weights, game_weights["triggers"])
|
||||
game_weights = weights[ret.game]
|
||||
|
||||
ret.name = get_choice('name', weights)
|
||||
for option_key, option in Options.CommonOptions.type_hints.items():
|
||||
for option_key, option in Options.common_options.items():
|
||||
setattr(ret, option_key, option.from_any(get_choice(option_key, weights, option.default)))
|
||||
|
||||
for option_key, option in world_type.options_dataclass.type_hints.items():
|
||||
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:
|
||||
if option_key in valid_keys:
|
||||
continue
|
||||
logging.warning(f"{option_key} is not a valid option name for {ret.game} and is not present in triggers "
|
||||
f"for player {ret.name}.")
|
||||
if ret.game in AutoWorldRegister.world_types:
|
||||
for option_key, option in world_type.option_definitions.items():
|
||||
handle_option(ret, game_weights, option_key, option)
|
||||
for option_key, option in Options.per_game_common_options.items():
|
||||
# skip setting this option if already set from common_options, defaulting to root option
|
||||
if not (option_key in Options.common_options and option_key not in game_weights):
|
||||
handle_option(ret, game_weights, option_key, option)
|
||||
if PlandoSettings.items in plando_options:
|
||||
ret.plando_items = game_weights.get("plando_items", [])
|
||||
if ret.game == "Minecraft" or ret.game == "Ocarina of Time":
|
||||
# bad hardcoded behavior to make this work for now
|
||||
ret.plando_connections = []
|
||||
if PlandoSettings.connections in plando_options:
|
||||
options = game_weights.get("plando_connections", [])
|
||||
for placement in options:
|
||||
if roll_percentage(get_choice("percentage", placement, 100)):
|
||||
ret.plando_connections.append(PlandoConnection(
|
||||
get_choice("entrance", placement),
|
||||
get_choice("exit", placement),
|
||||
get_choice("direction", placement)
|
||||
))
|
||||
elif ret.game == "A Link to the Past":
|
||||
roll_alttp_settings(ret, game_weights, plando_options)
|
||||
else:
|
||||
raise Exception(f"Unsupported game {ret.game}")
|
||||
|
||||
return ret
|
||||
|
||||
|
||||
def roll_alttp_settings(ret: argparse.Namespace, weights):
|
||||
def roll_alttp_settings(ret: argparse.Namespace, weights, plando_options):
|
||||
if "dungeon_items" in weights and get_choice_legacy('dungeon_items', weights, "none") != "none":
|
||||
raise Exception(f"dungeon_items key in A Link to the Past was removed, but is present in these weights as {get_choice_legacy('dungeon_items', weights, False)}.")
|
||||
glitches_required = get_choice_legacy('glitches_required', weights)
|
||||
if glitches_required not in [None, 'none', 'no_logic', 'overworld_glitches', 'hybrid_major_glitches', 'minor_glitches']:
|
||||
logging.warning("Only NMG, OWG, HMG and No Logic supported")
|
||||
glitches_required = 'none'
|
||||
ret.logic = {None: 'noglitches', 'none': 'noglitches', 'no_logic': 'nologic', 'overworld_glitches': 'owglitches',
|
||||
'minor_glitches': 'minorglitches', 'hybrid_major_glitches': 'hybridglitches'}[
|
||||
glitches_required]
|
||||
|
||||
ret.dark_room_logic = get_choice_legacy("dark_room_logic", weights, "lamp")
|
||||
if not ret.dark_room_logic: # None/False
|
||||
ret.dark_room_logic = "none"
|
||||
if ret.dark_room_logic == "sconces":
|
||||
ret.dark_room_logic = "torches"
|
||||
if ret.dark_room_logic not in {"lamp", "torches", "none"}:
|
||||
raise ValueError(f"Unknown Dark Room Logic: \"{ret.dark_room_logic}\"")
|
||||
|
||||
entrance_shuffle = get_choice_legacy('entrance_shuffle', weights, 'vanilla')
|
||||
if entrance_shuffle.startswith('none-'):
|
||||
ret.shuffle = 'vanilla'
|
||||
else:
|
||||
ret.shuffle = entrance_shuffle if entrance_shuffle != 'none' else 'vanilla'
|
||||
|
||||
goal = get_choice_legacy('goals', weights, 'ganon')
|
||||
|
||||
ret.goal = goals[goal]
|
||||
|
||||
|
||||
extra_pieces = get_choice_legacy('triforce_pieces_mode', weights, 'available')
|
||||
|
||||
ret.triforce_pieces_required = LttPOptions.TriforcePieces.from_any(get_choice_legacy('triforce_pieces_required', weights, 20))
|
||||
|
||||
# sum a percentage to required
|
||||
if extra_pieces == 'percentage':
|
||||
percentage = max(100, float(get_choice_legacy('triforce_pieces_percentage', weights, 150))) / 100
|
||||
ret.triforce_pieces_available = int(round(ret.triforce_pieces_required * percentage, 0))
|
||||
# vanilla mode (specify how many pieces are)
|
||||
elif extra_pieces == 'available':
|
||||
ret.triforce_pieces_available = LttPOptions.TriforcePieces.from_any(
|
||||
get_choice_legacy('triforce_pieces_available', weights, 30))
|
||||
# required pieces + fixed extra
|
||||
elif extra_pieces == 'extra':
|
||||
extra_pieces = max(0, int(get_choice_legacy('triforce_pieces_extra', weights, 10)))
|
||||
ret.triforce_pieces_available = ret.triforce_pieces_required + extra_pieces
|
||||
|
||||
# change minimum to required pieces to avoid problems
|
||||
ret.triforce_pieces_available = min(max(ret.triforce_pieces_required, int(ret.triforce_pieces_available)), 90)
|
||||
|
||||
ret.shop_shuffle = get_choice_legacy('shop_shuffle', weights, '')
|
||||
if not ret.shop_shuffle:
|
||||
ret.shop_shuffle = ''
|
||||
|
||||
ret.mode = get_choice_legacy("mode", weights)
|
||||
|
||||
ret.difficulty = get_choice_legacy('item_pool', weights)
|
||||
|
||||
ret.item_functionality = get_choice_legacy('item_functionality', weights)
|
||||
|
||||
boss_shuffle = get_choice_legacy('boss_shuffle', weights)
|
||||
ret.shufflebosses = get_plando_bosses(boss_shuffle, plando_options)
|
||||
|
||||
ret.enemy_damage = {None: 'default',
|
||||
'default': 'default',
|
||||
'shuffled': 'shuffled',
|
||||
'random': 'chaos', # to be removed
|
||||
'chaos': 'chaos',
|
||||
}[get_choice_legacy('enemy_damage', weights)]
|
||||
|
||||
ret.enemy_health = get_choice_legacy('enemy_health', weights)
|
||||
|
||||
ret.timer = {'none': False,
|
||||
None: False,
|
||||
False: False,
|
||||
'timed': 'timed',
|
||||
'timed_ohko': 'timed-ohko',
|
||||
'ohko': 'ohko',
|
||||
'timed_countdown': 'timed-countdown',
|
||||
'display': 'display'}[get_choice_legacy('timer', weights, False)]
|
||||
|
||||
ret.countdown_start_time = int(get_choice_legacy('countdown_start_time', weights, 10))
|
||||
ret.red_clock_time = int(get_choice_legacy('red_clock_time', weights, -2))
|
||||
ret.blue_clock_time = int(get_choice_legacy('blue_clock_time', weights, 2))
|
||||
ret.green_clock_time = int(get_choice_legacy('green_clock_time', weights, 4))
|
||||
|
||||
ret.dungeon_counters = get_choice_legacy('dungeon_counters', weights, 'default')
|
||||
|
||||
ret.shuffle_prizes = get_choice_legacy('shuffle_prizes', weights, "g")
|
||||
|
||||
ret.required_medallions = [get_choice_legacy("misery_mire_medallion", weights, "random"),
|
||||
get_choice_legacy("turtle_rock_medallion", weights, "random")]
|
||||
|
||||
for index, medallion in enumerate(ret.required_medallions):
|
||||
ret.required_medallions[index] = {"ether": "Ether", "quake": "Quake", "bombos": "Bombos", "random": "random"} \
|
||||
.get(medallion.lower(), None)
|
||||
if not ret.required_medallions[index]:
|
||||
raise Exception(f"unknown Medallion {medallion} for {'misery mire' if index == 0 else 'turtle rock'}")
|
||||
|
||||
ret.plando_texts = {}
|
||||
if PlandoSettings.texts in plando_options:
|
||||
tt = TextTable()
|
||||
tt.removeUnwantedText()
|
||||
options = weights.get("plando_texts", [])
|
||||
for placement in options:
|
||||
if roll_percentage(get_choice_legacy("percentage", placement, 100)):
|
||||
at = str(get_choice_legacy("at", placement))
|
||||
if at not in tt:
|
||||
raise Exception(f"No text target \"{at}\" found.")
|
||||
ret.plando_texts[at] = str(get_choice_legacy("text", placement))
|
||||
|
||||
ret.plando_connections = []
|
||||
if PlandoSettings.connections in plando_options:
|
||||
options = weights.get("plando_connections", [])
|
||||
for placement in options:
|
||||
if roll_percentage(get_choice_legacy("percentage", placement, 100)):
|
||||
ret.plando_connections.append(PlandoConnection(
|
||||
get_choice_legacy("entrance", placement),
|
||||
get_choice_legacy("exit", placement),
|
||||
get_choice_legacy("direction", placement, "both")
|
||||
))
|
||||
|
||||
ret.sprite_pool = weights.get('sprite_pool', [])
|
||||
ret.sprite = get_choice_legacy('sprite', weights, "Link")
|
||||
if 'random_sprite_on_event' in weights:
|
||||
@@ -547,17 +725,6 @@ def roll_alttp_settings(ret: argparse.Namespace, weights):
|
||||
if __name__ == '__main__':
|
||||
import atexit
|
||||
confirmation = atexit.register(input, "Press enter to close.")
|
||||
erargs, seed = main()
|
||||
from Main import main as ERmain
|
||||
multiworld = ERmain(erargs, seed)
|
||||
if __debug__:
|
||||
import gc
|
||||
import sys
|
||||
import weakref
|
||||
weak = weakref.ref(multiworld)
|
||||
del multiworld
|
||||
gc.collect() # need to collect to deref all hard references
|
||||
assert not weak(), f"MultiWorld object was not de-allocated, it's referenced {sys.getrefcount(weak())} times." \
|
||||
" This would be a memory leak."
|
||||
main()
|
||||
# in case of error-free exit should not need confirmation
|
||||
atexit.unregister(confirmation)
|
||||
|
||||
@@ -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()
|
||||
@@ -1,8 +0,0 @@
|
||||
import ModuleUpdate
|
||||
import Utils
|
||||
from worlds.kh2.Client import launch
|
||||
ModuleUpdate.update()
|
||||
|
||||
if __name__ == '__main__':
|
||||
Utils.init_logging("KH2Client", exception_logger="Client")
|
||||
launch()
|
||||
2
LICENSE
@@ -1,7 +1,7 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2017 LLCoolDave
|
||||
Copyright (c) 2025 Berserker66
|
||||
Copyright (c) 2022 Berserker66
|
||||
Copyright (c) 2022 CaitSith2
|
||||
Copyright (c) 2021 LegendaryLinux
|
||||
|
||||
|
||||
413
Launcher.py
@@ -11,33 +11,24 @@ Scroll down to components= to add components to the launcher as well as setup.py
|
||||
|
||||
import argparse
|
||||
import itertools
|
||||
import logging
|
||||
import multiprocessing
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
import urllib.parse
|
||||
import webbrowser
|
||||
from enum import Enum, auto
|
||||
from os.path import isfile
|
||||
from shutil import which
|
||||
from typing import Callable, Optional, Sequence, Tuple, Union
|
||||
from typing import Iterable, Sequence, Callable, Union, Optional
|
||||
|
||||
if __name__ == "__main__":
|
||||
import ModuleUpdate
|
||||
ModuleUpdate.update()
|
||||
|
||||
import settings
|
||||
import Utils
|
||||
from Utils import (init_logging, is_frozen, is_linux, is_macos, is_windows, local_path, messagebox, open_filename,
|
||||
user_path)
|
||||
from worlds.LauncherComponents import Component, components, icon_paths, SuffixIdentifier, Type
|
||||
from Utils import is_frozen, user_path, local_path, init_logging, open_filename, messagebox, \
|
||||
is_windows, is_macos, is_linux
|
||||
|
||||
|
||||
def open_host_yaml():
|
||||
s = settings.get_settings()
|
||||
file = s.filename
|
||||
s.save()
|
||||
assert file, "host.yaml missing"
|
||||
file = user_path('host.yaml')
|
||||
if is_linux:
|
||||
exe = which('sensible-editor') or which('gedit') or \
|
||||
which('xdg-open') or which('gnome-open') or which('kde-open')
|
||||
@@ -46,161 +37,161 @@ def open_host_yaml():
|
||||
exe = which("open")
|
||||
subprocess.Popen([exe, file])
|
||||
else:
|
||||
import webbrowser
|
||||
webbrowser.open(file)
|
||||
|
||||
|
||||
def open_patch():
|
||||
suffixes = []
|
||||
for c in components:
|
||||
if c.type == Type.CLIENT and \
|
||||
isinstance(c.file_identifier, SuffixIdentifier) and \
|
||||
(c.script_name is None or isfile(get_exe(c)[-1])):
|
||||
suffixes += c.file_identifier.suffixes
|
||||
if isfile(get_exe(c)[-1]):
|
||||
suffixes += c.file_identifier.suffixes if c.type == Type.CLIENT and \
|
||||
isinstance(c.file_identifier, SuffixIdentifier) else []
|
||||
try:
|
||||
filename = open_filename("Select patch", (("Patches", suffixes),))
|
||||
filename = open_filename('Select patch', (('Patches', suffixes),))
|
||||
except Exception as e:
|
||||
messagebox("Error", str(e), error=True)
|
||||
messagebox('Error', str(e), error=True)
|
||||
else:
|
||||
file, component = identify(filename)
|
||||
file, _, component = identify(filename)
|
||||
if file and component:
|
||||
exe = get_exe(component)
|
||||
if exe is None or not isfile(exe[-1]):
|
||||
exe = get_exe("Launcher")
|
||||
|
||||
launch([*exe, file], component.cli)
|
||||
|
||||
|
||||
def generate_yamls():
|
||||
from Options import generate_yaml_templates
|
||||
|
||||
target = Utils.user_path("Players", "Templates")
|
||||
generate_yaml_templates(target, False)
|
||||
open_folder(target)
|
||||
launch([*get_exe(component), file], component.cli)
|
||||
|
||||
|
||||
def browse_files():
|
||||
open_folder(user_path())
|
||||
|
||||
|
||||
def open_folder(folder_path):
|
||||
file = user_path()
|
||||
if is_linux:
|
||||
exe = which('xdg-open') or which('gnome-open') or which('kde-open')
|
||||
subprocess.Popen([exe, folder_path])
|
||||
subprocess.Popen([exe, file])
|
||||
elif is_macos:
|
||||
exe = which("open")
|
||||
subprocess.Popen([exe, folder_path])
|
||||
subprocess.Popen([exe, file])
|
||||
else:
|
||||
webbrowser.open(folder_path)
|
||||
import webbrowser
|
||||
webbrowser.open(file)
|
||||
|
||||
|
||||
def update_settings():
|
||||
from settings import get_settings
|
||||
get_settings().save()
|
||||
# noinspection PyArgumentList
|
||||
class Type(Enum):
|
||||
TOOL = auto()
|
||||
FUNC = auto() # not a real component
|
||||
CLIENT = auto()
|
||||
ADJUSTER = auto()
|
||||
|
||||
|
||||
components.extend([
|
||||
class SuffixIdentifier:
|
||||
suffixes: Iterable[str]
|
||||
|
||||
def __init__(self, *args: str):
|
||||
self.suffixes = args
|
||||
|
||||
def __call__(self, path: str):
|
||||
if isinstance(path, str):
|
||||
for suffix in self.suffixes:
|
||||
if path.endswith(suffix):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
class Component:
|
||||
display_name: str
|
||||
type: Optional[Type]
|
||||
script_name: Optional[str]
|
||||
frozen_name: Optional[str]
|
||||
icon: str # just the name, no suffix
|
||||
cli: bool
|
||||
func: Optional[Callable]
|
||||
file_identifier: Optional[Callable[[str], bool]]
|
||||
|
||||
def __init__(self, display_name: str, script_name: Optional[str] = None, frozen_name: Optional[str] = None,
|
||||
cli: bool = False, icon: str = 'icon', component_type: Type = None, func: Optional[Callable] = None,
|
||||
file_identifier: Optional[Callable[[str], bool]] = None):
|
||||
self.display_name = display_name
|
||||
self.script_name = script_name
|
||||
self.frozen_name = frozen_name or f'Archipelago{script_name}' if script_name else None
|
||||
self.icon = icon
|
||||
self.cli = cli
|
||||
self.type = component_type or \
|
||||
None if not display_name else \
|
||||
Type.FUNC if func else \
|
||||
Type.CLIENT if 'Client' in display_name else \
|
||||
Type.ADJUSTER if 'Adjuster' in display_name else Type.TOOL
|
||||
self.func = func
|
||||
self.file_identifier = file_identifier
|
||||
|
||||
def handles_file(self, path: str):
|
||||
return self.file_identifier(path) if self.file_identifier else False
|
||||
|
||||
|
||||
components: Iterable[Component] = (
|
||||
# Launcher
|
||||
Component('', 'Launcher'),
|
||||
# Core
|
||||
Component('Host', 'MultiServer', 'ArchipelagoServer', cli=True,
|
||||
file_identifier=SuffixIdentifier('.archipelago', '.zip')),
|
||||
Component('Generate', 'Generate', cli=True),
|
||||
Component('Text Client', 'CommonClient', 'ArchipelagoTextClient'),
|
||||
# SNI
|
||||
Component('SNI Client', 'SNIClient',
|
||||
file_identifier=SuffixIdentifier('.apz3', '.apm3', '.apsoe', '.aplttp', '.apsm', '.apsmz3', '.apdkc3')),
|
||||
Component('LttP Adjuster', 'LttPAdjuster'),
|
||||
# Factorio
|
||||
Component('Factorio Client', 'FactorioClient'),
|
||||
# Minecraft
|
||||
Component('Minecraft Client', 'MinecraftClient', icon='mcicon', cli=True,
|
||||
file_identifier=SuffixIdentifier('.apmc')),
|
||||
# Ocarina of Time
|
||||
Component('OoT Client', 'OoTClient',
|
||||
file_identifier=SuffixIdentifier('.apz5')),
|
||||
Component('OoT Adjuster', 'OoTAdjuster'),
|
||||
# FF1
|
||||
Component('FF1 Client', 'FF1Client'),
|
||||
# ChecksFinder
|
||||
Component('ChecksFinder Client', 'ChecksFinderClient'),
|
||||
# Starcraft 2
|
||||
Component('Starcraft 2 Client', 'Starcraft2Client'),
|
||||
# Functions
|
||||
Component("Open host.yaml", func=open_host_yaml),
|
||||
Component("Open Patch", func=open_patch),
|
||||
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("Unrated/18+ Discord Server", icon="discord", func=lambda: webbrowser.open("https://discord.gg/fqvNCCRsu4")),
|
||||
Component("Browse Files", func=browse_files),
|
||||
])
|
||||
Component('Open host.yaml', func=open_host_yaml),
|
||||
Component('Open Patch', func=open_patch),
|
||||
Component('Browse Files', func=browse_files),
|
||||
)
|
||||
icon_paths = {
|
||||
'icon': local_path('data', 'icon.ico' if is_windows else 'icon.png'),
|
||||
'mcicon': local_path('data', 'mcicon.ico')
|
||||
}
|
||||
|
||||
|
||||
def handle_uri(path: str, launch_args: Tuple[str, ...]) -> None:
|
||||
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]]:
|
||||
def identify(path: Union[None, str]):
|
||||
if path is None:
|
||||
return None, None
|
||||
return None, None, None
|
||||
for component in components:
|
||||
if component.handles_file(path):
|
||||
return path, component
|
||||
elif path == component.display_name or path == component.script_name:
|
||||
return None, component
|
||||
return None, None
|
||||
return path, component.script_name, component
|
||||
return (None, None, None) if '/' in path or '\\' in path else (None, path, None)
|
||||
|
||||
|
||||
def get_exe(component: Union[str, Component]) -> Optional[Sequence[str]]:
|
||||
if isinstance(component, str):
|
||||
name = component
|
||||
component = None
|
||||
if name.startswith("Archipelago"):
|
||||
if name.startswith('Archipelago'):
|
||||
name = name[11:]
|
||||
if name.endswith(".exe"):
|
||||
if name.endswith('.exe'):
|
||||
name = name[:-4]
|
||||
if name.endswith(".py"):
|
||||
if name.endswith('.py'):
|
||||
name = name[:-3]
|
||||
if not name:
|
||||
return None
|
||||
for c in components:
|
||||
if c.script_name == name or c.frozen_name == f"Archipelago{name}":
|
||||
if c.script_name == name or c.frozen_name == f'Archipelago{name}':
|
||||
component = c
|
||||
break
|
||||
if not component:
|
||||
return None
|
||||
if is_frozen():
|
||||
suffix = ".exe" if is_windows else ""
|
||||
return [local_path(f"{component.frozen_name}{suffix}")] if component.frozen_name else None
|
||||
suffix = '.exe' if is_windows else ''
|
||||
return [local_path(f'{component.frozen_name}{suffix}')]
|
||||
else:
|
||||
return [sys.executable, local_path(f"{component.script_name}.py")] if component.script_name else None
|
||||
return [sys.executable, local_path(f'{component.script_name}.py')]
|
||||
|
||||
|
||||
def launch(exe, in_terminal=False):
|
||||
@@ -220,137 +211,61 @@ def launch(exe, in_terminal=False):
|
||||
subprocess.Popen(exe)
|
||||
|
||||
|
||||
refresh_components: Optional[Callable[[], None]] = None
|
||||
|
||||
|
||||
def run_gui():
|
||||
from kvui import App, ContainerLayout, GridLayout, Button, Label, ScrollBox, Widget, ApAsyncImage
|
||||
from kivy.core.window import Window
|
||||
from kivy.uix.relativelayout import RelativeLayout
|
||||
from kvui import App, ContainerLayout, GridLayout, Button, Label
|
||||
|
||||
class Launcher(App):
|
||||
base_title: str = "Archipelago Launcher"
|
||||
container: ContainerLayout
|
||||
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 and isfile(get_exe(c)[-1])}
|
||||
_clients = {c.display_name: c for c in components if c.type == Type.CLIENT and isfile(get_exe(c)[-1])}
|
||||
_adjusters = {c.display_name: c for c in components if c.type == Type.ADJUSTER and isfile(get_exe(c)[-1])}
|
||||
_funcs = {c.display_name: c for c in components if c.type == Type.FUNC}
|
||||
|
||||
def __init__(self, ctx=None):
|
||||
self.title = self.base_title + " " + Utils.__version__
|
||||
self.title = self.base_title
|
||||
self.ctx = ctx
|
||||
self.icon = r"data/icon.png"
|
||||
super().__init__()
|
||||
|
||||
def _refresh_components(self) -> None:
|
||||
|
||||
def build_button(component: Component) -> Widget:
|
||||
"""
|
||||
Builds a button widget for a given component.
|
||||
|
||||
Args:
|
||||
component (Component): The component associated with the button.
|
||||
|
||||
Returns:
|
||||
None. The button is added to the parent grid layout.
|
||||
|
||||
"""
|
||||
button = Button(text=component.display_name, size_hint_y=None, height=40)
|
||||
button.component = component
|
||||
button.bind(on_release=self.component_action)
|
||||
if component.icon != "icon":
|
||||
image = ApAsyncImage(source=icon_paths[component.icon],
|
||||
size=(38, 38), size_hint=(None, 1), pos=(5, 0))
|
||||
box_layout = RelativeLayout(size_hint_y=None, height=40)
|
||||
box_layout.add_widget(button)
|
||||
box_layout.add_widget(image)
|
||||
return box_layout
|
||||
return button
|
||||
|
||||
# clear before repopulating
|
||||
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(
|
||||
_tools.items(), _miscs.items(), _adjusters.items()
|
||||
), _clients.items()):
|
||||
# column 1
|
||||
if tool:
|
||||
self._tool_layout.layout.add_widget(build_button(tool[1]))
|
||||
# column 2
|
||||
if client:
|
||||
self._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)
|
||||
button_layout = self.grid # make buttons fill the window
|
||||
for (tool, client) in itertools.zip_longest(itertools.chain(
|
||||
self._tools.items(), self._funcs.items(), self._adjusters.items()), self._clients.items()):
|
||||
# column 1
|
||||
if tool:
|
||||
button = Button(text=tool[0])
|
||||
button.component = tool[1]
|
||||
button.bind(on_release=self.component_action)
|
||||
button_layout.add_widget(button)
|
||||
else:
|
||||
button_layout.add_widget(Label())
|
||||
# column 2
|
||||
if client:
|
||||
button = Button(text=client[0])
|
||||
button.component = client[1]
|
||||
button.bind(on_press=self.component_action)
|
||||
button_layout.add_widget(button)
|
||||
else:
|
||||
button_layout.add_widget(Label())
|
||||
|
||||
return self.container
|
||||
|
||||
@staticmethod
|
||||
def component_action(button):
|
||||
if button.component.func:
|
||||
if button.component.type == Type.FUNC:
|
||||
button.component.func()
|
||||
else:
|
||||
launch(get_exe(button.component), button.component.cli)
|
||||
|
||||
def _on_drop_file(self, window: Window, filename: bytes, x: int, y: int) -> None:
|
||||
""" When a patch file is dropped into the window, run the associated component. """
|
||||
file, component = identify(filename.decode())
|
||||
if file and component:
|
||||
run_component(component, file)
|
||||
else:
|
||||
logging.warning(f"unable to identify component for {file}")
|
||||
|
||||
def _stop(self, *largs):
|
||||
# ran into what appears to be https://groups.google.com/g/kivy-users/c/saWDLoYCSZ4 with PyCharm.
|
||||
# Closing the window explicitly cleans it up.
|
||||
self.root_window.close()
|
||||
super()._stop(*largs)
|
||||
|
||||
Launcher().run()
|
||||
|
||||
# avoiding Launcher reference leak
|
||||
# and don't try to do something with widgets after window closed
|
||||
global refresh_components
|
||||
refresh_components = None
|
||||
|
||||
|
||||
def run_component(component: Component, *args):
|
||||
if component.func:
|
||||
component.func(*args)
|
||||
if refresh_components:
|
||||
refresh_components()
|
||||
elif component.script_name:
|
||||
subprocess.run([*get_exe(component.script_name), *args])
|
||||
else:
|
||||
logging.warning(f"Component {component} does not appear to be executable.")
|
||||
|
||||
|
||||
def main(args: Optional[Union[argparse.Namespace, dict]] = None):
|
||||
if isinstance(args, argparse.Namespace):
|
||||
@@ -358,49 +273,25 @@ def main(args: Optional[Union[argparse.Namespace, dict]] = None):
|
||||
elif not args:
|
||||
args = {}
|
||||
|
||||
path = args.get("Patch|Game|Component|url", None)
|
||||
if path is not None:
|
||||
if path.startswith("archipelago://"):
|
||||
handle_uri(path, args.get("args", ()))
|
||||
return
|
||||
file, component = identify(path)
|
||||
if "Patch|Game|Component" in args:
|
||||
file, component, _ = identify(args["Patch|Game|Component"])
|
||||
if file:
|
||||
args['file'] = file
|
||||
if component:
|
||||
args['component'] = component
|
||||
if not component:
|
||||
logging.warning(f"Could not identify Component responsible for {path}")
|
||||
|
||||
if args["update_settings"]:
|
||||
update_settings()
|
||||
if "file" in args:
|
||||
run_component(args["component"], args["file"], *args["args"])
|
||||
elif "component" in args:
|
||||
run_component(args["component"], *args["args"])
|
||||
elif not args["update_settings"]:
|
||||
if 'file' in args:
|
||||
subprocess.run([*get_exe(args['component']), args['file'], *args['args']])
|
||||
elif 'component' in args:
|
||||
subprocess.run([*get_exe(args['component']), *args['args']])
|
||||
else:
|
||||
run_gui()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
init_logging('Launcher')
|
||||
Utils.freeze_support()
|
||||
multiprocessing.set_start_method("spawn") # if launched process uses kivy, fork won't work
|
||||
parser = argparse.ArgumentParser(
|
||||
description='Archipelago Launcher',
|
||||
usage="[-h] [--update_settings] [Patch|Game|Component] [-- component args here]"
|
||||
)
|
||||
run_group = parser.add_argument_group("Run")
|
||||
run_group.add_argument("--update_settings", action="store_true",
|
||||
help="Update host.yaml and exit.")
|
||||
run_group.add_argument("Patch|Game|Component|url", type=str, nargs="?",
|
||||
help="Pass either a patch file, a generated game, the component name to run, or a url to "
|
||||
"connect with.")
|
||||
run_group.add_argument("args", nargs="*",
|
||||
help="Arguments to pass to component.")
|
||||
parser = argparse.ArgumentParser(description='Archipelago Launcher')
|
||||
parser.add_argument('Patch|Game|Component', type=str, nargs='?',
|
||||
help="Pass either a patch file, a generated game or the name of a component to run.")
|
||||
parser.add_argument('args', nargs="*", help="Arguments to pass to component.")
|
||||
main(parser.parse_args())
|
||||
|
||||
from worlds.LauncherComponents import processes
|
||||
for process in processes:
|
||||
# we await all child processes to close before we tear down the process host
|
||||
# this makes it feel like each one is its own program, as the Launcher is closed now
|
||||
process.join()
|
||||
|
||||
@@ -1,710 +0,0 @@
|
||||
import ModuleUpdate
|
||||
ModuleUpdate.update()
|
||||
|
||||
import Utils
|
||||
|
||||
if __name__ == "__main__":
|
||||
Utils.init_logging("LinksAwakeningContext", exception_logger="Client")
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import binascii
|
||||
import colorama
|
||||
import io
|
||||
import os
|
||||
import re
|
||||
import select
|
||||
import shlex
|
||||
import socket
|
||||
import struct
|
||||
import sys
|
||||
import subprocess
|
||||
import time
|
||||
import typing
|
||||
|
||||
|
||||
from CommonClient import (CommonContext, get_base_parser, gui_enabled, logger,
|
||||
server_loop)
|
||||
from NetUtils import ClientStatus
|
||||
from worlds.ladx.Common import BASE_ID as LABaseID
|
||||
from worlds.ladx.GpsTracker import GpsTracker
|
||||
from worlds.ladx.ItemTracker import ItemTracker
|
||||
from worlds.ladx.LADXR.checkMetadata import checkMetadataTable
|
||||
from worlds.ladx.Locations import get_locations_to_id, meta_to_name
|
||||
from worlds.ladx.Tracker import LocationTracker, MagpieBridge
|
||||
|
||||
|
||||
class GameboyException(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class RetroArchDisconnectError(GameboyException):
|
||||
pass
|
||||
|
||||
|
||||
class InvalidEmulatorStateError(GameboyException):
|
||||
pass
|
||||
|
||||
|
||||
class BadRetroArchResponse(GameboyException):
|
||||
pass
|
||||
|
||||
|
||||
def magpie_logo():
|
||||
from kivy.uix.image import CoreImage
|
||||
binary_data = """
|
||||
iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAIAAACQkWg2AAAAAXN
|
||||
SR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA
|
||||
7DAcdvqGQAAADGSURBVDhPhVLBEcIwDHOYhjHCBuXHj2OTbAL8+
|
||||
MEGZIxOQ1CinOOk0Op0bmo7tlXXeR9FJMYDLOD9mwcLjQK7+hSZ
|
||||
wgcWMZJOAGeGKtChNHFL0j+FZD3jSCuo0w7l03wDrWdg00C4/aW
|
||||
eDEYNenuzPOfPspBnxf0kssE80vN0L8361j10P03DK4x6FHabuV
|
||||
ear8fHme+b17rwSjbAXeUMLb+EVTV2QHm46MWQanmnydA98KsVS
|
||||
XkV+qFpGQXrLhT/fqraQeQLuplpNH5g+WkAAAAASUVORK5CYII="""
|
||||
binary_data = base64.b64decode(binary_data)
|
||||
data = io.BytesIO(binary_data)
|
||||
return CoreImage(data, ext="png").texture
|
||||
|
||||
|
||||
class LAClientConstants:
|
||||
# Connector version
|
||||
VERSION = 0x01
|
||||
#
|
||||
# Memory locations of LADXR
|
||||
ROMGameID = 0x0051 # 4 bytes
|
||||
SlotName = 0x0134
|
||||
# Unused
|
||||
# ROMWorldID = 0x0055
|
||||
# ROMConnectorVersion = 0x0056
|
||||
# RO: We should only act if this is higher then 6, as it indicates that the game is running normally
|
||||
wGameplayType = 0xDB95
|
||||
# RO: Starts at 0, increases every time an item is received from the server and processed
|
||||
wLinkSyncSequenceNumber = 0xDDF6
|
||||
wLinkStatusBits = 0xDDF7 # RW:
|
||||
# Bit0: wLinkGive* contains valid data, set from script cleared from ROM.
|
||||
wLinkHealth = 0xDB5A
|
||||
wLinkGiveItem = 0xDDF8 # RW
|
||||
wLinkGiveItemFrom = 0xDDF9 # RW
|
||||
# All of these six bytes are unused, we can repurpose
|
||||
# wLinkSendItemRoomHigh = 0xDDFA # RO
|
||||
# wLinkSendItemRoomLow = 0xDDFB # RO
|
||||
# wLinkSendItemTarget = 0xDDFC # RO
|
||||
# wLinkSendItemItem = 0xDDFD # RO
|
||||
# wLinkSendShopItem = 0xDDFE # RO, which item to send (1 based, order of the shop items)
|
||||
# RO, which player to send to, but it's just the X position of the NPC used, so 0x18 is player 0
|
||||
# wLinkSendShopTarget = 0xDDFF
|
||||
|
||||
|
||||
wRecvIndex = 0xDDFD # Two bytes
|
||||
wCheckAddress = 0xC0FF - 0x4
|
||||
WRamCheckSize = 0x4
|
||||
WRamSafetyValue = bytearray([0]*WRamCheckSize)
|
||||
|
||||
MinGameplayValue = 0x06
|
||||
MaxGameplayValue = 0x1A
|
||||
VictoryGameplayAndSub = 0x0102
|
||||
|
||||
|
||||
class RAGameboy():
|
||||
cache = []
|
||||
cache_start = 0
|
||||
cache_size = 0
|
||||
last_cache_read = None
|
||||
socket = None
|
||||
|
||||
def __init__(self, address, port) -> None:
|
||||
self.address = address
|
||||
self.port = port
|
||||
self.socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
assert (self.socket)
|
||||
self.socket.setblocking(False)
|
||||
|
||||
async def send_command(self, command, timeout=1.0):
|
||||
self.send(f'{command}\n')
|
||||
response_str = await self.async_recv()
|
||||
self.check_command_response(command, response_str)
|
||||
return response_str.rstrip()
|
||||
|
||||
async def get_retroarch_version(self):
|
||||
return await self.send_command("VERSION")
|
||||
|
||||
async def get_retroarch_status(self):
|
||||
return await self.send_command("GET_STATUS")
|
||||
|
||||
def set_cache_limits(self, cache_start, cache_size):
|
||||
self.cache_start = cache_start
|
||||
self.cache_size = cache_size
|
||||
|
||||
def send(self, b):
|
||||
if type(b) is str:
|
||||
b = b.encode('ascii')
|
||||
self.socket.sendto(b, (self.address, self.port))
|
||||
|
||||
def recv(self):
|
||||
select.select([self.socket], [], [])
|
||||
response, _ = self.socket.recvfrom(4096)
|
||||
return response
|
||||
|
||||
async def async_recv(self, timeout=1.0):
|
||||
response = await asyncio.wait_for(asyncio.get_event_loop().sock_recv(self.socket, 4096), timeout)
|
||||
return response
|
||||
|
||||
async def check_safe_gameplay(self, throw=True):
|
||||
async def check_wram():
|
||||
check_values = await self.async_read_memory(LAClientConstants.wCheckAddress, LAClientConstants.WRamCheckSize)
|
||||
|
||||
if check_values != LAClientConstants.WRamSafetyValue:
|
||||
if throw:
|
||||
raise InvalidEmulatorStateError()
|
||||
return False
|
||||
return True
|
||||
|
||||
if not await check_wram():
|
||||
if throw:
|
||||
raise InvalidEmulatorStateError()
|
||||
return False
|
||||
|
||||
gameplay_value = await self.async_read_memory(LAClientConstants.wGameplayType)
|
||||
gameplay_value = gameplay_value[0]
|
||||
# In gameplay or credits
|
||||
if not (LAClientConstants.MinGameplayValue <= gameplay_value <= LAClientConstants.MaxGameplayValue) and gameplay_value != 0x1:
|
||||
if throw:
|
||||
logger.info("invalid emu state")
|
||||
raise InvalidEmulatorStateError()
|
||||
return False
|
||||
if not await check_wram():
|
||||
if throw:
|
||||
raise InvalidEmulatorStateError()
|
||||
return False
|
||||
return True
|
||||
|
||||
# We're sadly unable to update the whole cache at once
|
||||
# as RetroArch only gives back some number of bytes at a time
|
||||
# So instead read as big as chunks at a time as we can manage
|
||||
async def update_cache(self):
|
||||
# First read the safety address - if it's invalid, bail
|
||||
self.cache = []
|
||||
|
||||
if not await self.check_safe_gameplay():
|
||||
return
|
||||
|
||||
cache = []
|
||||
remaining_size = self.cache_size
|
||||
while remaining_size:
|
||||
block = await self.async_read_memory(self.cache_start + len(cache), remaining_size)
|
||||
remaining_size -= len(block)
|
||||
cache += block
|
||||
|
||||
if not await self.check_safe_gameplay():
|
||||
return
|
||||
|
||||
self.cache = cache
|
||||
self.last_cache_read = time.time()
|
||||
|
||||
async def read_memory_cache(self, addresses):
|
||||
# TODO: can we just update once per frame?
|
||||
if not self.last_cache_read or self.last_cache_read + 0.1 < time.time():
|
||||
await self.update_cache()
|
||||
if not self.cache:
|
||||
return None
|
||||
assert (len(self.cache) == self.cache_size)
|
||||
for address in addresses:
|
||||
assert self.cache_start <= address <= self.cache_start + self.cache_size
|
||||
r = {address: self.cache[address - self.cache_start]
|
||||
for address in addresses}
|
||||
return r
|
||||
|
||||
async def async_read_memory_safe(self, address, size=1):
|
||||
# whenever we do a read for a check, we need to make sure that we aren't reading
|
||||
# garbage memory values - we also need to protect against reading a value, then the emulator resetting
|
||||
#
|
||||
# ...actually, we probably _only_ need the post check
|
||||
|
||||
# Check before read
|
||||
if not await self.check_safe_gameplay():
|
||||
return None
|
||||
|
||||
# Do read
|
||||
r = await self.async_read_memory(address, size)
|
||||
|
||||
# Check after read
|
||||
if not await self.check_safe_gameplay():
|
||||
return None
|
||||
|
||||
return r
|
||||
|
||||
def check_command_response(self, command: str, response: bytes):
|
||||
if command == "VERSION":
|
||||
ok = re.match(r"\d+\.\d+\.\d+", response.decode('ascii')) is not None
|
||||
else:
|
||||
ok = response.startswith(command.encode())
|
||||
if not ok:
|
||||
logger.warning(f"Bad response to command {command} - {response}")
|
||||
raise BadRetroArchResponse()
|
||||
|
||||
def read_memory(self, address, size=1):
|
||||
command = "READ_CORE_MEMORY"
|
||||
|
||||
self.send(f'{command} {hex(address)} {size}\n')
|
||||
response = self.recv()
|
||||
|
||||
self.check_command_response(command, response)
|
||||
|
||||
splits = response.decode().split(" ", 2)
|
||||
# Ignore the address for now
|
||||
if splits[2][:2] == "-1":
|
||||
raise BadRetroArchResponse()
|
||||
|
||||
# TODO: check response address, check hex behavior between RA and BH
|
||||
|
||||
return bytearray.fromhex(splits[2])
|
||||
|
||||
async def async_read_memory(self, address, size=1):
|
||||
command = "READ_CORE_MEMORY"
|
||||
|
||||
self.send(f'{command} {hex(address)} {size}\n')
|
||||
response = await self.async_recv()
|
||||
self.check_command_response(command, response)
|
||||
response = response[:-1]
|
||||
splits = response.decode().split(" ", 2)
|
||||
try:
|
||||
response_addr = int(splits[1], 16)
|
||||
except ValueError:
|
||||
raise BadRetroArchResponse()
|
||||
|
||||
if response_addr != address:
|
||||
raise BadRetroArchResponse()
|
||||
|
||||
ret = bytearray.fromhex(splits[2])
|
||||
if len(ret) > size:
|
||||
raise BadRetroArchResponse()
|
||||
return ret
|
||||
|
||||
def write_memory(self, address, bytes):
|
||||
command = "WRITE_CORE_MEMORY"
|
||||
|
||||
self.send(f'{command} {hex(address)} {" ".join(hex(b) for b in bytes)}')
|
||||
select.select([self.socket], [], [])
|
||||
response, _ = self.socket.recvfrom(4096)
|
||||
self.check_command_response(command, response)
|
||||
splits = response.decode().split(" ", 3)
|
||||
|
||||
assert (splits[0] == command)
|
||||
|
||||
if splits[2] == "-1":
|
||||
logger.info(splits[3])
|
||||
|
||||
|
||||
class LinksAwakeningClient():
|
||||
socket = None
|
||||
gameboy = None
|
||||
tracker = None
|
||||
auth = None
|
||||
game_crc = None
|
||||
pending_deathlink = False
|
||||
deathlink_debounce = True
|
||||
recvd_checks = {}
|
||||
retroarch_address = None
|
||||
retroarch_port = None
|
||||
gameboy = None
|
||||
|
||||
def msg(self, m):
|
||||
logger.info(m)
|
||||
s = f"SHOW_MSG {m}\n"
|
||||
self.gameboy.send(s)
|
||||
|
||||
def __init__(self, retroarch_address="127.0.0.1", retroarch_port=55355):
|
||||
self.retroarch_address = retroarch_address
|
||||
self.retroarch_port = retroarch_port
|
||||
pass
|
||||
|
||||
stop_bizhawk_spam = False
|
||||
async def wait_for_retroarch_connection(self):
|
||||
if not self.stop_bizhawk_spam:
|
||||
logger.info("Waiting on connection to Retroarch...")
|
||||
self.stop_bizhawk_spam = True
|
||||
self.gameboy = RAGameboy(self.retroarch_address, self.retroarch_port)
|
||||
|
||||
while True:
|
||||
try:
|
||||
version = await self.gameboy.get_retroarch_version()
|
||||
NO_CONTENT = b"GET_STATUS CONTENTLESS"
|
||||
status = NO_CONTENT
|
||||
core_type = None
|
||||
GAME_BOY = b"game_boy"
|
||||
while status == NO_CONTENT or core_type != GAME_BOY:
|
||||
status = await self.gameboy.get_retroarch_status()
|
||||
if status.count(b" ") < 2:
|
||||
await asyncio.sleep(1.0)
|
||||
continue
|
||||
GET_STATUS, PLAYING, info = status.split(b" ", 2)
|
||||
if status.count(b",") < 2:
|
||||
await asyncio.sleep(1.0)
|
||||
continue
|
||||
core_type, rom_name, self.game_crc = info.split(b",", 2)
|
||||
if core_type != GAME_BOY:
|
||||
logger.info(
|
||||
f"Core type should be '{GAME_BOY}', found {core_type} instead - wrong type of ROM?")
|
||||
await asyncio.sleep(1.0)
|
||||
continue
|
||||
self.stop_bizhawk_spam = False
|
||||
logger.info(f"Connected to Retroarch {version.decode('ascii', errors='replace')} "
|
||||
f"running {rom_name.decode('ascii', errors='replace')}")
|
||||
return
|
||||
except (BlockingIOError, TimeoutError, ConnectionResetError):
|
||||
await asyncio.sleep(1.0)
|
||||
pass
|
||||
|
||||
async def reset_auth(self):
|
||||
auth = binascii.hexlify(await self.gameboy.async_read_memory(0x0134, 12)).decode()
|
||||
self.auth = auth
|
||||
|
||||
async def wait_and_init_tracker(self):
|
||||
await self.wait_for_game_ready()
|
||||
self.tracker = LocationTracker(self.gameboy)
|
||||
self.item_tracker = ItemTracker(self.gameboy)
|
||||
self.gps_tracker = GpsTracker(self.gameboy)
|
||||
|
||||
async def recved_item_from_ap(self, item_id, from_player, next_index):
|
||||
# Don't allow getting an item until you've got your first check
|
||||
if not self.tracker.has_start_item():
|
||||
return
|
||||
|
||||
# Spin until we either:
|
||||
# get an exception from a bad read (emu shut down or reset)
|
||||
# beat the game
|
||||
# the client handles the last pending item
|
||||
status = (await self.gameboy.async_read_memory_safe(LAClientConstants.wLinkStatusBits))[0]
|
||||
while not (await self.is_victory()) and status & 1 == 1:
|
||||
time.sleep(0.1)
|
||||
status = (await self.gameboy.async_read_memory_safe(LAClientConstants.wLinkStatusBits))[0]
|
||||
|
||||
item_id -= LABaseID
|
||||
# The player name table only goes up to 100, so don't go past that
|
||||
# Even if it didn't, the remote player _index_ byte is just a byte, so 255 max
|
||||
if from_player > 100:
|
||||
from_player = 100
|
||||
|
||||
next_index += 1
|
||||
self.gameboy.write_memory(LAClientConstants.wLinkGiveItem, [
|
||||
item_id, from_player])
|
||||
status |= 1
|
||||
status = self.gameboy.write_memory(LAClientConstants.wLinkStatusBits, [status])
|
||||
self.gameboy.write_memory(LAClientConstants.wRecvIndex, struct.pack(">H", next_index))
|
||||
|
||||
should_reset_auth = False
|
||||
async def wait_for_game_ready(self):
|
||||
logger.info("Waiting on game to be in valid state...")
|
||||
while not await self.gameboy.check_safe_gameplay(throw=False):
|
||||
if self.should_reset_auth:
|
||||
self.should_reset_auth = False
|
||||
raise GameboyException("Resetting due to wrong archipelago server")
|
||||
logger.info("Game connection ready!")
|
||||
|
||||
async def is_victory(self):
|
||||
return (await self.gameboy.read_memory_cache([LAClientConstants.wGameplayType]))[LAClientConstants.wGameplayType] == 1
|
||||
|
||||
async def main_tick(self, item_get_cb, win_cb, deathlink_cb):
|
||||
await self.tracker.readChecks(item_get_cb)
|
||||
await self.item_tracker.readItems()
|
||||
await self.gps_tracker.read_location()
|
||||
|
||||
current_health = (await self.gameboy.read_memory_cache([LAClientConstants.wLinkHealth]))[LAClientConstants.wLinkHealth]
|
||||
if self.deathlink_debounce and current_health != 0:
|
||||
self.deathlink_debounce = False
|
||||
elif not self.deathlink_debounce and current_health == 0:
|
||||
# logger.info("YOU DIED.")
|
||||
await deathlink_cb()
|
||||
self.deathlink_debounce = True
|
||||
|
||||
if self.pending_deathlink:
|
||||
logger.info("Got a deathlink")
|
||||
self.gameboy.write_memory(LAClientConstants.wLinkHealth, [0])
|
||||
self.pending_deathlink = False
|
||||
self.deathlink_debounce = True
|
||||
|
||||
if await self.is_victory():
|
||||
await win_cb()
|
||||
|
||||
recv_index = struct.unpack(">H", await self.gameboy.async_read_memory(LAClientConstants.wRecvIndex, 2))[0]
|
||||
|
||||
# Play back one at a time
|
||||
if recv_index in self.recvd_checks:
|
||||
item = self.recvd_checks[recv_index]
|
||||
await self.recved_item_from_ap(item.item, item.player, recv_index)
|
||||
|
||||
|
||||
all_tasks = set()
|
||||
|
||||
def create_task_log_exception(awaitable) -> asyncio.Task:
|
||||
async def _log_exception(awaitable):
|
||||
try:
|
||||
return await awaitable
|
||||
except Exception as e:
|
||||
logger.exception(e)
|
||||
pass
|
||||
finally:
|
||||
all_tasks.remove(task)
|
||||
task = asyncio.create_task(_log_exception(awaitable))
|
||||
all_tasks.add(task)
|
||||
|
||||
|
||||
class LinksAwakeningContext(CommonContext):
|
||||
tags = {"AP"}
|
||||
game = "Links Awakening DX"
|
||||
items_handling = 0b101
|
||||
want_slot_data = True
|
||||
la_task = None
|
||||
client = None
|
||||
# TODO: does this need to re-read on reset?
|
||||
found_checks = []
|
||||
last_resend = time.time()
|
||||
|
||||
magpie_enabled = False
|
||||
magpie = None
|
||||
magpie_task = None
|
||||
won = False
|
||||
|
||||
def __init__(self, server_address: typing.Optional[str], password: typing.Optional[str], magpie: typing.Optional[bool]) -> None:
|
||||
self.client = LinksAwakeningClient()
|
||||
self.slot_data = {}
|
||||
|
||||
if magpie:
|
||||
self.magpie_enabled = True
|
||||
self.magpie = MagpieBridge()
|
||||
super().__init__(server_address, password)
|
||||
|
||||
def run_gui(self) -> None:
|
||||
import webbrowser
|
||||
import kvui
|
||||
from kvui import Button, GameManager
|
||||
from kivy.uix.image import Image
|
||||
|
||||
class LADXManager(GameManager):
|
||||
logging_pairs = [
|
||||
("Client", "Archipelago"),
|
||||
("Tracker", "Tracker"),
|
||||
]
|
||||
base_title = "Archipelago Links Awakening DX Client"
|
||||
|
||||
def build(self):
|
||||
b = super().build()
|
||||
|
||||
if self.ctx.magpie_enabled:
|
||||
button = Button(text="", size=(30, 30), size_hint_x=None,
|
||||
on_press=lambda _: webbrowser.open('https://magpietracker.us/?enable_autotracker=1'))
|
||||
image = Image(size=(16, 16), texture=magpie_logo())
|
||||
button.add_widget(image)
|
||||
|
||||
def set_center(_, center):
|
||||
image.center = center
|
||||
button.bind(center=set_center)
|
||||
|
||||
self.connect_layout.add_widget(button)
|
||||
return b
|
||||
|
||||
self.ui = LADXManager(self)
|
||||
self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI")
|
||||
|
||||
async def send_checks(self):
|
||||
message = [{"cmd": 'LocationChecks', "locations": self.found_checks}]
|
||||
await self.send_msgs(message)
|
||||
|
||||
had_invalid_slot_data = None
|
||||
def event_invalid_slot(self):
|
||||
# The next time we try to connect, reset the game loop for new auth
|
||||
self.had_invalid_slot_data = True
|
||||
self.auth = None
|
||||
# Don't try to autoreconnect, it will just fail
|
||||
self.disconnected_intentionally = True
|
||||
CommonContext.event_invalid_slot(self)
|
||||
|
||||
ENABLE_DEATHLINK = False
|
||||
async def send_deathlink(self):
|
||||
if self.ENABLE_DEATHLINK:
|
||||
message = [{"cmd": 'Deathlink',
|
||||
'time': time.time(),
|
||||
'cause': 'Had a nightmare',
|
||||
# 'source': self.slot_info[self.slot].name,
|
||||
}]
|
||||
await self.send_msgs(message)
|
||||
|
||||
async def send_victory(self):
|
||||
if not self.won:
|
||||
message = [{"cmd": "StatusUpdate",
|
||||
"status": ClientStatus.CLIENT_GOAL}]
|
||||
logger.info("victory!")
|
||||
await self.send_msgs(message)
|
||||
self.won = True
|
||||
|
||||
async def on_deathlink(self, data: typing.Dict[str, typing.Any]) -> None:
|
||||
if self.ENABLE_DEATHLINK:
|
||||
self.client.pending_deathlink = True
|
||||
|
||||
def new_checks(self, item_ids, ladxr_ids):
|
||||
self.found_checks += item_ids
|
||||
create_task_log_exception(self.send_checks())
|
||||
if self.magpie_enabled:
|
||||
create_task_log_exception(self.magpie.send_new_checks(ladxr_ids))
|
||||
|
||||
async def server_auth(self, password_requested: bool = False):
|
||||
if password_requested and not self.password:
|
||||
await super(LinksAwakeningContext, self).server_auth(password_requested)
|
||||
|
||||
if self.had_invalid_slot_data:
|
||||
# We are connecting when previously we had the wrong ROM or server - just in case
|
||||
# re-read the ROM so that if the user had the correct address but wrong ROM, we
|
||||
# allow a successful reconnect
|
||||
self.client.should_reset_auth = True
|
||||
self.had_invalid_slot_data = False
|
||||
|
||||
while self.client.auth == None:
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
# Just return if we're closing
|
||||
if self.exit_event.is_set():
|
||||
return
|
||||
self.auth = self.client.auth
|
||||
await self.send_connect()
|
||||
|
||||
def on_package(self, cmd: str, args: dict):
|
||||
if cmd == "Connected":
|
||||
self.game = self.slot_info[self.slot].game
|
||||
self.slot_data = args.get("slot_data", {})
|
||||
|
||||
# TODO - use watcher_event
|
||||
if cmd == "ReceivedItems":
|
||||
for index, item in enumerate(args["items"], start=args["index"]):
|
||||
self.client.recvd_checks[index] = item
|
||||
|
||||
async def sync(self):
|
||||
sync_msg = [{'cmd': 'Sync'}]
|
||||
await self.send_msgs(sync_msg)
|
||||
|
||||
item_id_lookup = get_locations_to_id()
|
||||
|
||||
async def run_game_loop(self):
|
||||
def on_item_get(ladxr_checks):
|
||||
checks = [self.item_id_lookup[meta_to_name(
|
||||
checkMetadataTable[check.id])] for check in ladxr_checks]
|
||||
self.new_checks(checks, [check.id for check in ladxr_checks])
|
||||
|
||||
async def victory():
|
||||
await self.send_victory()
|
||||
|
||||
async def deathlink():
|
||||
await self.send_deathlink()
|
||||
|
||||
if self.magpie_enabled:
|
||||
self.magpie_task = asyncio.create_task(self.magpie.serve())
|
||||
|
||||
# yield to allow UI to start
|
||||
await asyncio.sleep(0)
|
||||
|
||||
while True:
|
||||
try:
|
||||
# TODO: cancel all client tasks
|
||||
if not self.client.stop_bizhawk_spam:
|
||||
logger.info("(Re)Starting game loop")
|
||||
self.found_checks.clear()
|
||||
# On restart of game loop, clear all checks, just in case we swapped ROMs
|
||||
# this isn't totally neccessary, but is extra safety against cross-ROM contamination
|
||||
self.client.recvd_checks.clear()
|
||||
await self.client.wait_for_retroarch_connection()
|
||||
await self.client.reset_auth()
|
||||
# If we find ourselves with new auth after the reset, reconnect
|
||||
if self.auth and self.client.auth != self.auth:
|
||||
# It would be neat to reconnect here, but connection needs this loop to be running
|
||||
logger.info("Detected new ROM, disconnecting...")
|
||||
await self.disconnect()
|
||||
continue
|
||||
|
||||
if not self.client.recvd_checks:
|
||||
await self.sync()
|
||||
|
||||
await self.client.wait_and_init_tracker()
|
||||
|
||||
while True:
|
||||
await self.client.main_tick(on_item_get, victory, deathlink)
|
||||
await asyncio.sleep(0.1)
|
||||
now = time.time()
|
||||
if self.last_resend + 5.0 < now:
|
||||
self.last_resend = now
|
||||
await self.send_checks()
|
||||
if self.magpie_enabled:
|
||||
try:
|
||||
self.magpie.set_checks(self.client.tracker.all_checks)
|
||||
await self.magpie.set_item_tracker(self.client.item_tracker)
|
||||
await self.magpie.send_gps(self.client.gps_tracker)
|
||||
self.magpie.slot_data = self.slot_data
|
||||
except Exception:
|
||||
# Don't let magpie errors take out the client
|
||||
pass
|
||||
if self.client.should_reset_auth:
|
||||
self.client.should_reset_auth = False
|
||||
raise GameboyException("Resetting due to wrong archipelago server")
|
||||
except (GameboyException, asyncio.TimeoutError, TimeoutError, ConnectionResetError):
|
||||
await asyncio.sleep(1.0)
|
||||
|
||||
def run_game(romfile: str) -> None:
|
||||
auto_start = typing.cast(typing.Union[bool, str],
|
||||
Utils.get_options()["ladx_options"].get("rom_start", True))
|
||||
if auto_start is True:
|
||||
import webbrowser
|
||||
webbrowser.open(romfile)
|
||||
elif isinstance(auto_start, str):
|
||||
args = shlex.split(auto_start)
|
||||
# Specify full path to ROM as we are going to cd in popen
|
||||
full_rom_path = os.path.realpath(romfile)
|
||||
args.append(full_rom_path)
|
||||
try:
|
||||
# set cwd so that paths to lua scripts are always relative to our client
|
||||
if getattr(sys, 'frozen', False):
|
||||
# The application is frozen
|
||||
script_dir = os.path.dirname(sys.executable)
|
||||
else:
|
||||
script_dir = os.path.dirname(os.path.realpath(__file__))
|
||||
|
||||
subprocess.Popen(args, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, cwd=script_dir)
|
||||
except FileNotFoundError:
|
||||
logger.error(f"Couldn't launch ROM, {args[0]} is missing")
|
||||
|
||||
async def main():
|
||||
parser = get_base_parser(description="Link's Awakening Client.")
|
||||
parser.add_argument("--url", help="Archipelago connection url")
|
||||
parser.add_argument("--no-magpie", dest='magpie', default=True, action='store_false', help="Disable magpie bridge")
|
||||
parser.add_argument('diff_file', default="", type=str, nargs="?",
|
||||
help='Path to a .apladx Archipelago Binary Patch file')
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.diff_file:
|
||||
import Patch
|
||||
logger.info("patch file was supplied - creating rom...")
|
||||
meta, rom_file = Patch.create_rom_file(args.diff_file)
|
||||
if "server" in meta and not args.connect:
|
||||
args.connect = meta["server"]
|
||||
logger.info(f"wrote rom file to {rom_file}")
|
||||
|
||||
|
||||
ctx = LinksAwakeningContext(args.connect, args.password, args.magpie)
|
||||
|
||||
ctx.server_task = asyncio.create_task(server_loop(ctx), name="server loop")
|
||||
|
||||
# TODO: nothing about the lambda about has to be in a lambda
|
||||
ctx.la_task = create_task_log_exception(ctx.run_game_loop())
|
||||
if gui_enabled:
|
||||
ctx.run_gui()
|
||||
ctx.run_cli()
|
||||
|
||||
# Down below run_gui so that we get errors out of the process
|
||||
if args.diff_file:
|
||||
run_game(rom_file)
|
||||
|
||||
await ctx.exit_event.wait()
|
||||
await ctx.shutdown()
|
||||
|
||||
if __name__ == '__main__':
|
||||
colorama.init()
|
||||
asyncio.run(main())
|
||||
colorama.deinit()
|
||||
232
LttPAdjuster.py
@@ -14,7 +14,7 @@ import tkinter as tk
|
||||
from argparse import Namespace
|
||||
from concurrent.futures import as_completed, ThreadPoolExecutor
|
||||
from glob import glob
|
||||
from tkinter import Tk, Frame, Label, StringVar, Entry, filedialog, messagebox, Button, Radiobutton, LEFT, X, 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
|
||||
from tkinter.constants import DISABLED, NORMAL
|
||||
from urllib.parse import urlparse
|
||||
@@ -25,23 +25,15 @@ ModuleUpdate.update()
|
||||
|
||||
from worlds.alttp.Rom import Sprite, LocalRom, apply_rom_settings, get_base_rom_bytes
|
||||
from Utils import output_path, local_path, user_path, open_file, get_cert_none_ssl_context, persistent_store, \
|
||||
get_adjuster_settings, get_adjuster_settings_no_defaults, tkinter_center_window, init_logging
|
||||
get_adjuster_settings, tkinter_center_window, init_logging
|
||||
from Patch import GAME_ALTTP
|
||||
|
||||
|
||||
GAME_ALTTP = "A Link to the Past"
|
||||
WINDOW_MIN_HEIGHT = 525
|
||||
WINDOW_MIN_WIDTH = 425
|
||||
|
||||
class AdjusterWorld(object):
|
||||
class AdjusterSubWorld(object):
|
||||
def __init__(self, random):
|
||||
self.random = random
|
||||
|
||||
def __init__(self, sprite_pool):
|
||||
import random
|
||||
self.sprite_pool = {1: sprite_pool}
|
||||
self.per_slot_randoms = {1: random}
|
||||
self.worlds = {1: self.AdjusterSubWorld(random)}
|
||||
self.slot_seeds = {1: random}
|
||||
|
||||
|
||||
class ArgumentDefaultsHelpFormatter(argparse.RawTextHelpFormatter):
|
||||
@@ -49,49 +41,8 @@ class ArgumentDefaultsHelpFormatter(argparse.RawTextHelpFormatter):
|
||||
def _get_help_string(self, action):
|
||||
return textwrap.dedent(action.help)
|
||||
|
||||
# See argparse.BooleanOptionalAction
|
||||
class BooleanOptionalActionWithDisable(argparse.Action):
|
||||
def __init__(self,
|
||||
option_strings,
|
||||
dest,
|
||||
default=None,
|
||||
type=None,
|
||||
choices=None,
|
||||
required=False,
|
||||
help=None,
|
||||
metavar=None):
|
||||
|
||||
_option_strings = []
|
||||
for option_string in option_strings:
|
||||
_option_strings.append(option_string)
|
||||
|
||||
if option_string.startswith('--'):
|
||||
option_string = '--disable' + option_string[2:]
|
||||
_option_strings.append(option_string)
|
||||
|
||||
if help is not None and default is not None:
|
||||
help += " (default: %(default)s)"
|
||||
|
||||
super().__init__(
|
||||
option_strings=_option_strings,
|
||||
dest=dest,
|
||||
nargs=0,
|
||||
default=default,
|
||||
type=type,
|
||||
choices=choices,
|
||||
required=required,
|
||||
help=help,
|
||||
metavar=metavar)
|
||||
|
||||
def __call__(self, parser, namespace, values, option_string=None):
|
||||
if option_string in self.option_strings:
|
||||
setattr(namespace, self.dest, not option_string.startswith('--disable'))
|
||||
|
||||
def format_usage(self):
|
||||
return ' | '.join(self.option_strings)
|
||||
|
||||
|
||||
def get_argparser() -> argparse.ArgumentParser:
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(formatter_class=ArgumentDefaultsHelpFormatter)
|
||||
|
||||
parser.add_argument('rom', nargs="?", default='AP_LttP.sfc', help='Path to an ALttP rom to adjust.')
|
||||
@@ -99,8 +50,6 @@ def get_argparser() -> argparse.ArgumentParser:
|
||||
help='Path to an ALttP Japan(1.0) rom to use as a base.')
|
||||
parser.add_argument('--loglevel', default='info', const='info', nargs='?',
|
||||
choices=['error', 'info', 'warning', 'debug'], help='Select level of logging for output.')
|
||||
parser.add_argument('--auto_apply', default='ask',
|
||||
choices=['ask', 'always', 'never'], help='Whether or not to apply settings automatically in the future.')
|
||||
parser.add_argument('--menuspeed', default='normal', const='normal', nargs='?',
|
||||
choices=['normal', 'instant', 'double', 'triple', 'quadruple', 'half'],
|
||||
help='''\
|
||||
@@ -110,7 +59,7 @@ def get_argparser() -> argparse.ArgumentParser:
|
||||
parser.add_argument('--quickswap', help='Enable quick item swapping with L and R.', action='store_true')
|
||||
parser.add_argument('--deathlink', help='Enable DeathLink system.', action='store_true')
|
||||
parser.add_argument('--allowcollect', help='Allow collection of other player items', action='store_true')
|
||||
parser.add_argument('--music', default=True, help='Enables/Disables game music.', action=BooleanOptionalActionWithDisable)
|
||||
parser.add_argument('--disablemusic', help='Disables game music.', action='store_true')
|
||||
parser.add_argument('--triforcehud', default='hide_goal', const='hide_goal', nargs='?',
|
||||
choices=['normal', 'hide_goal', 'hide_required', 'hide_both'],
|
||||
help='''\
|
||||
@@ -134,6 +83,9 @@ def get_argparser() -> argparse.ArgumentParser:
|
||||
parser.add_argument('--ow_palettes', default='default',
|
||||
choices=['default', 'random', 'blackout', 'puke', 'classic', 'grayscale', 'negative', 'dizzy',
|
||||
'sick'])
|
||||
# parser.add_argument('--link_palettes', default='default',
|
||||
# choices=['default', 'random', 'blackout', 'puke', 'classic', 'grayscale', 'negative', 'dizzy',
|
||||
# 'sick'])
|
||||
parser.add_argument('--shield_palettes', default='default',
|
||||
choices=['default', 'random', 'blackout', 'puke', 'classic', 'grayscale', 'negative', 'dizzy',
|
||||
'sick'])
|
||||
@@ -153,23 +105,10 @@ def get_argparser() -> argparse.ArgumentParser:
|
||||
Alternatively, can be a ALttP Rom patched with a Link
|
||||
sprite that will be extracted.
|
||||
''')
|
||||
parser.add_argument('--sprite_pool', nargs='+', default=[], help='''
|
||||
A list of sprites to pull from.
|
||||
''')
|
||||
parser.add_argument('--oof', help='''\
|
||||
Path to a sound effect to replace Link's "oof" sound.
|
||||
Needs to be in a .brr format and have a length of no
|
||||
more than 2673 bytes, created from a 16-bit signed PCM
|
||||
.wav at 12khz. https://github.com/boldowa/snesbrr
|
||||
''')
|
||||
parser.add_argument('--names', default='', type=str)
|
||||
parser.add_argument('--update_sprites', action='store_true', help='Update Sprite Database, then exit.')
|
||||
return parser
|
||||
|
||||
|
||||
def main():
|
||||
parser = get_argparser()
|
||||
args = parser.parse_args(namespace=get_adjuster_settings_no_defaults(GAME_ALTTP))
|
||||
|
||||
args = parser.parse_args()
|
||||
args.music = not args.disablemusic
|
||||
# set up logger
|
||||
loglevel = {'error': logging.ERROR, 'info': logging.INFO, 'warning': logging.WARNING, 'debug': logging.DEBUG}[
|
||||
args.loglevel]
|
||||
@@ -185,13 +124,6 @@ def main():
|
||||
if args.sprite is not None and not os.path.isfile(args.sprite) and not Sprite.get_sprite_from_name(args.sprite):
|
||||
input('Could not find link sprite sheet at given location. \nPress Enter to exit.')
|
||||
sys.exit(1)
|
||||
if args.oof is not None and not os.path.isfile(args.oof):
|
||||
input('Could not find oof sound effect at given location. \nPress Enter to exit.')
|
||||
sys.exit(1)
|
||||
if args.oof is not None and os.path.getsize(args.oof) > 2673:
|
||||
input('"oof" sound effect cannot exceed 2673 bytes. \nPress Enter to exit.')
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
args, path = adjust(args=args)
|
||||
if isinstance(args.sprite, Sprite):
|
||||
@@ -207,7 +139,7 @@ def adjust(args):
|
||||
vanillaRom = args.baserom
|
||||
if not os.path.exists(vanillaRom) and not os.path.isabs(vanillaRom):
|
||||
vanillaRom = local_path(vanillaRom)
|
||||
if os.path.splitext(args.rom)[-1].lower() == '.aplttp':
|
||||
if os.path.splitext(args.rom)[-1].lower() in {'.apbp', '.aplttp'}:
|
||||
import Patch
|
||||
meta, args.rom = Patch.create_rom_file(args.rom)
|
||||
|
||||
@@ -231,7 +163,7 @@ def adjust(args):
|
||||
world = getattr(args, "world")
|
||||
|
||||
apply_rom_settings(rom, args.heartbeep, args.heartcolor, args.quickswap, args.menuspeed, args.music,
|
||||
args.sprite, args.oof, palettes_options, reduceflashing=args.reduceflashing or racerom, world=world,
|
||||
args.sprite, palettes_options, reduceflashing=args.reduceflashing or racerom, world=world,
|
||||
deathlink=args.deathlink, allowcollect=args.allowcollect)
|
||||
path = output_path(f'{os.path.basename(args.rom)[:-4]}_adjusted.sfc')
|
||||
rom.write_to_file(path)
|
||||
@@ -246,31 +178,30 @@ def adjustGUI():
|
||||
from tkinter import Tk, LEFT, BOTTOM, TOP, \
|
||||
StringVar, Frame, Label, X, Entry, Button, filedialog, messagebox, ttk
|
||||
from argparse import Namespace
|
||||
from Utils import __version__ as MWVersion
|
||||
from Main import __version__ as MWVersion
|
||||
adjustWindow = Tk()
|
||||
adjustWindow.minsize(WINDOW_MIN_WIDTH, WINDOW_MIN_HEIGHT)
|
||||
adjustWindow.wm_title("Archipelago %s LttP Adjuster" % MWVersion)
|
||||
set_icon(adjustWindow)
|
||||
|
||||
rom_options_frame, rom_vars, set_sprite = get_rom_options_frame(adjustWindow)
|
||||
|
||||
bottomFrame2 = Frame(adjustWindow, padx=8, pady=2)
|
||||
bottomFrame2 = 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')
|
||||
romVar2 = StringVar()
|
||||
romEntry2 = Entry(romDialogFrame, textvariable=romVar2)
|
||||
|
||||
def RomSelect2():
|
||||
rom = filedialog.askopenfilename(filetypes=[("Rom Files", (".sfc", ".smc", ".aplttp")), ("All Files", "*")])
|
||||
rom = filedialog.askopenfilename(filetypes=[("Rom Files", (".sfc", ".smc", ".apbp")), ("All Files", "*")])
|
||||
romVar2.set(rom)
|
||||
|
||||
romSelectButton2 = Button(romDialogFrame, text='Select Rom', command=RomSelect2)
|
||||
romDialogFrame.pack(side=TOP, expand=False, fill=X)
|
||||
baseRomLabel2.pack(side=LEFT, expand=False, fill=X, padx=(0, 8))
|
||||
romEntry2.pack(side=LEFT, expand=True, fill=BOTH, pady=1)
|
||||
romDialogFrame.pack(side=TOP, expand=True, fill=X)
|
||||
baseRomLabel2.pack(side=LEFT)
|
||||
romEntry2.pack(side=LEFT, expand=True, fill=X)
|
||||
romSelectButton2.pack(side=LEFT)
|
||||
|
||||
def adjustRom():
|
||||
@@ -294,7 +225,6 @@ def adjustGUI():
|
||||
guiargs.sprite = rom_vars.sprite
|
||||
if rom_vars.sprite_pool:
|
||||
guiargs.world = AdjusterWorld(rom_vars.sprite_pool)
|
||||
guiargs.oof = rom_vars.oof
|
||||
|
||||
try:
|
||||
guiargs, path = adjust(args=guiargs)
|
||||
@@ -333,16 +263,16 @@ def adjustGUI():
|
||||
else:
|
||||
guiargs.sprite = rom_vars.sprite
|
||||
guiargs.sprite_pool = rom_vars.sprite_pool
|
||||
guiargs.oof = rom_vars.oof
|
||||
persistent_store("adjuster", GAME_ALTTP, guiargs)
|
||||
messagebox.showinfo(title="Success", message="Settings saved to persistent storage")
|
||||
|
||||
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))
|
||||
|
||||
saveButton = Button(bottomFrame2, text='Save Settings', command=saveGUISettings)
|
||||
saveButton.pack(side=LEFT, padx=(5,5))
|
||||
|
||||
bottomFrame2.pack(side=TOP, pady=(5,5))
|
||||
|
||||
tkinter_center_window(adjustWindow)
|
||||
@@ -549,40 +479,13 @@ class BackgroundTaskProgressNullWindow(BackgroundTask):
|
||||
self.stop()
|
||||
|
||||
|
||||
class AttachTooltip(object):
|
||||
|
||||
def __init__(self, parent, text):
|
||||
self._parent = parent
|
||||
self._text = text
|
||||
self._window = None
|
||||
parent.bind('<Enter>', lambda event : self.show())
|
||||
parent.bind('<Leave>', lambda event : self.hide())
|
||||
|
||||
def show(self):
|
||||
if self._window or not self._text:
|
||||
return
|
||||
self._window = Toplevel(self._parent)
|
||||
#remove window bar controls
|
||||
self._window.wm_overrideredirect(1)
|
||||
#adjust positioning
|
||||
x, y, *_ = self._parent.bbox("insert")
|
||||
x = x + self._parent.winfo_rootx() + 20
|
||||
y = y + self._parent.winfo_rooty() + 20
|
||||
self._window.wm_geometry("+{0}+{1}".format(x,y))
|
||||
#show text
|
||||
label = Label(self._window, text=self._text, justify=LEFT)
|
||||
label.pack(ipadx=1)
|
||||
|
||||
def hide(self):
|
||||
if self._window:
|
||||
self._window.destroy()
|
||||
self._window = None
|
||||
|
||||
|
||||
def get_rom_frame(parent=None):
|
||||
adjuster_settings = get_adjuster_settings(GAME_ALTTP)
|
||||
if not adjuster_settings:
|
||||
adjuster_settings = Namespace()
|
||||
adjuster_settings.baserom = "Zelda no Densetsu - Kamigami no Triforce (Japan).sfc"
|
||||
|
||||
romFrame = Frame(parent, padx=8, pady=8)
|
||||
romFrame = Frame(parent)
|
||||
baseRomLabel = Label(romFrame, text='LttP Base Rom: ')
|
||||
romVar = StringVar(value=adjuster_settings.baserom)
|
||||
romEntry = Entry(romFrame, textvariable=romVar)
|
||||
@@ -602,19 +505,44 @@ def get_rom_frame(parent=None):
|
||||
romSelectButton = Button(romFrame, text='Select Rom', command=RomSelect)
|
||||
|
||||
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)
|
||||
romFrame.pack(side=TOP, fill=X)
|
||||
romFrame.pack(side=TOP, expand=True, fill=X)
|
||||
|
||||
return romFrame, romVar
|
||||
|
||||
|
||||
def get_rom_options_frame(parent=None):
|
||||
adjuster_settings = get_adjuster_settings(GAME_ALTTP)
|
||||
defaults = {
|
||||
"auto_apply": 'ask',
|
||||
"music": True,
|
||||
"reduceflashing": True,
|
||||
"deathlink": False,
|
||||
"sprite": None,
|
||||
"quickswap": True,
|
||||
"menuspeed": 'normal',
|
||||
"heartcolor": 'red',
|
||||
"heartbeep": 'normal',
|
||||
"ow_palettes": 'default',
|
||||
"uw_palettes": 'default',
|
||||
"hud_palettes": 'default',
|
||||
"sword_palettes": 'default',
|
||||
"shield_palettes": 'default',
|
||||
"sprite_pool": [],
|
||||
"allowcollect": False,
|
||||
}
|
||||
if not adjuster_settings:
|
||||
adjuster_settings = Namespace()
|
||||
for key, defaultvalue in defaults.items():
|
||||
if not hasattr(adjuster_settings, key):
|
||||
setattr(adjuster_settings, key, defaultvalue)
|
||||
|
||||
romOptionsFrame = LabelFrame(parent, text="Rom options", 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):
|
||||
romOptionsFrame.rowconfigure(i, weight=0, pad=4)
|
||||
romOptionsFrame.rowconfigure(i, weight=1)
|
||||
vars = Namespace()
|
||||
|
||||
vars.MusicVar = IntVar()
|
||||
@@ -665,53 +593,15 @@ def get_rom_options_frame(parent=None):
|
||||
spriteSelectButton = Button(spriteDialogFrame, text='...', command=SpriteSelect)
|
||||
|
||||
baseSpriteLabel.pack(side=LEFT)
|
||||
spriteEntry.pack(side=LEFT, expand=True, fill=X)
|
||||
spriteEntry.pack(side=LEFT)
|
||||
spriteSelectButton.pack(side=LEFT)
|
||||
|
||||
oofDialogFrame = Frame(romOptionsFrame)
|
||||
oofDialogFrame.grid(row=1, column=1)
|
||||
baseOofLabel = Label(oofDialogFrame, text='"OOF" Sound:')
|
||||
|
||||
vars.oofNameVar = StringVar()
|
||||
vars.oof = adjuster_settings.oof
|
||||
|
||||
def set_oof(oof_param):
|
||||
nonlocal vars
|
||||
if isinstance(oof_param, str) and os.path.isfile(oof_param) and os.path.getsize(oof_param) <= 2673:
|
||||
vars.oof = oof_param
|
||||
vars.oofNameVar.set(oof_param.rsplit('/',1)[-1])
|
||||
else:
|
||||
vars.oof = None
|
||||
vars.oofNameVar.set('(unchanged)')
|
||||
|
||||
set_oof(adjuster_settings.oof)
|
||||
oofEntry = Label(oofDialogFrame, textvariable=vars.oofNameVar)
|
||||
|
||||
def OofSelect():
|
||||
nonlocal vars
|
||||
oof_file = filedialog.askopenfilename(
|
||||
filetypes=[("BRR files", ".brr"),
|
||||
("All Files", "*")])
|
||||
try:
|
||||
set_oof(oof_file)
|
||||
except Exception:
|
||||
set_oof(None)
|
||||
|
||||
oofSelectButton = Button(oofDialogFrame, text='...', command=OofSelect)
|
||||
AttachTooltip(oofSelectButton,
|
||||
text="Select a .brr file no more than 2673 bytes.\n" + \
|
||||
"This can be created from a <=0.394s 16-bit signed PCM .wav file at 12khz using snesbrr.")
|
||||
|
||||
baseOofLabel.pack(side=LEFT)
|
||||
oofEntry.pack(side=LEFT)
|
||||
oofSelectButton.pack(side=LEFT)
|
||||
|
||||
vars.quickSwapVar = IntVar(value=adjuster_settings.quickswap)
|
||||
quickSwapCheckbutton = Checkbutton(romOptionsFrame, text="L/R Quickswapping", variable=vars.quickSwapVar)
|
||||
quickSwapCheckbutton.grid(row=1, column=0, sticky=E)
|
||||
|
||||
menuspeedFrame = Frame(romOptionsFrame)
|
||||
menuspeedFrame.grid(row=6, column=1, sticky=E)
|
||||
menuspeedFrame.grid(row=1, column=1, sticky=E)
|
||||
menuspeedLabel = Label(menuspeedFrame, text='Menu speed')
|
||||
menuspeedLabel.pack(side=LEFT)
|
||||
vars.menuspeedVar = StringVar()
|
||||
@@ -835,7 +725,7 @@ def get_rom_options_frame(parent=None):
|
||||
vars.auto_apply = StringVar(value=adjuster_settings.auto_apply)
|
||||
autoApplyFrame = Frame(romOptionsFrame)
|
||||
autoApplyFrame.grid(row=9, column=0, columnspan=2, sticky=W)
|
||||
filler = Label(autoApplyFrame, text="Automatically apply last used settings on opening .aplttp files")
|
||||
filler = Label(autoApplyFrame, text="Automatically apply last used settings on opening .apbp files")
|
||||
filler.pack(side=TOP, expand=True, fill=X)
|
||||
askRadio = Radiobutton(autoApplyFrame, text='Ask', variable=vars.auto_apply, value='ask')
|
||||
askRadio.pack(side=LEFT, padx=5, pady=5)
|
||||
@@ -1009,7 +899,6 @@ class SpriteSelector():
|
||||
self.add_to_sprite_pool(sprite)
|
||||
|
||||
def icon_section(self, frame_label, path, no_results_label):
|
||||
os.makedirs(path, exist_ok=True)
|
||||
frame = LabelFrame(self.window, labelwidget=frame_label, padx=5, pady=5)
|
||||
frame.pack(side=TOP, fill=X)
|
||||
|
||||
@@ -1165,6 +1054,7 @@ class SpriteSelector():
|
||||
def custom_sprite_dir(self):
|
||||
return user_path("data", "sprites", "custom")
|
||||
|
||||
|
||||
def get_image_for_sprite(sprite, gif_only: bool = False):
|
||||
if not sprite.valid:
|
||||
return None
|
||||
|
||||
376
MMBN3Client.py
@@ -1,376 +0,0 @@
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import multiprocessing
|
||||
import subprocess
|
||||
import zipfile
|
||||
|
||||
from asyncio import StreamReader, StreamWriter
|
||||
|
||||
import bsdiff4
|
||||
|
||||
from CommonClient import CommonContext, server_loop, gui_enabled, \
|
||||
ClientCommandProcessor, logger, get_base_parser
|
||||
import Utils
|
||||
from NetUtils import ClientStatus
|
||||
from worlds.mmbn3.Items import items_by_id
|
||||
from worlds.mmbn3.Rom import get_base_rom_path
|
||||
from worlds.mmbn3.Locations import all_locations, scoutable_locations
|
||||
|
||||
SYSTEM_MESSAGE_ID = 0
|
||||
|
||||
CONNECTION_TIMING_OUT_STATUS = "Connection timing out. Please restart your emulator, then restart connector_mmbn3.lua"
|
||||
CONNECTION_REFUSED_STATUS = \
|
||||
"Connection refused. Please start your emulator and make sure connector_mmbn3.lua is running"
|
||||
CONNECTION_RESET_STATUS = "Connection was reset. Please restart your emulator, then restart connector_mmbn3.lua"
|
||||
CONNECTION_TENTATIVE_STATUS = "Initial Connection Made"
|
||||
CONNECTION_CONNECTED_STATUS = "Connected"
|
||||
CONNECTION_INITIAL_STATUS = "Connection has not been initiated"
|
||||
CONNECTION_INCORRECT_ROM = "Supplied Base Rom does not match US GBA Blue Version. Please provide the correct ROM version"
|
||||
|
||||
script_version: int = 2
|
||||
|
||||
debugEnabled = False
|
||||
locations_checked = []
|
||||
items_sent = []
|
||||
itemIndex = 1
|
||||
|
||||
CHECKSUM_BLUE = "6fe31df0144759b34ad666badaacc442"
|
||||
|
||||
|
||||
class MMBN3CommandProcessor(ClientCommandProcessor):
|
||||
def __init__(self, ctx):
|
||||
super().__init__(ctx)
|
||||
|
||||
def _cmd_gba(self):
|
||||
"""Check GBA Connection State"""
|
||||
if isinstance(self.ctx, MMBN3Context):
|
||||
logger.info(f"GBA Status: {self.ctx.gba_status}")
|
||||
|
||||
def _cmd_debug(self):
|
||||
"""Toggle the Debug Text overlay in ROM"""
|
||||
global debugEnabled
|
||||
debugEnabled = not debugEnabled
|
||||
logger.info("Debug Overlay Enabled" if debugEnabled else "Debug Overlay Disabled")
|
||||
|
||||
|
||||
class MMBN3Context(CommonContext):
|
||||
command_processor = MMBN3CommandProcessor
|
||||
game = "MegaMan Battle Network 3"
|
||||
items_handling = 0b101 # full local except starting items
|
||||
|
||||
def __init__(self, server_address, password):
|
||||
super().__init__(server_address, password)
|
||||
self.gba_streams: (StreamReader, StreamWriter) = None
|
||||
self.gba_sync_task = None
|
||||
self.gba_status = CONNECTION_INITIAL_STATUS
|
||||
self.awaiting_rom = False
|
||||
self.location_table = {}
|
||||
self.version_warning = False
|
||||
self.auth_name = None
|
||||
self.slot_data = dict()
|
||||
self.patching_error = False
|
||||
self.sent_hints = []
|
||||
|
||||
async def server_auth(self, password_requested: bool = False):
|
||||
if password_requested and not self.password:
|
||||
await super(MMBN3Context, self).server_auth(password_requested)
|
||||
|
||||
if self.auth_name is None:
|
||||
self.awaiting_rom = True
|
||||
logger.info("No ROM detected, awaiting conection to Bizhawk to authenticate to the multiworld server")
|
||||
return
|
||||
|
||||
logger.info("Attempting to decode from ROM... ")
|
||||
self.awaiting_rom = False
|
||||
self.auth = self.auth_name.decode("utf8").replace('\x00', '')
|
||||
logger.info("Connecting as "+self.auth)
|
||||
await self.send_connect(name=self.auth)
|
||||
|
||||
def run_gui(self):
|
||||
from kvui import GameManager
|
||||
|
||||
class MMBN3Manager(GameManager):
|
||||
logging_pairs = [
|
||||
("Client", "Archipelago")
|
||||
]
|
||||
base_title = "Archipelago MegaMan Battle Network 3 Client"
|
||||
|
||||
self.ui = MMBN3Manager(self)
|
||||
self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI")
|
||||
|
||||
def on_package(self, cmd: str, args: dict):
|
||||
if cmd == 'Connected':
|
||||
self.slot_data = args.get("slot_data", {})
|
||||
print(self.slot_data)
|
||||
|
||||
class ItemInfo:
|
||||
id = 0x00
|
||||
sender = ""
|
||||
type = ""
|
||||
count = 1
|
||||
itemName = "Unknown"
|
||||
itemID = 0x00 # Item ID, Chip ID, etc.
|
||||
subItemID = 0x00 # Code for chips, color for programs
|
||||
itemIndex = 1
|
||||
|
||||
def __init__(self, id, sender, type):
|
||||
self.id = id
|
||||
self.sender = sender
|
||||
self.type = type
|
||||
|
||||
def get_json(self):
|
||||
json_data = {
|
||||
"id": self.id,
|
||||
"sender": self.sender,
|
||||
"type": self.type,
|
||||
"itemName": self.itemName,
|
||||
"itemID": self.itemID,
|
||||
"subItemID": self.subItemID,
|
||||
"count": self.count,
|
||||
"itemIndex": self.itemIndex
|
||||
}
|
||||
return json_data
|
||||
|
||||
|
||||
def get_payload(ctx: MMBN3Context):
|
||||
global debugEnabled
|
||||
|
||||
items_sent = []
|
||||
for i, item in enumerate(ctx.items_received):
|
||||
item_data = items_by_id[item.item]
|
||||
new_item = ItemInfo(i, ctx.player_names[item.player], item_data.type)
|
||||
new_item.itemIndex = i+1
|
||||
new_item.itemName = item_data.itemName
|
||||
new_item.type = item_data.type
|
||||
new_item.itemID = item_data.itemID
|
||||
new_item.subItemID = item_data.subItemID
|
||||
new_item.count = item_data.count
|
||||
items_sent.append(new_item)
|
||||
|
||||
return json.dumps({
|
||||
"items": [item.get_json() for item in items_sent],
|
||||
"debug": debugEnabled
|
||||
})
|
||||
|
||||
|
||||
async def parse_payload(payload: dict, ctx: MMBN3Context, force: bool):
|
||||
# Game completion handling
|
||||
if payload["gameComplete"] and not ctx.finished_game:
|
||||
await ctx.send_msgs([{
|
||||
"cmd": "StatusUpdate",
|
||||
"status": ClientStatus.CLIENT_GOAL
|
||||
}])
|
||||
ctx.finished_game = True
|
||||
|
||||
# Locations handling
|
||||
if ctx.location_table != payload["locations"]:
|
||||
ctx.location_table = payload["locations"]
|
||||
locs = [loc.id for loc in all_locations
|
||||
if check_location_packet(loc, ctx.location_table)]
|
||||
await ctx.send_msgs([{
|
||||
"cmd": "LocationChecks",
|
||||
"locations": locs
|
||||
}])
|
||||
|
||||
# If trade hinting is enabled, send scout checks
|
||||
if ctx.slot_data.get("trade_quest_hinting", 0) == 2:
|
||||
trade_bits = [loc.id for loc in scoutable_locations
|
||||
if check_location_scouted(loc, payload["locations"])]
|
||||
scouted_locs = [loc for loc in trade_bits if loc not in ctx.sent_hints]
|
||||
if len(scouted_locs) > 0:
|
||||
ctx.sent_hints.extend(scouted_locs)
|
||||
await ctx.send_msgs([{
|
||||
"cmd": "LocationScouts",
|
||||
"locations": scouted_locs,
|
||||
"create_as_hint": 2
|
||||
}])
|
||||
|
||||
|
||||
def check_location_packet(location, memory):
|
||||
if len(memory) == 0:
|
||||
return False
|
||||
# Our keys have to be strings to come through the JSON lua plugin so we have to turn our memory address into a string as well
|
||||
location_key = hex(location.flag_byte)[2:]
|
||||
byte = memory.get(location_key)
|
||||
if byte is not None:
|
||||
return byte & location.flag_mask
|
||||
|
||||
|
||||
def check_location_scouted(location, memory):
|
||||
if len(memory) == 0:
|
||||
return False
|
||||
location_key = hex(location.hint_flag)[2:]
|
||||
byte = memory.get(location_key)
|
||||
if byte is not None:
|
||||
return byte & location.hint_flag_mask
|
||||
|
||||
|
||||
async def gba_sync_task(ctx: MMBN3Context):
|
||||
logger.info("Starting GBA connector. Use /gba for status information.")
|
||||
if ctx.patching_error:
|
||||
logger.error('Unable to Patch ROM. No ROM provided or ROM does not match US GBA Blue Version.')
|
||||
while not ctx.exit_event.is_set():
|
||||
error_status = None
|
||||
if ctx.gba_streams:
|
||||
(reader, writer) = ctx.gba_streams
|
||||
msg = get_payload(ctx).encode()
|
||||
writer.write(msg)
|
||||
writer.write(b'\n')
|
||||
try:
|
||||
await asyncio.wait_for(writer.drain(), timeout=1.5)
|
||||
try:
|
||||
# Data will return a dict with up to four fields
|
||||
# 1. str: player name (always)
|
||||
# 2. int: script version (always)
|
||||
# 3. dict[str, byte]: value of location's memory byte
|
||||
# 4. bool: whether the game currently registers as complete
|
||||
data = await asyncio.wait_for(reader.readline(), timeout=10)
|
||||
data_decoded = json.loads(data.decode())
|
||||
reported_version = data_decoded.get("scriptVersion", 0)
|
||||
if reported_version >= script_version:
|
||||
if ctx.game is not None and "locations" in data_decoded:
|
||||
# Not just a keep alive ping, parse
|
||||
asyncio.create_task((parse_payload(data_decoded, ctx, False)))
|
||||
if not ctx.auth:
|
||||
ctx.auth_name = bytes(data_decoded["playerName"])
|
||||
|
||||
if ctx.awaiting_rom:
|
||||
logger.info("Awaiting data from ROM...")
|
||||
await ctx.server_auth(False)
|
||||
else:
|
||||
if not ctx.version_warning:
|
||||
logger.warning(f"Your Lua script is version {reported_version}, expected {script_version}."
|
||||
"Please update to the latest version."
|
||||
"Your connection to the Archipelago server will not be accepted.")
|
||||
ctx.version_warning = True
|
||||
except asyncio.TimeoutError:
|
||||
logger.debug("Read Timed Out, Reconnecting")
|
||||
error_status = CONNECTION_TIMING_OUT_STATUS
|
||||
writer.close()
|
||||
ctx.gba_streams = None
|
||||
except ConnectionResetError:
|
||||
logger.debug("Read failed due to Connection Lost, Reconnecting")
|
||||
error_status = CONNECTION_RESET_STATUS
|
||||
writer.close()
|
||||
ctx.gba_streams = None
|
||||
except TimeoutError:
|
||||
logger.debug("Connection Timed Out, Reconnecting")
|
||||
error_status = CONNECTION_TIMING_OUT_STATUS
|
||||
writer.close()
|
||||
ctx.gba_streams = None
|
||||
except ConnectionResetError:
|
||||
logger.debug("Connection Lost, Reconnecting")
|
||||
error_status = CONNECTION_RESET_STATUS
|
||||
writer.close()
|
||||
ctx.gba_streams = None
|
||||
if ctx.gba_status == CONNECTION_TENTATIVE_STATUS:
|
||||
if not error_status:
|
||||
logger.info("Successfully Connected to GBA")
|
||||
ctx.gba_status = CONNECTION_CONNECTED_STATUS
|
||||
else:
|
||||
ctx.gba_status = f"Was tentatively connected but error occurred: {error_status}"
|
||||
elif error_status:
|
||||
ctx.gba_status = error_status
|
||||
logger.info("Lost connection to GBA and attempting to reconnect. Use /gba for status updates")
|
||||
else:
|
||||
try:
|
||||
logger.debug("Attempting to connect to GBA")
|
||||
ctx.gba_streams = await asyncio.wait_for(asyncio.open_connection("localhost", 28922), timeout=10)
|
||||
ctx.gba_status = CONNECTION_TENTATIVE_STATUS
|
||||
except TimeoutError:
|
||||
logger.debug("Connection Timed Out, Trying Again")
|
||||
ctx.gba_status = CONNECTION_TIMING_OUT_STATUS
|
||||
continue
|
||||
except ConnectionRefusedError:
|
||||
logger.debug("Connection Refused, Trying Again")
|
||||
ctx.gba_status = CONNECTION_REFUSED_STATUS
|
||||
continue
|
||||
|
||||
|
||||
async def run_game(romfile):
|
||||
options = Utils.get_options().get("mmbn3_options", None)
|
||||
if options is None:
|
||||
auto_start = True
|
||||
else:
|
||||
auto_start = options.get("rom_start", True)
|
||||
if auto_start:
|
||||
import webbrowser
|
||||
webbrowser.open(romfile)
|
||||
elif os.path.isfile(auto_start):
|
||||
subprocess.Popen([auto_start, romfile],
|
||||
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
|
||||
|
||||
async def patch_and_run_game(apmmbn3_file):
|
||||
base_name = os.path.splitext(apmmbn3_file)[0]
|
||||
|
||||
with zipfile.ZipFile(apmmbn3_file, 'r') as patch_archive:
|
||||
try:
|
||||
with patch_archive.open("delta.bsdiff4", 'r') as stream:
|
||||
patch_data = stream.read()
|
||||
except KeyError:
|
||||
raise FileNotFoundError("Patch file missing from archive.")
|
||||
rom_file = get_base_rom_path()
|
||||
|
||||
with open(rom_file, 'rb') as rom:
|
||||
rom_bytes = rom.read()
|
||||
|
||||
patched_bytes = bsdiff4.patch(rom_bytes, patch_data)
|
||||
patched_rom_file = base_name+".gba"
|
||||
with open(patched_rom_file, 'wb') as patched_rom:
|
||||
patched_rom.write(patched_bytes)
|
||||
|
||||
asyncio.create_task(run_game(patched_rom_file))
|
||||
|
||||
|
||||
def confirm_checksum():
|
||||
rom_file = get_base_rom_path()
|
||||
if not os.path.exists(rom_file):
|
||||
return False
|
||||
|
||||
with open(rom_file, 'rb') as rom:
|
||||
rom_bytes = rom.read()
|
||||
|
||||
basemd5 = hashlib.md5()
|
||||
basemd5.update(rom_bytes)
|
||||
return CHECKSUM_BLUE == basemd5.hexdigest()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
Utils.init_logging("MMBN3Client")
|
||||
|
||||
async def main():
|
||||
multiprocessing.freeze_support()
|
||||
parser = get_base_parser()
|
||||
parser.add_argument("patch_file", default="", type=str, nargs="?",
|
||||
help="Path to an APMMBN3 file")
|
||||
args = parser.parse_args()
|
||||
checksum_matches = confirm_checksum()
|
||||
if checksum_matches:
|
||||
if args.patch_file:
|
||||
asyncio.create_task(patch_and_run_game(args.patch_file))
|
||||
|
||||
ctx = MMBN3Context(args.connect, args.password)
|
||||
if not checksum_matches:
|
||||
ctx.patching_error = True
|
||||
ctx.server_task = asyncio.create_task(server_loop(ctx), name="Server Loop")
|
||||
if gui_enabled:
|
||||
ctx.run_gui()
|
||||
ctx.run_cli()
|
||||
|
||||
ctx.gba_sync_task = asyncio.create_task(gba_sync_task(ctx), name="GBA Sync")
|
||||
await ctx.exit_event.wait()
|
||||
ctx.server_address = None
|
||||
await ctx.shutdown()
|
||||
|
||||
if ctx.gba_sync_task:
|
||||
await ctx.gba_sync_task
|
||||
|
||||
import colorama
|
||||
|
||||
colorama.init()
|
||||
|
||||
asyncio.run(main())
|
||||
colorama.deinit()
|
||||
682
Main.py
@@ -1,328 +1,386 @@
|
||||
import collections
|
||||
import concurrent.futures
|
||||
from itertools import zip_longest, chain
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
import zlib
|
||||
import concurrent.futures
|
||||
import pickle
|
||||
import tempfile
|
||||
import time
|
||||
import zipfile
|
||||
import zlib
|
||||
from typing import Dict, List, Optional, Set, Tuple, Union
|
||||
from typing import Dict, Tuple, Optional, Set
|
||||
|
||||
import worlds
|
||||
from BaseClasses import CollectionState, Item, Location, LocationProgressType, MultiWorld, Region
|
||||
from Fill import FillError, balance_multiworld_progression, distribute_items_restrictive, distribute_planned, \
|
||||
flood_items
|
||||
from Options import StartInventoryPool
|
||||
from Utils import __version__, output_path, version_tuple, get_settings
|
||||
from settings import get_settings
|
||||
from BaseClasses import MultiWorld, CollectionState, Region, RegionType, LocationProgressType, Location
|
||||
from worlds.alttp.Items import item_name_groups
|
||||
from worlds.alttp.Regions import lookup_vanilla_location_to_entrance
|
||||
from Fill import distribute_items_restrictive, flood_items, balance_multiworld_progression, distribute_planned
|
||||
from worlds.alttp.Shops import SHOP_ID_START, total_shop_slots, FillDisabledShopSlots
|
||||
from Utils import output_path, get_options, __version__, version_tuple
|
||||
from worlds.generic.Rules import locality_rules, exclusion_rules, group_locality_rules
|
||||
from worlds import AutoWorld
|
||||
from worlds.generic.Rules import exclusion_rules, locality_rules
|
||||
|
||||
__all__ = ["main"]
|
||||
ordered_areas = (
|
||||
'Light World', 'Dark World', 'Hyrule Castle', 'Agahnims Tower', 'Eastern Palace', 'Desert Palace',
|
||||
'Tower of Hera', 'Palace of Darkness', 'Swamp Palace', 'Skull Woods', 'Thieves Town', 'Ice Palace',
|
||||
'Misery Mire', 'Turtle Rock', 'Ganons Tower', "Total"
|
||||
)
|
||||
|
||||
|
||||
def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = None):
|
||||
if not baked_server_options:
|
||||
baked_server_options = get_settings().server_options.as_dict()
|
||||
assert isinstance(baked_server_options, dict)
|
||||
baked_server_options = get_options()["server_options"]
|
||||
if args.outputpath:
|
||||
os.makedirs(args.outputpath, exist_ok=True)
|
||||
output_path.cached_path = args.outputpath
|
||||
|
||||
start = time.perf_counter()
|
||||
# initialize the multiworld
|
||||
multiworld = MultiWorld(args.multi)
|
||||
# initialize the world
|
||||
world = MultiWorld(args.multi)
|
||||
|
||||
logger = logging.getLogger()
|
||||
multiworld.set_seed(seed, args.race, str(args.outputname) if args.outputname else None)
|
||||
multiworld.plando_options = args.plando_options
|
||||
multiworld.plando_items = args.plando_items.copy()
|
||||
multiworld.plando_texts = args.plando_texts.copy()
|
||||
multiworld.plando_connections = args.plando_connections.copy()
|
||||
multiworld.game = args.game.copy()
|
||||
multiworld.player_name = args.name.copy()
|
||||
multiworld.sprite = args.sprite.copy()
|
||||
multiworld.sprite_pool = args.sprite_pool.copy()
|
||||
world.set_seed(seed, args.race, str(args.outputname if args.outputname else world.seed))
|
||||
|
||||
multiworld.set_options(args)
|
||||
if args.csv_output:
|
||||
from Options import dump_player_options
|
||||
dump_player_options(multiworld)
|
||||
multiworld.set_item_links()
|
||||
multiworld.state = CollectionState(multiworld)
|
||||
logger.info('Archipelago Version %s - Seed: %s\n', __version__, multiworld.seed)
|
||||
world.shuffle = args.shuffle.copy()
|
||||
world.logic = args.logic.copy()
|
||||
world.mode = args.mode.copy()
|
||||
world.difficulty = args.difficulty.copy()
|
||||
world.item_functionality = args.item_functionality.copy()
|
||||
world.timer = args.timer.copy()
|
||||
world.goal = args.goal.copy()
|
||||
world.boss_shuffle = args.shufflebosses.copy()
|
||||
world.enemy_health = args.enemy_health.copy()
|
||||
world.enemy_damage = args.enemy_damage.copy()
|
||||
world.beemizer_total_chance = args.beemizer_total_chance.copy()
|
||||
world.beemizer_trap_chance = args.beemizer_trap_chance.copy()
|
||||
world.timer = args.timer.copy()
|
||||
world.countdown_start_time = args.countdown_start_time.copy()
|
||||
world.red_clock_time = args.red_clock_time.copy()
|
||||
world.blue_clock_time = args.blue_clock_time.copy()
|
||||
world.green_clock_time = args.green_clock_time.copy()
|
||||
world.dungeon_counters = args.dungeon_counters.copy()
|
||||
world.triforce_pieces_available = args.triforce_pieces_available.copy()
|
||||
world.triforce_pieces_required = args.triforce_pieces_required.copy()
|
||||
world.shop_shuffle = args.shop_shuffle.copy()
|
||||
world.shuffle_prizes = args.shuffle_prizes.copy()
|
||||
world.sprite_pool = args.sprite_pool.copy()
|
||||
world.dark_room_logic = args.dark_room_logic.copy()
|
||||
world.plando_items = args.plando_items.copy()
|
||||
world.plando_texts = args.plando_texts.copy()
|
||||
world.plando_connections = args.plando_connections.copy()
|
||||
world.required_medallions = args.required_medallions.copy()
|
||||
world.game = args.game.copy()
|
||||
world.player_name = args.name.copy()
|
||||
world.sprite = args.sprite.copy()
|
||||
world.glitch_triforce = args.glitch_triforce # This is enabled/disabled globally, no per player option.
|
||||
|
||||
logger.info(f"Found {len(AutoWorld.AutoWorldRegister.world_types)} World Types:")
|
||||
world.set_options(args)
|
||||
world.set_item_links()
|
||||
world.state = CollectionState(world)
|
||||
logger.info('Archipelago Version %s - Seed: %s\n', __version__, world.seed)
|
||||
|
||||
logger.info("Found World Types:")
|
||||
longest_name = max(len(text) for text in AutoWorld.AutoWorldRegister.world_types)
|
||||
|
||||
max_item = 0
|
||||
max_location = 0
|
||||
for cls in AutoWorld.AutoWorldRegister.world_types.values():
|
||||
if cls.item_id_to_name:
|
||||
max_item = max(max_item, max(cls.item_id_to_name))
|
||||
max_location = max(max_location, max(cls.location_id_to_name))
|
||||
|
||||
item_digits = len(str(max_item))
|
||||
location_digits = len(str(max_location))
|
||||
item_count = len(str(max(len(cls.item_names) for cls in AutoWorld.AutoWorldRegister.world_types.values())))
|
||||
location_count = len(str(max(len(cls.location_names) for cls in AutoWorld.AutoWorldRegister.world_types.values())))
|
||||
del max_item, max_location
|
||||
|
||||
numlength = 8
|
||||
for name, cls in AutoWorld.AutoWorldRegister.world_types.items():
|
||||
if not cls.hidden and len(cls.item_names) > 0:
|
||||
logger.info(f" {name:{longest_name}}: {len(cls.item_names):{item_count}} "
|
||||
f"Items (IDs: {min(cls.item_id_to_name):{item_digits}} - "
|
||||
f"{max(cls.item_id_to_name):{item_digits}}) | "
|
||||
f"{len(cls.location_names):{location_count}} "
|
||||
f"Locations (IDs: {min(cls.location_id_to_name):{location_digits}} - "
|
||||
f"{max(cls.location_id_to_name):{location_digits}})")
|
||||
if not cls.hidden:
|
||||
logger.info(f" {name:{longest_name}}: {len(cls.item_names):3} "
|
||||
f"Items (IDs: {min(cls.item_id_to_name):{numlength}} - "
|
||||
f"{max(cls.item_id_to_name):{numlength}}) | "
|
||||
f"{len(cls.location_names):3} "
|
||||
f"Locations (IDs: {min(cls.location_id_to_name):{numlength}} - "
|
||||
f"{max(cls.location_id_to_name):{numlength}})")
|
||||
|
||||
del item_digits, location_digits, item_count, location_count
|
||||
AutoWorld.call_stage(world, "assert_generate")
|
||||
|
||||
# This assertion method should not be necessary to run if we are not outputting any multidata.
|
||||
if not args.skip_output:
|
||||
AutoWorld.call_stage(multiworld, "assert_generate")
|
||||
|
||||
AutoWorld.call_all(multiworld, "generate_early")
|
||||
AutoWorld.call_all(world, "generate_early")
|
||||
|
||||
logger.info('')
|
||||
|
||||
for player in multiworld.player_ids:
|
||||
for item_name, count in multiworld.worlds[player].options.start_inventory.value.items():
|
||||
for player in world.player_ids:
|
||||
for item_name, count in world.start_inventory[player].value.items():
|
||||
for _ in range(count):
|
||||
multiworld.push_precollected(multiworld.create_item(item_name, player))
|
||||
world.push_precollected(world.create_item(item_name, player))
|
||||
|
||||
for item_name, count in getattr(multiworld.worlds[player].options,
|
||||
"start_inventory_from_pool",
|
||||
StartInventoryPool({})).value.items():
|
||||
for _ in range(count):
|
||||
multiworld.push_precollected(multiworld.create_item(item_name, player))
|
||||
# remove from_pool items also from early items handling, as starting is plenty early.
|
||||
early = multiworld.early_items[player].get(item_name, 0)
|
||||
if early:
|
||||
multiworld.early_items[player][item_name] = max(0, early-count)
|
||||
remaining_count = count-early
|
||||
if remaining_count > 0:
|
||||
local_early = multiworld.local_early_items[player].get(item_name, 0)
|
||||
if local_early:
|
||||
multiworld.early_items[player][item_name] = max(0, local_early - remaining_count)
|
||||
del local_early
|
||||
del early
|
||||
for player in world.player_ids:
|
||||
if player in world.get_game_players("A Link to the Past"):
|
||||
# enforce pre-defined local items.
|
||||
if world.goal[player] in ["localtriforcehunt", "localganontriforcehunt"]:
|
||||
world.local_items[player].value.add('Triforce Piece')
|
||||
|
||||
logger.info('Creating MultiWorld.')
|
||||
AutoWorld.call_all(multiworld, "create_regions")
|
||||
# Not possible to place pendants/crystals out side of boss prizes yet.
|
||||
world.non_local_items[player].value -= item_name_groups['Pendants']
|
||||
world.non_local_items[player].value -= item_name_groups['Crystals']
|
||||
|
||||
# items can't be both local and non-local, prefer local
|
||||
world.non_local_items[player].value -= world.local_items[player].value
|
||||
|
||||
logger.info('Creating World.')
|
||||
AutoWorld.call_all(world, "create_regions")
|
||||
|
||||
logger.info('Creating Items.')
|
||||
AutoWorld.call_all(multiworld, "create_items")
|
||||
AutoWorld.call_all(world, "create_items")
|
||||
|
||||
logger.info('Calculating Access Rules.')
|
||||
|
||||
for player in multiworld.player_ids:
|
||||
# items can't be both local and non-local, prefer local
|
||||
multiworld.worlds[player].options.non_local_items.value -= multiworld.worlds[player].options.local_items.value
|
||||
multiworld.worlds[player].options.non_local_items.value -= set(multiworld.local_early_items[player])
|
||||
|
||||
AutoWorld.call_all(multiworld, "set_rules")
|
||||
|
||||
for player in multiworld.player_ids:
|
||||
exclusion_rules(multiworld, player, multiworld.worlds[player].options.exclude_locations.value)
|
||||
multiworld.worlds[player].options.priority_locations.value -= multiworld.worlds[player].options.exclude_locations.value
|
||||
world_excluded_locations = set()
|
||||
for location_name in multiworld.worlds[player].options.priority_locations.value:
|
||||
try:
|
||||
location = multiworld.get_location(location_name, player)
|
||||
except KeyError:
|
||||
continue
|
||||
|
||||
if location.progress_type != LocationProgressType.EXCLUDED:
|
||||
location.progress_type = LocationProgressType.PRIORITY
|
||||
else:
|
||||
logger.warning(f"Unable to prioritize location \"{location_name}\" in player {player}'s world because the world excluded it.")
|
||||
world_excluded_locations.add(location_name)
|
||||
multiworld.worlds[player].options.priority_locations.value -= world_excluded_locations
|
||||
|
||||
# Set local and non-local item rules.
|
||||
if multiworld.players > 1:
|
||||
locality_rules(multiworld)
|
||||
if world.players > 1:
|
||||
for player in world.player_ids:
|
||||
locality_rules(world, player)
|
||||
group_locality_rules(world)
|
||||
else:
|
||||
multiworld.worlds[1].options.non_local_items.value = set()
|
||||
multiworld.worlds[1].options.local_items.value = set()
|
||||
world.non_local_items[1].value = set()
|
||||
world.local_items[1].value = set()
|
||||
|
||||
AutoWorld.call_all(multiworld, "connect_entrances")
|
||||
AutoWorld.call_all(multiworld, "generate_basic")
|
||||
AutoWorld.call_all(world, "set_rules")
|
||||
|
||||
# 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.
|
||||
fallback_inventory = StartInventoryPool({})
|
||||
depletion_pool: Dict[int, Dict[str, int]] = {
|
||||
player: getattr(multiworld.worlds[player].options, "start_inventory_from_pool", fallback_inventory).value.copy()
|
||||
for player in multiworld.player_ids
|
||||
}
|
||||
target_per_player = {
|
||||
player: sum(target_items.values()) for player, target_items in depletion_pool.items() if target_items
|
||||
}
|
||||
for player in world.player_ids:
|
||||
exclusion_rules(world, player, world.exclude_locations[player].value)
|
||||
world.priority_locations[player].value -= world.exclude_locations[player].value
|
||||
for location_name in world.priority_locations[player].value:
|
||||
world.get_location(location_name, player).progress_type = LocationProgressType.PRIORITY
|
||||
|
||||
if target_per_player:
|
||||
new_itempool: List[Item] = []
|
||||
AutoWorld.call_all(world, "generate_basic")
|
||||
|
||||
# Make new itempool with start_inventory_from_pool items removed
|
||||
for item in multiworld.itempool:
|
||||
if depletion_pool[item.player].get(item.name, 0):
|
||||
depletion_pool[item.player][item.name] -= 1
|
||||
# temporary home for item links, should be moved out of Main
|
||||
for group_id, group in world.groups.items():
|
||||
def find_common_pool(players: Set[int], shared_pool: Set[str]):
|
||||
classifications = collections.defaultdict(int)
|
||||
counters = {player: {name: 0 for name in shared_pool} for player in players}
|
||||
for item in world.itempool:
|
||||
if item.player in counters and item.name in shared_pool:
|
||||
counters[item.player][item.name] += 1
|
||||
classifications[item.name] |= item.classification
|
||||
|
||||
for player in players.copy():
|
||||
if all([counters[player][item] == 0 for item in shared_pool]):
|
||||
players.remove(player)
|
||||
del(counters[player])
|
||||
|
||||
if not players:
|
||||
return None, None
|
||||
|
||||
for item in shared_pool:
|
||||
count = min(counters[player][item] for player in players)
|
||||
if count:
|
||||
for player in players:
|
||||
counters[player][item] = count
|
||||
else:
|
||||
for player in players:
|
||||
del(counters[player][item])
|
||||
return counters, classifications
|
||||
|
||||
common_item_count, classifications = find_common_pool(group["players"], group["item_pool"])
|
||||
if not common_item_count:
|
||||
continue
|
||||
|
||||
new_itempool = []
|
||||
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", RegionType.Generic, "ItemLink", group_id, world)
|
||||
world.regions.append(region)
|
||||
locations = region.locations = []
|
||||
for item in world.itempool:
|
||||
count = common_item_count.get(item.player, {}).get(item.name, 0)
|
||||
if count:
|
||||
loc = Location(group_id, f"Item Link: {item.name} -> {world.player_name[item.player]} {count}",
|
||||
None, region)
|
||||
loc.access_rule = lambda state, item_name = item.name, group_id_ = group_id, count_ = count: \
|
||||
state.has(item_name, group_id_, count_)
|
||||
|
||||
locations.append(loc)
|
||||
loc.place_locked_item(item)
|
||||
common_item_count[item.player][item.name] -= 1
|
||||
else:
|
||||
new_itempool.append(item)
|
||||
|
||||
# Create filler in place of the removed items, warn if any items couldn't be found in the multiworld itempool
|
||||
for player, target in target_per_player.items():
|
||||
unfound_items = {item: count for item, count in depletion_pool[player].items() if count}
|
||||
itemcount = len(world.itempool)
|
||||
world.itempool = new_itempool
|
||||
|
||||
if unfound_items:
|
||||
player_name = multiworld.get_player_name(player)
|
||||
logger.warning(f"{player_name} tried to remove items from their pool that don't exist: {unfound_items}")
|
||||
while itemcount > len(world.itempool):
|
||||
items_to_add = []
|
||||
for player in group["players"]:
|
||||
if group["replacement_items"][player]:
|
||||
items_to_add.append(AutoWorld.call_single(world, "create_item", player,
|
||||
group["replacement_items"][player]))
|
||||
else:
|
||||
items_to_add.append(AutoWorld.call_single(world, "create_filler", player))
|
||||
world.random.shuffle(items_to_add)
|
||||
world.itempool.extend(items_to_add[:itemcount - len(world.itempool)])
|
||||
|
||||
needed_items = target_per_player[player] - sum(unfound_items.values())
|
||||
new_itempool += [multiworld.worlds[player].create_filler() for _ in range(needed_items)]
|
||||
if any(world.item_links.values()):
|
||||
world._recache()
|
||||
world._all_state = None
|
||||
|
||||
assert len(multiworld.itempool) == len(new_itempool), "Item Pool amounts should not change."
|
||||
multiworld.itempool[:] = new_itempool
|
||||
logger.info("Running Item Plando")
|
||||
|
||||
multiworld.link_items()
|
||||
|
||||
if any(multiworld.item_links.values()):
|
||||
multiworld._all_state = None
|
||||
|
||||
logger.info("Running Item Plando.")
|
||||
|
||||
distribute_planned(multiworld)
|
||||
distribute_planned(world)
|
||||
|
||||
logger.info('Running Pre Main Fill.')
|
||||
|
||||
AutoWorld.call_all(multiworld, "pre_fill")
|
||||
AutoWorld.call_all(world, "pre_fill")
|
||||
|
||||
logger.info(f'Filling the multiworld with {len(multiworld.itempool)} items.')
|
||||
logger.info(f'Filling the world with {len(world.itempool)} items.')
|
||||
|
||||
if multiworld.algorithm == 'flood':
|
||||
flood_items(multiworld) # different algo, biased towards early game progress items
|
||||
elif multiworld.algorithm == 'balanced':
|
||||
distribute_items_restrictive(multiworld, get_settings().generator.panic_method)
|
||||
if world.algorithm == 'flood':
|
||||
flood_items(world) # different algo, biased towards early game progress items
|
||||
elif world.algorithm == 'balanced':
|
||||
distribute_items_restrictive(world)
|
||||
|
||||
AutoWorld.call_all(multiworld, 'post_fill')
|
||||
AutoWorld.call_all(world, 'post_fill')
|
||||
|
||||
if multiworld.players > 1 and not args.skip_prog_balancing:
|
||||
balance_multiworld_progression(multiworld)
|
||||
else:
|
||||
logger.info("Progression balancing skipped.")
|
||||
|
||||
# we're about to output using multithreading, so we're removing the global random state to prevent accidental use
|
||||
multiworld.random.passthrough = False
|
||||
|
||||
if args.skip_output:
|
||||
logger.info('Done. Skipped output/spoiler generation. Total Time: %s', time.perf_counter() - start)
|
||||
return multiworld
|
||||
if world.players > 1:
|
||||
balance_multiworld_progression(world)
|
||||
|
||||
logger.info(f'Beginning output...')
|
||||
outfilebase = 'AP_' + multiworld.seed_name
|
||||
outfilebase = 'AP_' + world.seed_name
|
||||
|
||||
output = tempfile.TemporaryDirectory()
|
||||
with output as temp_dir:
|
||||
output_players = [player for player in multiworld.player_ids if AutoWorld.World.generate_output.__code__
|
||||
is not multiworld.worlds[player].generate_output.__code__]
|
||||
with concurrent.futures.ThreadPoolExecutor(len(output_players) + 2) as pool:
|
||||
check_accessibility_task = pool.submit(multiworld.fulfills_accessibility)
|
||||
with concurrent.futures.ThreadPoolExecutor(world.players + 2) as pool:
|
||||
check_accessibility_task = pool.submit(world.fulfills_accessibility)
|
||||
|
||||
output_file_futures = [pool.submit(AutoWorld.call_stage, multiworld, "generate_output", temp_dir)]
|
||||
for player in output_players:
|
||||
output_file_futures = [pool.submit(AutoWorld.call_stage, world, "generate_output", temp_dir)]
|
||||
for player in world.player_ids:
|
||||
# skip starting a thread for methods that say "pass".
|
||||
output_file_futures.append(
|
||||
pool.submit(AutoWorld.call_single, multiworld, "generate_output", player, temp_dir))
|
||||
if AutoWorld.World.generate_output.__code__ is not world.worlds[player].generate_output.__code__:
|
||||
output_file_futures.append(
|
||||
pool.submit(AutoWorld.call_single, world, "generate_output", player, temp_dir))
|
||||
|
||||
def get_entrance_to_region(region: Region):
|
||||
for entrance in region.entrances:
|
||||
if entrance.parent_region.type in (RegionType.DarkWorld, RegionType.LightWorld, RegionType.Generic):
|
||||
return entrance
|
||||
for entrance in region.entrances: # BFS might be better here, trying DFS for now.
|
||||
return get_entrance_to_region(entrance.parent_region)
|
||||
|
||||
# collect ER hint info
|
||||
er_hint_data: Dict[int, Dict[int, str]] = {}
|
||||
AutoWorld.call_all(multiworld, 'extend_hint_information', er_hint_data)
|
||||
er_hint_data = {player: {} for player in world.get_game_players("A Link to the Past") if
|
||||
world.shuffle[player] != "vanilla" or world.retro_caves[player]}
|
||||
|
||||
for region in world.regions:
|
||||
if region.player in er_hint_data and region.locations:
|
||||
main_entrance = get_entrance_to_region(region)
|
||||
for location in region.locations:
|
||||
if type(location.address) == int: # skips events and crystals
|
||||
if lookup_vanilla_location_to_entrance[location.address] != main_entrance.name:
|
||||
er_hint_data[region.player][location.address] = main_entrance.name
|
||||
|
||||
checks_in_area = {player: {area: list() for area in ordered_areas}
|
||||
for player in range(1, world.players + 1)}
|
||||
|
||||
for player in range(1, world.players + 1):
|
||||
checks_in_area[player]["Total"] = 0
|
||||
|
||||
for location in world.get_filled_locations():
|
||||
if type(location.address) is int:
|
||||
main_entrance = get_entrance_to_region(location.parent_region)
|
||||
if location.game != "A Link to the Past":
|
||||
checks_in_area[location.player]["Light World"].append(location.address)
|
||||
elif location.parent_region.dungeon:
|
||||
dungeonname = {'Inverted Agahnims Tower': 'Agahnims Tower',
|
||||
'Inverted Ganons Tower': 'Ganons Tower'} \
|
||||
.get(location.parent_region.dungeon.name, location.parent_region.dungeon.name)
|
||||
checks_in_area[location.player][dungeonname].append(location.address)
|
||||
elif location.parent_region.type == RegionType.LightWorld:
|
||||
checks_in_area[location.player]["Light World"].append(location.address)
|
||||
elif location.parent_region.type == RegionType.DarkWorld:
|
||||
checks_in_area[location.player]["Dark World"].append(location.address)
|
||||
elif main_entrance.parent_region.type == RegionType.LightWorld:
|
||||
checks_in_area[location.player]["Light World"].append(location.address)
|
||||
elif main_entrance.parent_region.type == RegionType.DarkWorld:
|
||||
checks_in_area[location.player]["Dark World"].append(location.address)
|
||||
checks_in_area[location.player]["Total"] += 1
|
||||
|
||||
oldmancaves = []
|
||||
takeanyregions = ["Old Man Sword Cave", "Take-Any #1", "Take-Any #2", "Take-Any #3", "Take-Any #4"]
|
||||
for index, take_any in enumerate(takeanyregions):
|
||||
for region in [world.get_region(take_any, player) for player in
|
||||
world.get_game_players("A Link to the Past") if world.retro_caves[player]]:
|
||||
item = world.create_item(
|
||||
region.shop.inventory[(0 if take_any == "Old Man Sword Cave" else 1)]['item'],
|
||||
region.player)
|
||||
player = region.player
|
||||
location_id = SHOP_ID_START + total_shop_slots + index
|
||||
|
||||
main_entrance = get_entrance_to_region(region)
|
||||
if main_entrance.parent_region.type == RegionType.LightWorld:
|
||||
checks_in_area[player]["Light World"].append(location_id)
|
||||
else:
|
||||
checks_in_area[player]["Dark World"].append(location_id)
|
||||
checks_in_area[player]["Total"] += 1
|
||||
|
||||
er_hint_data[player][location_id] = main_entrance.name
|
||||
oldmancaves.append(((location_id, player), (item.code, player)))
|
||||
|
||||
FillDisabledShopSlots(world)
|
||||
|
||||
def write_multidata():
|
||||
import NetUtils
|
||||
from NetUtils import HintStatus
|
||||
slot_data = {}
|
||||
client_versions = {}
|
||||
games = {}
|
||||
minimum_versions = {"server": AutoWorld.World.required_server_version, "clients": client_versions}
|
||||
slot_info = {}
|
||||
names = [[name for player, name in sorted(multiworld.player_name.items())]]
|
||||
for slot in multiworld.player_ids:
|
||||
player_world: AutoWorld.World = multiworld.worlds[slot]
|
||||
names = [[name for player, name in sorted(world.player_name.items())]]
|
||||
for slot in world.player_ids:
|
||||
player_world: AutoWorld.World = world.worlds[slot]
|
||||
minimum_versions["server"] = max(minimum_versions["server"], player_world.required_server_version)
|
||||
client_versions[slot] = player_world.required_client_version
|
||||
games[slot] = multiworld.game[slot]
|
||||
slot_info[slot] = NetUtils.NetworkSlot(names[0][slot - 1], multiworld.game[slot],
|
||||
multiworld.player_types[slot])
|
||||
for slot, group in multiworld.groups.items():
|
||||
games[slot] = multiworld.game[slot]
|
||||
slot_info[slot] = NetUtils.NetworkSlot(group["name"], multiworld.game[slot], multiworld.player_types[slot],
|
||||
games[slot] = world.game[slot]
|
||||
slot_info[slot] = NetUtils.NetworkSlot(names[0][slot - 1], world.game[slot],
|
||||
world.player_types[slot])
|
||||
for slot, group in world.groups.items():
|
||||
games[slot] = world.game[slot]
|
||||
slot_info[slot] = NetUtils.NetworkSlot(group["name"], world.game[slot], world.player_types[slot],
|
||||
group_members=sorted(group["players"]))
|
||||
precollected_items = {player: [item.code for item in world_precollected if type(item.code) == int]
|
||||
for player, world_precollected in multiworld.precollected_items.items()}
|
||||
precollected_hints = {player: set() for player in range(1, multiworld.players + 1 + len(multiworld.groups))}
|
||||
for player, world_precollected in world.precollected_items.items()}
|
||||
precollected_hints = {player: set() for player in range(1, world.players + 1 + len(world.groups))}
|
||||
|
||||
for slot in multiworld.player_ids:
|
||||
slot_data[slot] = multiworld.worlds[slot].fill_slot_data()
|
||||
|
||||
def precollect_hint(location: Location, auto_status: HintStatus):
|
||||
for slot in world.player_ids:
|
||||
slot_data[slot] = world.worlds[slot].fill_slot_data()
|
||||
|
||||
def precollect_hint(location):
|
||||
entrance = er_hint_data.get(location.player, {}).get(location.address, "")
|
||||
hint = NetUtils.Hint(location.item.player, location.player, location.address,
|
||||
location.item.code, False, entrance, location.item.flags, auto_status)
|
||||
location.item.code, False, entrance, location.item.flags)
|
||||
precollected_hints[location.player].add(hint)
|
||||
if location.item.player not in multiworld.groups:
|
||||
if location.item.player not in world.groups:
|
||||
precollected_hints[location.item.player].add(hint)
|
||||
else:
|
||||
for player in multiworld.groups[location.item.player]["players"]:
|
||||
for player in world.groups[location.item.player]["players"]:
|
||||
precollected_hints[player].add(hint)
|
||||
|
||||
locations_data: Dict[int, Dict[int, Tuple[int, int, int]]] = {player: {} for player in multiworld.player_ids}
|
||||
for location in multiworld.get_filled_locations():
|
||||
locations_data: Dict[int, Dict[int, Tuple[int, int, int]]] = {player: {} for player in world.player_ids}
|
||||
for location in world.get_filled_locations():
|
||||
if type(location.address) == int:
|
||||
assert location.item.code is not None, "item code None should be event, " \
|
||||
"location.address should then also be None. Location: " \
|
||||
f" {location}, Item: {location.item}"
|
||||
assert location.address not in locations_data[location.player], (
|
||||
f"Locations with duplicate address. {location} and "
|
||||
f"{locations_data[location.player][location.address]}")
|
||||
f" {location}"
|
||||
locations_data[location.player][location.address] = \
|
||||
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 not location.item.trap: # Unspecified status for location hints, except traps
|
||||
auto_status = HintStatus.HINT_UNSPECIFIED
|
||||
precollect_hint(location, auto_status)
|
||||
elif location.item.name in multiworld.worlds[location.item.player].options.start_hints:
|
||||
precollect_hint(location, auto_status)
|
||||
elif any([location.item.name in multiworld.worlds[player].options.start_hints
|
||||
for player in multiworld.groups.get(location.item.player, {}).get("players", [])]):
|
||||
precollect_hint(location, auto_status)
|
||||
|
||||
# embedded data package
|
||||
data_package = {
|
||||
game_world.game: worlds.network_data_package["games"][game_world.game]
|
||||
for game_world in multiworld.worlds.values()
|
||||
}
|
||||
|
||||
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))
|
||||
if location.name in world.start_location_hints[location.player]:
|
||||
precollect_hint(location)
|
||||
elif location.item.name in world.start_hints[location.item.player]:
|
||||
precollect_hint(location)
|
||||
elif any([location.item.name in world.start_hints[player]
|
||||
for player in world.groups.get(location.item.player, {}).get("players", [])]):
|
||||
precollect_hint(location)
|
||||
|
||||
multidata = {
|
||||
"slot_data": slot_data,
|
||||
"slot_info": slot_info,
|
||||
"connect_names": {name: (0, player) for player, name in multiworld.player_name.items()},
|
||||
"names": names, # TODO: remove around 0.2.5 in favor of slot_info
|
||||
"games": games, # TODO: remove around 0.2.5 in favor of slot_info
|
||||
"connect_names": {name: (0, player) for player, name in world.player_name.items()},
|
||||
"remote_items": {player for player in world.player_ids if
|
||||
world.worlds[player].remote_items},
|
||||
"remote_start_inventory": {player for player in world.player_ids if
|
||||
world.worlds[player].remote_start_inventory},
|
||||
"locations": locations_data,
|
||||
"checks_in_area": checks_in_area,
|
||||
"server_options": baked_server_options,
|
||||
@@ -332,12 +390,9 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
||||
"version": tuple(version_tuple),
|
||||
"tags": ["AP"],
|
||||
"minimum_versions": minimum_versions,
|
||||
"seed_name": multiworld.seed_name,
|
||||
"spheres": spheres,
|
||||
"datapackage": data_package,
|
||||
"race_mode": int(multiworld.is_race),
|
||||
"seed_name": world.seed_name
|
||||
}
|
||||
AutoWorld.call_all(multiworld, "modify_multidata", multidata)
|
||||
AutoWorld.call_all(world, "modify_multidata", multidata)
|
||||
|
||||
multidata = zlib.compress(pickle.dumps(multidata), 9)
|
||||
|
||||
@@ -345,14 +400,15 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
||||
f.write(bytes([3])) # version of format
|
||||
f.write(multidata)
|
||||
|
||||
output_file_futures.append(pool.submit(write_multidata))
|
||||
multidata_task = pool.submit(write_multidata)
|
||||
if not check_accessibility_task.result():
|
||||
if not multiworld.can_beat_game():
|
||||
raise FillError("Game appears as unbeatable. Aborting.", multiworld=multiworld)
|
||||
if not world.can_beat_game():
|
||||
raise Exception("Game appears as unbeatable. Aborting.")
|
||||
else:
|
||||
logger.warning("Location Accessibility requirements not fulfilled.")
|
||||
|
||||
# retrieve exceptions via .result() if they occurred.
|
||||
multidata_task.result()
|
||||
for i, future in enumerate(concurrent.futures.as_completed(output_file_futures), start=1):
|
||||
if i % 10 == 0 or i == len(output_file_futures):
|
||||
logger.info(f'Generating output files ({i}/{len(output_file_futures)}).')
|
||||
@@ -360,12 +416,12 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
||||
|
||||
if args.spoiler > 1:
|
||||
logger.info('Calculating playthrough.')
|
||||
multiworld.spoiler.create_playthrough(create_paths=args.spoiler > 2)
|
||||
create_playthrough(world)
|
||||
|
||||
if args.spoiler:
|
||||
multiworld.spoiler.to_file(os.path.join(temp_dir, '%s_Spoiler.txt' % outfilebase))
|
||||
world.spoiler.to_file(os.path.join(temp_dir, '%s_Spoiler.txt' % outfilebase))
|
||||
|
||||
zipfilename = output_path(f"AP_{multiworld.seed_name}.zip")
|
||||
zipfilename = output_path(f"AP_{world.seed_name}.zip")
|
||||
logger.info(f"Creating final archive at {zipfilename}")
|
||||
with zipfile.ZipFile(zipfilename, mode="w", compression=zipfile.ZIP_DEFLATED,
|
||||
compresslevel=9) as zf:
|
||||
@@ -373,4 +429,144 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
||||
zf.write(file.path, arcname=file.name)
|
||||
|
||||
logger.info('Done. Enjoy. Total Time: %s', time.perf_counter() - start)
|
||||
return multiworld
|
||||
return world
|
||||
|
||||
|
||||
def create_playthrough(world):
|
||||
"""Destructive to the world while it is run, damage gets repaired afterwards."""
|
||||
# get locations containing progress items
|
||||
prog_locations = {location for location in world.get_filled_locations() if location.item.advancement}
|
||||
state_cache = [None]
|
||||
collection_spheres = []
|
||||
state = CollectionState(world)
|
||||
sphere_candidates = set(prog_locations)
|
||||
logging.debug('Building up collection spheres.')
|
||||
while sphere_candidates:
|
||||
|
||||
# build up spheres of collection radius.
|
||||
# Everything in each sphere is independent from each other in dependencies and only depends on lower spheres
|
||||
|
||||
sphere = {location for location in sphere_candidates if state.can_reach(location)}
|
||||
|
||||
for location in sphere:
|
||||
state.collect(location.item, True, location)
|
||||
|
||||
sphere_candidates -= sphere
|
||||
collection_spheres.append(sphere)
|
||||
state_cache.append(state.copy())
|
||||
|
||||
logging.debug('Calculated sphere %i, containing %i of %i progress items.', len(collection_spheres), len(sphere),
|
||||
len(prog_locations))
|
||||
if not sphere:
|
||||
logging.debug('The following items could not be reached: %s', ['%s (Player %d) at %s (Player %d)' % (
|
||||
location.item.name, location.item.player, location.name, location.player) for location in
|
||||
sphere_candidates])
|
||||
if any([world.accessibility[location.item.player] != 'minimal' for location in sphere_candidates]):
|
||||
raise RuntimeError(f'Not all progression items reachable ({sphere_candidates}). '
|
||||
f'Something went terribly wrong here.')
|
||||
else:
|
||||
world.spoiler.unreachables = sphere_candidates
|
||||
break
|
||||
|
||||
# in the second phase, we cull each sphere such that the game is still beatable,
|
||||
# reducing each range of influence to the bare minimum required inside it
|
||||
restore_later = {}
|
||||
for num, sphere in reversed(tuple(enumerate(collection_spheres))):
|
||||
to_delete = set()
|
||||
for location in sphere:
|
||||
# we remove the item at location and check if game is still beatable
|
||||
logging.debug('Checking if %s (Player %d) is required to beat the game.', location.item.name,
|
||||
location.item.player)
|
||||
old_item = location.item
|
||||
location.item = None
|
||||
if world.can_beat_game(state_cache[num]):
|
||||
to_delete.add(location)
|
||||
restore_later[location] = old_item
|
||||
else:
|
||||
# still required, got to keep it around
|
||||
location.item = old_item
|
||||
|
||||
# cull entries in spheres for spoiler walkthrough at end
|
||||
sphere -= to_delete
|
||||
|
||||
# second phase, sphere 0
|
||||
removed_precollected = []
|
||||
for item in (i for i in chain.from_iterable(world.precollected_items.values()) if i.advancement):
|
||||
logging.debug('Checking if %s (Player %d) is required to beat the game.', item.name, item.player)
|
||||
world.precollected_items[item.player].remove(item)
|
||||
world.state.remove(item)
|
||||
if not world.can_beat_game():
|
||||
world.push_precollected(item)
|
||||
else:
|
||||
removed_precollected.append(item)
|
||||
|
||||
# we are now down to just the required progress items in collection_spheres. Unfortunately
|
||||
# the previous pruning stage could potentially have made certain items dependant on others
|
||||
# in the same or later sphere (because the location had 2 ways to access but the item originally
|
||||
# used to access it was deemed not required.) So we need to do one final sphere collection pass
|
||||
# to build up the correct spheres
|
||||
|
||||
required_locations = {item for sphere in collection_spheres for item in sphere}
|
||||
state = CollectionState(world)
|
||||
collection_spheres = []
|
||||
while required_locations:
|
||||
state.sweep_for_events(key_only=True)
|
||||
|
||||
sphere = set(filter(state.can_reach, required_locations))
|
||||
|
||||
for location in sphere:
|
||||
state.collect(location.item, True, location)
|
||||
|
||||
required_locations -= sphere
|
||||
|
||||
collection_spheres.append(sphere)
|
||||
|
||||
logging.debug('Calculated final sphere %i, containing %i of %i progress items.', len(collection_spheres),
|
||||
len(sphere), len(required_locations))
|
||||
if not sphere:
|
||||
raise RuntimeError(f'Not all required items reachable. Unreachable locations: {required_locations}')
|
||||
|
||||
def flist_to_iter(node):
|
||||
while node:
|
||||
value, node = node
|
||||
yield value
|
||||
|
||||
def get_path(state, region):
|
||||
reversed_path_as_flist = state.path.get(region, (region, None))
|
||||
string_path_flat = reversed(list(map(str, flist_to_iter(reversed_path_as_flist))))
|
||||
# Now we combine the flat string list into (region, exit) pairs
|
||||
pathsiter = iter(string_path_flat)
|
||||
pathpairs = zip_longest(pathsiter, pathsiter)
|
||||
return list(pathpairs)
|
||||
|
||||
world.spoiler.paths = {}
|
||||
topology_worlds = (player for player in world.player_ids if world.worlds[player].topology_present)
|
||||
for player in topology_worlds:
|
||||
world.spoiler.paths.update(
|
||||
{str(location): get_path(state, location.parent_region) for sphere in collection_spheres for location in
|
||||
sphere if location.player == player})
|
||||
if player in world.get_game_players("A Link to the Past"):
|
||||
# If Pyramid Fairy Entrance needs to be reached, also path to Big Bomb Shop
|
||||
# Maybe move the big bomb over to the Event system instead?
|
||||
if any(exit_path == 'Pyramid Fairy' for path in world.spoiler.paths.values() for (_, exit_path) in path):
|
||||
if world.mode[player] != 'inverted':
|
||||
world.spoiler.paths[str(world.get_region('Big Bomb Shop', player))] = \
|
||||
get_path(state, world.get_region('Big Bomb Shop', player))
|
||||
else:
|
||||
world.spoiler.paths[str(world.get_region('Inverted Big Bomb Shop', player))] = \
|
||||
get_path(state, world.get_region('Inverted Big Bomb Shop', player))
|
||||
|
||||
# we can finally output our playthrough
|
||||
world.spoiler.playthrough = {"0": sorted([str(item) for item in
|
||||
chain.from_iterable(world.precollected_items.values())
|
||||
if item.advancement])}
|
||||
|
||||
for i, sphere in enumerate(collection_spheres):
|
||||
world.spoiler.playthrough[str(i + 1)] = {str(location): str(location.item) for location in sorted(sphere)}
|
||||
|
||||
# repair the world again
|
||||
for location, item in restore_later.items():
|
||||
location.item = item
|
||||
|
||||
for item in removed_precollected:
|
||||
world.push_precollected(item)
|
||||
|
||||
@@ -77,34 +77,49 @@ def read_apmc_file(apmc_file):
|
||||
return json.loads(b64decode(f.read()))
|
||||
|
||||
|
||||
def update_mod(forge_dir, url: str):
|
||||
def update_mod(forge_dir, minecraft_version: str, get_prereleases=False):
|
||||
"""Check mod version, download new mod from GitHub releases page if needed. """
|
||||
ap_randomizer = find_ap_randomizer_jar(forge_dir)
|
||||
os.path.basename(url)
|
||||
if ap_randomizer is not None:
|
||||
logging.info(f"Your current mod is {ap_randomizer}.")
|
||||
else:
|
||||
logging.info(f"You do not have the AP randomizer mod installed.")
|
||||
|
||||
if ap_randomizer != os.path.basename(url):
|
||||
logging.info(f"A new release of the Minecraft AP randomizer mod was found: "
|
||||
f"{os.path.basename(url)}")
|
||||
if prompt_yes_no("Would you like to update?"):
|
||||
old_ap_mod = os.path.join(forge_dir, 'mods', ap_randomizer) if ap_randomizer is not None else None
|
||||
new_ap_mod = os.path.join(forge_dir, 'mods', os.path.basename(url))
|
||||
logging.info("Downloading AP randomizer mod. This may take a moment...")
|
||||
apmod_resp = requests.get(url)
|
||||
if apmod_resp.status_code == 200:
|
||||
with open(new_ap_mod, 'wb') as f:
|
||||
f.write(apmod_resp.content)
|
||||
logging.info(f"Wrote new mod file to {new_ap_mod}")
|
||||
if old_ap_mod is not None:
|
||||
os.remove(old_ap_mod)
|
||||
logging.info(f"Removed old mod file from {old_ap_mod}")
|
||||
else:
|
||||
logging.error(f"Error retrieving the randomizer mod (status code {apmod_resp.status_code}).")
|
||||
logging.error(f"Please report this issue on the Archipelago Discord server.")
|
||||
sys.exit(1)
|
||||
client_releases_endpoint = "https://api.github.com/repos/KonoTyran/Minecraft_AP_Randomizer/releases"
|
||||
resp = requests.get(client_releases_endpoint)
|
||||
if resp.status_code == 200: # OK
|
||||
try:
|
||||
latest_release = next(filter(lambda release: (not release['prerelease'] or get_prereleases) and
|
||||
(minecraft_version in release['assets'][0]['name']),
|
||||
resp.json()))
|
||||
if ap_randomizer != latest_release['assets'][0]['name']:
|
||||
logging.info(f"A new release of the Minecraft AP randomizer mod was found: "
|
||||
f"{latest_release['assets'][0]['name']}")
|
||||
if ap_randomizer is not None:
|
||||
logging.info(f"Your current mod is {ap_randomizer}.")
|
||||
else:
|
||||
logging.info(f"You do not have the AP randomizer mod installed.")
|
||||
if prompt_yes_no("Would you like to update?"):
|
||||
old_ap_mod = os.path.join(forge_dir, 'mods', ap_randomizer) if ap_randomizer is not None else None
|
||||
new_ap_mod = os.path.join(forge_dir, 'mods', latest_release['assets'][0]['name'])
|
||||
logging.info("Downloading AP randomizer mod. This may take a moment...")
|
||||
apmod_resp = requests.get(latest_release['assets'][0]['browser_download_url'])
|
||||
if apmod_resp.status_code == 200:
|
||||
with open(new_ap_mod, 'wb') as f:
|
||||
f.write(apmod_resp.content)
|
||||
logging.info(f"Wrote new mod file to {new_ap_mod}")
|
||||
if old_ap_mod is not None:
|
||||
os.remove(old_ap_mod)
|
||||
logging.info(f"Removed old mod file from {old_ap_mod}")
|
||||
else:
|
||||
logging.error(f"Error retrieving the randomizer mod (status code {apmod_resp.status_code}).")
|
||||
logging.error(f"Please report this issue on the Archipelago Discord server.")
|
||||
sys.exit(1)
|
||||
except StopIteration:
|
||||
logging.warning(f"No compatible mod version found for {minecraft_version}.")
|
||||
if not prompt_yes_no("Run server anyway?"):
|
||||
sys.exit(0)
|
||||
else:
|
||||
logging.error(f"Error checking for randomizer mod updates (status code {resp.status_code}).")
|
||||
logging.error(f"If this was not expected, please report this issue on the Archipelago Discord server.")
|
||||
if not prompt_yes_no("Continue anyways?"):
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
def check_eula(forge_dir):
|
||||
@@ -249,13 +264,8 @@ def get_minecraft_versions(version, release_channel="release"):
|
||||
return next(filter(lambda entry: entry["version"] == version, data[release_channel]))
|
||||
else:
|
||||
return resp.json()[release_channel][0]
|
||||
except (StopIteration, KeyError):
|
||||
logging.error(f"No compatible mod version found for client version {version} on \"{release_channel}\" channel.")
|
||||
if release_channel != "release":
|
||||
logging.error("Consider switching \"release_channel\" to \"release\" in your Host.yaml file")
|
||||
else:
|
||||
logging.error("No suitable mod found on the \"release\" channel. Please Contact us on discord to report this error.")
|
||||
sys.exit(0)
|
||||
except StopIteration:
|
||||
logging.error(f"No compatible mod version found for client version {version}.")
|
||||
|
||||
|
||||
def is_correct_forge(forge_dir) -> bool:
|
||||
@@ -276,8 +286,6 @@ if __name__ == '__main__':
|
||||
help="specify java version.")
|
||||
parser.add_argument('--forge', '-f', metavar='1.18.2-40.1.0', dest='forge', type=str, default=False, action='store',
|
||||
help="specify forge version. (Minecraft Version-Forge Version)")
|
||||
parser.add_argument('--version', '-v', metavar='9', dest='data_version', type=int, action='store',
|
||||
help="specify Mod data version to download.")
|
||||
|
||||
args = parser.parse_args()
|
||||
apmc_file = os.path.abspath(args.apmc_file) if args.apmc_file else None
|
||||
@@ -288,22 +296,21 @@ if __name__ == '__main__':
|
||||
options = Utils.get_options()
|
||||
channel = args.channel or options["minecraft_options"]["release_channel"]
|
||||
apmc_data = None
|
||||
data_version = args.data_version or None
|
||||
data_version = None
|
||||
|
||||
if apmc_file is None and not args.install:
|
||||
apmc_file = Utils.open_filename('Select APMC file', (('APMC File', ('.apmc',)),))
|
||||
|
||||
if apmc_file is not None and data_version is None:
|
||||
if apmc_file is not None:
|
||||
apmc_data = read_apmc_file(apmc_file)
|
||||
data_version = apmc_data.get('client_version', '')
|
||||
|
||||
versions = get_minecraft_versions(data_version, channel)
|
||||
|
||||
forge_dir = options["minecraft_options"]["forge_directory"]
|
||||
forge_dir = Utils.user_path(options["minecraft_options"]["forge_directory"])
|
||||
max_heap = options["minecraft_options"]["max_heap_size"]
|
||||
forge_version = args.forge or versions["forge"]
|
||||
java_version = args.java or versions["java"]
|
||||
mod_url = versions["url"]
|
||||
java_dir = find_jdk_dir(java_version)
|
||||
|
||||
if args.install:
|
||||
@@ -337,7 +344,7 @@ if __name__ == '__main__':
|
||||
if not max_heap_re.match(max_heap):
|
||||
raise Exception(f"Max heap size {max_heap} in incorrect format. Use a number followed by M or G, e.g. 512M or 2G.")
|
||||
|
||||
update_mod(forge_dir, mod_url)
|
||||
update_mod(forge_dir, f"MC{forge_version.split('-')[0]}", channel != "release")
|
||||
replace_apmc_files(forge_dir, apmc_file)
|
||||
check_eula(forge_dir)
|
||||
server_process = run_forge_server(forge_dir, java_version, max_heap)
|
||||
|
||||
134
ModuleUpdate.py
@@ -1,149 +1,57 @@
|
||||
import os
|
||||
import sys
|
||||
import subprocess
|
||||
import multiprocessing
|
||||
import warnings
|
||||
|
||||
|
||||
if sys.platform in ("win32", "darwin") and sys.version_info < (3, 10, 11):
|
||||
# Official micro version updates. This should match the number in docs/running from source.md.
|
||||
raise RuntimeError(f"Incompatible Python Version found: {sys.version_info}. Official 3.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)
|
||||
_skip_update = bool(getattr(sys, "frozen", False) or multiprocessing.parent_process())
|
||||
update_ran = _skip_update
|
||||
|
||||
|
||||
class RequirementsSet(set):
|
||||
def add(self, e):
|
||||
global update_ran
|
||||
update_ran &= _skip_update
|
||||
super().add(e)
|
||||
|
||||
def update(self, *s):
|
||||
global update_ran
|
||||
update_ran &= _skip_update
|
||||
super().update(*s)
|
||||
|
||||
import pkg_resources
|
||||
|
||||
local_dir = os.path.dirname(__file__)
|
||||
requirements_files = RequirementsSet((os.path.join(local_dir, 'requirements.txt'),))
|
||||
requirements_files = {os.path.join(local_dir, 'requirements.txt')}
|
||||
|
||||
if sys.version_info < (3, 8, 6):
|
||||
raise RuntimeError("Incompatible Python Version. 3.8.7+ is supported.")
|
||||
|
||||
update_ran = getattr(sys, "frozen", False) # don't run update if environment is frozen/compiled
|
||||
|
||||
if not update_ran:
|
||||
for entry in os.scandir(os.path.join(local_dir, "worlds")):
|
||||
# skip .* (hidden / disabled) folders
|
||||
if not entry.name.startswith("."):
|
||||
if entry.is_dir():
|
||||
req_file = os.path.join(entry.path, "requirements.txt")
|
||||
if os.path.exists(req_file):
|
||||
requirements_files.add(req_file)
|
||||
|
||||
|
||||
def check_pip():
|
||||
# detect if pip is available
|
||||
try:
|
||||
import pip # noqa: F401
|
||||
except ImportError:
|
||||
raise RuntimeError("pip not available. Please install pip.")
|
||||
|
||||
|
||||
def confirm(msg: str):
|
||||
try:
|
||||
input(f"\n{msg}")
|
||||
except KeyboardInterrupt:
|
||||
print("\nAborting")
|
||||
sys.exit(1)
|
||||
if entry.is_dir():
|
||||
req_file = os.path.join(entry.path, "requirements.txt")
|
||||
if os.path.exists(req_file):
|
||||
requirements_files.add(req_file)
|
||||
|
||||
|
||||
def update_command():
|
||||
check_pip()
|
||||
for file in requirements_files:
|
||||
subprocess.call([sys.executable, "-m", "pip", "install", "-r", file, "--upgrade"])
|
||||
subprocess.call([sys.executable, '-m', 'pip', 'install', '-r', file, '--upgrade'])
|
||||
|
||||
|
||||
def install_pkg_resources(yes=False):
|
||||
try:
|
||||
import pkg_resources # noqa: F401
|
||||
except ImportError:
|
||||
check_pip()
|
||||
if not yes:
|
||||
confirm("pkg_resources not found, press enter to install it")
|
||||
subprocess.call([sys.executable, "-m", "pip", "install", "--upgrade", "setuptools"])
|
||||
|
||||
|
||||
def update(yes: bool = False, force: bool = False) -> None:
|
||||
def update(yes=False, force=False):
|
||||
global update_ran
|
||||
if not update_ran:
|
||||
update_ran = True
|
||||
|
||||
install_pkg_resources(yes=yes)
|
||||
import pkg_resources
|
||||
|
||||
if force:
|
||||
update_command()
|
||||
return
|
||||
|
||||
prev = "" # if a line ends in \ we store here and merge later
|
||||
for req_file in requirements_files:
|
||||
path = os.path.join(os.path.dirname(sys.argv[0]), req_file)
|
||||
if not os.path.exists(path):
|
||||
path = os.path.join(os.path.dirname(__file__), req_file)
|
||||
with open(path) as requirementsfile:
|
||||
for line in requirementsfile:
|
||||
if not line or line.lstrip(" \t")[0] == "#":
|
||||
if not prev:
|
||||
continue # ignore comments
|
||||
line = ""
|
||||
elif line.rstrip("\r\n").endswith("\\"):
|
||||
prev = prev + line.rstrip("\r\n")[:-1] + " " # continue on next line
|
||||
continue
|
||||
line = prev + line
|
||||
line = line.split("--hash=")[0] # remove hashes from requirement for version checking
|
||||
prev = ""
|
||||
if line.startswith(("https://", "git+https://")):
|
||||
# extract name and version for url
|
||||
rest = line.split('/')[-1]
|
||||
line = ""
|
||||
if "#egg=" in rest:
|
||||
# from egg info
|
||||
rest, egg = rest.split("#egg=", 1)
|
||||
egg = egg.split(";", 1)[0].rstrip()
|
||||
if any(compare in egg for compare in ("==", ">=", ">", "<", "<=", "!=")):
|
||||
warnings.warn(f"Specifying version as #egg={egg} will become unavailable in pip 25.0. "
|
||||
"Use name @ url#version instead.", DeprecationWarning)
|
||||
line = egg
|
||||
else:
|
||||
egg = ""
|
||||
if "@" in rest and not line:
|
||||
raise ValueError("Can't deduce version from requirement")
|
||||
elif not line:
|
||||
# from filename
|
||||
rest = rest.replace(".zip", "-").replace(".tar.gz", "-")
|
||||
name, version, _ = rest.split("-", 2)
|
||||
line = f'{egg or name}=={version}'
|
||||
elif "@" in line and "#" in line:
|
||||
# PEP 508 does not allow us to specify a version, so we use custom syntax
|
||||
# name @ url#version ; marker
|
||||
name, rest = line.split("@", 1)
|
||||
version = rest.split("#", 1)[1].split(";", 1)[0].rstrip()
|
||||
line = f"{name.rstrip()}=={version}"
|
||||
if ";" in rest: # keep marker
|
||||
line += rest[rest.find(";"):]
|
||||
if line.startswith('https://'):
|
||||
# extract name and version from url
|
||||
wheel = line.split('/')[-1]
|
||||
name, version, _ = wheel.split('-', 2)
|
||||
line = f'{name}=={version}'
|
||||
requirements = pkg_resources.parse_requirements(line)
|
||||
for requirement in map(str, requirements):
|
||||
for requirement in requirements:
|
||||
requirement = str(requirement)
|
||||
try:
|
||||
pkg_resources.require(requirement)
|
||||
except pkg_resources.ResolutionError:
|
||||
if not yes:
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
confirm(f"Requirement {requirement} is not satisfied, press enter to install it")
|
||||
input(f'Requirement {requirement} is not satisfied, press enter to install it')
|
||||
update_command()
|
||||
return
|
||||
|
||||
|
||||
1577
MultiServer.py
183
NetUtils.py
@@ -2,21 +2,11 @@ from __future__ import annotations
|
||||
|
||||
import typing
|
||||
import enum
|
||||
import warnings
|
||||
from json import JSONEncoder, JSONDecoder
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from websockets import WebSocketServerProtocol as ServerConnection
|
||||
import websockets
|
||||
|
||||
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
|
||||
from Utils import Version
|
||||
|
||||
|
||||
class JSONMessagePart(typing.TypedDict, total=False):
|
||||
@@ -28,11 +18,9 @@ class JSONMessagePart(typing.TypedDict, total=False):
|
||||
player: int
|
||||
# if type == item indicates item flags
|
||||
flags: int
|
||||
# if type == hint_status
|
||||
hint_status: HintStatus
|
||||
|
||||
|
||||
class ClientStatus(ByValue, enum.IntEnum):
|
||||
class ClientStatus(enum.IntEnum):
|
||||
CLIENT_UNKNOWN = 0
|
||||
CLIENT_CONNECTED = 5
|
||||
CLIENT_READY = 10
|
||||
@@ -40,22 +28,22 @@ class ClientStatus(ByValue, enum.IntEnum):
|
||||
CLIENT_GOAL = 30
|
||||
|
||||
|
||||
class SlotType(ByValue, enum.IntFlag):
|
||||
class SlotType(enum.IntFlag):
|
||||
spectator = 0b00
|
||||
player = 0b01
|
||||
group = 0b10
|
||||
|
||||
@property
|
||||
def always_goal(self) -> bool:
|
||||
"""Mark this slot as having reached its goal instantly."""
|
||||
"""Mark this slot has having reached its goal instantly."""
|
||||
return self.value != 0b01
|
||||
|
||||
|
||||
class Permission(ByValue, enum.IntFlag):
|
||||
class Permission(enum.IntFlag):
|
||||
disabled = 0b000 # 0, completely disables access
|
||||
enabled = 0b001 # 1, allows manual use
|
||||
goal = 0b010 # 2, allows manual use after goal completion
|
||||
auto = 0b110 # 6, forces use after goal completion, only works for release
|
||||
auto = 0b110 # 6, forces use after goal completion, only works for forfeit
|
||||
auto_enabled = 0b111 # 7, forces use after goal completion, allows manual use any time
|
||||
|
||||
@staticmethod
|
||||
@@ -90,7 +78,6 @@ class NetworkItem(typing.NamedTuple):
|
||||
item: int
|
||||
location: int
|
||||
player: int
|
||||
""" Sending player, except in LocationInfo (from LocationScouts), where it is the receiving player. """
|
||||
flags: int = 0
|
||||
|
||||
|
||||
@@ -99,7 +86,7 @@ def _scan_for_TypedTuples(obj: typing.Any) -> typing.Any:
|
||||
data = obj._asdict()
|
||||
data["class"] = obj.__class__.__name__
|
||||
return data
|
||||
if isinstance(obj, (tuple, list, set, frozenset)):
|
||||
if isinstance(obj, (tuple, list, set)):
|
||||
return tuple(_scan_for_TypedTuples(o) for o in obj)
|
||||
if isinstance(obj, dict):
|
||||
return {key: _scan_for_TypedTuples(value) for key, value in obj.items()}
|
||||
@@ -113,7 +100,7 @@ _encode = JSONEncoder(
|
||||
).encode
|
||||
|
||||
|
||||
def encode(obj: typing.Any) -> str:
|
||||
def encode(obj):
|
||||
return _encode(_scan_for_TypedTuples(obj))
|
||||
|
||||
|
||||
@@ -122,7 +109,7 @@ def get_any_version(data: dict) -> Version:
|
||||
return Version(int(data["major"]), int(data["minor"]), int(data["build"]))
|
||||
|
||||
|
||||
allowlist = {
|
||||
whitelist = {
|
||||
"NetworkPlayer": NetworkPlayer,
|
||||
"NetworkItem": NetworkItem,
|
||||
"NetworkSlot": NetworkSlot
|
||||
@@ -138,7 +125,7 @@ def _object_hook(o: typing.Any) -> typing.Any:
|
||||
hook = custom_hooks.get(o.get("class", None), None)
|
||||
if hook:
|
||||
return hook(o)
|
||||
cls = allowlist.get(o.get("class", None), None)
|
||||
cls = whitelist.get(o.get("class", None), None)
|
||||
if cls:
|
||||
for key in tuple(o):
|
||||
if key not in cls._fields:
|
||||
@@ -152,7 +139,7 @@ decode = JSONDecoder(object_hook=_object_hook).decode
|
||||
|
||||
|
||||
class Endpoint:
|
||||
socket: "ServerConnection"
|
||||
socket: websockets.WebSocketServerProtocol
|
||||
|
||||
def __init__(self, socket):
|
||||
self.socket = socket
|
||||
@@ -195,7 +182,6 @@ class JSONTypes(str, enum.Enum):
|
||||
location_name = "location_name"
|
||||
location_id = "location_id"
|
||||
entrance_name = "entrance_name"
|
||||
hint_status = "hint_status"
|
||||
|
||||
|
||||
class JSONtoTextParser(metaclass=HandlerMeta):
|
||||
@@ -211,8 +197,7 @@ class JSONtoTextParser(metaclass=HandlerMeta):
|
||||
"slateblue": "6D8BE8",
|
||||
"plum": "AF99EF",
|
||||
"salmon": "FA8072",
|
||||
"white": "FFFFFF",
|
||||
"orange": "FF7700",
|
||||
"white": "FFFFFF"
|
||||
}
|
||||
|
||||
def __init__(self, ctx):
|
||||
@@ -236,7 +221,7 @@ class JSONtoTextParser(metaclass=HandlerMeta):
|
||||
|
||||
def _handle_player_id(self, node: JSONMessagePart):
|
||||
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]
|
||||
return self._handle_color(node)
|
||||
|
||||
@@ -261,7 +246,7 @@ class JSONtoTextParser(metaclass=HandlerMeta):
|
||||
|
||||
def _handle_item_id(self, node: JSONMessagePart):
|
||||
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)
|
||||
|
||||
def _handle_location_name(self, node: JSONMessagePart):
|
||||
@@ -269,18 +254,14 @@ class JSONtoTextParser(metaclass=HandlerMeta):
|
||||
return self._handle_color(node)
|
||||
|
||||
def _handle_location_id(self, node: JSONMessagePart):
|
||||
location_id = int(node["text"])
|
||||
node["text"] = self.ctx.location_names.lookup_in_slot(location_id, node["player"])
|
||||
item_id = int(node["text"])
|
||||
node["text"] = self.ctx.location_names[item_id]
|
||||
return self._handle_location_name(node)
|
||||
|
||||
def _handle_entrance_name(self, node: JSONMessagePart):
|
||||
node["color"] = 'blue'
|
||||
return self._handle_color(node)
|
||||
|
||||
def _handle_hint_status(self, node: JSONMessagePart):
|
||||
node["color"] = status_colors.get(node["hint_status"], "red")
|
||||
return self._handle_color(node)
|
||||
|
||||
|
||||
class RawJSONtoTextParser(JSONtoTextParser):
|
||||
def _handle_color(self, node: JSONMessagePart):
|
||||
@@ -289,8 +270,7 @@ class RawJSONtoTextParser(JSONtoTextParser):
|
||||
|
||||
color_codes = {'reset': 0, 'bold': 1, 'underline': 4, 'black': 30, 'red': 31, 'green': 32, 'yellow': 33, 'blue': 34,
|
||||
'magenta': 35, 'cyan': 36, 'white': 37, 'black_bg': 40, 'red_bg': 41, 'green_bg': 42, 'yellow_bg': 43,
|
||||
'blue_bg': 44, 'magenta_bg': 45, 'cyan_bg': 46, 'white_bg': 47,
|
||||
'plum': 35, 'slateblue': 34, 'salmon': 31,} # convert ui colors to terminal colors
|
||||
'blue_bg': 44, 'magenta_bg': 45, 'cyan_bg': 46, 'white_bg': 47}
|
||||
|
||||
|
||||
def color_code(*args):
|
||||
@@ -309,29 +289,8 @@ def add_json_item(parts: list, item_id: int, player: int = 0, item_flags: int =
|
||||
parts.append({"text": str(item_id), "player": player, "flags": item_flags, "type": JSONTypes.item_id, **kwargs})
|
||||
|
||||
|
||||
def add_json_location(parts: list, location_id: int, player: int = 0, **kwargs) -> None:
|
||||
parts.append({"text": str(location_id), "player": player, "type": JSONTypes.location_id, **kwargs})
|
||||
|
||||
|
||||
status_names: typing.Dict[HintStatus, str] = {
|
||||
HintStatus.HINT_FOUND: "(found)",
|
||||
HintStatus.HINT_UNSPECIFIED: "(unspecified)",
|
||||
HintStatus.HINT_NO_PRIORITY: "(no priority)",
|
||||
HintStatus.HINT_AVOID: "(avoid)",
|
||||
HintStatus.HINT_PRIORITY: "(priority)",
|
||||
}
|
||||
status_colors: typing.Dict[HintStatus, str] = {
|
||||
HintStatus.HINT_FOUND: "green",
|
||||
HintStatus.HINT_UNSPECIFIED: "white",
|
||||
HintStatus.HINT_NO_PRIORITY: "slateblue",
|
||||
HintStatus.HINT_AVOID: "salmon",
|
||||
HintStatus.HINT_PRIORITY: "plum",
|
||||
}
|
||||
|
||||
|
||||
def add_json_hint_status(parts: list, hint_status: HintStatus, text: typing.Optional[str] = None, **kwargs):
|
||||
parts.append({"text": text if text != None else status_names.get(hint_status, "(unknown)"),
|
||||
"hint_status": hint_status, "type": JSONTypes.hint_status, **kwargs})
|
||||
def add_json_location(parts: list, item_id: int, player: int = 0, **kwargs) -> None:
|
||||
parts.append({"text": str(item_id), "player": player, "type": JSONTypes.location_id, **kwargs})
|
||||
|
||||
|
||||
class Hint(typing.NamedTuple):
|
||||
@@ -342,21 +301,14 @@ class Hint(typing.NamedTuple):
|
||||
found: bool
|
||||
entrance: str = ""
|
||||
item_flags: int = 0
|
||||
status: HintStatus = HintStatus.HINT_UNSPECIFIED
|
||||
|
||||
def re_check(self, ctx, team) -> Hint:
|
||||
if self.found and self.status == HintStatus.HINT_FOUND:
|
||||
if self.found:
|
||||
return self
|
||||
found = self.location in ctx.location_checks[team, self.finding_player]
|
||||
if found:
|
||||
return self._replace(found=found, status=HintStatus.HINT_FOUND)
|
||||
return self
|
||||
|
||||
def re_prioritize(self, ctx, status: HintStatus) -> Hint:
|
||||
if self.found and status != HintStatus.HINT_FOUND:
|
||||
status = HintStatus.HINT_FOUND
|
||||
if status != self.status:
|
||||
return self._replace(status=status)
|
||||
return Hint(self.receiving_player, self.finding_player, self.location, self.item, found, self.entrance,
|
||||
self.item_flags)
|
||||
return self
|
||||
|
||||
def __hash__(self):
|
||||
@@ -378,7 +330,10 @@ class Hint(typing.NamedTuple):
|
||||
else:
|
||||
add_json_text(parts, "'s World")
|
||||
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",
|
||||
"receiving": self.receiving_player,
|
||||
@@ -388,87 +343,3 @@ class Hint(typing.NamedTuple):
|
||||
@property
|
||||
def local(self):
|
||||
return self.receiving_player == self.finding_player
|
||||
|
||||
|
||||
class _LocationStore(dict, typing.MutableMapping[int, typing.Dict[int, typing.Tuple[int, int, int]]]):
|
||||
def __init__(self, values: typing.MutableMapping[int, typing.Dict[int, typing.Tuple[int, int, int]]]):
|
||||
super().__init__(values)
|
||||
|
||||
if not self:
|
||||
raise ValueError(f"Rejecting game with 0 players")
|
||||
|
||||
if len(self) != max(self):
|
||||
raise ValueError("Player IDs not continuous")
|
||||
|
||||
if len(self.get(0, {})):
|
||||
raise ValueError("Invalid player id 0 for location")
|
||||
|
||||
def find_item(self, slots: typing.Set[int], seeked_item_id: int
|
||||
) -> typing.Generator[typing.Tuple[int, int, int, int, int], None, None]:
|
||||
for finding_player, check_data in self.items():
|
||||
for location_id, (item_id, receiving_player, item_flags) in check_data.items():
|
||||
if receiving_player in slots and item_id == seeked_item_id:
|
||||
yield finding_player, location_id, item_id, receiving_player, item_flags
|
||||
|
||||
def get_for_player(self, slot: int) -> typing.Dict[int, typing.Set[int]]:
|
||||
import collections
|
||||
all_locations: typing.Dict[int, typing.Set[int]] = collections.defaultdict(set)
|
||||
for source_slot, location_data in self.items():
|
||||
for location_id, values in location_data.items():
|
||||
if values[1] == slot:
|
||||
all_locations[source_slot].add(location_id)
|
||||
return all_locations
|
||||
|
||||
def get_checked(self, state: typing.Dict[typing.Tuple[int, int], typing.Set[int]], team: int, slot: int
|
||||
) -> typing.List[int]:
|
||||
checked = state[team, slot]
|
||||
if not checked:
|
||||
# This optimizes the case where everyone connects to a fresh game at the same time.
|
||||
if slot not in self:
|
||||
raise KeyError(slot)
|
||||
return []
|
||||
return [location_id for
|
||||
location_id in self[slot] if
|
||||
location_id in checked]
|
||||
|
||||
def get_missing(self, state: typing.Dict[typing.Tuple[int, int], typing.Set[int]], team: int, slot: int
|
||||
) -> typing.List[int]:
|
||||
checked = state[team, slot]
|
||||
if not checked:
|
||||
# This optimizes the case where everyone connects to a fresh game at the same time.
|
||||
return list(self[slot])
|
||||
return [location_id for
|
||||
location_id in self[slot] if
|
||||
location_id not in checked]
|
||||
|
||||
def get_remaining(self, state: typing.Dict[typing.Tuple[int, int], typing.Set[int]], team: int, slot: int
|
||||
) -> typing.List[typing.Tuple[int, int]]:
|
||||
checked = state[team, slot]
|
||||
player_locations = self[slot]
|
||||
return sorted([(player_locations[location_id][1], player_locations[location_id][0]) for
|
||||
location_id in player_locations if
|
||||
location_id not in checked])
|
||||
|
||||
|
||||
if typing.TYPE_CHECKING: # type-check with pure python implementation until we have a typing stub
|
||||
LocationStore = _LocationStore
|
||||
else:
|
||||
try:
|
||||
from _speedups import LocationStore
|
||||
import _speedups
|
||||
import os.path
|
||||
if os.path.isfile("_speedups.pyx") and os.path.getctime(_speedups.__file__) < os.path.getctime("_speedups.pyx"):
|
||||
warnings.warn(f"{_speedups.__file__} outdated! "
|
||||
f"Please rebuild with `cythonize -b -i _speedups.pyx` or delete it!")
|
||||
except ImportError:
|
||||
try:
|
||||
import pyximport
|
||||
pyximport.install()
|
||||
except ImportError:
|
||||
pyximport = None
|
||||
try:
|
||||
from _speedups import LocationStore
|
||||
except ImportError:
|
||||
warnings.warn("_speedups not available. Falling back to pure python LocationStore. "
|
||||
"Install a matching C++ compiler for your platform to compile _speedups.")
|
||||
LocationStore = _LocationStore
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import tkinter as tk
|
||||
import argparse
|
||||
import logging
|
||||
import random
|
||||
import os
|
||||
import zipfile
|
||||
from itertools import chain
|
||||
|
||||
from BaseClasses import MultiWorld
|
||||
@@ -43,7 +43,7 @@ def adjustGUI():
|
||||
StringVar, IntVar, Checkbutton, Frame, Label, X, Entry, Button, \
|
||||
OptionMenu, filedialog, messagebox, ttk
|
||||
from argparse import Namespace
|
||||
from Utils import __version__ as MWVersion
|
||||
from Main import __version__ as MWVersion
|
||||
|
||||
window = tk.Tk()
|
||||
window.wm_title(f"Archipelago {MWVersion} OoT Adjuster")
|
||||
@@ -194,9 +194,10 @@ def set_icon(window):
|
||||
window.tk.call('wm', 'iconphoto', window._w, logo)
|
||||
|
||||
def adjust(args):
|
||||
# Create a fake multiworld and OOTWorld to use as a base
|
||||
multiworld = MultiWorld(1)
|
||||
ootworld = OOTWorld(multiworld, 1)
|
||||
# Create a fake world and OOTWorld to use as a base
|
||||
world = MultiWorld(1)
|
||||
world.slot_seeds = {1: random}
|
||||
ootworld = OOTWorld(world, 1)
|
||||
# Set options in the fake OOTWorld
|
||||
for name, option in chain(cosmetic_options.items(), sfx_options.items()):
|
||||
result = getattr(args, name, None)
|
||||
@@ -216,18 +217,13 @@ def adjust(args):
|
||||
# Load up the ROM
|
||||
rom = Rom(file=args.rom, force_use=True)
|
||||
delete_zootdec = True
|
||||
elif os.path.splitext(args.rom)[-1] in ['.apz5', '.zpf']:
|
||||
elif os.path.splitext(args.rom)[-1] == '.apz5':
|
||||
# Load vanilla ROM
|
||||
rom = Rom(file=args.vanilla_rom, force_use=True)
|
||||
apz5_file = args.rom
|
||||
base_name = os.path.splitext(apz5_file)[0]
|
||||
# Patch file
|
||||
apply_patch_file(rom, apz5_file,
|
||||
sub_file=(os.path.basename(base_name) + '.zpf'
|
||||
if zipfile.is_zipfile(apz5_file)
|
||||
else None))
|
||||
apply_patch_file(rom, args.rom)
|
||||
else:
|
||||
raise Exception("Invalid file extension; requires .n64, .z64, .apz5, .zpf")
|
||||
raise Exception("Invalid file extension; requires .n64, .z64, .apz5")
|
||||
# Call patch_cosmetics
|
||||
try:
|
||||
patch_cosmetics(ootworld, rom)
|
||||
|
||||
93
OoTClient.py
@@ -3,23 +3,20 @@ import json
|
||||
import os
|
||||
import multiprocessing
|
||||
import subprocess
|
||||
import zipfile
|
||||
from asyncio import StreamReader, StreamWriter
|
||||
|
||||
# CommonClient import first to trigger ModuleUpdater
|
||||
from CommonClient import CommonContext, server_loop, gui_enabled, \
|
||||
from CommonClient import CommonContext, server_loop, gui_enabled, console_loop, \
|
||||
ClientCommandProcessor, logger, get_base_parser
|
||||
import Utils
|
||||
from Utils import async_start
|
||||
from worlds import network_data_package
|
||||
from worlds.oot.Rom import Rom, compress_rom_file
|
||||
from worlds.oot.N64Patch import apply_patch_file
|
||||
from worlds.oot.Utils import data_path
|
||||
|
||||
|
||||
CONNECTION_TIMING_OUT_STATUS = "Connection timing out. Please restart your emulator, then restart connector_oot.lua"
|
||||
CONNECTION_REFUSED_STATUS = "Connection refused. Please start your emulator and make sure connector_oot.lua is running"
|
||||
CONNECTION_RESET_STATUS = "Connection was reset. Please restart your emulator, then restart connector_oot.lua"
|
||||
CONNECTION_TIMING_OUT_STATUS = "Connection timing out. Please restart your emulator, then restart oot_connector.lua"
|
||||
CONNECTION_REFUSED_STATUS = "Connection refused. Please start your emulator and make sure oot_connector.lua is running"
|
||||
CONNECTION_RESET_STATUS = "Connection was reset. Please restart your emulator, then restart oot_connector.lua"
|
||||
CONNECTION_TENTATIVE_STATUS = "Initial Connection Made"
|
||||
CONNECTION_CONNECTED_STATUS = "Connected"
|
||||
CONNECTION_INITIAL_STATUS = "Connection has not been initiated"
|
||||
@@ -51,7 +48,7 @@ deathlink_sent_this_death: we interacted with the multiworld on this death, wait
|
||||
|
||||
oot_loc_name_to_id = network_data_package["games"]["Ocarina of Time"]["location_name_to_id"]
|
||||
|
||||
script_version: int = 3
|
||||
script_version: int = 2
|
||||
|
||||
def get_item_value(ap_id):
|
||||
return ap_id - 66000
|
||||
@@ -71,7 +68,7 @@ class OoTCommandProcessor(ClientCommandProcessor):
|
||||
if isinstance(self.ctx, OoTContext):
|
||||
self.ctx.deathlink_client_override = True
|
||||
self.ctx.deathlink_enabled = not self.ctx.deathlink_enabled
|
||||
async_start(self.ctx.update_death_link(self.ctx.deathlink_enabled), name="Update Deathlink")
|
||||
asyncio.create_task(self.ctx.update_death_link(self.ctx.deathlink_enabled), name="Update Deathlink")
|
||||
|
||||
|
||||
class OoTContext(CommonContext):
|
||||
@@ -86,9 +83,6 @@ class OoTContext(CommonContext):
|
||||
self.n64_status = CONNECTION_INITIAL_STATUS
|
||||
self.awaiting_rom = False
|
||||
self.location_table = {}
|
||||
self.collectible_table = {}
|
||||
self.collectible_override_flags_address = 0
|
||||
self.collectible_offsets = {}
|
||||
self.deathlink_enabled = False
|
||||
self.deathlink_pending = False
|
||||
self.deathlink_sent_this_death = False
|
||||
@@ -100,7 +94,7 @@ class OoTContext(CommonContext):
|
||||
await super(OoTContext, self).server_auth(password_requested)
|
||||
if not self.auth:
|
||||
self.awaiting_rom = True
|
||||
logger.info('Awaiting connection to EmuHawk to get player information')
|
||||
logger.info('Awaiting connection to Bizhawk to get player information')
|
||||
return
|
||||
|
||||
await self.send_connect()
|
||||
@@ -121,13 +115,6 @@ class OoTContext(CommonContext):
|
||||
self.ui = OoTManager(self)
|
||||
self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI")
|
||||
|
||||
def on_package(self, cmd, args):
|
||||
if cmd == 'Connected':
|
||||
slot_data = args.get('slot_data', None)
|
||||
if slot_data:
|
||||
self.collectible_override_flags_address = slot_data.get('collectible_override_flags', 0)
|
||||
self.collectible_offsets = slot_data.get('collectible_flag_offsets', {})
|
||||
|
||||
|
||||
def get_payload(ctx: OoTContext):
|
||||
if ctx.deathlink_enabled and ctx.deathlink_pending:
|
||||
@@ -136,32 +123,15 @@ def get_payload(ctx: OoTContext):
|
||||
else:
|
||||
trigger_death = False
|
||||
|
||||
payload = json.dumps({
|
||||
return json.dumps({
|
||||
"items": [get_item_value(item.item) for item in ctx.items_received],
|
||||
"playerNames": [name for (i, name) in ctx.player_names.items() if i != 0],
|
||||
"triggerDeath": trigger_death,
|
||||
"collectibleOverrides": ctx.collectible_override_flags_address,
|
||||
"collectibleOffsets": ctx.collectible_offsets
|
||||
"triggerDeath": trigger_death
|
||||
})
|
||||
return payload
|
||||
|
||||
|
||||
async def parse_payload(payload: dict, ctx: OoTContext, force: bool):
|
||||
|
||||
# Refuse to do anything if ROM is detected as changed
|
||||
if ctx.auth and payload['playerName'] != ctx.auth:
|
||||
logger.warning("ROM change detected. Disconnecting and reconnecting...")
|
||||
ctx.deathlink_enabled = False
|
||||
ctx.deathlink_client_override = False
|
||||
ctx.finished_game = False
|
||||
ctx.location_table = {}
|
||||
ctx.collectible_table = {}
|
||||
ctx.deathlink_pending = False
|
||||
ctx.deathlink_sent_this_death = False
|
||||
ctx.auth = payload['playerName']
|
||||
await ctx.send_connect()
|
||||
return
|
||||
|
||||
# Turn on deathlink if it is on, and if the client hasn't overriden it
|
||||
if payload['deathlinkActive'] and not ctx.deathlink_enabled and not ctx.deathlink_client_override:
|
||||
await ctx.update_death_link(True)
|
||||
@@ -176,23 +146,11 @@ async def parse_payload(payload: dict, ctx: OoTContext, force: bool):
|
||||
ctx.finished_game = True
|
||||
|
||||
# Locations handling
|
||||
locations = payload['locations']
|
||||
collectibles = payload['collectibles']
|
||||
|
||||
# The Lua JSON library serializes an empty table into a list instead of a dict. Verify types for safety:
|
||||
if isinstance(locations, list):
|
||||
locations = {}
|
||||
if isinstance(collectibles, list):
|
||||
collectibles = {}
|
||||
|
||||
if ctx.location_table != locations or ctx.collectible_table != collectibles:
|
||||
ctx.location_table = locations
|
||||
ctx.collectible_table = collectibles
|
||||
locs1 = [oot_loc_name_to_id[loc] for loc, b in ctx.location_table.items() if b]
|
||||
locs2 = [int(loc) for loc, b in ctx.collectible_table.items() if b]
|
||||
if ctx.location_table != payload['locations']:
|
||||
ctx.location_table = payload['locations']
|
||||
await ctx.send_msgs([{
|
||||
"cmd": "LocationChecks",
|
||||
"locations": locs1 + locs2
|
||||
"locations": [oot_loc_name_to_id[loc] for loc in ctx.location_table if ctx.location_table[loc]]
|
||||
}])
|
||||
|
||||
# Deathlink handling
|
||||
@@ -218,13 +176,20 @@ async def n64_sync_task(ctx: OoTContext):
|
||||
try:
|
||||
await asyncio.wait_for(writer.drain(), timeout=1.5)
|
||||
try:
|
||||
# Data will return a dict with up to six fields:
|
||||
# 1. str: player name (always)
|
||||
# 2. int: script version (always)
|
||||
# 3. bool: deathlink active (always)
|
||||
# 4. dict[str, bool]: checked locations
|
||||
# 5. bool: whether Link is currently at 0 HP
|
||||
# 6. bool: whether the game currently registers as complete
|
||||
data = await asyncio.wait_for(reader.readline(), timeout=10)
|
||||
data_decoded = json.loads(data.decode())
|
||||
reported_version = data_decoded.get('scriptVersion', 0)
|
||||
if reported_version >= script_version:
|
||||
if ctx.game is not None and 'locations' in data_decoded:
|
||||
# Not just a keep alive ping, parse
|
||||
async_start(parse_payload(data_decoded, ctx, False))
|
||||
asyncio.create_task(parse_payload(data_decoded, ctx, False))
|
||||
if not ctx.auth:
|
||||
ctx.auth = data_decoded['playerName']
|
||||
if ctx.awaiting_rom:
|
||||
@@ -290,27 +255,17 @@ async def run_game(romfile):
|
||||
|
||||
|
||||
async def patch_and_run_game(apz5_file):
|
||||
apz5_file = os.path.abspath(apz5_file)
|
||||
base_name = os.path.splitext(apz5_file)[0]
|
||||
decomp_path = base_name + '-decomp.z64'
|
||||
comp_path = base_name + '.z64'
|
||||
# Load vanilla ROM, patch file, compress ROM
|
||||
rom_file_name = Utils.get_options()["oot_options"]["rom_file"]
|
||||
rom = Rom(rom_file_name)
|
||||
|
||||
sub_file = None
|
||||
if zipfile.is_zipfile(apz5_file):
|
||||
for name in zipfile.ZipFile(apz5_file).namelist():
|
||||
if name.endswith('.zpf'):
|
||||
sub_file = name
|
||||
break
|
||||
|
||||
apply_patch_file(rom, apz5_file, sub_file=sub_file)
|
||||
rom = Rom(Utils.local_path(Utils.get_options()["oot_options"]["rom_file"]))
|
||||
apply_patch_file(rom, apz5_file)
|
||||
rom.write_to_file(decomp_path)
|
||||
os.chdir(data_path("Compress"))
|
||||
compress_rom_file(decomp_path, comp_path)
|
||||
os.remove(decomp_path)
|
||||
async_start(run_game(comp_path))
|
||||
asyncio.create_task(run_game(comp_path))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
@@ -326,7 +281,7 @@ if __name__ == '__main__':
|
||||
|
||||
if args.apz5_file:
|
||||
logger.info("APZ5 file supplied, beginning patching process...")
|
||||
async_start(patch_and_run_game(args.apz5_file))
|
||||
asyncio.create_task(patch_and_run_game(args.apz5_file))
|
||||
|
||||
ctx = OoTContext(args.connect, args.password)
|
||||
ctx.server_task = asyncio.create_task(server_loop(ctx), name="Server Loop")
|
||||
|
||||
1087
Options.py
430
Patch.py
@@ -1,35 +1,439 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import json
|
||||
import bsdiff4
|
||||
import yaml
|
||||
import os
|
||||
import lzma
|
||||
import threading
|
||||
import concurrent.futures
|
||||
import zipfile
|
||||
import sys
|
||||
from typing import Tuple, Optional, TypedDict
|
||||
from typing import Tuple, Optional, Dict, Any, Union, BinaryIO
|
||||
|
||||
if __name__ == "__main__":
|
||||
import ModuleUpdate
|
||||
ModuleUpdate.update()
|
||||
import ModuleUpdate
|
||||
ModuleUpdate.update()
|
||||
|
||||
from worlds.Files import AutoPatchRegister, APAutoPatchInterface
|
||||
import Utils
|
||||
|
||||
current_patch_version = 5
|
||||
|
||||
|
||||
class RomMeta(TypedDict):
|
||||
server: str
|
||||
class AutoPatchRegister(type):
|
||||
patch_types: Dict[str, APDeltaPatch] = {}
|
||||
file_endings: Dict[str, APDeltaPatch] = {}
|
||||
|
||||
def __new__(cls, name: str, bases, dct: Dict[str, Any]):
|
||||
# construct class
|
||||
new_class = super().__new__(cls, name, bases, dct)
|
||||
if "game" in dct:
|
||||
AutoPatchRegister.patch_types[dct["game"]] = new_class
|
||||
if not dct["patch_file_ending"]:
|
||||
raise Exception(f"Need an expected file ending for {name}")
|
||||
AutoPatchRegister.file_endings[dct["patch_file_ending"]] = new_class
|
||||
return new_class
|
||||
|
||||
@staticmethod
|
||||
def get_handler(file: str) -> Optional[type(APDeltaPatch)]:
|
||||
for file_ending, handler in AutoPatchRegister.file_endings.items():
|
||||
if file.endswith(file_ending):
|
||||
return handler
|
||||
|
||||
|
||||
class APContainer:
|
||||
"""A zipfile containing at least archipelago.json"""
|
||||
version: int = current_patch_version
|
||||
compression_level: int = 9
|
||||
compression_method: int = zipfile.ZIP_DEFLATED
|
||||
game: Optional[str] = None
|
||||
|
||||
# instance attributes:
|
||||
path: Optional[str]
|
||||
player: Optional[int]
|
||||
player_name: str
|
||||
server: str
|
||||
|
||||
def __init__(self, path: Optional[str] = None, player: Optional[int] = None,
|
||||
player_name: str = "", server: str = ""):
|
||||
self.path = path
|
||||
self.player = player
|
||||
self.player_name = player_name
|
||||
self.server = server
|
||||
|
||||
def write(self, file: Optional[Union[str, BinaryIO]] = None):
|
||||
if not self.path and not file:
|
||||
raise FileNotFoundError(f"Cannot write {self.__class__.__name__} due to no path provided.")
|
||||
with zipfile.ZipFile(file if file else self.path, "w", self.compression_method, True, self.compression_level) \
|
||||
as zf:
|
||||
if file:
|
||||
self.path = zf.filename
|
||||
self.write_contents(zf)
|
||||
|
||||
def write_contents(self, opened_zipfile: zipfile.ZipFile):
|
||||
manifest = self.get_manifest()
|
||||
try:
|
||||
manifest = json.dumps(manifest)
|
||||
except Exception as e:
|
||||
raise Exception(f"Manifest {manifest} did not convert to json.") from e
|
||||
else:
|
||||
opened_zipfile.writestr("archipelago.json", manifest)
|
||||
|
||||
def read(self, file: Optional[Union[str, BinaryIO]] = None):
|
||||
"""Read data into patch object. file can be file-like, such as an outer zip file's stream."""
|
||||
if not self.path and not file:
|
||||
raise FileNotFoundError(f"Cannot read {self.__class__.__name__} due to no path provided.")
|
||||
with zipfile.ZipFile(file if file else self.path, "r") as zf:
|
||||
if file:
|
||||
self.path = zf.filename
|
||||
self.read_contents(zf)
|
||||
|
||||
def read_contents(self, opened_zipfile: zipfile.ZipFile):
|
||||
with opened_zipfile.open("archipelago.json", "r") as f:
|
||||
manifest = json.load(f)
|
||||
if manifest["compatible_version"] > self.version:
|
||||
raise Exception(f"File (version: {manifest['compatible_version']}) too new "
|
||||
f"for this handler (version: {self.version})")
|
||||
self.player = manifest["player"]
|
||||
self.server = manifest["server"]
|
||||
self.player_name = manifest["player_name"]
|
||||
|
||||
def get_manifest(self) -> dict:
|
||||
return {
|
||||
"server": self.server, # allow immediate connection to server in multiworld. Empty string otherwise
|
||||
"player": self.player,
|
||||
"player_name": self.player_name,
|
||||
"game": self.game,
|
||||
# minimum version of patch system expected for patching to be successful
|
||||
"compatible_version": 4,
|
||||
"version": current_patch_version,
|
||||
}
|
||||
|
||||
|
||||
def create_rom_file(patch_file: str) -> Tuple[RomMeta, str]:
|
||||
class APDeltaPatch(APContainer, metaclass=AutoPatchRegister):
|
||||
"""An APContainer that additionally has delta.bsdiff4
|
||||
containing a delta patch to get the desired file, often a rom."""
|
||||
|
||||
hash = Optional[str] # base checksum of source file
|
||||
patch_file_ending: str = ""
|
||||
delta: Optional[bytes] = None
|
||||
result_file_ending: str = ".sfc"
|
||||
source_data: bytes
|
||||
|
||||
def __init__(self, *args, patched_path: str = "", **kwargs):
|
||||
self.patched_path = patched_path
|
||||
super(APDeltaPatch, self).__init__(*args, **kwargs)
|
||||
|
||||
def get_manifest(self) -> dict:
|
||||
manifest = super(APDeltaPatch, self).get_manifest()
|
||||
manifest["base_checksum"] = self.hash
|
||||
manifest["result_file_ending"] = self.result_file_ending
|
||||
manifest["patch_file_ending"] = self.patch_file_ending
|
||||
return manifest
|
||||
|
||||
@classmethod
|
||||
def get_source_data(cls) -> bytes:
|
||||
"""Get Base data"""
|
||||
raise NotImplementedError()
|
||||
|
||||
@classmethod
|
||||
def get_source_data_with_cache(cls) -> bytes:
|
||||
if not hasattr(cls, "source_data"):
|
||||
cls.source_data = cls.get_source_data()
|
||||
return cls.source_data
|
||||
|
||||
def write_contents(self, opened_zipfile: zipfile.ZipFile):
|
||||
super(APDeltaPatch, self).write_contents(opened_zipfile)
|
||||
# write Delta
|
||||
opened_zipfile.writestr("delta.bsdiff4",
|
||||
bsdiff4.diff(self.get_source_data_with_cache(), open(self.patched_path, "rb").read()),
|
||||
compress_type=zipfile.ZIP_STORED) # bsdiff4 is a format with integrated compression
|
||||
|
||||
def read_contents(self, opened_zipfile: zipfile.ZipFile):
|
||||
super(APDeltaPatch, self).read_contents(opened_zipfile)
|
||||
self.delta = opened_zipfile.read("delta.bsdiff4")
|
||||
|
||||
def patch(self, target: str):
|
||||
"""Base + Delta -> Patched"""
|
||||
if not self.delta:
|
||||
self.read()
|
||||
result = bsdiff4.patch(self.get_source_data_with_cache(), self.delta)
|
||||
with open(target, "wb") as f:
|
||||
f.write(result)
|
||||
|
||||
|
||||
# legacy patch handling follows:
|
||||
GAME_ALTTP = "A Link to the Past"
|
||||
GAME_SM = "Super Metroid"
|
||||
GAME_SOE = "Secret of Evermore"
|
||||
GAME_SMZ3 = "SMZ3"
|
||||
GAME_DKC3 = "Donkey Kong Country 3"
|
||||
supported_games = {"A Link to the Past", "Super Metroid", "Secret of Evermore", "SMZ3", "Donkey Kong Country 3"}
|
||||
|
||||
preferred_endings = {
|
||||
GAME_ALTTP: "apbp",
|
||||
GAME_SM: "apm3",
|
||||
GAME_SOE: "apsoe",
|
||||
GAME_SMZ3: "apsmz",
|
||||
GAME_DKC3: "apdkc3"
|
||||
}
|
||||
|
||||
|
||||
def generate_yaml(patch: bytes, metadata: Optional[dict] = None, game: str = GAME_ALTTP) -> bytes:
|
||||
if game == GAME_ALTTP:
|
||||
from worlds.alttp.Rom import LTTPJPN10HASH as HASH
|
||||
elif game == GAME_SM:
|
||||
from worlds.sm.Rom import SMJUHASH as HASH
|
||||
elif game == GAME_SOE:
|
||||
from worlds.soe.Patch import USHASH as HASH
|
||||
elif game == GAME_SMZ3:
|
||||
from worlds.alttp.Rom import LTTPJPN10HASH as ALTTPHASH
|
||||
from worlds.sm.Rom import SMJUHASH as SMHASH
|
||||
HASH = ALTTPHASH + SMHASH
|
||||
elif game == GAME_DKC3:
|
||||
from worlds.dkc3.Rom import USHASH as HASH
|
||||
else:
|
||||
raise RuntimeError(f"Selected game {game} for base rom not found.")
|
||||
|
||||
patch = yaml.dump({"meta": metadata,
|
||||
"patch": patch,
|
||||
"game": game,
|
||||
# minimum version of patch system expected for patching to be successful
|
||||
"compatible_version": 3,
|
||||
"version": current_patch_version,
|
||||
"base_checksum": HASH})
|
||||
return patch.encode(encoding="utf-8-sig")
|
||||
|
||||
|
||||
def generate_patch(rom: bytes, metadata: Optional[dict] = None, game: str = GAME_ALTTP) -> bytes:
|
||||
if metadata is None:
|
||||
metadata = {}
|
||||
patch = bsdiff4.diff(get_base_rom_data(game), rom)
|
||||
return generate_yaml(patch, metadata, game)
|
||||
|
||||
|
||||
def create_patch_file(rom_file_to_patch: str, server: str = "", destination: str = None,
|
||||
player: int = 0, player_name: str = "", game: str = GAME_ALTTP) -> str:
|
||||
meta = {"server": server, # allow immediate connection to server in multiworld. Empty string otherwise
|
||||
"player_id": player,
|
||||
"player_name": player_name}
|
||||
bytes = generate_patch(load_bytes(rom_file_to_patch),
|
||||
meta,
|
||||
game)
|
||||
target = destination if destination else os.path.splitext(rom_file_to_patch)[0] + (
|
||||
".apbp" if game == GAME_ALTTP
|
||||
else ".apsmz" if game == GAME_SMZ3
|
||||
else ".apdkc3" if game == GAME_DKC3
|
||||
else ".apm3")
|
||||
write_lzma(bytes, target)
|
||||
return target
|
||||
|
||||
|
||||
def create_rom_bytes(patch_file: str, ignore_version: bool = False) -> Tuple[dict, str, bytearray]:
|
||||
data = Utils.parse_yaml(lzma.decompress(load_bytes(patch_file)).decode("utf-8-sig"))
|
||||
game_name = data["game"]
|
||||
if not ignore_version and data["compatible_version"] > current_patch_version:
|
||||
raise RuntimeError("Patch file is incompatible with this patcher, likely an update is required.")
|
||||
patched_data = bsdiff4.patch(get_base_rom_data(game_name), data["patch"])
|
||||
rom_hash = patched_data[int(0x7FC0):int(0x7FD5)]
|
||||
data["meta"]["hash"] = "".join(chr(x) for x in rom_hash)
|
||||
target = os.path.splitext(patch_file)[0] + ".sfc"
|
||||
return data["meta"], target, patched_data
|
||||
|
||||
|
||||
def get_base_rom_data(game: str):
|
||||
if game == GAME_ALTTP:
|
||||
from worlds.alttp.Rom import get_base_rom_bytes
|
||||
elif game == "alttp": # old version for A Link to the Past
|
||||
from worlds.alttp.Rom import get_base_rom_bytes
|
||||
elif game == GAME_SM:
|
||||
from worlds.sm.Rom import get_base_rom_bytes
|
||||
elif game == GAME_SOE:
|
||||
from worlds.soe.Patch import get_base_rom_path
|
||||
get_base_rom_bytes = lambda: bytes(read_rom(open(get_base_rom_path(), "rb")))
|
||||
elif game == GAME_SMZ3:
|
||||
from worlds.smz3.Rom import get_base_rom_bytes
|
||||
elif game == GAME_DKC3:
|
||||
from worlds.dkc3.Rom import get_base_rom_bytes
|
||||
else:
|
||||
raise RuntimeError("Selected game for base rom not found.")
|
||||
return get_base_rom_bytes()
|
||||
|
||||
|
||||
def create_rom_file(patch_file: str) -> Tuple[dict, str]:
|
||||
auto_handler = AutoPatchRegister.get_handler(patch_file)
|
||||
if auto_handler:
|
||||
handler: APAutoPatchInterface = auto_handler(patch_file)
|
||||
handler: APDeltaPatch = auto_handler(patch_file)
|
||||
target = os.path.splitext(patch_file)[0]+handler.result_file_ending
|
||||
handler.patch(target)
|
||||
return {"server": handler.server,
|
||||
"player": handler.player,
|
||||
"player_name": handler.player_name}, target
|
||||
raise NotImplementedError(f"No Handler for {patch_file} found.")
|
||||
else:
|
||||
data, target, patched_data = create_rom_bytes(patch_file)
|
||||
with open(target, "wb") as f:
|
||||
f.write(patched_data)
|
||||
return data, target
|
||||
|
||||
|
||||
def update_patch_data(patch_data: bytes, server: str = "") -> bytes:
|
||||
data = Utils.parse_yaml(lzma.decompress(patch_data).decode("utf-8-sig"))
|
||||
data["meta"]["server"] = server
|
||||
bytes = generate_yaml(data["patch"], data["meta"], data["game"])
|
||||
return lzma.compress(bytes)
|
||||
|
||||
|
||||
def load_bytes(path: str) -> bytes:
|
||||
with open(path, "rb") as f:
|
||||
return f.read()
|
||||
|
||||
|
||||
def write_lzma(data: bytes, path: str):
|
||||
with lzma.LZMAFile(path, 'wb') as f:
|
||||
f.write(data)
|
||||
|
||||
|
||||
def read_rom(stream, strip_header=True) -> bytearray:
|
||||
"""Reads rom into bytearray and optionally strips off any smc header"""
|
||||
buffer = bytearray(stream.read())
|
||||
if strip_header and len(buffer) % 0x400 == 0x200:
|
||||
return buffer[0x200:]
|
||||
return buffer
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
for file in sys.argv[1:]:
|
||||
meta_data, result_file = create_rom_file(file)
|
||||
print(f"Patch with meta-data {meta_data} was written to {result_file}")
|
||||
host = Utils.get_public_ipv4()
|
||||
options = Utils.get_options()['server_options']
|
||||
if options['host']:
|
||||
host = options['host']
|
||||
|
||||
address = f"{host}:{options['port']}"
|
||||
ziplock = threading.Lock()
|
||||
print(f"Host for patches to be created is {address}")
|
||||
with concurrent.futures.ThreadPoolExecutor() as pool:
|
||||
for rom in sys.argv:
|
||||
try:
|
||||
if rom.endswith(".sfc"):
|
||||
print(f"Creating patch for {rom}")
|
||||
result = pool.submit(create_patch_file, rom, address)
|
||||
result.add_done_callback(lambda task: print(f"Created patch {task.result()}"))
|
||||
|
||||
elif rom.endswith(".apbp"):
|
||||
print(f"Applying patch {rom}")
|
||||
data, target = create_rom_file(rom)
|
||||
#romfile, adjusted = Utils.get_adjuster_settings(target)
|
||||
adjuster_settings = Utils.get_adjuster_settings(GAME_ALTTP)
|
||||
adjusted = False
|
||||
if adjuster_settings:
|
||||
import pprint
|
||||
from worlds.alttp.Rom import get_base_rom_path
|
||||
adjuster_settings.rom = target
|
||||
adjuster_settings.baserom = get_base_rom_path()
|
||||
adjuster_settings.world = None
|
||||
whitelist = {"music", "menuspeed", "heartbeep", "heartcolor", "ow_palettes", "quickswap",
|
||||
"uw_palettes", "sprite", "sword_palettes", "shield_palettes", "hud_palettes",
|
||||
"reduceflashing", "deathlink"}
|
||||
printed_options = {name: value for name, value in vars(adjuster_settings).items() if name in whitelist}
|
||||
if hasattr(adjuster_settings, "sprite_pool"):
|
||||
sprite_pool = {}
|
||||
for sprite in getattr(adjuster_settings, "sprite_pool"):
|
||||
if sprite in sprite_pool:
|
||||
sprite_pool[sprite] += 1
|
||||
else:
|
||||
sprite_pool[sprite] = 1
|
||||
if sprite_pool:
|
||||
printed_options["sprite_pool"] = sprite_pool
|
||||
|
||||
adjust_wanted = str('no')
|
||||
if not hasattr(adjuster_settings, 'auto_apply') or 'ask' in adjuster_settings.auto_apply:
|
||||
adjust_wanted = input(f"Last used adjuster settings were found. Would you like to apply these? \n"
|
||||
f"{pprint.pformat(printed_options)}\n"
|
||||
f"Enter yes, no, always or never: ")
|
||||
if adjuster_settings.auto_apply == 'never': # never adjust, per user request
|
||||
adjust_wanted = 'no'
|
||||
elif adjuster_settings.auto_apply == 'always':
|
||||
adjust_wanted = 'yes'
|
||||
|
||||
if adjust_wanted and "never" in adjust_wanted:
|
||||
adjuster_settings.auto_apply = 'never'
|
||||
Utils.persistent_store("adjuster", GAME_ALTTP, adjuster_settings)
|
||||
|
||||
elif adjust_wanted and "always" in adjust_wanted:
|
||||
adjuster_settings.auto_apply = 'always'
|
||||
Utils.persistent_store("adjuster", GAME_ALTTP, adjuster_settings)
|
||||
|
||||
if adjust_wanted and adjust_wanted.startswith("y"):
|
||||
if hasattr(adjuster_settings, "sprite_pool"):
|
||||
from LttPAdjuster import AdjusterWorld
|
||||
adjuster_settings.world = AdjusterWorld(getattr(adjuster_settings, "sprite_pool"))
|
||||
|
||||
adjusted = True
|
||||
import LttPAdjuster
|
||||
_, romfile = LttPAdjuster.adjust(adjuster_settings)
|
||||
|
||||
if hasattr(adjuster_settings, "world"):
|
||||
delattr(adjuster_settings, "world")
|
||||
else:
|
||||
adjusted = False
|
||||
if adjusted:
|
||||
try:
|
||||
shutil.move(romfile, target)
|
||||
romfile = target
|
||||
except Exception as e:
|
||||
print(e)
|
||||
print(f"Created rom {romfile if adjusted else target}.")
|
||||
if 'server' in data:
|
||||
Utils.persistent_store("servers", data['hash'], data['server'])
|
||||
print(f"Host is {data['server']}")
|
||||
elif rom.endswith(".apm3"):
|
||||
print(f"Applying patch {rom}")
|
||||
data, target = create_rom_file(rom)
|
||||
print(f"Created rom {target}.")
|
||||
if 'server' in data:
|
||||
Utils.persistent_store("servers", data['hash'], data['server'])
|
||||
print(f"Host is {data['server']}")
|
||||
elif rom.endswith(".apsmz"):
|
||||
print(f"Applying patch {rom}")
|
||||
data, target = create_rom_file(rom)
|
||||
print(f"Created rom {target}.")
|
||||
if 'server' in data:
|
||||
Utils.persistent_store("servers", data['hash'], data['server'])
|
||||
print(f"Host is {data['server']}")
|
||||
elif rom.endswith(".apdkc3"):
|
||||
print(f"Applying patch {rom}")
|
||||
data, target = create_rom_file(rom)
|
||||
print(f"Created rom {target}.")
|
||||
if 'server' in data:
|
||||
Utils.persistent_store("servers", data['hash'], data['server'])
|
||||
print(f"Host is {data['server']}")
|
||||
|
||||
elif rom.endswith(".zip"):
|
||||
print(f"Updating host in patch files contained in {rom}")
|
||||
|
||||
|
||||
def _handle_zip_file_entry(zfinfo: zipfile.ZipInfo, server: str):
|
||||
data = zfr.read(zfinfo)
|
||||
if zfinfo.filename.endswith(".apbp") or \
|
||||
zfinfo.filename.endswith(".apm3") or \
|
||||
zfinfo.filename.endswith(".apdkc3"):
|
||||
data = update_patch_data(data, server)
|
||||
with ziplock:
|
||||
zfw.writestr(zfinfo, data)
|
||||
return zfinfo.filename
|
||||
|
||||
|
||||
futures = []
|
||||
with zipfile.ZipFile(rom, "r") as zfr:
|
||||
updated_zip = os.path.splitext(rom)[0] + "_updated.zip"
|
||||
with zipfile.ZipFile(updated_zip, "w", compression=zipfile.ZIP_DEFLATED,
|
||||
compresslevel=9) as zfw:
|
||||
for zfname in zfr.namelist():
|
||||
futures.append(pool.submit(_handle_zip_file_entry, zfr.getinfo(zfname), address))
|
||||
for future in futures:
|
||||
print(f"File {future.result()} added to {os.path.split(updated_zip)[1]}")
|
||||
|
||||
except:
|
||||
import traceback
|
||||
|
||||
traceback.print_exc()
|
||||
input("Press enter to close.")
|
||||
|
||||
97
README.md
@@ -1,10 +1,8 @@
|
||||
# [Archipelago](https://archipelago.gg)  | [Install](https://github.com/ArchipelagoMW/Archipelago/releases)
|
||||
|
||||
Archipelago provides a generic framework for developing multiworld capability for game randomizers. In all cases,
|
||||
presently, Archipelago is also the randomizer itself.
|
||||
Archipelago provides a generic framework for developing multiworld capability for game randomizers. In all cases, presently, Archipelago is also the randomizer itself.
|
||||
|
||||
Currently, the following games are supported:
|
||||
|
||||
* The Legend of Zelda: A Link to the Past
|
||||
* Factorio
|
||||
* Minecraft
|
||||
@@ -27,59 +25,9 @@ Currently, the following games are supported:
|
||||
* Hollow Knight
|
||||
* The Witness
|
||||
* Sonic Adventure 2: Battle
|
||||
* Starcraft 2
|
||||
* Starcraft 2: Wings of Liberty
|
||||
* Donkey Kong Country 3
|
||||
* Dark Souls 3
|
||||
* Super Mario World
|
||||
* Pokémon Red and Blue
|
||||
* Hylics 2
|
||||
* Overcooked! 2
|
||||
* Zillion
|
||||
* Lufia II Ancient Cave
|
||||
* Blasphemous
|
||||
* Wargroove
|
||||
* Stardew Valley
|
||||
* The Legend of Zelda
|
||||
* The Messenger
|
||||
* Kingdom Hearts 2
|
||||
* The Legend of Zelda: Link's Awakening DX
|
||||
* Clique
|
||||
* Adventure
|
||||
* DLC Quest
|
||||
* Noita
|
||||
* Undertale
|
||||
* Bumper Stickers
|
||||
* Mega Man Battle Network 3: Blue Version
|
||||
* Muse Dash
|
||||
* DOOM 1993
|
||||
* Terraria
|
||||
* Lingo
|
||||
* Pokémon Emerald
|
||||
* DOOM II
|
||||
* Shivers
|
||||
* Heretic
|
||||
* Landstalker: The Treasures of King Nole
|
||||
* Final Fantasy Mystic Quest
|
||||
* TUNIC
|
||||
* Kirby's Dream Land 3
|
||||
* Celeste 64
|
||||
* Zork Grand Inquisitor
|
||||
* Castlevania 64
|
||||
* A Short Hike
|
||||
* Yoshi's Island
|
||||
* Mario & Luigi: Superstar Saga
|
||||
* Bomb Rush Cyberfunk
|
||||
* Aquaria
|
||||
* Yu-Gi-Oh! Ultimate Masters: World Championship Tournament 2006
|
||||
* A Hat in Time
|
||||
* Old School Runescape
|
||||
* Kingdom Hearts 1
|
||||
* Mega Man 2
|
||||
* Yacht Dice
|
||||
* Faxanadu
|
||||
* Saving Princess
|
||||
* Castlevania: Circle of the Moon
|
||||
* Inscryption
|
||||
|
||||
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
|
||||
@@ -87,57 +35,36 @@ windows binaries.
|
||||
|
||||
## History
|
||||
|
||||
Archipelago is built upon a strong legacy of brilliant hobbyists. We want to honor that legacy by showing it here.
|
||||
The repositories which Archipelago is built upon, inspired by, or otherwise owes its gratitude to are:
|
||||
Archipelago is built upon a strong legacy of brilliant hobbyists. We want to honor that legacy by showing it here. The repositories which Archipelago is built upon, inspired by, or otherwise owes its gratitude to are:
|
||||
|
||||
* [bonta0's MultiWorld](https://github.com/Bonta0/ALttPEntranceRandomizer/tree/multiworld_31)
|
||||
* [AmazingAmpharos' Entrance Randomizer](https://github.com/AmazingAmpharos/ALttPEntranceRandomizer)
|
||||
* [VT Web Randomizer](https://github.com/sporchia/alttp_vt_randomizer)
|
||||
* [Dessyreqt's alttprandomizer](https://github.com/Dessyreqt/alttprandomizer)
|
||||
* [Zarby89's](https://github.com/Ijwu/Enemizer/commits?author=Zarby89)
|
||||
and [sosuke3's](https://github.com/Ijwu/Enemizer/commits?author=sosuke3) contributions to Enemizer, which make up the
|
||||
vast majority of Enemizer contributions.
|
||||
* [Zarby89's](https://github.com/Ijwu/Enemizer/commits?author=Zarby89) and [sosuke3's](https://github.com/Ijwu/Enemizer/commits?author=sosuke3) contributions to Enemizer, which make the vast majority of Enemizer contributions.
|
||||
|
||||
We recognize that there is a strong community of incredibly smart people that have come before us and helped pave the
|
||||
path. Just because one person's name may be in a repository title does not mean that only one person made that project
|
||||
happen. We can't hope to perfectly cover every single contribution that lead up to Archipelago, but we hope to honor
|
||||
them fairly.
|
||||
We recognize that there is a strong community of incredibly smart people that have come before us and helped pave the path. Just because one person's name may be in a repository title does not mean that only one person made that project happen. We can't hope to perfectly cover every single contribution that lead up to Archipelago but we hope to honor them fairly.
|
||||
|
||||
### Path to the Archipelago
|
||||
|
||||
Archipelago was directly forked from bonta0's `multiworld_31` branch of ALttPEntranceRandomizer (this project has a
|
||||
long legacy of its own, please check it out linked above) on January 12, 2020. The repository was then named to
|
||||
_MultiWorld-Utilities_ to better encompass its intended function. As Archipelago matured, then known as
|
||||
"Berserker's MultiWorld" by some, we found it necessary to transform our repository into a root level repository
|
||||
(as opposed to a 'forked repo') and change the name (which came later) to better reflect our project.
|
||||
Archipelago was directly forked from bonta0's `multiworld_31` branch of ALttPEntranceRandomizer (this project has a long legacy of its own, please check it out linked above) on January 12, 2020. The repository was then named to _MultiWorld-Utilities_ to better encompass its intended function. As Archipelago matured, then known as "Berserker's MultiWorld" by some, we found it necessary to transform our repository into a root level repository (as opposed to a 'forked repo') and change the name (which came later) to better reflect our project.
|
||||
|
||||
## Running Archipelago
|
||||
For most people all you need to do is head over to the [releases](https://github.com/ArchipelagoMW/Archipelago/releases) page then download and run the appropriate installer. The installers function on Windows only.
|
||||
|
||||
For most people, all you need to do is head over to
|
||||
the [releases page](https://github.com/ArchipelagoMW/Archipelago/releases), then download and run the appropriate
|
||||
installer, or AppImage for Linux-based systems.
|
||||
|
||||
If you are a developer or are running on a platform with no compiled releases available, please see our doc on
|
||||
[running Archipelago from source](docs/running%20from%20source.md).
|
||||
If you are running Archipelago from a non-Windows system then the likely scenario is that you are comfortable running source code directly. Please see our doc on [running Archipelago from source](docs/running%20from%20source.md).
|
||||
|
||||
## Related Repositories
|
||||
|
||||
This project makes use of multiple other projects. We wouldn't be here without these other repositories and the
|
||||
contributions of their developers, past and present.
|
||||
This project makes use of multiple other projects. We wouldn't be here without these other repositories and the contributions of their developers, past and present.
|
||||
|
||||
* [z3randomizer](https://github.com/ArchipelagoMW/z3randomizer)
|
||||
* [Enemizer](https://github.com/Ijwu/Enemizer)
|
||||
* [Ocarina of Time Randomizer](https://github.com/TestRunnerSRL/OoT-Randomizer)
|
||||
|
||||
## Contributing
|
||||
|
||||
To contribute to Archipelago, including the WebHost, core program, or by adding a new game, see our
|
||||
[Contributing guidelines](/docs/contributing.md).
|
||||
For contribution guidelines, please see our [Contributing doc.](/docs/contributing.md)
|
||||
|
||||
## FAQ
|
||||
|
||||
For Frequently asked questions, please see the website's [FAQ Page](https://archipelago.gg/faq/en/).
|
||||
For Frequently asked questions, please see the website's [FAQ Page.](https://archipelago.gg/faq/en/)
|
||||
|
||||
## Code of Conduct
|
||||
|
||||
Please refer to our [code of conduct](/docs/code_of_conduct.md).
|
||||
Please refer to our [code of conduct.](/docs/code_of_conduct.md)
|
||||
|
||||
1221
SNIClient.py
@@ -1,11 +1,876 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import ModuleUpdate
|
||||
ModuleUpdate.update()
|
||||
import asyncio
|
||||
import copy
|
||||
import ctypes
|
||||
import logging
|
||||
import multiprocessing
|
||||
import os.path
|
||||
import re
|
||||
import sys
|
||||
import typing
|
||||
import queue
|
||||
from pathlib import Path
|
||||
|
||||
from worlds.sc2.Client import launch
|
||||
import Utils
|
||||
import nest_asyncio
|
||||
import sc2
|
||||
from sc2.bot_ai import BotAI
|
||||
from sc2.data import Race
|
||||
from sc2.main import run_game
|
||||
from sc2.player import Bot
|
||||
|
||||
from MultiServer import mark_raw
|
||||
from Utils import init_logging, is_windows
|
||||
from worlds.sc2wol import SC2WoLWorld
|
||||
from worlds.sc2wol.Items import lookup_id_to_name, item_table
|
||||
from worlds.sc2wol.Locations import SC2WOL_LOC_ID_OFFSET
|
||||
from worlds.sc2wol.MissionTables import lookup_id_to_mission
|
||||
from worlds.sc2wol.Regions import MissionInfo
|
||||
|
||||
if __name__ == "__main__":
|
||||
Utils.init_logging("Starcraft2Client", exception_logger="Client")
|
||||
launch()
|
||||
init_logging("SC2Client", exception_logger="Client")
|
||||
|
||||
logger = logging.getLogger("Client")
|
||||
sc2_logger = logging.getLogger("Starcraft2")
|
||||
|
||||
import colorama
|
||||
|
||||
from NetUtils import ClientStatus, RawJSONtoTextParser
|
||||
from CommonClient import CommonContext, server_loop, ClientCommandProcessor, gui_enabled, get_base_parser
|
||||
|
||||
nest_asyncio.apply()
|
||||
max_bonus: int = 8
|
||||
victory_modulo: int = 100
|
||||
|
||||
|
||||
class StarcraftClientProcessor(ClientCommandProcessor):
|
||||
ctx: SC2Context
|
||||
|
||||
def _cmd_difficulty(self, difficulty: str = "") -> bool:
|
||||
"""Overrides the current difficulty set for the seed. Takes the argument casual, normal, hard, or brutal"""
|
||||
options = difficulty.split()
|
||||
num_options = len(options)
|
||||
difficulty_choice = options[0].lower()
|
||||
|
||||
if num_options > 0:
|
||||
if difficulty_choice == "casual":
|
||||
self.ctx.difficulty_override = 0
|
||||
elif difficulty_choice == "normal":
|
||||
self.ctx.difficulty_override = 1
|
||||
elif difficulty_choice == "hard":
|
||||
self.ctx.difficulty_override = 2
|
||||
elif difficulty_choice == "brutal":
|
||||
self.ctx.difficulty_override = 3
|
||||
else:
|
||||
self.output("Unable to parse difficulty '" + options[0] + "'")
|
||||
return False
|
||||
|
||||
self.output("Difficulty set to " + options[0])
|
||||
return True
|
||||
|
||||
else:
|
||||
self.output("Difficulty needs to be specified in the command.")
|
||||
return False
|
||||
|
||||
def _cmd_disable_mission_check(self) -> bool:
|
||||
"""Disables the check to see if a mission is available to play. Meant for co-op runs where one player can play
|
||||
the next mission in a chain the other player is doing."""
|
||||
self.ctx.missions_unlocked = True
|
||||
sc2_logger.info("Mission check has been disabled")
|
||||
return True
|
||||
|
||||
def _cmd_play(self, mission_id: str = "") -> bool:
|
||||
"""Start a Starcraft 2 mission"""
|
||||
|
||||
options = mission_id.split()
|
||||
num_options = len(options)
|
||||
|
||||
if num_options > 0:
|
||||
mission_number = int(options[0])
|
||||
|
||||
self.ctx.play_mission(mission_number)
|
||||
|
||||
else:
|
||||
sc2_logger.info(
|
||||
"Mission ID needs to be specified. Use /unfinished or /available to view ids for available missions.")
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def _cmd_available(self) -> bool:
|
||||
"""Get what missions are currently available to play"""
|
||||
|
||||
request_available_missions(self.ctx)
|
||||
return True
|
||||
|
||||
def _cmd_unfinished(self) -> bool:
|
||||
"""Get what missions are currently available to play and have not had all locations checked"""
|
||||
|
||||
request_unfinished_missions(self.ctx)
|
||||
return True
|
||||
|
||||
@mark_raw
|
||||
def _cmd_set_path(self, path: str = '') -> bool:
|
||||
"""Manually set the SC2 install directory (if the automatic detection fails)."""
|
||||
if path:
|
||||
os.environ["SC2PATH"] = path
|
||||
check_mod_install()
|
||||
return True
|
||||
else:
|
||||
sc2_logger.warning("When using set_path, you must type the path to your SC2 install directory.")
|
||||
return False
|
||||
|
||||
|
||||
class SC2Context(CommonContext):
|
||||
command_processor = StarcraftClientProcessor
|
||||
game = "Starcraft 2 Wings of Liberty"
|
||||
items_handling = 0b111
|
||||
difficulty = -1
|
||||
all_in_choice = 0
|
||||
mission_req_table: typing.Dict[str, MissionInfo] = {}
|
||||
announcements = queue.Queue()
|
||||
sc2_run_task: typing.Optional[asyncio.Task] = None
|
||||
missions_unlocked: bool = False # allow launching missions ignoring requirements
|
||||
current_tooltip = None
|
||||
last_loc_list = None
|
||||
difficulty_override = -1
|
||||
mission_id_to_location_ids: typing.Dict[int, typing.List[int]] = {}
|
||||
raw_text_parser: RawJSONtoTextParser
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(SC2Context, self).__init__(*args, **kwargs)
|
||||
self.raw_text_parser = RawJSONtoTextParser(self)
|
||||
|
||||
async def server_auth(self, password_requested: bool = False):
|
||||
if password_requested and not self.password:
|
||||
await super(SC2Context, self).server_auth(password_requested)
|
||||
await self.get_username()
|
||||
await self.send_connect()
|
||||
|
||||
def on_package(self, cmd: str, args: dict):
|
||||
if cmd in {"Connected"}:
|
||||
self.difficulty = args["slot_data"]["game_difficulty"]
|
||||
self.all_in_choice = args["slot_data"]["all_in_map"]
|
||||
slot_req_table = args["slot_data"]["mission_req"]
|
||||
self.mission_req_table = {
|
||||
mission: MissionInfo(**slot_req_table[mission]) for mission in slot_req_table
|
||||
}
|
||||
|
||||
self.build_location_to_mission_mapping()
|
||||
|
||||
# Look for and set SC2PATH.
|
||||
# check_game_install_path() returns True if and only if it finds + sets SC2PATH.
|
||||
if "SC2PATH" not in os.environ and check_game_install_path():
|
||||
check_mod_install()
|
||||
|
||||
def on_print_json(self, args: dict):
|
||||
if "receiving" in args and self.slot_concerns_self(args["receiving"]):
|
||||
relevant = True
|
||||
elif "item" in args and self.slot_concerns_self(args["item"].player):
|
||||
relevant = True
|
||||
else:
|
||||
relevant = False
|
||||
|
||||
if relevant:
|
||||
self.announcements.put(self.raw_text_parser(copy.deepcopy(args["data"])))
|
||||
|
||||
super(SC2Context, self).on_print_json(args)
|
||||
|
||||
def run_gui(self):
|
||||
from kvui import GameManager, HoverBehavior, ServerToolTip
|
||||
from kivy.app import App
|
||||
from kivy.clock import Clock
|
||||
from kivy.uix.tabbedpanel import TabbedPanelItem
|
||||
from kivy.uix.gridlayout import GridLayout
|
||||
from kivy.lang import Builder
|
||||
from kivy.uix.label import Label
|
||||
from kivy.uix.button import Button
|
||||
from kivy.uix.floatlayout import FloatLayout
|
||||
from kivy.properties import StringProperty
|
||||
|
||||
import Utils
|
||||
|
||||
class HoverableButton(HoverBehavior, Button):
|
||||
pass
|
||||
|
||||
class MissionButton(HoverableButton):
|
||||
tooltip_text = StringProperty("Test")
|
||||
ctx: SC2Context
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(HoverableButton, self).__init__(*args, **kwargs)
|
||||
self.layout = FloatLayout()
|
||||
self.popuplabel = ServerToolTip(text=self.text)
|
||||
self.layout.add_widget(self.popuplabel)
|
||||
|
||||
def on_enter(self):
|
||||
self.popuplabel.text = self.tooltip_text
|
||||
|
||||
if self.ctx.current_tooltip:
|
||||
App.get_running_app().root.remove_widget(self.ctx.current_tooltip)
|
||||
|
||||
if self.tooltip_text == "":
|
||||
self.ctx.current_tooltip = None
|
||||
else:
|
||||
App.get_running_app().root.add_widget(self.layout)
|
||||
self.ctx.current_tooltip = self.layout
|
||||
|
||||
def on_leave(self):
|
||||
self.ctx.ui.clear_tooltip()
|
||||
|
||||
@property
|
||||
def ctx(self) -> CommonContext:
|
||||
return App.get_running_app().ctx
|
||||
|
||||
class MissionLayout(GridLayout):
|
||||
pass
|
||||
|
||||
class MissionCategory(GridLayout):
|
||||
pass
|
||||
|
||||
class SC2Manager(GameManager):
|
||||
logging_pairs = [
|
||||
("Client", "Archipelago"),
|
||||
("Starcraft2", "Starcraft2"),
|
||||
]
|
||||
base_title = "Archipelago Starcraft 2 Client"
|
||||
|
||||
mission_panel = None
|
||||
last_checked_locations = {}
|
||||
mission_id_to_button = {}
|
||||
launching: typing.Union[bool, int] = False # if int -> mission ID
|
||||
refresh_from_launching = True
|
||||
first_check = True
|
||||
ctx: SC2Context
|
||||
|
||||
def __init__(self, ctx):
|
||||
super().__init__(ctx)
|
||||
|
||||
def clear_tooltip(self):
|
||||
if self.ctx.current_tooltip:
|
||||
App.get_running_app().root.remove_widget(self.ctx.current_tooltip)
|
||||
|
||||
self.ctx.current_tooltip = None
|
||||
|
||||
def build(self):
|
||||
container = super().build()
|
||||
|
||||
panel = TabbedPanelItem(text="Starcraft 2 Launcher")
|
||||
self.mission_panel = panel.content = MissionLayout()
|
||||
|
||||
self.tabs.add_widget(panel)
|
||||
|
||||
Clock.schedule_interval(self.build_mission_table, 0.5)
|
||||
|
||||
return container
|
||||
|
||||
def build_mission_table(self, dt):
|
||||
if (not self.launching and (not self.last_checked_locations == self.ctx.checked_locations or
|
||||
not self.refresh_from_launching)) or self.first_check:
|
||||
self.refresh_from_launching = True
|
||||
|
||||
self.mission_panel.clear_widgets()
|
||||
|
||||
if self.ctx.mission_req_table:
|
||||
self.last_checked_locations = self.ctx.checked_locations.copy()
|
||||
self.first_check = False
|
||||
|
||||
self.mission_id_to_button = {}
|
||||
categories = {}
|
||||
available_missions, unfinished_missions = calc_unfinished_missions(self.ctx)
|
||||
|
||||
# separate missions into categories
|
||||
for mission in self.ctx.mission_req_table:
|
||||
if not self.ctx.mission_req_table[mission].category in categories:
|
||||
categories[self.ctx.mission_req_table[mission].category] = []
|
||||
|
||||
categories[self.ctx.mission_req_table[mission].category].append(mission)
|
||||
|
||||
for category in categories:
|
||||
category_panel = MissionCategory()
|
||||
category_panel.add_widget(
|
||||
Label(text=category, size_hint_y=None, height=50, outline_width=1))
|
||||
|
||||
# Map is completed
|
||||
for mission in categories[category]:
|
||||
text = mission
|
||||
tooltip = ""
|
||||
|
||||
# Map has uncollected locations
|
||||
if mission in unfinished_missions:
|
||||
text = f"[color=6495ED]{text}[/color]"
|
||||
|
||||
tooltip = f"Uncollected locations:\n"
|
||||
tooltip += "\n".join([self.ctx.location_names[loc] for loc in
|
||||
self.ctx.locations_for_mission(mission)
|
||||
if loc in self.ctx.missing_locations])
|
||||
elif mission in available_missions:
|
||||
text = f"[color=FFFFFF]{text}[/color]"
|
||||
# Map requirements not met
|
||||
else:
|
||||
text = f"[color=a9a9a9]{text}[/color]"
|
||||
tooltip = f"Requires: "
|
||||
if len(self.ctx.mission_req_table[mission].required_world) > 0:
|
||||
tooltip += ", ".join(list(self.ctx.mission_req_table)[req_mission - 1] for
|
||||
req_mission in
|
||||
self.ctx.mission_req_table[mission].required_world)
|
||||
|
||||
if self.ctx.mission_req_table[mission].number > 0:
|
||||
tooltip += " and "
|
||||
if self.ctx.mission_req_table[mission].number > 0:
|
||||
tooltip += f"{self.ctx.mission_req_table[mission].number} missions completed"
|
||||
|
||||
mission_button = MissionButton(text=text, size_hint_y=None, height=50)
|
||||
mission_button.tooltip_text = tooltip
|
||||
mission_button.bind(on_press=self.mission_callback)
|
||||
self.mission_id_to_button[self.ctx.mission_req_table[mission].id] = mission_button
|
||||
category_panel.add_widget(mission_button)
|
||||
|
||||
category_panel.add_widget(Label(text=""))
|
||||
self.mission_panel.add_widget(category_panel)
|
||||
|
||||
elif self.launching:
|
||||
self.refresh_from_launching = False
|
||||
|
||||
self.mission_panel.clear_widgets()
|
||||
self.mission_panel.add_widget(Label(text="Launching Mission: " +
|
||||
lookup_id_to_mission[self.launching]))
|
||||
if self.ctx.ui:
|
||||
self.ctx.ui.clear_tooltip()
|
||||
|
||||
def mission_callback(self, button):
|
||||
if not self.launching:
|
||||
mission_id: int = next(k for k, v in self.mission_id_to_button.items() if v == button)
|
||||
self.ctx.play_mission(mission_id)
|
||||
self.launching = mission_id
|
||||
Clock.schedule_once(self.finish_launching, 10)
|
||||
|
||||
def finish_launching(self, dt):
|
||||
self.launching = False
|
||||
|
||||
self.ui = SC2Manager(self)
|
||||
self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI")
|
||||
|
||||
Builder.load_file(Utils.local_path(os.path.dirname(SC2WoLWorld.__file__), "Starcraft2.kv"))
|
||||
|
||||
async def shutdown(self):
|
||||
await super(SC2Context, self).shutdown()
|
||||
if self.sc2_run_task:
|
||||
self.sc2_run_task.cancel()
|
||||
|
||||
def play_mission(self, mission_id: int):
|
||||
if self.missions_unlocked or \
|
||||
is_mission_available(self, mission_id):
|
||||
if self.sc2_run_task:
|
||||
if not self.sc2_run_task.done():
|
||||
sc2_logger.warning("Starcraft 2 Client is still running!")
|
||||
self.sc2_run_task.cancel() # doesn't actually close the game, just stops the python task
|
||||
if self.slot is None:
|
||||
sc2_logger.warning("Launching Mission without Archipelago authentication, "
|
||||
"checks will not be registered to server.")
|
||||
self.sc2_run_task = asyncio.create_task(starcraft_launch(self, mission_id),
|
||||
name="Starcraft 2 Launch")
|
||||
else:
|
||||
sc2_logger.info(
|
||||
f"{lookup_id_to_mission[mission_id]} is not currently unlocked. "
|
||||
f"Use /unfinished or /available to see what is available.")
|
||||
|
||||
def build_location_to_mission_mapping(self):
|
||||
mission_id_to_location_ids: typing.Dict[int, typing.Set[int]] = {
|
||||
mission_info.id: set() for mission_info in self.mission_req_table.values()
|
||||
}
|
||||
|
||||
for loc in self.server_locations:
|
||||
mission_id, objective = divmod(loc - SC2WOL_LOC_ID_OFFSET, victory_modulo)
|
||||
mission_id_to_location_ids[mission_id].add(objective)
|
||||
self.mission_id_to_location_ids = {mission_id: sorted(objectives) for mission_id, objectives in
|
||||
mission_id_to_location_ids.items()}
|
||||
|
||||
def locations_for_mission(self, mission: str):
|
||||
mission_id: int = self.mission_req_table[mission].id
|
||||
objectives = self.mission_id_to_location_ids[self.mission_req_table[mission].id]
|
||||
for objective in objectives:
|
||||
yield SC2WOL_LOC_ID_OFFSET + mission_id * 100 + objective
|
||||
|
||||
|
||||
async def main():
|
||||
multiprocessing.freeze_support()
|
||||
parser = get_base_parser()
|
||||
parser.add_argument('--name', default=None, help="Slot Name to connect as.")
|
||||
args = parser.parse_args()
|
||||
|
||||
ctx = SC2Context(args.connect, args.password)
|
||||
ctx.auth = args.name
|
||||
if ctx.server_task is None:
|
||||
ctx.server_task = asyncio.create_task(server_loop(ctx), name="ServerLoop")
|
||||
|
||||
if gui_enabled:
|
||||
ctx.run_gui()
|
||||
ctx.run_cli()
|
||||
|
||||
await ctx.exit_event.wait()
|
||||
|
||||
await ctx.shutdown()
|
||||
|
||||
|
||||
maps_table = [
|
||||
"ap_traynor01", "ap_traynor02", "ap_traynor03",
|
||||
"ap_thanson01", "ap_thanson02", "ap_thanson03a", "ap_thanson03b",
|
||||
"ap_ttychus01", "ap_ttychus02", "ap_ttychus03", "ap_ttychus04", "ap_ttychus05",
|
||||
"ap_ttosh01", "ap_ttosh02", "ap_ttosh03a", "ap_ttosh03b",
|
||||
"ap_thorner01", "ap_thorner02", "ap_thorner03", "ap_thorner04", "ap_thorner05s",
|
||||
"ap_tzeratul01", "ap_tzeratul02", "ap_tzeratul03", "ap_tzeratul04",
|
||||
"ap_tvalerian01", "ap_tvalerian02a", "ap_tvalerian02b", "ap_tvalerian03"
|
||||
]
|
||||
|
||||
wol_default_categories = [
|
||||
"Mar Sara", "Mar Sara", "Mar Sara", "Colonist", "Colonist", "Colonist", "Colonist",
|
||||
"Artifact", "Artifact", "Artifact", "Artifact", "Artifact", "Covert", "Covert", "Covert", "Covert",
|
||||
"Rebellion", "Rebellion", "Rebellion", "Rebellion", "Rebellion", "Prophecy", "Prophecy", "Prophecy", "Prophecy",
|
||||
"Char", "Char", "Char", "Char"
|
||||
]
|
||||
|
||||
|
||||
def calculate_items(items):
|
||||
unit_unlocks = 0
|
||||
armory1_unlocks = 0
|
||||
armory2_unlocks = 0
|
||||
upgrade_unlocks = 0
|
||||
building_unlocks = 0
|
||||
merc_unlocks = 0
|
||||
lab_unlocks = 0
|
||||
protoss_unlock = 0
|
||||
minerals = 0
|
||||
vespene = 0
|
||||
supply = 0
|
||||
|
||||
for item in items:
|
||||
data = lookup_id_to_name[item.item]
|
||||
|
||||
if item_table[data].type == "Unit":
|
||||
unit_unlocks += (1 << item_table[data].number)
|
||||
elif item_table[data].type == "Upgrade":
|
||||
upgrade_unlocks += (1 << item_table[data].number)
|
||||
elif item_table[data].type == "Armory 1":
|
||||
armory1_unlocks += (1 << item_table[data].number)
|
||||
elif item_table[data].type == "Armory 2":
|
||||
armory2_unlocks += (1 << item_table[data].number)
|
||||
elif item_table[data].type == "Building":
|
||||
building_unlocks += (1 << item_table[data].number)
|
||||
elif item_table[data].type == "Mercenary":
|
||||
merc_unlocks += (1 << item_table[data].number)
|
||||
elif item_table[data].type == "Laboratory":
|
||||
lab_unlocks += (1 << item_table[data].number)
|
||||
elif item_table[data].type == "Protoss":
|
||||
protoss_unlock += (1 << item_table[data].number)
|
||||
elif item_table[data].type == "Minerals":
|
||||
minerals += item_table[data].number
|
||||
elif item_table[data].type == "Vespene":
|
||||
vespene += item_table[data].number
|
||||
elif item_table[data].type == "Supply":
|
||||
supply += item_table[data].number
|
||||
|
||||
return [unit_unlocks, upgrade_unlocks, armory1_unlocks, armory2_unlocks, building_unlocks, merc_unlocks,
|
||||
lab_unlocks, protoss_unlock, minerals, vespene, supply]
|
||||
|
||||
|
||||
def calc_difficulty(difficulty):
|
||||
if difficulty == 0:
|
||||
return 'C'
|
||||
elif difficulty == 1:
|
||||
return 'N'
|
||||
elif difficulty == 2:
|
||||
return 'H'
|
||||
elif difficulty == 3:
|
||||
return 'B'
|
||||
|
||||
return 'X'
|
||||
|
||||
|
||||
async def starcraft_launch(ctx: SC2Context, mission_id: int):
|
||||
sc2_logger.info(f"Launching {lookup_id_to_mission[mission_id]}. If game does not launch check log file for errors.")
|
||||
|
||||
with DllDirectory(None):
|
||||
run_game(sc2.maps.get(maps_table[mission_id - 1]), [Bot(Race.Terran, ArchipelagoBot(ctx, mission_id),
|
||||
name="Archipelago", fullscreen=True)], realtime=True)
|
||||
|
||||
|
||||
class ArchipelagoBot(sc2.bot_ai.BotAI):
|
||||
game_running: bool = False
|
||||
mission_completed: bool = False
|
||||
boni: typing.List[bool]
|
||||
setup_done: bool
|
||||
ctx: SC2Context
|
||||
mission_id: int
|
||||
|
||||
can_read_game = False
|
||||
|
||||
last_received_update: int = 0
|
||||
|
||||
def __init__(self, ctx: SC2Context, mission_id):
|
||||
self.setup_done = False
|
||||
self.ctx = ctx
|
||||
self.mission_id = mission_id
|
||||
self.boni = [False for _ in range(max_bonus)]
|
||||
|
||||
super(ArchipelagoBot, self).__init__()
|
||||
|
||||
async def on_step(self, iteration: int):
|
||||
game_state = 0
|
||||
if not self.setup_done:
|
||||
self.setup_done = True
|
||||
start_items = calculate_items(self.ctx.items_received)
|
||||
if self.ctx.difficulty_override >= 0:
|
||||
difficulty = calc_difficulty(self.ctx.difficulty_override)
|
||||
else:
|
||||
difficulty = calc_difficulty(self.ctx.difficulty)
|
||||
await self.chat_send("ArchipelagoLoad {} {} {} {} {} {} {} {} {} {} {} {} {}".format(
|
||||
difficulty,
|
||||
start_items[0], start_items[1], start_items[2], start_items[3], start_items[4],
|
||||
start_items[5], start_items[6], start_items[7], start_items[8], start_items[9],
|
||||
self.ctx.all_in_choice, start_items[10]))
|
||||
self.last_received_update = len(self.ctx.items_received)
|
||||
|
||||
else:
|
||||
if not self.ctx.announcements.empty():
|
||||
message = self.ctx.announcements.get(timeout=1)
|
||||
await self.chat_send("SendMessage " + message)
|
||||
self.ctx.announcements.task_done()
|
||||
|
||||
# Archipelago reads the health
|
||||
for unit in self.all_own_units():
|
||||
if unit.health_max == 38281:
|
||||
game_state = int(38281 - unit.health)
|
||||
self.can_read_game = True
|
||||
|
||||
if iteration == 160 and not game_state & 1:
|
||||
await self.chat_send("SendMessage Warning: Archipelago unable to connect or has lost connection to " +
|
||||
"Starcraft 2 (This is likely a map issue)")
|
||||
|
||||
if self.last_received_update < len(self.ctx.items_received):
|
||||
current_items = calculate_items(self.ctx.items_received)
|
||||
await self.chat_send("UpdateTech {} {} {} {} {} {} {} {}".format(
|
||||
current_items[0], current_items[1], current_items[2], current_items[3], current_items[4],
|
||||
current_items[5], current_items[6], current_items[7]))
|
||||
self.last_received_update = len(self.ctx.items_received)
|
||||
|
||||
if game_state & 1:
|
||||
if not self.game_running:
|
||||
print("Archipelago Connected")
|
||||
self.game_running = True
|
||||
|
||||
if self.can_read_game:
|
||||
if game_state & (1 << 1) and not self.mission_completed:
|
||||
if self.mission_id != 29:
|
||||
print("Mission Completed")
|
||||
await self.ctx.send_msgs(
|
||||
[{"cmd": 'LocationChecks',
|
||||
"locations": [SC2WOL_LOC_ID_OFFSET + victory_modulo * self.mission_id]}])
|
||||
self.mission_completed = True
|
||||
else:
|
||||
print("Game Complete")
|
||||
await self.ctx.send_msgs([{"cmd": 'StatusUpdate', "status": ClientStatus.CLIENT_GOAL}])
|
||||
self.mission_completed = True
|
||||
|
||||
for x, completed in enumerate(self.boni):
|
||||
if not completed and game_state & (1 << (x + 2)):
|
||||
await self.ctx.send_msgs(
|
||||
[{"cmd": 'LocationChecks',
|
||||
"locations": [SC2WOL_LOC_ID_OFFSET + victory_modulo * self.mission_id + x + 1]}])
|
||||
self.boni[x] = True
|
||||
|
||||
else:
|
||||
await self.chat_send("LostConnection - Lost connection to game.")
|
||||
|
||||
|
||||
def request_unfinished_missions(ctx: SC2Context):
|
||||
if ctx.mission_req_table:
|
||||
message = "Unfinished Missions: "
|
||||
unlocks = initialize_blank_mission_dict(ctx.mission_req_table)
|
||||
unfinished_locations = initialize_blank_mission_dict(ctx.mission_req_table)
|
||||
|
||||
_, unfinished_missions = calc_unfinished_missions(ctx, unlocks=unlocks)
|
||||
|
||||
message += ", ".join(f"{mark_up_mission_name(ctx, mission, unlocks)}[{ctx.mission_req_table[mission].id}] " +
|
||||
mark_up_objectives(
|
||||
f"[{len(unfinished_missions[mission])}/"
|
||||
f"{sum(1 for _ in ctx.locations_for_mission(mission))}]",
|
||||
ctx, unfinished_locations, mission)
|
||||
for mission in unfinished_missions)
|
||||
|
||||
if ctx.ui:
|
||||
ctx.ui.log_panels['All'].on_message_markup(message)
|
||||
ctx.ui.log_panels['Starcraft2'].on_message_markup(message)
|
||||
else:
|
||||
sc2_logger.info(message)
|
||||
else:
|
||||
sc2_logger.warning("No mission table found, you are likely not connected to a server.")
|
||||
|
||||
|
||||
def calc_unfinished_missions(ctx: SC2Context, unlocks=None):
|
||||
unfinished_missions = []
|
||||
locations_completed = []
|
||||
|
||||
if not unlocks:
|
||||
unlocks = initialize_blank_mission_dict(ctx.mission_req_table)
|
||||
|
||||
available_missions = calc_available_missions(ctx, unlocks)
|
||||
|
||||
for name in available_missions:
|
||||
objectives = set(ctx.locations_for_mission(name))
|
||||
if objectives:
|
||||
objectives_completed = ctx.checked_locations & objectives
|
||||
if len(objectives_completed) < len(objectives):
|
||||
unfinished_missions.append(name)
|
||||
locations_completed.append(objectives_completed)
|
||||
|
||||
else: # infer that this is the final mission as it has no objectives
|
||||
unfinished_missions.append(name)
|
||||
locations_completed.append(-1)
|
||||
|
||||
return available_missions, dict(zip(unfinished_missions, locations_completed))
|
||||
|
||||
|
||||
def is_mission_available(ctx: SC2Context, mission_id_to_check):
|
||||
unfinished_missions = calc_available_missions(ctx)
|
||||
|
||||
return any(mission_id_to_check == ctx.mission_req_table[mission].id for mission in unfinished_missions)
|
||||
|
||||
|
||||
def mark_up_mission_name(ctx: SC2Context, mission, unlock_table):
|
||||
"""Checks if the mission is required for game completion and adds '*' to the name to mark that."""
|
||||
|
||||
if ctx.mission_req_table[mission].completion_critical:
|
||||
if ctx.ui:
|
||||
message = "[color=AF99EF]" + mission + "[/color]"
|
||||
else:
|
||||
message = "*" + mission + "*"
|
||||
else:
|
||||
message = mission
|
||||
|
||||
if ctx.ui:
|
||||
unlocks = unlock_table[mission]
|
||||
|
||||
if len(unlocks) > 0:
|
||||
pre_message = f"[ref={list(ctx.mission_req_table).index(mission)}|Unlocks: "
|
||||
pre_message += ", ".join(f"{unlock}({ctx.mission_req_table[unlock].id})" for unlock in unlocks)
|
||||
pre_message += f"]"
|
||||
message = pre_message + message + "[/ref]"
|
||||
|
||||
return message
|
||||
|
||||
|
||||
def mark_up_objectives(message, ctx, unfinished_locations, mission):
|
||||
formatted_message = message
|
||||
|
||||
if ctx.ui:
|
||||
locations = unfinished_locations[mission]
|
||||
|
||||
pre_message = f"[ref={list(ctx.mission_req_table).index(mission) + 30}|"
|
||||
pre_message += "<br>".join(location for location in locations)
|
||||
pre_message += f"]"
|
||||
formatted_message = pre_message + message + "[/ref]"
|
||||
|
||||
return formatted_message
|
||||
|
||||
|
||||
def request_available_missions(ctx: SC2Context):
|
||||
if ctx.mission_req_table:
|
||||
message = "Available Missions: "
|
||||
|
||||
# Initialize mission unlock table
|
||||
unlocks = initialize_blank_mission_dict(ctx.mission_req_table)
|
||||
|
||||
missions = calc_available_missions(ctx, unlocks)
|
||||
message += \
|
||||
", ".join(f"{mark_up_mission_name(ctx, mission, unlocks)}"
|
||||
f"[{ctx.mission_req_table[mission].id}]"
|
||||
for mission in missions)
|
||||
|
||||
if ctx.ui:
|
||||
ctx.ui.log_panels['All'].on_message_markup(message)
|
||||
ctx.ui.log_panels['Starcraft2'].on_message_markup(message)
|
||||
else:
|
||||
sc2_logger.info(message)
|
||||
else:
|
||||
sc2_logger.warning("No mission table found, you are likely not connected to a server.")
|
||||
|
||||
|
||||
def calc_available_missions(ctx: SC2Context, unlocks=None):
|
||||
available_missions = []
|
||||
missions_complete = 0
|
||||
|
||||
# Get number of missions completed
|
||||
for loc in ctx.checked_locations:
|
||||
if loc % victory_modulo == 0:
|
||||
missions_complete += 1
|
||||
|
||||
for name in ctx.mission_req_table:
|
||||
# Go through the required missions for each mission and fill up unlock table used later for hover-over tooltips
|
||||
if unlocks:
|
||||
for unlock in ctx.mission_req_table[name].required_world:
|
||||
unlocks[list(ctx.mission_req_table)[unlock - 1]].append(name)
|
||||
|
||||
if mission_reqs_completed(ctx, name, missions_complete):
|
||||
available_missions.append(name)
|
||||
|
||||
return available_missions
|
||||
|
||||
|
||||
def mission_reqs_completed(ctx: SC2Context, mission_name: str, missions_complete):
|
||||
"""Returns a bool signifying if the mission has all requirements complete and can be done
|
||||
|
||||
Arguments:
|
||||
ctx -- instance of SC2Context
|
||||
locations_to_check -- the mission string name to check
|
||||
missions_complete -- an int of how many missions have been completed
|
||||
"""
|
||||
if len(ctx.mission_req_table[mission_name].required_world) >= 1:
|
||||
# A check for when the requirements are being or'd
|
||||
or_success = False
|
||||
|
||||
# Loop through required missions
|
||||
for req_mission in ctx.mission_req_table[mission_name].required_world:
|
||||
req_success = True
|
||||
|
||||
# Check if required mission has been completed
|
||||
if not (ctx.mission_req_table[list(ctx.mission_req_table)[req_mission - 1]].id *
|
||||
victory_modulo + SC2WOL_LOC_ID_OFFSET) in ctx.checked_locations:
|
||||
if not ctx.mission_req_table[mission_name].or_requirements:
|
||||
return False
|
||||
else:
|
||||
req_success = False
|
||||
|
||||
# Recursively check required mission to see if it's requirements are met, in case !collect has been done
|
||||
if not mission_reqs_completed(ctx, list(ctx.mission_req_table)[req_mission - 1], missions_complete):
|
||||
if not ctx.mission_req_table[mission_name].or_requirements:
|
||||
return False
|
||||
else:
|
||||
req_success = False
|
||||
|
||||
# If requirement check succeeded mark or as satisfied
|
||||
if ctx.mission_req_table[mission_name].or_requirements and req_success:
|
||||
or_success = True
|
||||
|
||||
if ctx.mission_req_table[mission_name].or_requirements:
|
||||
# Return false if or requirements not met
|
||||
if not or_success:
|
||||
return False
|
||||
|
||||
# Check number of missions
|
||||
if missions_complete >= ctx.mission_req_table[mission_name].number:
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
else:
|
||||
return True
|
||||
|
||||
|
||||
def initialize_blank_mission_dict(location_table):
|
||||
unlocks = {}
|
||||
|
||||
for mission in list(location_table):
|
||||
unlocks[mission] = []
|
||||
|
||||
return unlocks
|
||||
|
||||
|
||||
def check_game_install_path() -> bool:
|
||||
# First thing: go to the default location for ExecuteInfo.
|
||||
# An exception for Windows is included because it's very difficult to find ~\Documents if the user moved it.
|
||||
if is_windows:
|
||||
# The next five lines of utterly inscrutable code are brought to you by copy-paste from Stack Overflow.
|
||||
# https://stackoverflow.com/questions/6227590/finding-the-users-my-documents-path/30924555#
|
||||
import ctypes.wintypes
|
||||
CSIDL_PERSONAL = 5 # My Documents
|
||||
SHGFP_TYPE_CURRENT = 0 # Get current, not default value
|
||||
|
||||
buf = ctypes.create_unicode_buffer(ctypes.wintypes.MAX_PATH)
|
||||
ctypes.windll.shell32.SHGetFolderPathW(None, CSIDL_PERSONAL, None, SHGFP_TYPE_CURRENT, buf)
|
||||
documentspath = buf.value
|
||||
einfo = str(documentspath / Path("StarCraft II\\ExecuteInfo.txt"))
|
||||
else:
|
||||
einfo = str(sc2.paths.get_home() / Path(sc2.paths.USERPATH[sc2.paths.PF]))
|
||||
|
||||
# Check if the file exists.
|
||||
if os.path.isfile(einfo):
|
||||
|
||||
# Open the file and read it, picking out the latest executable's path.
|
||||
with open(einfo) as f:
|
||||
content = f.read()
|
||||
if content:
|
||||
base = re.search(r" = (.*)Versions", content).group(1)
|
||||
if os.path.exists(base):
|
||||
executable = sc2.paths.latest_executeble(Path(base).expanduser() / "Versions")
|
||||
|
||||
# Finally, check the path for an actual executable.
|
||||
# If we find one, great. Set up the SC2PATH.
|
||||
if os.path.isfile(executable):
|
||||
sc2_logger.info(f"Found an SC2 install at {base}!")
|
||||
sc2_logger.debug(f"Latest executable at {executable}.")
|
||||
os.environ["SC2PATH"] = base
|
||||
sc2_logger.debug(f"SC2PATH set to {base}.")
|
||||
return True
|
||||
else:
|
||||
sc2_logger.warning(f"We may have found an SC2 install at {base}, but couldn't find {executable}.")
|
||||
else:
|
||||
sc2_logger.warning(f"{einfo} pointed to {base}, but we could not find an SC2 install there.")
|
||||
else:
|
||||
sc2_logger.warning(f"Couldn't find {einfo}. Please run /set_path with your SC2 install directory.")
|
||||
return False
|
||||
|
||||
|
||||
def check_mod_install() -> bool:
|
||||
# Pull up the SC2PATH if set. If not, encourage the user to manually run /set_path.
|
||||
try:
|
||||
# Check inside the Mods folder for Archipelago.SC2Mod. If found, tell user. If not, tell user.
|
||||
if os.path.isfile(modfile := (os.environ["SC2PATH"] / Path("Mods") / Path("Archipelago.SC2Mod"))):
|
||||
sc2_logger.info(f"Archipelago mod found at {modfile}.")
|
||||
return True
|
||||
else:
|
||||
sc2_logger.warning(f"Archipelago mod could not be found at {modfile}. Please install the mod file there.")
|
||||
except KeyError:
|
||||
sc2_logger.warning(f"SC2PATH isn't set. Please run /set_path with the path to your SC2 install.")
|
||||
return False
|
||||
|
||||
|
||||
class DllDirectory:
|
||||
# Credit to Black Sliver for this code.
|
||||
# More info: https://docs.microsoft.com/en-us/windows/win32/api/winbase/nf-winbase-setdlldirectoryw
|
||||
_old: typing.Optional[str] = None
|
||||
_new: typing.Optional[str] = None
|
||||
|
||||
def __init__(self, new: typing.Optional[str]):
|
||||
self._new = new
|
||||
|
||||
def __enter__(self):
|
||||
old = self.get()
|
||||
if self.set(self._new):
|
||||
self._old = old
|
||||
|
||||
def __exit__(self, *args):
|
||||
if self._old is not None:
|
||||
self.set(self._old)
|
||||
|
||||
@staticmethod
|
||||
def get() -> typing.Optional[str]:
|
||||
if sys.platform == "win32":
|
||||
n = ctypes.windll.kernel32.GetDllDirectoryW(0, None)
|
||||
buf = ctypes.create_unicode_buffer(n)
|
||||
ctypes.windll.kernel32.GetDllDirectoryW(n, buf)
|
||||
return buf.value
|
||||
# NOTE: other OS may support os.environ["LD_LIBRARY_PATH"], but this fix is windows-specific
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def set(s: typing.Optional[str]) -> bool:
|
||||
if sys.platform == "win32":
|
||||
return ctypes.windll.kernel32.SetDllDirectoryW(s) != 0
|
||||
# NOTE: other OS may support os.environ["LD_LIBRARY_PATH"], but this fix is windows-specific
|
||||
return False
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
colorama.init()
|
||||
asyncio.run(main())
|
||||
colorama.deinit()
|
||||
|
||||
@@ -1,512 +0,0 @@
|
||||
from __future__ import annotations
|
||||
import os
|
||||
import sys
|
||||
import asyncio
|
||||
import typing
|
||||
import bsdiff4
|
||||
import shutil
|
||||
|
||||
import Utils
|
||||
|
||||
from NetUtils import NetworkItem, ClientStatus
|
||||
from worlds import undertale
|
||||
from MultiServer import mark_raw
|
||||
from CommonClient import CommonContext, server_loop, \
|
||||
gui_enabled, ClientCommandProcessor, logger, get_base_parser
|
||||
from Utils import async_start
|
||||
|
||||
|
||||
class UndertaleCommandProcessor(ClientCommandProcessor):
|
||||
def __init__(self, ctx):
|
||||
super().__init__(ctx)
|
||||
|
||||
def _cmd_resync(self):
|
||||
"""Manually trigger a resync."""
|
||||
if isinstance(self.ctx, UndertaleContext):
|
||||
self.output(f"Syncing items.")
|
||||
self.ctx.syncing = True
|
||||
|
||||
def _cmd_patch(self):
|
||||
"""Patch the game. Only use this command if /auto_patch fails."""
|
||||
if isinstance(self.ctx, UndertaleContext):
|
||||
os.makedirs(name=Utils.user_path("Undertale"), exist_ok=True)
|
||||
self.ctx.patch_game()
|
||||
self.output("Patched.")
|
||||
|
||||
def _cmd_savepath(self, directory: str):
|
||||
"""Redirect to proper save data folder. This is necessary for Linux users to use before connecting."""
|
||||
if isinstance(self.ctx, UndertaleContext):
|
||||
self.ctx.save_game_folder = directory
|
||||
self.output("Changed to the following directory: " + self.ctx.save_game_folder)
|
||||
|
||||
@mark_raw
|
||||
def _cmd_auto_patch(self, steaminstall: typing.Optional[str] = None):
|
||||
"""Patch the game automatically."""
|
||||
if isinstance(self.ctx, UndertaleContext):
|
||||
os.makedirs(name=Utils.user_path("Undertale"), exist_ok=True)
|
||||
tempInstall = steaminstall
|
||||
if not os.path.isfile(os.path.join(tempInstall, "data.win")):
|
||||
tempInstall = None
|
||||
if tempInstall is None:
|
||||
tempInstall = "C:\\Program Files (x86)\\Steam\\steamapps\\common\\Undertale"
|
||||
if not os.path.exists(tempInstall):
|
||||
tempInstall = "C:\\Program Files\\Steam\\steamapps\\common\\Undertale"
|
||||
elif not os.path.exists(tempInstall):
|
||||
tempInstall = "C:\\Program Files (x86)\\Steam\\steamapps\\common\\Undertale"
|
||||
if not os.path.exists(tempInstall):
|
||||
tempInstall = "C:\\Program Files\\Steam\\steamapps\\common\\Undertale"
|
||||
if not os.path.exists(tempInstall) or not os.path.exists(tempInstall) or not os.path.isfile(os.path.join(tempInstall, "data.win")):
|
||||
self.output("ERROR: Cannot find Undertale. Please rerun the command with the correct folder."
|
||||
" command. \"/auto_patch (Steam directory)\".")
|
||||
else:
|
||||
for file_name in os.listdir(tempInstall):
|
||||
if file_name != "steam_api.dll":
|
||||
shutil.copy(os.path.join(tempInstall, file_name),
|
||||
Utils.user_path("Undertale", file_name))
|
||||
self.ctx.patch_game()
|
||||
self.output("Patching successful!")
|
||||
|
||||
def _cmd_online(self):
|
||||
"""Toggles seeing other Undertale players."""
|
||||
if isinstance(self.ctx, UndertaleContext):
|
||||
self.ctx.update_online_mode(not ("Online" in self.ctx.tags))
|
||||
if "Online" in self.ctx.tags:
|
||||
self.output(f"Now online.")
|
||||
else:
|
||||
self.output(f"Now offline.")
|
||||
|
||||
def _cmd_deathlink(self):
|
||||
"""Toggles deathlink"""
|
||||
if isinstance(self.ctx, UndertaleContext):
|
||||
self.ctx.deathlink_status = not self.ctx.deathlink_status
|
||||
if self.ctx.deathlink_status:
|
||||
self.output(f"Deathlink enabled.")
|
||||
else:
|
||||
self.output(f"Deathlink disabled.")
|
||||
|
||||
|
||||
class UndertaleContext(CommonContext):
|
||||
tags = {"AP", "Online"}
|
||||
game = "Undertale"
|
||||
command_processor = UndertaleCommandProcessor
|
||||
items_handling = 0b111
|
||||
route = None
|
||||
pieces_needed = None
|
||||
completed_routes = None
|
||||
completed_count = 0
|
||||
save_game_folder = os.path.expandvars(r"%localappdata%/UNDERTALE")
|
||||
|
||||
def __init__(self, server_address, password):
|
||||
super().__init__(server_address, password)
|
||||
self.pieces_needed = 0
|
||||
self.finished_game = False
|
||||
self.game = "Undertale"
|
||||
self.got_deathlink = False
|
||||
self.syncing = False
|
||||
self.deathlink_status = False
|
||||
self.tem_armor = False
|
||||
self.completed_count = 0
|
||||
self.completed_routes = {"pacifist": 0, "genocide": 0, "neutral": 0}
|
||||
# self.save_game_folder: files go in this path to pass data between us and the actual game
|
||||
self.save_game_folder = os.path.expandvars(r"%localappdata%/UNDERTALE")
|
||||
|
||||
def patch_game(self):
|
||||
with open(Utils.user_path("Undertale", "data.win"), "rb") as f:
|
||||
patchedFile = bsdiff4.patch(f.read(), undertale.data_path("patch.bsdiff"))
|
||||
with open(Utils.user_path("Undertale", "data.win"), "wb") as f:
|
||||
f.write(patchedFile)
|
||||
os.makedirs(name=Utils.user_path("Undertale", "Custom Sprites"), exist_ok=True)
|
||||
with open(os.path.expandvars(Utils.user_path("Undertale", "Custom Sprites",
|
||||
"Which Character.txt")), "w") as f:
|
||||
f.writelines(["// Put the folder name of the sprites you want to play as, make sure it is the only "
|
||||
"line other than this one.\n", "frisk"])
|
||||
f.close()
|
||||
|
||||
async def server_auth(self, password_requested: bool = False):
|
||||
if password_requested and not self.password:
|
||||
await super().server_auth(password_requested)
|
||||
await self.get_username()
|
||||
await self.send_connect()
|
||||
|
||||
def clear_undertale_files(self):
|
||||
path = self.save_game_folder
|
||||
self.finished_game = False
|
||||
for root, dirs, files in os.walk(path):
|
||||
for file in files:
|
||||
if "check.spot" == file or "scout" == file:
|
||||
os.remove(os.path.join(root, file))
|
||||
elif file.endswith((".item", ".victory", ".route", ".playerspot", ".mad",
|
||||
".youDied", ".LV", ".mine", ".flag", ".hint")):
|
||||
os.remove(os.path.join(root, file))
|
||||
|
||||
async def connect(self, address: typing.Optional[str] = None):
|
||||
self.clear_undertale_files()
|
||||
await super().connect(address)
|
||||
|
||||
async def disconnect(self, allow_autoreconnect: bool = False):
|
||||
self.clear_undertale_files()
|
||||
await super().disconnect(allow_autoreconnect)
|
||||
|
||||
async def connection_closed(self):
|
||||
self.clear_undertale_files()
|
||||
await super().connection_closed()
|
||||
|
||||
async def shutdown(self):
|
||||
self.clear_undertale_files()
|
||||
await super().shutdown()
|
||||
|
||||
def update_online_mode(self, online):
|
||||
old_tags = self.tags.copy()
|
||||
if online:
|
||||
self.tags.add("Online")
|
||||
else:
|
||||
self.tags -= {"Online"}
|
||||
if old_tags != self.tags and self.server and not self.server.socket.closed:
|
||||
async_start(self.send_msgs([{"cmd": "ConnectUpdate", "tags": self.tags}]))
|
||||
|
||||
def on_package(self, cmd: str, args: dict):
|
||||
if cmd == "Connected":
|
||||
self.game = self.slot_info[self.slot].game
|
||||
async_start(process_undertale_cmd(self, cmd, args))
|
||||
|
||||
def run_gui(self):
|
||||
from kvui import GameManager
|
||||
|
||||
class UTManager(GameManager):
|
||||
logging_pairs = [
|
||||
("Client", "Archipelago")
|
||||
]
|
||||
base_title = "Archipelago Undertale Client"
|
||||
|
||||
self.ui = UTManager(self)
|
||||
self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI")
|
||||
|
||||
def on_deathlink(self, data: typing.Dict[str, typing.Any]):
|
||||
self.got_deathlink = True
|
||||
super().on_deathlink(data)
|
||||
|
||||
|
||||
def to_room_name(place_name: str):
|
||||
if place_name == "Old Home Exit":
|
||||
return "room_ruinsexit"
|
||||
elif place_name == "Snowdin Forest":
|
||||
return "room_tundra1"
|
||||
elif place_name == "Snowdin Town Exit":
|
||||
return "room_fogroom"
|
||||
elif place_name == "Waterfall":
|
||||
return "room_water1"
|
||||
elif place_name == "Waterfall Exit":
|
||||
return "room_fire2"
|
||||
elif place_name == "Hotland":
|
||||
return "room_fire_prelab"
|
||||
elif place_name == "Hotland Exit":
|
||||
return "room_fire_precore"
|
||||
elif place_name == "Core":
|
||||
return "room_fire_core1"
|
||||
|
||||
|
||||
async def process_undertale_cmd(ctx: UndertaleContext, cmd: str, args: dict):
|
||||
if cmd == "Connected":
|
||||
if not os.path.exists(ctx.save_game_folder):
|
||||
os.mkdir(ctx.save_game_folder)
|
||||
ctx.route = args["slot_data"]["route"]
|
||||
ctx.pieces_needed = args["slot_data"]["key_pieces"]
|
||||
ctx.tem_armor = args["slot_data"]["temy_armor_include"]
|
||||
|
||||
await ctx.send_msgs([{"cmd": "Get", "keys": [str(ctx.slot)+" RoutesDone neutral",
|
||||
str(ctx.slot)+" RoutesDone pacifist",
|
||||
str(ctx.slot)+" RoutesDone genocide"]}])
|
||||
await ctx.send_msgs([{"cmd": "SetNotify", "keys": [str(ctx.slot)+" RoutesDone neutral",
|
||||
str(ctx.slot)+" RoutesDone pacifist",
|
||||
str(ctx.slot)+" RoutesDone genocide"]}])
|
||||
if args["slot_data"]["only_flakes"]:
|
||||
with open(os.path.join(ctx.save_game_folder, "GenoNoChest.flag"), "w") as f:
|
||||
f.close()
|
||||
if not args["slot_data"]["key_hunt"]:
|
||||
ctx.pieces_needed = 0
|
||||
if args["slot_data"]["rando_love"]:
|
||||
filename = f"LOVErando.LV"
|
||||
with open(os.path.join(ctx.save_game_folder, filename), "w") as f:
|
||||
f.close()
|
||||
if args["slot_data"]["rando_stats"]:
|
||||
filename = f"STATrando.LV"
|
||||
with open(os.path.join(ctx.save_game_folder, filename), "w") as f:
|
||||
f.close()
|
||||
filename = f"{ctx.route}.route"
|
||||
with open(os.path.join(ctx.save_game_folder, filename), "w") as f:
|
||||
f.close()
|
||||
filename = f"check.spot"
|
||||
with open(os.path.join(ctx.save_game_folder, filename), "a") as f:
|
||||
for ss in set(args["checked_locations"]):
|
||||
f.write(str(ss-12000)+"\n")
|
||||
f.close()
|
||||
elif cmd == "LocationInfo":
|
||||
for l in args["locations"]:
|
||||
locationid = l.location
|
||||
filename = f"{str(locationid-12000)}.hint"
|
||||
with open(os.path.join(ctx.save_game_folder, filename), "w") as f:
|
||||
toDraw = ""
|
||||
for i in range(20):
|
||||
if i < len(str(ctx.item_names.lookup_in_game(l.item))):
|
||||
toDraw += str(ctx.item_names.lookup_in_game(l.item))[i]
|
||||
else:
|
||||
break
|
||||
f.write(toDraw)
|
||||
f.close()
|
||||
elif cmd == "Retrieved":
|
||||
if str(ctx.slot)+" RoutesDone neutral" in args["keys"]:
|
||||
if args["keys"][str(ctx.slot)+" RoutesDone neutral"] is not None:
|
||||
ctx.completed_routes["neutral"] = args["keys"][str(ctx.slot)+" RoutesDone neutral"]
|
||||
if str(ctx.slot)+" RoutesDone genocide" in args["keys"]:
|
||||
if args["keys"][str(ctx.slot)+" RoutesDone genocide"] is not None:
|
||||
ctx.completed_routes["genocide"] = args["keys"][str(ctx.slot)+" RoutesDone genocide"]
|
||||
if str(ctx.slot)+" RoutesDone pacifist" in args["keys"]:
|
||||
if args["keys"][str(ctx.slot) + " RoutesDone pacifist"] is not None:
|
||||
ctx.completed_routes["pacifist"] = args["keys"][str(ctx.slot)+" RoutesDone pacifist"]
|
||||
elif cmd == "SetReply":
|
||||
if args["value"] is not None:
|
||||
if str(ctx.slot)+" RoutesDone pacifist" == args["key"]:
|
||||
ctx.completed_routes["pacifist"] = args["value"]
|
||||
elif str(ctx.slot)+" RoutesDone genocide" == args["key"]:
|
||||
ctx.completed_routes["genocide"] = args["value"]
|
||||
elif str(ctx.slot)+" RoutesDone neutral" == args["key"]:
|
||||
ctx.completed_routes["neutral"] = args["value"]
|
||||
elif cmd == "ReceivedItems":
|
||||
start_index = args["index"]
|
||||
|
||||
if start_index == 0:
|
||||
ctx.items_received = []
|
||||
elif start_index != len(ctx.items_received):
|
||||
sync_msg = [{"cmd": "Sync"}]
|
||||
if ctx.locations_checked:
|
||||
sync_msg.append({"cmd": "LocationChecks",
|
||||
"locations": list(ctx.locations_checked)})
|
||||
await ctx.send_msgs(sync_msg)
|
||||
if start_index == len(ctx.items_received):
|
||||
counter = -1
|
||||
placedWeapon = 0
|
||||
placedArmor = 0
|
||||
for item in args["items"]:
|
||||
id = NetworkItem(*item).location
|
||||
while NetworkItem(*item).location < 0 and \
|
||||
counter <= id:
|
||||
id -= 1
|
||||
if NetworkItem(*item).location < 0:
|
||||
counter -= 1
|
||||
filename = f"{str(id)}PLR{str(NetworkItem(*item).player)}.item"
|
||||
with open(os.path.join(ctx.save_game_folder, filename), "w") as f:
|
||||
if NetworkItem(*item).item == 77701:
|
||||
if placedWeapon == 0:
|
||||
f.write(str(77013-11000))
|
||||
elif placedWeapon == 1:
|
||||
f.write(str(77014-11000))
|
||||
elif placedWeapon == 2:
|
||||
f.write(str(77025-11000))
|
||||
elif placedWeapon == 3:
|
||||
f.write(str(77045-11000))
|
||||
elif placedWeapon == 4:
|
||||
f.write(str(77049-11000))
|
||||
elif placedWeapon == 5:
|
||||
f.write(str(77047-11000))
|
||||
elif placedWeapon == 6:
|
||||
if str(ctx.route) == "genocide" or str(ctx.route) == "all_routes":
|
||||
f.write(str(77052-11000))
|
||||
else:
|
||||
f.write(str(77051-11000))
|
||||
else:
|
||||
f.write(str(77003-11000))
|
||||
placedWeapon += 1
|
||||
elif NetworkItem(*item).item == 77702:
|
||||
if placedArmor == 0:
|
||||
f.write(str(77012-11000))
|
||||
elif placedArmor == 1:
|
||||
f.write(str(77015-11000))
|
||||
elif placedArmor == 2:
|
||||
f.write(str(77024-11000))
|
||||
elif placedArmor == 3:
|
||||
f.write(str(77044-11000))
|
||||
elif placedArmor == 4:
|
||||
f.write(str(77048-11000))
|
||||
elif placedArmor == 5:
|
||||
if str(ctx.route) == "genocide":
|
||||
f.write(str(77053-11000))
|
||||
else:
|
||||
f.write(str(77046-11000))
|
||||
elif placedArmor == 6 and ((not str(ctx.route) == "genocide") or ctx.tem_armor):
|
||||
if str(ctx.route) == "all_routes":
|
||||
f.write(str(77053-11000))
|
||||
elif str(ctx.route) == "genocide":
|
||||
f.write(str(77064-11000))
|
||||
else:
|
||||
f.write(str(77050-11000))
|
||||
elif placedArmor == 7 and ctx.tem_armor and not str(ctx.route) == "genocide":
|
||||
f.write(str(77064-11000))
|
||||
else:
|
||||
f.write(str(77004-11000))
|
||||
placedArmor += 1
|
||||
else:
|
||||
f.write(str(NetworkItem(*item).item-11000))
|
||||
f.close()
|
||||
ctx.items_received.append(NetworkItem(*item))
|
||||
if [item.item for item in ctx.items_received].count(77000) >= ctx.pieces_needed > 0:
|
||||
filename = f"{str(-99999)}PLR{str(0)}.item"
|
||||
with open(os.path.join(ctx.save_game_folder, filename), "w") as f:
|
||||
f.write(str(77787 - 11000))
|
||||
f.close()
|
||||
filename = f"{str(-99998)}PLR{str(0)}.item"
|
||||
with open(os.path.join(ctx.save_game_folder, filename), "w") as f:
|
||||
f.write(str(77789 - 11000))
|
||||
f.close()
|
||||
ctx.watcher_event.set()
|
||||
|
||||
elif cmd == "RoomUpdate":
|
||||
if "checked_locations" in args:
|
||||
filename = f"check.spot"
|
||||
with open(os.path.join(ctx.save_game_folder, filename), "a") as f:
|
||||
for ss in set(args["checked_locations"]):
|
||||
f.write(str(ss-12000)+"\n")
|
||||
f.close()
|
||||
|
||||
elif cmd == "Bounced":
|
||||
tags = args.get("tags", [])
|
||||
if "Online" in tags:
|
||||
data = args.get("data", {})
|
||||
if data["player"] != ctx.slot and data["player"] is not None:
|
||||
filename = f"FRISK" + str(data["player"]) + ".playerspot"
|
||||
with open(os.path.join(ctx.save_game_folder, filename), "w") as f:
|
||||
f.write(str(data["x"]) + str(data["y"]) + str(data["room"]) + str(
|
||||
data["spr"]) + str(data["frm"]))
|
||||
f.close()
|
||||
|
||||
|
||||
async def multi_watcher(ctx: UndertaleContext):
|
||||
while not ctx.exit_event.is_set():
|
||||
path = ctx.save_game_folder
|
||||
for root, dirs, files in os.walk(path):
|
||||
for file in files:
|
||||
if "spots.mine" in file and "Online" in ctx.tags:
|
||||
with open(os.path.join(root, file), "r") as mine:
|
||||
this_x = mine.readline()
|
||||
this_y = mine.readline()
|
||||
this_room = mine.readline()
|
||||
this_sprite = mine.readline()
|
||||
this_frame = mine.readline()
|
||||
mine.close()
|
||||
message = [{"cmd": "Bounce", "tags": ["Online"],
|
||||
"data": {"player": ctx.slot, "x": this_x, "y": this_y, "room": this_room,
|
||||
"spr": this_sprite, "frm": this_frame}}]
|
||||
await ctx.send_msgs(message)
|
||||
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
|
||||
async def game_watcher(ctx: UndertaleContext):
|
||||
while not ctx.exit_event.is_set():
|
||||
await ctx.update_death_link(ctx.deathlink_status)
|
||||
path = ctx.save_game_folder
|
||||
if ctx.syncing:
|
||||
for root, dirs, files in os.walk(path):
|
||||
for file in files:
|
||||
if ".item" in file:
|
||||
os.remove(os.path.join(root, file))
|
||||
sync_msg = [{"cmd": "Sync"}]
|
||||
if ctx.locations_checked:
|
||||
sync_msg.append({"cmd": "LocationChecks", "locations": list(ctx.locations_checked)})
|
||||
await ctx.send_msgs(sync_msg)
|
||||
ctx.syncing = False
|
||||
if ctx.got_deathlink:
|
||||
ctx.got_deathlink = False
|
||||
with open(os.path.join(ctx.save_game_folder, "WelcomeToTheDead.youDied"), "w") as f:
|
||||
f.close()
|
||||
sending = []
|
||||
victory = False
|
||||
found_routes = 0
|
||||
for root, dirs, files in os.walk(path):
|
||||
for file in files:
|
||||
if "DontBeMad.mad" in file:
|
||||
os.remove(os.path.join(root, file))
|
||||
if "DeathLink" in ctx.tags:
|
||||
await ctx.send_death()
|
||||
if "scout" == file:
|
||||
sending = []
|
||||
try:
|
||||
with open(os.path.join(root, file), "r") as f:
|
||||
lines = f.readlines()
|
||||
for l in lines:
|
||||
if ctx.server_locations.__contains__(int(l)+12000):
|
||||
sending = sending + [int(l.rstrip('\n'))+12000]
|
||||
finally:
|
||||
await ctx.send_msgs([{"cmd": "LocationScouts", "locations": sending,
|
||||
"create_as_hint": int(2)}])
|
||||
os.remove(os.path.join(root, file))
|
||||
if "check.spot" in file:
|
||||
sending = []
|
||||
try:
|
||||
with open(os.path.join(root, file), "r") as f:
|
||||
lines = f.readlines()
|
||||
for l in lines:
|
||||
sending = sending+[(int(l.rstrip('\n')))+12000]
|
||||
finally:
|
||||
await ctx.send_msgs([{"cmd": "LocationChecks", "locations": sending}])
|
||||
if "victory" in file and str(ctx.route) in file:
|
||||
victory = True
|
||||
if ".playerspot" in file and "Online" not in ctx.tags:
|
||||
os.remove(os.path.join(root, file))
|
||||
if "victory" in file:
|
||||
if str(ctx.route) == "all_routes":
|
||||
if "neutral" in file and ctx.completed_routes["neutral"] != 1:
|
||||
await ctx.send_msgs([{"cmd": "Set", "key": str(ctx.slot)+" RoutesDone neutral",
|
||||
"default": 0, "want_reply": True, "operations": [{"operation": "max",
|
||||
"value": 1}]}])
|
||||
elif "pacifist" in file and ctx.completed_routes["pacifist"] != 1:
|
||||
await ctx.send_msgs([{"cmd": "Set", "key": str(ctx.slot)+" RoutesDone pacifist",
|
||||
"default": 0, "want_reply": True, "operations": [{"operation": "max",
|
||||
"value": 1}]}])
|
||||
elif "genocide" in file and ctx.completed_routes["genocide"] != 1:
|
||||
await ctx.send_msgs([{"cmd": "Set", "key": str(ctx.slot)+" RoutesDone genocide",
|
||||
"default": 0, "want_reply": True, "operations": [{"operation": "max",
|
||||
"value": 1}]}])
|
||||
if str(ctx.route) == "all_routes":
|
||||
found_routes += ctx.completed_routes["neutral"]
|
||||
found_routes += ctx.completed_routes["pacifist"]
|
||||
found_routes += ctx.completed_routes["genocide"]
|
||||
if str(ctx.route) == "all_routes" and found_routes >= 3:
|
||||
victory = True
|
||||
ctx.locations_checked = sending
|
||||
if (not ctx.finished_game) and victory:
|
||||
await ctx.send_msgs([{"cmd": "StatusUpdate", "status": ClientStatus.CLIENT_GOAL}])
|
||||
ctx.finished_game = True
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
|
||||
def main():
|
||||
Utils.init_logging("UndertaleClient", exception_logger="Client")
|
||||
|
||||
async def _main():
|
||||
ctx = UndertaleContext(None, None)
|
||||
ctx.server_task = asyncio.create_task(server_loop(ctx), name="server loop")
|
||||
asyncio.create_task(
|
||||
game_watcher(ctx), name="UndertaleProgressionWatcher")
|
||||
|
||||
asyncio.create_task(
|
||||
multi_watcher(ctx), name="UndertaleMultiplayerWatcher")
|
||||
|
||||
if gui_enabled:
|
||||
ctx.run_gui()
|
||||
ctx.run_cli()
|
||||
|
||||
await ctx.exit_event.wait()
|
||||
await ctx.shutdown()
|
||||
|
||||
import colorama
|
||||
|
||||
colorama.init()
|
||||
|
||||
asyncio.run(_main())
|
||||
colorama.deinit()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = get_base_parser(description="Undertale Client, for text interfacing.")
|
||||
args = parser.parse_args()
|
||||
main()
|
||||
@@ -1,451 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import atexit
|
||||
import os
|
||||
import sys
|
||||
import asyncio
|
||||
import random
|
||||
import shutil
|
||||
from typing import Tuple, List, Iterable, Dict
|
||||
|
||||
from worlds.wargroove import WargrooveWorld
|
||||
from worlds.wargroove.Items import item_table, faction_table, CommanderData, ItemData
|
||||
|
||||
import ModuleUpdate
|
||||
ModuleUpdate.update()
|
||||
|
||||
import Utils
|
||||
import json
|
||||
import logging
|
||||
|
||||
if __name__ == "__main__":
|
||||
Utils.init_logging("WargrooveClient", exception_logger="Client")
|
||||
|
||||
from NetUtils import NetworkItem, ClientStatus
|
||||
from CommonClient import gui_enabled, logger, get_base_parser, ClientCommandProcessor, \
|
||||
CommonContext, server_loop
|
||||
|
||||
wg_logger = logging.getLogger("WG")
|
||||
|
||||
|
||||
class WargrooveClientCommandProcessor(ClientCommandProcessor):
|
||||
def _cmd_resync(self):
|
||||
"""Manually trigger a resync."""
|
||||
self.output(f"Syncing items.")
|
||||
self.ctx.syncing = True
|
||||
|
||||
def _cmd_commander(self, *commander_name: Iterable[str]):
|
||||
"""Set the current commander to the given commander."""
|
||||
if commander_name:
|
||||
self.ctx.set_commander(' '.join(commander_name))
|
||||
else:
|
||||
if self.ctx.can_choose_commander:
|
||||
commanders = self.ctx.get_commanders()
|
||||
wg_logger.info('Unlocked commanders: ' +
|
||||
', '.join((commander.name for commander, unlocked in commanders if unlocked)))
|
||||
wg_logger.info('Locked commanders: ' +
|
||||
', '.join((commander.name for commander, unlocked in commanders if not unlocked)))
|
||||
else:
|
||||
wg_logger.error('Cannot set commanders in this game mode.')
|
||||
|
||||
|
||||
class WargrooveContext(CommonContext):
|
||||
command_processor: int = WargrooveClientCommandProcessor
|
||||
game = "Wargroove"
|
||||
items_handling = 0b111 # full remote
|
||||
current_commander: CommanderData = faction_table["Starter"][0]
|
||||
can_choose_commander: bool = False
|
||||
commander_defense_boost_multiplier: int = 0
|
||||
income_boost_multiplier: int = 0
|
||||
starting_groove_multiplier: float
|
||||
faction_item_ids = {
|
||||
'Starter': 0,
|
||||
'Cherrystone': 52025,
|
||||
'Felheim': 52026,
|
||||
'Floran': 52027,
|
||||
'Heavensong': 52028,
|
||||
'Requiem': 52029,
|
||||
'Outlaw': 52030
|
||||
}
|
||||
buff_item_ids = {
|
||||
'Income Boost': 52023,
|
||||
'Commander Defense Boost': 52024,
|
||||
}
|
||||
|
||||
def __init__(self, server_address, password):
|
||||
super(WargrooveContext, self).__init__(server_address, password)
|
||||
self.send_index: int = 0
|
||||
self.syncing = False
|
||||
self.awaiting_bridge = False
|
||||
# self.game_communication_path: files go in this path to pass data between us and the actual game
|
||||
if "appdata" in os.environ:
|
||||
options = Utils.get_options()
|
||||
root_directory = os.path.join(options["wargroove_options"]["root_directory"])
|
||||
data_directory = os.path.join("lib", "worlds", "wargroove", "data")
|
||||
dev_data_directory = os.path.join("worlds", "wargroove", "data")
|
||||
appdata_wargroove = os.path.expandvars(os.path.join("%APPDATA%", "Chucklefish", "Wargroove"))
|
||||
if not os.path.isfile(os.path.join(root_directory, "win64_bin", "wargroove64.exe")):
|
||||
print_error_and_close("WargrooveClient couldn't find wargroove64.exe. "
|
||||
"Unable to infer required game_communication_path")
|
||||
self.game_communication_path = os.path.join(root_directory, "AP")
|
||||
if not os.path.exists(self.game_communication_path):
|
||||
os.makedirs(self.game_communication_path)
|
||||
self.remove_communication_files()
|
||||
atexit.register(self.remove_communication_files)
|
||||
if not os.path.isdir(appdata_wargroove):
|
||||
print_error_and_close("WargrooveClient couldn't find Wargoove in appdata!"
|
||||
"Boot Wargroove and then close it to attempt to fix this error")
|
||||
if not os.path.isdir(data_directory):
|
||||
data_directory = dev_data_directory
|
||||
if not os.path.isdir(data_directory):
|
||||
print_error_and_close("WargrooveClient couldn't find Wargoove mod and save files in install!")
|
||||
shutil.copytree(data_directory, appdata_wargroove, dirs_exist_ok=True)
|
||||
else:
|
||||
print_error_and_close("WargrooveClient couldn't detect system type. "
|
||||
"Unable to infer required game_communication_path")
|
||||
|
||||
async def server_auth(self, password_requested: bool = False):
|
||||
if password_requested and not self.password:
|
||||
await super(WargrooveContext, self).server_auth(password_requested)
|
||||
await self.get_username()
|
||||
await self.send_connect()
|
||||
|
||||
async def connection_closed(self):
|
||||
await super(WargrooveContext, self).connection_closed()
|
||||
self.remove_communication_files()
|
||||
self.checked_locations.clear()
|
||||
self.server_locations.clear()
|
||||
self.finished_game = False
|
||||
|
||||
@property
|
||||
def endpoints(self):
|
||||
if self.server:
|
||||
return [self.server]
|
||||
else:
|
||||
return []
|
||||
|
||||
async def shutdown(self):
|
||||
await super(WargrooveContext, self).shutdown()
|
||||
self.remove_communication_files()
|
||||
self.checked_locations.clear()
|
||||
self.server_locations.clear()
|
||||
self.finished_game = False
|
||||
|
||||
def remove_communication_files(self):
|
||||
for root, dirs, files in os.walk(self.game_communication_path):
|
||||
for file in files:
|
||||
os.remove(root + "/" + file)
|
||||
|
||||
def on_package(self, cmd: str, args: dict):
|
||||
if cmd in {"Connected"}:
|
||||
filename = f"AP_settings.json"
|
||||
with open(os.path.join(self.game_communication_path, filename), 'w') as f:
|
||||
slot_data = args["slot_data"]
|
||||
json.dump(args["slot_data"], f)
|
||||
self.can_choose_commander = slot_data["can_choose_commander"]
|
||||
print('can choose commander:', self.can_choose_commander)
|
||||
self.starting_groove_multiplier = slot_data["starting_groove_multiplier"]
|
||||
self.income_boost_multiplier = slot_data["income_boost"]
|
||||
self.commander_defense_boost_multiplier = slot_data["commander_defense_boost"]
|
||||
f.close()
|
||||
for ss in self.checked_locations:
|
||||
filename = f"send{ss}"
|
||||
with open(os.path.join(self.game_communication_path, filename), 'w') as f:
|
||||
f.close()
|
||||
self.update_commander_data()
|
||||
self.ui.update_tracker()
|
||||
|
||||
random.seed(self.seed_name + str(self.slot))
|
||||
# Our indexes start at 1 and we have 24 levels
|
||||
for i in range(1, 25):
|
||||
filename = f"seed{i}"
|
||||
with open(os.path.join(self.game_communication_path, filename), 'w') as f:
|
||||
f.write(str(random.randint(0, 4294967295)))
|
||||
f.close()
|
||||
|
||||
if cmd in {"RoomInfo"}:
|
||||
self.seed_name = args["seed_name"]
|
||||
|
||||
if cmd in {"ReceivedItems"}:
|
||||
received_ids = [item.item for item in self.items_received]
|
||||
for network_item in self.items_received:
|
||||
filename = f"AP_{str(network_item.item)}.item"
|
||||
path = os.path.join(self.game_communication_path, filename)
|
||||
|
||||
# Newly-obtained items
|
||||
if not os.path.isfile(path):
|
||||
open(path, 'w').close()
|
||||
# Announcing commander unlocks
|
||||
item_name = self.item_names.lookup_in_game(network_item.item)
|
||||
if item_name in faction_table.keys():
|
||||
for commander in faction_table[item_name]:
|
||||
logger.info(f"{commander.name} has been unlocked!")
|
||||
|
||||
with open(path, 'w') as f:
|
||||
item_count = received_ids.count(network_item.item)
|
||||
if self.buff_item_ids["Income Boost"] == network_item.item:
|
||||
f.write(f"{item_count * self.income_boost_multiplier}")
|
||||
elif self.buff_item_ids["Commander Defense Boost"] == network_item.item:
|
||||
f.write(f"{item_count * self.commander_defense_boost_multiplier}")
|
||||
else:
|
||||
f.write(f"{item_count}")
|
||||
f.close()
|
||||
|
||||
print_filename = f"AP_{str(network_item.item)}.item.print"
|
||||
print_path = os.path.join(self.game_communication_path, print_filename)
|
||||
if not os.path.isfile(print_path):
|
||||
open(print_path, 'w').close()
|
||||
with open(print_path, 'w') as f:
|
||||
f.write("Received " +
|
||||
self.item_names.lookup_in_game(network_item.item) +
|
||||
" from " +
|
||||
self.player_names[network_item.player])
|
||||
f.close()
|
||||
self.update_commander_data()
|
||||
self.ui.update_tracker()
|
||||
|
||||
if cmd in {"RoomUpdate"}:
|
||||
if "checked_locations" in args:
|
||||
for ss in self.checked_locations:
|
||||
filename = f"send{ss}"
|
||||
with open(os.path.join(self.game_communication_path, filename), 'w') as f:
|
||||
f.close()
|
||||
|
||||
def run_gui(self):
|
||||
"""Import kivy UI system and start running it as self.ui_task."""
|
||||
from kvui import GameManager, HoverBehavior, ServerToolTip
|
||||
from kivy.uix.tabbedpanel import TabbedPanelItem
|
||||
from kivy.lang import Builder
|
||||
from kivy.uix.button import Button
|
||||
from kivy.uix.togglebutton import ToggleButton
|
||||
from kivy.uix.boxlayout import BoxLayout
|
||||
from kivy.uix.gridlayout import GridLayout
|
||||
from kivy.uix.image import AsyncImage, Image
|
||||
from kivy.uix.stacklayout import StackLayout
|
||||
from kivy.uix.label import Label
|
||||
from kivy.properties import ColorProperty
|
||||
from kivy.uix.image import Image
|
||||
import pkgutil
|
||||
|
||||
class TrackerLayout(BoxLayout):
|
||||
pass
|
||||
|
||||
class CommanderSelect(BoxLayout):
|
||||
pass
|
||||
|
||||
class CommanderButton(ToggleButton):
|
||||
pass
|
||||
|
||||
class FactionBox(BoxLayout):
|
||||
pass
|
||||
|
||||
class CommanderGroup(BoxLayout):
|
||||
pass
|
||||
|
||||
class ItemTracker(BoxLayout):
|
||||
pass
|
||||
|
||||
class ItemLabel(Label):
|
||||
pass
|
||||
|
||||
class WargrooveManager(GameManager):
|
||||
logging_pairs = [
|
||||
("Client", "Archipelago"),
|
||||
("WG", "WG Console"),
|
||||
]
|
||||
base_title = "Archipelago Wargroove Client"
|
||||
ctx: WargrooveContext
|
||||
unit_tracker: ItemTracker
|
||||
trigger_tracker: BoxLayout
|
||||
boost_tracker: BoxLayout
|
||||
commander_buttons: Dict[int, List[CommanderButton]]
|
||||
tracker_items = {
|
||||
"Swordsman": ItemData(None, "Unit", False),
|
||||
"Dog": ItemData(None, "Unit", False),
|
||||
**item_table
|
||||
}
|
||||
|
||||
def build(self):
|
||||
container = super().build()
|
||||
self.add_client_tab("Wargroove", self.build_tracker())
|
||||
return container
|
||||
|
||||
def build_tracker(self) -> TrackerLayout:
|
||||
try:
|
||||
tracker = TrackerLayout(orientation="horizontal")
|
||||
commander_select = CommanderSelect(orientation="vertical")
|
||||
self.commander_buttons = {}
|
||||
|
||||
for faction, commanders in faction_table.items():
|
||||
faction_box = FactionBox(size_hint=(None, None), width=100 * len(commanders), height=70)
|
||||
commander_group = CommanderGroup()
|
||||
commander_buttons = []
|
||||
for commander in commanders:
|
||||
commander_button = CommanderButton(text=commander.name, group="commanders")
|
||||
if faction == "Starter":
|
||||
commander_button.disabled = False
|
||||
commander_button.bind(on_press=lambda instance: self.ctx.set_commander(instance.text))
|
||||
commander_buttons.append(commander_button)
|
||||
commander_group.add_widget(commander_button)
|
||||
self.commander_buttons[faction] = commander_buttons
|
||||
faction_box.add_widget(Label(text=faction, size_hint_x=None, pos_hint={'left': 1}, size_hint_y=None, height=10))
|
||||
faction_box.add_widget(commander_group)
|
||||
commander_select.add_widget(faction_box)
|
||||
item_tracker = ItemTracker(padding=[0,20])
|
||||
self.unit_tracker = BoxLayout(orientation="vertical")
|
||||
other_tracker = BoxLayout(orientation="vertical")
|
||||
self.trigger_tracker = BoxLayout(orientation="vertical")
|
||||
self.boost_tracker = BoxLayout(orientation="vertical")
|
||||
other_tracker.add_widget(self.trigger_tracker)
|
||||
other_tracker.add_widget(self.boost_tracker)
|
||||
item_tracker.add_widget(self.unit_tracker)
|
||||
item_tracker.add_widget(other_tracker)
|
||||
tracker.add_widget(commander_select)
|
||||
tracker.add_widget(item_tracker)
|
||||
self.update_tracker()
|
||||
return tracker
|
||||
except Exception as e:
|
||||
print(e)
|
||||
|
||||
def update_tracker(self):
|
||||
received_ids = [item.item for item in self.ctx.items_received]
|
||||
for faction, item_id in self.ctx.faction_item_ids.items():
|
||||
for commander_button in self.commander_buttons[faction]:
|
||||
commander_button.disabled = not (faction == "Starter" or item_id in received_ids)
|
||||
self.unit_tracker.clear_widgets()
|
||||
self.trigger_tracker.clear_widgets()
|
||||
for name, item in self.tracker_items.items():
|
||||
if item.type in ("Unit", "Trigger"):
|
||||
status_color = (1, 1, 1, 1) if item.code is None or item.code in received_ids else (0.6, 0.2, 0.2, 1)
|
||||
label = ItemLabel(text=name, color=status_color)
|
||||
if item.type == "Unit":
|
||||
self.unit_tracker.add_widget(label)
|
||||
else:
|
||||
self.trigger_tracker.add_widget(label)
|
||||
self.boost_tracker.clear_widgets()
|
||||
extra_income = received_ids.count(52023) * self.ctx.income_boost_multiplier
|
||||
extra_defense = received_ids.count(52024) * self.ctx.commander_defense_boost_multiplier
|
||||
income_boost = ItemLabel(text="Extra Income: " + str(extra_income))
|
||||
defense_boost = ItemLabel(text="Comm Defense: " + str(100 + extra_defense))
|
||||
self.boost_tracker.add_widget(income_boost)
|
||||
self.boost_tracker.add_widget(defense_boost)
|
||||
|
||||
self.ui = WargrooveManager(self)
|
||||
data = pkgutil.get_data(WargrooveWorld.__module__, "Wargroove.kv").decode()
|
||||
Builder.load_string(data)
|
||||
self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI")
|
||||
|
||||
def update_commander_data(self):
|
||||
if self.can_choose_commander:
|
||||
faction_items = 0
|
||||
faction_item_names = [faction + ' Commanders' for faction in faction_table.keys()]
|
||||
for network_item in self.items_received:
|
||||
if self.item_names.lookup_in_game(network_item.item) in faction_item_names:
|
||||
faction_items += 1
|
||||
starting_groove = (faction_items - 1) * self.starting_groove_multiplier
|
||||
# Must be an integer larger than 0
|
||||
starting_groove = int(max(starting_groove, 0))
|
||||
data = {
|
||||
"commander": self.current_commander.internal_name,
|
||||
"starting_groove": starting_groove
|
||||
}
|
||||
else:
|
||||
data = {
|
||||
"commander": "seed",
|
||||
"starting_groove": 0
|
||||
}
|
||||
filename = 'commander.json'
|
||||
with open(os.path.join(self.game_communication_path, filename), 'w') as f:
|
||||
json.dump(data, f)
|
||||
if self.ui:
|
||||
self.ui.update_tracker()
|
||||
|
||||
def set_commander(self, commander_name: str) -> bool:
|
||||
"""Sets the current commander to the given one, if possible"""
|
||||
if not self.can_choose_commander:
|
||||
wg_logger.error("Cannot set commanders in this game mode.")
|
||||
return
|
||||
match_name = commander_name.lower()
|
||||
for commander, unlocked in self.get_commanders():
|
||||
if commander.name.lower() == match_name or commander.alt_name and commander.alt_name.lower() == match_name:
|
||||
if unlocked:
|
||||
self.current_commander = commander
|
||||
self.syncing = True
|
||||
wg_logger.info(f"Commander set to {commander.name}.")
|
||||
self.update_commander_data()
|
||||
return True
|
||||
else:
|
||||
wg_logger.error(f"Commander {commander.name} has not been unlocked.")
|
||||
return False
|
||||
else:
|
||||
wg_logger.error(f"{commander_name} is not a recognized Wargroove commander.")
|
||||
|
||||
def get_commanders(self) -> List[Tuple[CommanderData, bool]]:
|
||||
"""Gets a list of commanders with their unlocked status"""
|
||||
commanders = []
|
||||
received_ids = [item.item for item in self.items_received]
|
||||
for faction in faction_table.keys():
|
||||
unlocked = faction == 'Starter' or self.faction_item_ids[faction] in received_ids
|
||||
commanders += [(commander, unlocked) for commander in faction_table[faction]]
|
||||
return commanders
|
||||
|
||||
|
||||
async def game_watcher(ctx: WargrooveContext):
|
||||
from worlds.wargroove.Locations import location_table
|
||||
while not ctx.exit_event.is_set():
|
||||
if ctx.syncing == True:
|
||||
sync_msg = [{'cmd': 'Sync'}]
|
||||
if ctx.locations_checked:
|
||||
sync_msg.append({"cmd": "LocationChecks", "locations": list(ctx.locations_checked)})
|
||||
await ctx.send_msgs(sync_msg)
|
||||
ctx.syncing = False
|
||||
sending = []
|
||||
victory = False
|
||||
for root, dirs, files in os.walk(ctx.game_communication_path):
|
||||
for file in files:
|
||||
if file.find("send") > -1:
|
||||
st = file.split("send", -1)[1]
|
||||
sending = sending+[(int(st))]
|
||||
os.remove(os.path.join(ctx.game_communication_path, file))
|
||||
if file.find("victory") > -1:
|
||||
victory = True
|
||||
os.remove(os.path.join(ctx.game_communication_path, file))
|
||||
ctx.locations_checked = sending
|
||||
message = [{"cmd": 'LocationChecks', "locations": sending}]
|
||||
await ctx.send_msgs(message)
|
||||
if not ctx.finished_game and victory:
|
||||
await ctx.send_msgs([{"cmd": "StatusUpdate", "status": ClientStatus.CLIENT_GOAL}])
|
||||
ctx.finished_game = True
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
|
||||
def print_error_and_close(msg):
|
||||
logger.error("Error: " + msg)
|
||||
Utils.messagebox("Error", msg, error=True)
|
||||
sys.exit(1)
|
||||
|
||||
if __name__ == '__main__':
|
||||
async def main(args):
|
||||
ctx = WargrooveContext(args.connect, args.password)
|
||||
ctx.server_task = asyncio.create_task(server_loop(ctx), name="server loop")
|
||||
if gui_enabled:
|
||||
ctx.run_gui()
|
||||
ctx.run_cli()
|
||||
progression_watcher = asyncio.create_task(
|
||||
game_watcher(ctx), name="WargrooveProgressionWatcher")
|
||||
|
||||
await ctx.exit_event.wait()
|
||||
ctx.server_address = None
|
||||
|
||||
await progression_watcher
|
||||
|
||||
await ctx.shutdown()
|
||||
|
||||
import colorama
|
||||
|
||||
parser = get_base_parser(description="Wargroove Client, for text interfacing.")
|
||||
|
||||
args, rest = parser.parse_known_args()
|
||||
colorama.init()
|
||||
asyncio.run(main(args))
|
||||
colorama.deinit()
|
||||
59
WebHost.py
@@ -1,5 +1,5 @@
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
import multiprocessing
|
||||
import logging
|
||||
import typing
|
||||
@@ -11,44 +11,29 @@ ModuleUpdate.update()
|
||||
|
||||
# in case app gets imported by something like gunicorn
|
||||
import Utils
|
||||
import settings
|
||||
from Utils import get_file_safe_name
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from flask import Flask
|
||||
Utils.local_path.cached_path = os.path.dirname(__file__) or "." # py3.8 is not abs. remove "." when dropping 3.8
|
||||
|
||||
from WebHostLib import register, app as raw_app
|
||||
from waitress import serve
|
||||
|
||||
from WebHostLib.models import db
|
||||
from WebHostLib.autolauncher import autohost, autogen
|
||||
from WebHostLib.lttpsprites import update_sprites_lttp
|
||||
from WebHostLib.options import create as create_options_files
|
||||
|
||||
Utils.local_path.cached_path = os.path.dirname(__file__)
|
||||
settings.no_gui = True
|
||||
configpath = os.path.abspath("config.yaml")
|
||||
if not os.path.exists(configpath): # fall back to config.yaml in home
|
||||
configpath = os.path.abspath(Utils.user_path('config.yaml'))
|
||||
|
||||
|
||||
def get_app() -> "Flask":
|
||||
from WebHostLib import register, cache, app as raw_app
|
||||
from WebHostLib.models import db
|
||||
|
||||
def get_app():
|
||||
register()
|
||||
app = raw_app
|
||||
if os.path.exists(configpath) and not app.config["TESTING"]:
|
||||
if os.path.exists(configpath):
|
||||
import yaml
|
||||
app.config.from_file(configpath, yaml.safe_load)
|
||||
logging.info(f"Updated config from {configpath}")
|
||||
# inside get_app() so it's usable in systems like gunicorn, which do not run WebHost.py, but import it.
|
||||
parser = argparse.ArgumentParser(allow_abbrev=False)
|
||||
parser.add_argument('--config_override', default=None,
|
||||
help="Path to yaml config file that overrules config.yaml.")
|
||||
args = parser.parse_known_args()[0]
|
||||
if args.config_override:
|
||||
import yaml
|
||||
app.config.from_file(os.path.abspath(args.config_override), yaml.safe_load)
|
||||
logging.info(f"Updated config from {args.config_override}")
|
||||
if not app.config["HOST_ADDRESS"]:
|
||||
logging.info("Getting public IP, as HOST_ADDRESS is empty.")
|
||||
app.config["HOST_ADDRESS"] = Utils.get_public_ipv4()
|
||||
logging.info(f"HOST_ADDRESS was set to {app.config['HOST_ADDRESS']}")
|
||||
|
||||
register()
|
||||
cache.init_app(app)
|
||||
db.bind(**app.config["PONY"])
|
||||
db.generate_mapping(create_tables=True)
|
||||
return app
|
||||
@@ -69,10 +54,9 @@ def create_ordered_tutorials_file() -> typing.List[typing.Dict[str, typing.Any]]
|
||||
worlds[game] = world
|
||||
|
||||
base_target_path = Utils.local_path("WebHostLib", "static", "generated", "docs")
|
||||
shutil.rmtree(base_target_path, ignore_errors=True)
|
||||
for game, world in worlds.items():
|
||||
# copy files from world's docs folder to the generated folder
|
||||
target_path = os.path.join(base_target_path, get_file_safe_name(game))
|
||||
target_path = os.path.join(base_target_path, game)
|
||||
os.makedirs(target_path, exist_ok=True)
|
||||
|
||||
if world.zip_path:
|
||||
@@ -84,7 +68,6 @@ def create_ordered_tutorials_file() -> typing.List[typing.Dict[str, typing.Any]]
|
||||
with zipfile.ZipFile(zipfile_path) as zf:
|
||||
for zfile in zf.infolist():
|
||||
if not zfile.is_dir() and "/docs/" in zfile.filename:
|
||||
zfile.filename = os.path.basename(zfile.filename)
|
||||
zf.extract(zfile, target_path)
|
||||
else:
|
||||
source_path = Utils.local_path(os.path.dirname(world.__file__), "docs")
|
||||
@@ -130,11 +113,6 @@ if __name__ == "__main__":
|
||||
multiprocessing.freeze_support()
|
||||
multiprocessing.set_start_method('spawn')
|
||||
logging.basicConfig(format='[%(asctime)s] %(message)s', level=logging.INFO)
|
||||
|
||||
from WebHostLib.lttpsprites import update_sprites_lttp
|
||||
from WebHostLib.autolauncher import autohost, autogen, stop
|
||||
from WebHostLib.options import create as create_options_files
|
||||
|
||||
try:
|
||||
update_sprites_lttp()
|
||||
except Exception as e:
|
||||
@@ -151,13 +129,4 @@ if __name__ == "__main__":
|
||||
if app.config["DEBUG"]:
|
||||
app.run(debug=True, port=app.config["PORT"])
|
||||
else:
|
||||
from waitress import serve
|
||||
serve(app, port=app.config["PORT"], threads=app.config["WAITRESS_THREADS"])
|
||||
else:
|
||||
from time import sleep
|
||||
try:
|
||||
while True:
|
||||
sleep(1) # wait for process to be killed
|
||||
except (SystemExit, KeyboardInterrupt):
|
||||
pass
|
||||
stop() # stop worker threads
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import base64
|
||||
import os
|
||||
import socket
|
||||
import uuid
|
||||
import base64
|
||||
import socket
|
||||
|
||||
from pony.flask import Pony
|
||||
from flask import Flask
|
||||
from flask_caching import Cache
|
||||
from flask_compress import Compress
|
||||
from pony.flask import Pony
|
||||
from werkzeug.routing import BaseConverter
|
||||
|
||||
from Utils import title_sorted, get_file_safe_name
|
||||
from Utils import title_sorted
|
||||
from .models import *
|
||||
|
||||
UPLOAD_FOLDER = os.path.relpath('uploads')
|
||||
LOGS_FOLDER = os.path.relpath('logs')
|
||||
@@ -20,14 +21,10 @@ Pony(app)
|
||||
|
||||
app.jinja_env.filters['any'] = any
|
||||
app.jinja_env.filters['all'] = all
|
||||
app.jinja_env.filters['get_file_safe_name'] = get_file_safe_name
|
||||
|
||||
app.config["SELFHOST"] = True # application process is in charge of running the websites
|
||||
app.config["GENERATORS"] = 8 # maximum concurrent world gens
|
||||
app.config["HOSTERS"] = 8 # maximum concurrent room hosters
|
||||
app.config["SELFLAUNCH"] = True # application process is in charge of launching Rooms.
|
||||
app.config["SELFLAUNCHCERT"] = None # can point to a SSL Certificate to encrypt Room websocket connections
|
||||
app.config["SELFLAUNCHKEY"] = None # can point to a SSL Certificate Key to encrypt Room websocket connections
|
||||
app.config["SELFGEN"] = True # application process is in charge of scheduling Generations.
|
||||
app.config["DEBUG"] = False
|
||||
app.config["PORT"] = 80
|
||||
@@ -35,12 +32,8 @@ app.config['UPLOAD_FOLDER'] = UPLOAD_FOLDER
|
||||
app.config['MAX_CONTENT_LENGTH'] = 64 * 1024 * 1024 # 64 megabyte limit
|
||||
# if you want to deploy, make sure you have a non-guessable secret key
|
||||
app.config["SECRET_KEY"] = bytes(socket.gethostname(), encoding="utf-8")
|
||||
# at what amount of worlds should scheduling be used, instead of rolling in the web-thread
|
||||
app.config["JOB_THRESHOLD"] = 1
|
||||
# after what time in seconds should generation be aborted, freeing the queue slot. Can be set to None to disable.
|
||||
app.config["JOB_TIME"] = 600
|
||||
# memory limit for generator processes in bytes
|
||||
app.config["GENERATOR_MEMORY_LIMIT"] = 4294967296
|
||||
# at what amount of worlds should scheduling be used, instead of rolling in the webthread
|
||||
app.config["JOB_THRESHOLD"] = 2
|
||||
app.config['SESSION_PERMANENT'] = True
|
||||
|
||||
# waitress uses one thread for I/O, these are for processing of views that then get sent
|
||||
@@ -53,11 +46,11 @@ app.config["PONY"] = {
|
||||
'create_db': True
|
||||
}
|
||||
app.config["MAX_ROLL"] = 20
|
||||
app.config["CACHE_TYPE"] = "SimpleCache"
|
||||
app.config["HOST_ADDRESS"] = ""
|
||||
app.config["ASSET_RIGHTS"] = False
|
||||
app.config["CACHE_TYPE"] = "flask_caching.backends.SimpleCache"
|
||||
app.config["JSON_AS_ASCII"] = False
|
||||
app.config["PATCH_TARGET"] = "archipelago.gg"
|
||||
|
||||
cache = Cache()
|
||||
cache = Cache(app)
|
||||
Compress(app)
|
||||
|
||||
|
||||
@@ -80,13 +73,11 @@ def register():
|
||||
"""Import submodules, triggering their registering on flask routing.
|
||||
Note: initializes worlds subsystem."""
|
||||
# has automatic patch integration
|
||||
import worlds.AutoWorld
|
||||
import worlds.Files
|
||||
app.jinja_env.filters['supports_apdeltapatch'] = lambda game_name: \
|
||||
game_name in worlds.Files.AutoPatchRegister.patch_types
|
||||
import Patch
|
||||
app.jinja_env.filters['supports_apdeltapatch'] = lambda game_name: game_name in Patch.AutoPatchRegister.patch_types
|
||||
|
||||
from WebHostLib.customserver import run_server_process
|
||||
# to trigger app routing picking up on it
|
||||
from . import tracker, upload, landing, check, generate, downloads, api, stats, misc, robots, options, session
|
||||
from . import tracker, upload, landing, check, generate, downloads, api, stats, misc
|
||||
|
||||
app.register_blueprint(api.api_endpoints)
|
||||
|
||||
@@ -1,15 +1,49 @@
|
||||
"""API endpoints package."""
|
||||
from uuid import UUID
|
||||
from typing import List, Tuple
|
||||
|
||||
from flask import Blueprint
|
||||
from flask import Blueprint, abort
|
||||
|
||||
from ..models import Seed, Slot
|
||||
from ..models import Room, Seed
|
||||
from .. import cache
|
||||
|
||||
api_endpoints = Blueprint('api', __name__, url_prefix="/api")
|
||||
|
||||
# unsorted/misc endpoints
|
||||
|
||||
|
||||
def get_players(seed: Seed) -> List[Tuple[str, str]]:
|
||||
return [(slot.player_name, slot.game) for slot in seed.slots.order_by(Slot.player_id)]
|
||||
return [(slot.player_name, slot.game) for slot in seed.slots]
|
||||
|
||||
|
||||
from . import datapackage, generate, room, user # trigger registration
|
||||
@api_endpoints.route('/room_status/<suuid:room>')
|
||||
def room_info(room: UUID):
|
||||
room = Room.get(id=room)
|
||||
if room is None:
|
||||
return abort(404)
|
||||
return {
|
||||
"tracker": room.tracker,
|
||||
"players": get_players(room.seed),
|
||||
"last_port": room.last_port,
|
||||
"last_activity": room.last_activity,
|
||||
"timeout": room.timeout
|
||||
}
|
||||
|
||||
|
||||
@api_endpoints.route('/datapackage')
|
||||
@cache.cached()
|
||||
def get_datapackage():
|
||||
from worlds import network_data_package
|
||||
return network_data_package
|
||||
|
||||
|
||||
@api_endpoints.route('/datapackage_version')
|
||||
@cache.cached()
|
||||
def get_datapackage_versions():
|
||||
from worlds import network_data_package, AutoWorldRegister
|
||||
version_package = {game: world.data_version for game, world in AutoWorldRegister.world_types.items()}
|
||||
version_package["version"] = network_data_package["version"]
|
||||
return version_package
|
||||
|
||||
|
||||
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,16 +1,15 @@
|
||||
import json
|
||||
import pickle
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from . import api_endpoints
|
||||
from flask import request, session, url_for
|
||||
from markupsafe import Markup
|
||||
from pony.orm import commit
|
||||
|
||||
from WebHostLib import app
|
||||
from WebHostLib import app, Generation, STATE_QUEUED, Seed, STATE_ERROR
|
||||
from WebHostLib.check import get_yaml_data, roll_options
|
||||
from WebHostLib.generate import get_meta
|
||||
from WebHostLib.models import Generation, STATE_QUEUED, Seed, STATE_ERROR
|
||||
from . import api_endpoints
|
||||
|
||||
|
||||
@api_endpoints.route('/generate', methods=['POST'])
|
||||
@@ -20,20 +19,15 @@ def generate_api():
|
||||
race = False
|
||||
meta_options_source = {}
|
||||
if 'file' in request.files:
|
||||
files = request.files.getlist('file')
|
||||
options = get_yaml_data(files)
|
||||
if isinstance(options, Markup):
|
||||
return {"text": options.striptags()}, 400
|
||||
if isinstance(options, str):
|
||||
file = request.files['file']
|
||||
options = get_yaml_data(file)
|
||||
if type(options) == str:
|
||||
return {"text": options}, 400
|
||||
if "race" in request.form:
|
||||
race = bool(0 if request.form["race"] in {"false"} else int(request.form["race"]))
|
||||
meta_options_source = request.form
|
||||
|
||||
# json_data is optional, we can have it silently fall to None as it used to do.
|
||||
# See https://flask.palletsprojects.com/en/2.2.x/api/#flask.Request.get_json -> Changelog -> 2.1
|
||||
json_data = request.get_json(silent=True)
|
||||
|
||||
json_data = request.get_json()
|
||||
if json_data:
|
||||
meta_options_source = json_data
|
||||
if 'weights' in json_data:
|
||||
@@ -49,8 +43,9 @@ def generate_api():
|
||||
if len(options) > app.config["MAX_ROLL"]:
|
||||
return {"text": "Max size of multiworld exceeded",
|
||||
"detail": app.config["MAX_ROLL"]}, 409
|
||||
meta = get_meta(meta_options_source, race)
|
||||
results, gen_options = roll_options(options, set(meta["plando_options"]))
|
||||
meta = get_meta(meta_options_source)
|
||||
meta["race"] = race
|
||||
results, gen_options = roll_options(options, meta["plando_options"])
|
||||
if any(type(result) == str for result in results.values()):
|
||||
return {"text": str(results),
|
||||
"detail": results}, 400
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
from flask import session, jsonify
|
||||
from pony.orm import select
|
||||
|
||||
from WebHostLib.models import Room, Seed
|
||||
from WebHostLib.models import *
|
||||
from . import api_endpoints, get_players
|
||||
|
||||
|
||||
@@ -30,4 +29,4 @@ def get_seeds():
|
||||
"creation_time": seed.creation_time,
|
||||
"players": get_players(seed.slots),
|
||||
})
|
||||
return jsonify(response)
|
||||
return jsonify(response)
|
||||
@@ -1,28 +1,77 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import json
|
||||
import multiprocessing
|
||||
import typing
|
||||
import threading
|
||||
from datetime import timedelta, datetime
|
||||
from threading import Event, Thread
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
import sys
|
||||
import typing
|
||||
import time
|
||||
import os
|
||||
|
||||
from pony.orm import db_session, select, commit
|
||||
|
||||
from Utils import restricted_loads
|
||||
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()
|
||||
class CommonLocker():
|
||||
"""Uses a file lock to signal that something is already running"""
|
||||
lock_folder = "file_locks"
|
||||
|
||||
def __init__(self, lockname: str, folder=None):
|
||||
if folder:
|
||||
self.lock_folder = folder
|
||||
os.makedirs(self.lock_folder, exist_ok=True)
|
||||
self.lockname = lockname
|
||||
self.lockfile = os.path.join(self.lock_folder, f"{self.lockname}.lck")
|
||||
|
||||
|
||||
class AlreadyRunningException(Exception):
|
||||
pass
|
||||
|
||||
|
||||
if sys.platform == 'win32':
|
||||
class Locker(CommonLocker):
|
||||
def __enter__(self):
|
||||
try:
|
||||
if os.path.exists(self.lockfile):
|
||||
os.unlink(self.lockfile)
|
||||
self.fp = os.open(
|
||||
self.lockfile, os.O_CREAT | os.O_EXCL | os.O_RDWR)
|
||||
except OSError as e:
|
||||
raise AlreadyRunningException() from e
|
||||
|
||||
def __exit__(self, _type, value, tb):
|
||||
fp = getattr(self, "fp", None)
|
||||
if fp:
|
||||
os.close(self.fp)
|
||||
os.unlink(self.lockfile)
|
||||
else: # unix
|
||||
import fcntl
|
||||
|
||||
|
||||
class Locker(CommonLocker):
|
||||
def __enter__(self):
|
||||
try:
|
||||
self.fp = open(self.lockfile, "wb")
|
||||
fcntl.flock(self.fp.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
except OSError as e:
|
||||
raise AlreadyRunningException() from e
|
||||
|
||||
def __exit__(self, _type, value, tb):
|
||||
fcntl.flock(self.fp.fileno(), fcntl.LOCK_UN)
|
||||
self.fp.close()
|
||||
|
||||
|
||||
def launch_room(room: Room, config: dict):
|
||||
# requires db_session!
|
||||
if room.last_activity >= datetime.utcnow() - timedelta(seconds=room.timeout):
|
||||
multiworld = multiworlds.get(room.id, None)
|
||||
if not multiworld:
|
||||
multiworld = MultiworldInstance(room, config)
|
||||
|
||||
multiworld.start()
|
||||
|
||||
|
||||
def handle_generation_success(seed_id):
|
||||
@@ -54,74 +103,39 @@ def launch_generator(pool: multiprocessing.pool.Pool, generation: Generation):
|
||||
generation.state = STATE_STARTED
|
||||
|
||||
|
||||
def init_generator(config: dict[str, Any]) -> None:
|
||||
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"]
|
||||
def init_db(pony_config: dict):
|
||||
db.bind(**pony_config)
|
||||
db.generate_mapping()
|
||||
|
||||
|
||||
def cleanup():
|
||||
"""delete unowned user-content"""
|
||||
with db_session:
|
||||
# >>> bool(uuid.UUID(int=0))
|
||||
# True
|
||||
rooms = Room.select(lambda room: room.owner == UUID(int=0)).delete(bulk=True)
|
||||
seeds = Seed.select(lambda seed: seed.owner == UUID(int=0) and not seed.rooms).delete(bulk=True)
|
||||
slots = Slot.select(lambda slot: not slot.seed).delete(bulk=True)
|
||||
# Command gets deleted by ponyorm Cascade Delete, as Room is Required
|
||||
if rooms or seeds or slots:
|
||||
logging.info(f"{rooms} Rooms, {seeds} Seeds and {slots} Slots have been deleted.")
|
||||
|
||||
|
||||
def autohost(config: dict):
|
||||
def keep_running():
|
||||
stop_event = _stop_event
|
||||
try:
|
||||
with Locker("autohost"):
|
||||
cleanup()
|
||||
hosters = []
|
||||
for x in range(config["HOSTERS"]):
|
||||
hoster = MultiworldInstance(config, x)
|
||||
hosters.append(hoster)
|
||||
hoster.start()
|
||||
|
||||
while not stop_event.wait(0.1):
|
||||
run_guardian()
|
||||
while 1:
|
||||
time.sleep(0.1)
|
||||
with db_session:
|
||||
rooms = select(
|
||||
room for room in Room if
|
||||
room.last_activity >= datetime.utcnow() - timedelta(days=3))
|
||||
for room in rooms:
|
||||
# we have to filter twice, as the per-room timeout can't currently be PonyORM transpiled.
|
||||
if room.last_activity >= datetime.utcnow() - timedelta(seconds=room.timeout + 5):
|
||||
hosters[room.id.int % len(hosters)].start_room(room.id)
|
||||
launch_room(room, config)
|
||||
|
||||
except AlreadyRunningException:
|
||||
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 keep_running():
|
||||
stop_event = _stop_event
|
||||
try:
|
||||
with Locker("autogen"):
|
||||
|
||||
with multiprocessing.Pool(config["GENERATORS"], initializer=init_generator,
|
||||
initargs=(config,), maxtasksperchild=10) as generator_pool:
|
||||
with multiprocessing.Pool(config["GENERATORS"], initializer=init_db,
|
||||
initargs=(config["PONY"],)) as generator_pool:
|
||||
with db_session:
|
||||
to_start = select(generation for generation in Generation if generation.state == STATE_STARTED)
|
||||
|
||||
@@ -137,7 +151,8 @@ def autogen(config: dict):
|
||||
commit()
|
||||
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:
|
||||
# for update locks the database row(s) during transaction, preventing writes from elsewhere
|
||||
to_start = select(
|
||||
@@ -148,45 +163,33 @@ def autogen(config: dict):
|
||||
except AlreadyRunningException:
|
||||
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] = {}
|
||||
|
||||
|
||||
class MultiworldInstance():
|
||||
def __init__(self, config: dict, id: int):
|
||||
self.room_ids = set()
|
||||
def __init__(self, room: Room, config: dict):
|
||||
self.room_id = room.id
|
||||
self.process: typing.Optional[multiprocessing.Process] = None
|
||||
with guardian_lock:
|
||||
multiworlds[self.room_id] = self
|
||||
self.ponyconfig = config["PONY"]
|
||||
self.cert = config["SELFLAUNCHCERT"]
|
||||
self.key = config["SELFLAUNCHKEY"]
|
||||
self.host = config["HOST_ADDRESS"]
|
||||
self.rooms_to_start = multiprocessing.Queue()
|
||||
self.rooms_shutting_down = multiprocessing.Queue()
|
||||
self.name = f"MultiHoster{id}"
|
||||
|
||||
def start(self):
|
||||
if self.process and self.process.is_alive():
|
||||
return False
|
||||
|
||||
logging.info(f"Spinning up {self.room_id}")
|
||||
process = multiprocessing.Process(group=None, target=run_server_process,
|
||||
args=(self.name, self.ponyconfig, get_static_server_data(),
|
||||
self.cert, self.key, self.host,
|
||||
self.rooms_to_start, self.rooms_shutting_down),
|
||||
name=self.name)
|
||||
args=(self.room_id, self.ponyconfig, get_static_server_data()),
|
||||
name="MultiHost")
|
||||
process.start()
|
||||
# bind after start to prevent thread sync issues with guardian.
|
||||
self.process = process
|
||||
|
||||
def start_room(self, room_id):
|
||||
while not self.rooms_shutting_down.empty():
|
||||
self.room_ids.remove(self.rooms_shutting_down.get(block=True, timeout=None))
|
||||
if room_id in self.room_ids:
|
||||
pass # should already be hosted currently.
|
||||
else:
|
||||
self.room_ids.add(room_id)
|
||||
self.rooms_to_start.put(room_id)
|
||||
|
||||
def stop(self):
|
||||
if self.process:
|
||||
self.process.terminate()
|
||||
@@ -200,6 +203,40 @@ class MultiworldInstance():
|
||||
self.process = None
|
||||
|
||||
|
||||
from .models import Room, Generation, STATE_QUEUED, STATE_STARTED, STATE_ERROR, db, Seed, Slot
|
||||
guardian = None
|
||||
guardian_lock = threading.Lock()
|
||||
|
||||
|
||||
def run_guardian():
|
||||
global guardian
|
||||
global multiworlds
|
||||
with guardian_lock:
|
||||
if not guardian:
|
||||
try:
|
||||
import resource
|
||||
except ModuleNotFoundError:
|
||||
pass # unix only module
|
||||
else:
|
||||
# Each Server is another file handle, so request as many as we can from the system
|
||||
file_limit = resource.getrlimit(resource.RLIMIT_NOFILE)[1]
|
||||
# set soft limit to hard limit
|
||||
resource.setrlimit(resource.RLIMIT_NOFILE, (file_limit, file_limit))
|
||||
|
||||
def guard():
|
||||
while 1:
|
||||
time.sleep(1)
|
||||
done = []
|
||||
with guardian_lock:
|
||||
for key, instance in multiworlds.items():
|
||||
if instance.done():
|
||||
instance.collect()
|
||||
done.append(key)
|
||||
for key in done:
|
||||
del (multiworlds[key])
|
||||
|
||||
guardian = threading.Thread(name="Guardian", target=guard)
|
||||
|
||||
|
||||
from .models import Room, Generation, STATE_QUEUED, STATE_STARTED, STATE_ERROR, db, Seed
|
||||
from .customserver import run_server_process, get_static_server_data
|
||||
from .generate import gen_game
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
import os
|
||||
import zipfile
|
||||
import base64
|
||||
from typing import Union, Dict, Set, Tuple
|
||||
from typing import *
|
||||
|
||||
from flask import request, flash, redirect, url_for, render_template
|
||||
from markupsafe import Markup
|
||||
from flask import request, flash, redirect, url_for, session, render_template
|
||||
|
||||
from WebHostLib import app
|
||||
from WebHostLib.upload import allowed_options, allowed_options_extensions, banned_file
|
||||
|
||||
from Generate import roll_settings, PlandoOptions
|
||||
banned_zip_contents = (".sfc",)
|
||||
|
||||
|
||||
def allowed_file(filename):
|
||||
return filename.endswith(('.txt', ".yaml", ".zip"))
|
||||
|
||||
|
||||
from Generate import roll_settings, PlandoSettings
|
||||
from Utils import parse_yamls
|
||||
|
||||
|
||||
@@ -20,21 +23,13 @@ def check():
|
||||
if 'file' not in request.files:
|
||||
flash('No file part')
|
||||
else:
|
||||
files = request.files.getlist('file')
|
||||
options = get_yaml_data(files)
|
||||
if isinstance(options, str):
|
||||
file = request.files['file']
|
||||
options = get_yaml_data(file)
|
||||
if type(options) == str:
|
||||
flash(options)
|
||||
else:
|
||||
results, _ = roll_options(options)
|
||||
if len(options) > 1:
|
||||
# offer combined file back
|
||||
combined_yaml = "\n---\n".join(f"# original filename: {file_name}\n{file_content.decode('utf-8-sig')}"
|
||||
for file_name, file_content in options.items())
|
||||
combined_yaml = base64.b64encode(combined_yaml.encode("utf-8-sig")).decode()
|
||||
else:
|
||||
combined_yaml = ""
|
||||
return render_template("checkResult.html",
|
||||
results=results, combined_yaml=combined_yaml)
|
||||
return render_template("checkResult.html", results=results)
|
||||
return render_template("check.html")
|
||||
|
||||
|
||||
@@ -43,51 +38,34 @@ def mysterycheck():
|
||||
return redirect(url_for("check"), 301)
|
||||
|
||||
|
||||
def get_yaml_data(files) -> Union[Dict[str, str], str, Markup]:
|
||||
def get_yaml_data(file) -> Union[Dict[str, str], str]:
|
||||
options = {}
|
||||
for uploaded_file in files:
|
||||
if banned_file(uploaded_file.filename):
|
||||
return ("Uploaded data contained a rom file, which is likely to contain copyrighted material. "
|
||||
"Your file was deleted.")
|
||||
# If the user does not select file, the browser will still submit an empty string without a file name.
|
||||
elif uploaded_file.filename == "":
|
||||
return "No selected file."
|
||||
elif uploaded_file.filename in options:
|
||||
return f"Conflicting files named {uploaded_file.filename} submitted."
|
||||
elif uploaded_file and allowed_options(uploaded_file.filename):
|
||||
if uploaded_file.filename.endswith(".zip"):
|
||||
if not zipfile.is_zipfile(uploaded_file):
|
||||
return f"Uploaded file {uploaded_file.filename} is not a valid .zip file and cannot be opened."
|
||||
# if user does not select file, browser also
|
||||
# submit an empty part without filename
|
||||
if file.filename == '':
|
||||
return 'No selected file'
|
||||
elif file and allowed_file(file.filename):
|
||||
if file.filename.endswith(".zip"):
|
||||
|
||||
uploaded_file.seek(0) # offset from is_zipfile check
|
||||
with zipfile.ZipFile(uploaded_file, "r") as zfile:
|
||||
for file in zfile.infolist():
|
||||
# Remove folder pathing from str (e.g. "__MACOSX/" folder paths from archives created by macOS).
|
||||
base_filename = os.path.basename(file.filename)
|
||||
|
||||
if base_filename.endswith(".archipelago"):
|
||||
return Markup("Error: Your .zip file contains an .archipelago file. "
|
||||
'Did you mean to <a href="/uploads">host a game</a>?')
|
||||
elif base_filename.endswith(".zip"):
|
||||
return "Nested .zip files inside a .zip are not supported."
|
||||
elif banned_file(base_filename):
|
||||
return ("Uploaded data contained a rom file, which is likely to contain copyrighted "
|
||||
"material. Your file was deleted.")
|
||||
# Ignore dot-files.
|
||||
elif not base_filename.startswith(".") and allowed_options(base_filename):
|
||||
options[file.filename] = zfile.open(file, "r").read()
|
||||
else:
|
||||
options[uploaded_file.filename] = uploaded_file.read()
|
||||
with zipfile.ZipFile(file, 'r') as zfile:
|
||||
infolist = zfile.infolist()
|
||||
|
||||
for file in infolist:
|
||||
if file.filename.endswith(banned_zip_contents):
|
||||
return "Uploaded data contained a rom file, which is likely to contain copyrighted material. Your file was deleted."
|
||||
elif file.filename.endswith((".yaml", ".json", ".yml", ".txt")):
|
||||
options[file.filename] = zfile.open(file, "r").read()
|
||||
else:
|
||||
options = {file.filename: file.read()}
|
||||
if not options:
|
||||
return f"Did not find any valid files to process. Accepted formats: {allowed_options_extensions}"
|
||||
return "Did not find a .yaml file to process."
|
||||
return options
|
||||
|
||||
|
||||
def roll_options(options: Dict[str, Union[dict, str]],
|
||||
plando_options: Set[str] = frozenset({"bosses", "items", "connections", "texts"})) -> \
|
||||
Tuple[Dict[str, Union[str, bool]], Dict[str, dict]]:
|
||||
plando_options = PlandoOptions.from_set(set(plando_options))
|
||||
plando_options = PlandoSettings.from_set(set(plando_options))
|
||||
results = {}
|
||||
rolled_results = {}
|
||||
for filename, text in options.items():
|
||||
@@ -105,14 +83,10 @@ def roll_options(options: Dict[str, Union[dict, str]],
|
||||
plando_options=plando_options)
|
||||
else:
|
||||
for i, yaml_data in enumerate(yaml_datas):
|
||||
if yaml_data is not None:
|
||||
rolled_results[f"{filename}/{i + 1}"] = roll_settings(yaml_data,
|
||||
plando_options=plando_options)
|
||||
rolled_results[f"{filename}/{i + 1}"] = roll_settings(yaml_data,
|
||||
plando_options=plando_options)
|
||||
except Exception as e:
|
||||
if e.__cause__:
|
||||
results[filename] = f"Failed to generate options in {filename}: {e} - {e.__cause__}"
|
||||
else:
|
||||
results[filename] = f"Failed to generate options in {filename}: {e}"
|
||||
results[filename] = f"Failed to generate mystery in {filename}: {e}"
|
||||
else:
|
||||
results[filename] = True
|
||||
return results, rolled_results
|
||||
|
||||
@@ -1,37 +1,28 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import collections
|
||||
import datetime
|
||||
import functools
|
||||
import logging
|
||||
import multiprocessing
|
||||
import pickle
|
||||
import random
|
||||
import websockets
|
||||
import asyncio
|
||||
import socket
|
||||
import threading
|
||||
import time
|
||||
import typing
|
||||
import sys
|
||||
|
||||
import websockets
|
||||
from pony.orm import commit, db_session, select
|
||||
import random
|
||||
import pickle
|
||||
import logging
|
||||
import datetime
|
||||
|
||||
import Utils
|
||||
from .models import db_session, Room, select, commit, Command, db
|
||||
|
||||
from MultiServer import Context, server, auto_shutdown, ServerCommandProcessor, ClientMessageProcessor, load_server_cert
|
||||
from Utils import restricted_loads, cache_argsless
|
||||
from .locker import Locker
|
||||
from .models import Command, GameDataPackage, Room, db
|
||||
from MultiServer import Context, server, auto_shutdown, ServerCommandProcessor, ClientMessageProcessor
|
||||
from Utils import get_public_ipv4, get_public_ipv6, restricted_loads, cache_argsless
|
||||
|
||||
|
||||
class CustomClientMessageProcessor(ClientMessageProcessor):
|
||||
ctx: WebHostContext
|
||||
|
||||
def _cmd_video(self, platform: str, user: str):
|
||||
"""Set a link for your name in the WebHostLib tracker pointing to a video stream.
|
||||
Currently, only YouTube and Twitch platforms are supported.
|
||||
"""
|
||||
def _cmd_video(self, platform, user):
|
||||
"""Set a link for your name in the WebHostLib tracker pointing to a video stream"""
|
||||
if platform.lower().startswith("t"): # twitch
|
||||
self.ctx.video[self.client.team, self.client.slot] = "Twitch", user
|
||||
self.ctx.save()
|
||||
@@ -54,37 +45,23 @@ del MultiServer
|
||||
|
||||
class DBCommandProcessor(ServerCommandProcessor):
|
||||
def output(self, text: str):
|
||||
self.ctx.logger.info(text)
|
||||
logging.info(text)
|
||||
|
||||
|
||||
class WebHostContext(Context):
|
||||
room_id: int
|
||||
|
||||
def __init__(self, static_server_data: dict, logger: logging.Logger):
|
||||
def __init__(self, static_server_data: dict):
|
||||
# static server data is used during _load_game_data to load required data,
|
||||
# without needing to import worlds system, which takes quite a bit of memory
|
||||
self.static_server_data = static_server_data
|
||||
super(WebHostContext, self).__init__("", 0, "", "", 1,
|
||||
40, True, "enabled", "enabled",
|
||||
"enabled", 0, 2, logger=logger)
|
||||
super(WebHostContext, self).__init__("", 0, "", "", 1, 40, True, "enabled", "enabled", "enabled", 0, 2)
|
||||
del self.static_server_data
|
||||
self.main_loop = asyncio.get_running_loop()
|
||||
self.video = {}
|
||||
self.tags = ["AP", "WebHost"]
|
||||
|
||||
def __del__(self):
|
||||
try:
|
||||
import psutil
|
||||
from Utils import format_SI_prefix
|
||||
self.logger.debug(f"Context destroyed, Mem: {format_SI_prefix(psutil.Process().memory_info().rss, 1024)}iB")
|
||||
except ImportError:
|
||||
self.logger.debug("Context destroyed")
|
||||
|
||||
def _load_game_data(self):
|
||||
for key, value in self.static_server_data.items():
|
||||
# NOTE: attributes are mutable and shared, so they will have to be copied before being modified
|
||||
setattr(self, key, value)
|
||||
self.non_hintable_names = collections.defaultdict(frozenset, self.non_hintable_names)
|
||||
|
||||
def listen_to_db_commands(self):
|
||||
cmdprocessor = DBCommandProcessor(self)
|
||||
@@ -108,40 +85,7 @@ class WebHostContext(Context):
|
||||
else:
|
||||
self.port = get_random_port()
|
||||
|
||||
multidata = self.decompress(room.seed.multidata)
|
||||
game_data_packages = {}
|
||||
|
||||
static_gamespackage = self.gamespackage # this is shared across all rooms
|
||||
static_item_name_groups = self.item_name_groups
|
||||
static_location_name_groups = self.location_name_groups
|
||||
self.gamespackage = {"Archipelago": static_gamespackage.get("Archipelago", {})} # this may be modified by _load
|
||||
self.item_name_groups = {"Archipelago": static_item_name_groups.get("Archipelago", {})}
|
||||
self.location_name_groups = {"Archipelago": static_location_name_groups.get("Archipelago", {})}
|
||||
|
||||
for game in list(multidata.get("datapackage", {})):
|
||||
game_data = multidata["datapackage"][game]
|
||||
if "checksum" in game_data:
|
||||
if static_gamespackage.get(game, {}).get("checksum") == game_data["checksum"]:
|
||||
# non-custom. remove from multidata and use static data
|
||||
# games package could be dropped from static data once all rooms embed data package
|
||||
del multidata["datapackage"][game]
|
||||
else:
|
||||
row = GameDataPackage.get(checksum=game_data["checksum"])
|
||||
if row: # None if rolled on >= 0.3.9 but uploaded to <= 0.3.8. multidata should be complete
|
||||
game_data_packages[game] = Utils.restricted_loads(row.data)
|
||||
continue
|
||||
else:
|
||||
self.logger.warning(f"Did not find game_data_package for {game}: {game_data['checksum']}")
|
||||
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:
|
||||
# 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(self.decompress(room.seed.multidata), True)
|
||||
|
||||
@db_session
|
||||
def init_save(self, enabled: bool = True):
|
||||
@@ -150,7 +94,7 @@ class WebHostContext(Context):
|
||||
savegame_data = Room.get(id=self.room_id).multisave
|
||||
if savegame_data:
|
||||
self.set_save(restricted_loads(Room.get(id=self.room_id).multisave))
|
||||
self._start_async_saving(atexit_save=False)
|
||||
self._start_async_saving()
|
||||
threading.Thread(target=self.listen_to_db_commands, daemon=True).start()
|
||||
|
||||
@db_session
|
||||
@@ -176,180 +120,62 @@ def get_random_port():
|
||||
def get_static_server_data() -> dict:
|
||||
import worlds
|
||||
data = {
|
||||
"non_hintable_names": {
|
||||
world_name: world.hint_blacklist
|
||||
for world_name, world in worlds.AutoWorldRegister.world_types.items()
|
||||
},
|
||||
"gamespackage": {
|
||||
world_name: {
|
||||
key: value
|
||||
for key, value in game_package.items()
|
||||
if key not in ("item_name_groups", "location_name_groups")
|
||||
}
|
||||
for world_name, game_package in worlds.network_data_package["games"].items()
|
||||
},
|
||||
"item_name_groups": {
|
||||
world_name: world.item_name_groups
|
||||
for world_name, world in worlds.AutoWorldRegister.world_types.items()
|
||||
},
|
||||
"location_name_groups": {
|
||||
world_name: world.location_name_groups
|
||||
for world_name, world in worlds.AutoWorldRegister.world_types.items()
|
||||
},
|
||||
"forced_auto_forfeits": {},
|
||||
"non_hintable_names": {},
|
||||
"gamespackage": worlds.network_data_package["games"],
|
||||
"item_name_groups": {world_name: world.item_name_groups for world_name, world in
|
||||
worlds.AutoWorldRegister.world_types.items()},
|
||||
}
|
||||
|
||||
for world_name, world in worlds.AutoWorldRegister.world_types.items():
|
||||
data["forced_auto_forfeits"][world_name] = world.forced_auto_forfeit
|
||||
data["non_hintable_names"][world_name] = world.hint_blacklist
|
||||
|
||||
return data
|
||||
|
||||
|
||||
def set_up_logging(room_id) -> logging.Logger:
|
||||
import os
|
||||
# logger setup
|
||||
logger = logging.getLogger(f"RoomLogger {room_id}")
|
||||
|
||||
# this *should* be empty, but just in case.
|
||||
for handler in logger.handlers[:]:
|
||||
logger.removeHandler(handler)
|
||||
handler.close()
|
||||
|
||||
file_handler = logging.FileHandler(
|
||||
os.path.join(Utils.user_path("logs"), f"{room_id}.txt"),
|
||||
"a",
|
||||
encoding="utf-8-sig")
|
||||
file_handler.setFormatter(logging.Formatter("[%(asctime)s]: %(message)s"))
|
||||
logger.setLevel(logging.INFO)
|
||||
logger.addHandler(file_handler)
|
||||
return logger
|
||||
|
||||
|
||||
def run_server_process(name: str, ponyconfig: dict, static_server_data: dict,
|
||||
cert_file: typing.Optional[str], cert_key_file: typing.Optional[str],
|
||||
host: str, rooms_to_run: multiprocessing.Queue, rooms_shutting_down: multiprocessing.Queue):
|
||||
Utils.init_logging(name)
|
||||
try:
|
||||
import resource
|
||||
except ModuleNotFoundError:
|
||||
pass # unix only module
|
||||
else:
|
||||
# Each Server is another file handle, so request as many as we can from the system
|
||||
file_limit = resource.getrlimit(resource.RLIMIT_NOFILE)[1]
|
||||
# set soft limit to hard limit
|
||||
resource.setrlimit(resource.RLIMIT_NOFILE, (file_limit, file_limit))
|
||||
del resource, file_limit
|
||||
|
||||
def run_server_process(room_id, ponyconfig: dict, static_server_data: dict):
|
||||
# establish DB connection for multidata and multisave
|
||||
db.bind(**ponyconfig)
|
||||
db.generate_mapping(check_tables=False)
|
||||
|
||||
if "worlds" in sys.modules:
|
||||
raise Exception("Worlds system should not be loaded in the custom server.")
|
||||
async def main():
|
||||
Utils.init_logging(str(room_id), write_mode="a")
|
||||
ctx = WebHostContext(static_server_data)
|
||||
ctx.load(room_id)
|
||||
ctx.init_save()
|
||||
|
||||
import gc
|
||||
ssl_context = load_server_cert(cert_file, cert_key_file) if cert_file else None
|
||||
del cert_file, cert_key_file, ponyconfig
|
||||
gc.collect() # free intermediate objects used during setup
|
||||
try:
|
||||
ctx.server = websockets.serve(functools.partial(server, ctx=ctx), ctx.host, ctx.port, ping_timeout=None,
|
||||
ping_interval=None)
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
await ctx.server
|
||||
except Exception: # likely port in use - in windows this is OSError, but I didn't check the others
|
||||
ctx.server = websockets.serve(functools.partial(server, ctx=ctx), ctx.host, 0, ping_timeout=None,
|
||||
ping_interval=None)
|
||||
|
||||
async def start_room(room_id):
|
||||
with Locker(f"RoomLocker {room_id}"):
|
||||
try:
|
||||
logger = set_up_logging(room_id)
|
||||
ctx = WebHostContext(static_server_data, logger)
|
||||
ctx.load(room_id)
|
||||
ctx.init_save()
|
||||
assert ctx.server is None
|
||||
try:
|
||||
ctx.server = websockets.serve(
|
||||
functools.partial(server, ctx=ctx), ctx.host, ctx.port, ssl=ssl_context)
|
||||
await ctx.server
|
||||
port = 0
|
||||
for wssocket in ctx.server.ws_server.sockets:
|
||||
socketname = wssocket.getsockname()
|
||||
if wssocket.family == socket.AF_INET6:
|
||||
logging.info(f'Hosting game at [{get_public_ipv6()}]:{socketname[1]}')
|
||||
# Prefer IPv4, as most users seem to not have working ipv6 support
|
||||
if not port:
|
||||
port = socketname[1]
|
||||
elif wssocket.family == socket.AF_INET:
|
||||
logging.info(f'Hosting game at {get_public_ipv4()}:{socketname[1]}')
|
||||
port = socketname[1]
|
||||
if port:
|
||||
with db_session:
|
||||
room = Room.get(id=ctx.room_id)
|
||||
room.last_port = port
|
||||
with db_session:
|
||||
ctx.auto_shutdown = Room.get(id=room_id).timeout
|
||||
ctx.shutdown_task = asyncio.create_task(auto_shutdown(ctx, []))
|
||||
await ctx.shutdown_task
|
||||
logging.info("Shutting down")
|
||||
|
||||
await ctx.server
|
||||
except OSError: # likely port in use
|
||||
ctx.server = websockets.serve(
|
||||
functools.partial(server, ctx=ctx), ctx.host, 0, ssl=ssl_context)
|
||||
|
||||
await ctx.server
|
||||
port = 0
|
||||
for wssocket in ctx.server.ws_server.sockets:
|
||||
socketname = wssocket.getsockname()
|
||||
if wssocket.family == socket.AF_INET6:
|
||||
# Prefer IPv4, as most users seem to not have working ipv6 support
|
||||
if not port:
|
||||
port = socketname[1]
|
||||
elif wssocket.family == socket.AF_INET:
|
||||
port = socketname[1]
|
||||
if port:
|
||||
ctx.logger.info(f'Hosting game at {host}:{port}')
|
||||
with db_session:
|
||||
room = Room.get(id=ctx.room_id)
|
||||
room.last_port = port
|
||||
else:
|
||||
ctx.logger.exception("Could not determine port. Likely hosting failure.")
|
||||
with db_session:
|
||||
ctx.auto_shutdown = Room.get(id=room_id).timeout
|
||||
if ctx.saving:
|
||||
setattr(asyncio.current_task(), "save", lambda: ctx._save(True))
|
||||
assert ctx.shutdown_task is None
|
||||
ctx.shutdown_task = asyncio.create_task(auto_shutdown(ctx, []))
|
||||
await ctx.shutdown_task
|
||||
|
||||
except (KeyboardInterrupt, SystemExit):
|
||||
if ctx.saving:
|
||||
ctx._save()
|
||||
setattr(asyncio.current_task(), "save", None)
|
||||
except Exception as e:
|
||||
with db_session:
|
||||
room = Room.get(id=room_id)
|
||||
room.last_port = -1
|
||||
logger.exception(e)
|
||||
raise
|
||||
else:
|
||||
if ctx.saving:
|
||||
ctx._save()
|
||||
setattr(asyncio.current_task(), "save", None)
|
||||
finally:
|
||||
try:
|
||||
ctx.save_dirty = False # make sure the saving thread does not write to DB after final wakeup
|
||||
ctx.exit_event.set() # make sure the saving thread stops at some point
|
||||
# NOTE: async saving should probably be an async task and could be merged with shutdown_task
|
||||
with (db_session):
|
||||
# ensure the Room does not spin up again on its own, minute of safety buffer
|
||||
room = Room.get(id=room_id)
|
||||
room.last_activity = datetime.datetime.utcnow() - \
|
||||
datetime.timedelta(minutes=1, seconds=room.timeout)
|
||||
logging.info(f"Shutting down room {room_id} on {name}.")
|
||||
finally:
|
||||
await asyncio.sleep(5)
|
||||
rooms_shutting_down.put(room_id)
|
||||
|
||||
class Starter(threading.Thread):
|
||||
_tasks: typing.List[asyncio.Future]
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._tasks = []
|
||||
|
||||
def _done(self, task: asyncio.Future):
|
||||
self._tasks.remove(task)
|
||||
task.result()
|
||||
|
||||
def run(self):
|
||||
while 1:
|
||||
next_room = rooms_to_run.get(block=True, timeout=None)
|
||||
gc.collect()
|
||||
task = asyncio.run_coroutine_threadsafe(start_room(next_room), loop)
|
||||
self._tasks.append(task)
|
||||
task.add_done_callback(self._done)
|
||||
logging.info(f"Starting room {next_room} on {name}.")
|
||||
del task # delete reference to task object
|
||||
|
||||
starter = Starter()
|
||||
starter.daemon = True
|
||||
starter.start()
|
||||
try:
|
||||
loop.run_forever()
|
||||
finally:
|
||||
# save all tasks that want to be saved during shutdown
|
||||
for task in asyncio.all_tasks(loop):
|
||||
save: typing.Optional[typing.Callable[[], typing.Any]] = getattr(task, "save", None)
|
||||
if save:
|
||||
save()
|
||||
from .autolauncher import Locker
|
||||
with Locker(room_id):
|
||||
asyncio.run(main())
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import json
|
||||
import zipfile
|
||||
import json
|
||||
from io import BytesIO
|
||||
|
||||
from flask import send_file, Response, render_template
|
||||
from pony.orm import select
|
||||
|
||||
from worlds.Files import AutoPatchRegister
|
||||
from . import app, cache
|
||||
from .models import Slot, Room, Seed
|
||||
from Patch import update_patch_data, preferred_endings, AutoPatchRegister
|
||||
from WebHostLib import app, Slot, Room, Seed, cache
|
||||
|
||||
|
||||
@app.route("/dl_patch/<suuid:room_id>/<int:patch_id>")
|
||||
@@ -26,7 +25,7 @@ def download_patch(room_id, patch_id):
|
||||
with zipfile.ZipFile(filelike, "a") as zf:
|
||||
with zf.open("archipelago.json", "r") as f:
|
||||
manifest = json.load(f)
|
||||
manifest["server"] = f"{app.config['HOST_ADDRESS']}:{last_port}" if last_port else None
|
||||
manifest["server"] = f"{app.config['PATCH_TARGET']}:{last_port}" if last_port else None
|
||||
with zipfile.ZipFile(new_file, "w") as new_zip:
|
||||
for file in zf.infolist():
|
||||
if file.filename == "archipelago.json":
|
||||
@@ -42,7 +41,12 @@ def download_patch(room_id, patch_id):
|
||||
new_file.seek(0)
|
||||
return send_file(new_file, as_attachment=True, download_name=fname)
|
||||
else:
|
||||
return "Old Patch file, no longer compatible."
|
||||
patch_data = update_patch_data(patch.data, server=f"{app.config['PATCH_TARGET']}:{last_port}")
|
||||
patch_data = BytesIO(patch_data)
|
||||
|
||||
fname = f"P{patch.player_id}_{patch.player_name}_{app.jinja_env.filters['suuid'](room_id)}." \
|
||||
f"{preferred_endings[patch.game]}"
|
||||
return send_file(patch_data, as_attachment=True, download_name=fname)
|
||||
|
||||
|
||||
@app.route("/dl_spoiler/<suuid:seed_id>")
|
||||
@@ -64,7 +68,7 @@ def download_slot_file(room_id, player_id: int):
|
||||
if slot_data.game == "Minecraft":
|
||||
from worlds.minecraft import mc_update_output
|
||||
fname = f"AP_{app.jinja_env.filters['suuid'](room_id)}_P{slot_data.player_id}_{slot_data.player_name}.apmc"
|
||||
data = mc_update_output(slot_data.data, server=app.config['HOST_ADDRESS'], port=room.last_port)
|
||||
data = mc_update_output(slot_data.data, server=app.config['PATCH_TARGET'], port=room.last_port)
|
||||
return send_file(io.BytesIO(data), as_attachment=True, download_name=fname)
|
||||
elif slot_data.game == "Factorio":
|
||||
with zipfile.ZipFile(io.BytesIO(slot_data.data)) as zf:
|
||||
@@ -72,26 +76,13 @@ def download_slot_file(room_id, player_id: int):
|
||||
if name.endswith("info.json"):
|
||||
fname = name.rsplit("/", 1)[0] + ".zip"
|
||||
elif slot_data.game == "Ocarina of Time":
|
||||
stream = io.BytesIO(slot_data.data)
|
||||
if zipfile.is_zipfile(stream):
|
||||
with zipfile.ZipFile(stream) as zf:
|
||||
for name in zf.namelist():
|
||||
if name.endswith(".zpf"):
|
||||
fname = name.rsplit(".", 1)[0] + ".apz5"
|
||||
else: # pre-ootr-7.0 support
|
||||
fname = f"AP_{app.jinja_env.filters['suuid'](room_id)}_P{slot_data.player_id}_{slot_data.player_name}.apz5"
|
||||
fname = f"AP_{app.jinja_env.filters['suuid'](room_id)}_P{slot_data.player_id}_{slot_data.player_name}.apz5"
|
||||
elif slot_data.game == "VVVVVV":
|
||||
fname = f"AP_{app.jinja_env.filters['suuid'](room_id)}_SP.apv6"
|
||||
elif slot_data.game == "Zillion":
|
||||
fname = f"AP_{app.jinja_env.filters['suuid'](room_id)}_SP.apzl"
|
||||
elif slot_data.game == "Super Mario 64":
|
||||
fname = f"AP_{app.jinja_env.filters['suuid'](room_id)}_SP.apsm64ex"
|
||||
elif slot_data.game == "Dark Souls III":
|
||||
fname = f"AP_{app.jinja_env.filters['suuid'](room_id)}.json"
|
||||
elif slot_data.game == "Kingdom Hearts 2":
|
||||
fname = f"AP_{app.jinja_env.filters['suuid'](room_id)}_P{slot_data.player_id}_{slot_data.player_name}.zip"
|
||||
elif slot_data.game == "Final Fantasy Mystic Quest":
|
||||
fname = f"AP+{app.jinja_env.filters['suuid'](room_id)}_P{slot_data.player_id}_{slot_data.player_name}.apmq"
|
||||
else:
|
||||
return "Game download not supported."
|
||||
return send_file(io.BytesIO(slot_data.data), as_attachment=True, download_name=fname)
|
||||
|
||||
@@ -1,57 +1,44 @@
|
||||
import concurrent.futures
|
||||
import json
|
||||
import os
|
||||
import pickle
|
||||
import random
|
||||
import tempfile
|
||||
import random
|
||||
import json
|
||||
import zipfile
|
||||
from collections import Counter
|
||||
from typing import Any, Dict, List, Optional, Union, Set
|
||||
|
||||
from flask import flash, redirect, render_template, request, session, url_for
|
||||
from pony.orm import commit, db_session
|
||||
|
||||
from BaseClasses import get_seed, seeddigits
|
||||
from Generate import PlandoOptions, handle_name
|
||||
from Main import main as ERmain
|
||||
from typing import Dict, Optional, Any
|
||||
from Utils import __version__
|
||||
from WebHostLib import app
|
||||
from settings import ServerOptions, GeneratorOptions
|
||||
|
||||
from flask import request, flash, redirect, url_for, session, render_template
|
||||
|
||||
from worlds.alttp.EntranceRandomizer import parse_arguments
|
||||
from Main import main as ERmain
|
||||
from BaseClasses import seeddigits, get_seed
|
||||
from Generate import handle_name, PlandoSettings
|
||||
import pickle
|
||||
|
||||
from .models import Generation, STATE_ERROR, STATE_QUEUED, commit, db_session, Seed, UUID
|
||||
from WebHostLib import app
|
||||
from .check import get_yaml_data, roll_options
|
||||
from .models import Generation, STATE_ERROR, STATE_QUEUED, Seed, UUID
|
||||
from .upload import upload_zip_to_db
|
||||
|
||||
|
||||
def get_meta(options_source: dict, race: bool = False) -> Dict[str, Union[List[str], Dict[str, Any]]]:
|
||||
plando_options: Set[str] = set()
|
||||
for substr in ("bosses", "items", "connections", "texts"):
|
||||
if options_source.get(f"plando_{substr}", substr in GeneratorOptions.plando_options):
|
||||
plando_options.add(substr)
|
||||
def get_meta(options_source: dict) -> dict:
|
||||
plando_options = {
|
||||
options_source.get("plando_bosses", ""),
|
||||
options_source.get("plando_items", ""),
|
||||
options_source.get("plando_connections", ""),
|
||||
options_source.get("plando_texts", "")
|
||||
}
|
||||
plando_options -= {""}
|
||||
|
||||
server_options = {
|
||||
"hint_cost": int(options_source.get("hint_cost", ServerOptions.hint_cost)),
|
||||
"release_mode": str(options_source.get("release_mode", ServerOptions.release_mode)),
|
||||
"remaining_mode": str(options_source.get("remaining_mode", ServerOptions.remaining_mode)),
|
||||
"collect_mode": str(options_source.get("collect_mode", ServerOptions.collect_mode)),
|
||||
"item_cheat": bool(int(options_source.get("item_cheat", not ServerOptions.disable_item_cheat))),
|
||||
"server_password": str(options_source.get("server_password", None)),
|
||||
}
|
||||
generator_options = {
|
||||
"spoiler": int(options_source.get("spoiler", GeneratorOptions.spoiler)),
|
||||
"race": race,
|
||||
}
|
||||
|
||||
if race:
|
||||
server_options["item_cheat"] = False
|
||||
server_options["remaining_mode"] = "disabled"
|
||||
generator_options["spoiler"] = 0
|
||||
|
||||
return {
|
||||
"server_options": server_options,
|
||||
"plando_options": list(plando_options),
|
||||
"generator_options": generator_options,
|
||||
"hint_cost": int(options_source.get("hint_cost", 10)),
|
||||
"forfeit_mode": options_source.get("forfeit_mode", "goal"),
|
||||
"remaining_mode": options_source.get("remaining_mode", "disabled"),
|
||||
"collect_mode": options_source.get("collect_mode", "disabled"),
|
||||
"item_cheat": bool(int(options_source.get("item_cheat", 1))),
|
||||
"server_password": options_source.get("server_password", None),
|
||||
}
|
||||
return {"server_options": server_options, "plando_options": list(plando_options)}
|
||||
|
||||
|
||||
@app.route('/generate', methods=['GET', 'POST'])
|
||||
@@ -62,56 +49,56 @@ def generate(race=False):
|
||||
if 'file' not in request.files:
|
||||
flash('No file part')
|
||||
else:
|
||||
files = request.files.getlist('file')
|
||||
options = get_yaml_data(files)
|
||||
if isinstance(options, str):
|
||||
file = request.files['file']
|
||||
options = get_yaml_data(file)
|
||||
if type(options) == str:
|
||||
flash(options)
|
||||
else:
|
||||
meta = get_meta(request.form, race)
|
||||
return start_generation(options, meta)
|
||||
meta = get_meta(request.form)
|
||||
meta["race"] = race
|
||||
results, gen_options = roll_options(options, meta["plando_options"])
|
||||
|
||||
if race:
|
||||
meta["server_options"]["item_cheat"] = False
|
||||
meta["server_options"]["remaining_mode"] = "disabled"
|
||||
|
||||
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__)
|
||||
|
||||
|
||||
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, meta: Optional[Dict[str, Any]] = None, owner=None, sid=None):
|
||||
if not meta:
|
||||
meta: Dict[str, Any] = {}
|
||||
|
||||
meta.setdefault("server_options", {}).setdefault("hint_cost", 10)
|
||||
race = meta.setdefault("generator_options", {}).setdefault("race", False)
|
||||
race = meta.setdefault("race", False)
|
||||
|
||||
def task():
|
||||
try:
|
||||
target = tempfile.TemporaryDirectory()
|
||||
playercount = len(gen_options)
|
||||
seed = get_seed()
|
||||
@@ -126,16 +113,13 @@ def gen_game(gen_options: dict, meta: Optional[Dict[str, Any]] = None, owner=Non
|
||||
erargs = parse_arguments(['--multi', str(playercount)])
|
||||
erargs.seed = seed
|
||||
erargs.name = {x: "" for x in range(1, playercount + 1)} # only so it can be overwritten in mystery
|
||||
erargs.spoiler = meta["generator_options"].get("spoiler", 0)
|
||||
erargs.spoiler = 0 if race else 2
|
||||
erargs.race = race
|
||||
erargs.outputname = seedname
|
||||
erargs.outputpath = target.name
|
||||
erargs.teams = 1
|
||||
erargs.plando_options = PlandoOptions.from_set(meta.setdefault("plando_options",
|
||||
{"bosses", "items", "connections", "texts"}))
|
||||
erargs.skip_prog_balancing = False
|
||||
erargs.skip_output = False
|
||||
erargs.csv_output = False
|
||||
erargs.plando_options = PlandoSettings.from_set(meta.setdefault("plando_options",
|
||||
{"bosses", "items", "connections", "texts"}))
|
||||
|
||||
name_counter = Counter()
|
||||
for player, (playerfile, settings) in enumerate(gen_options.items(), 1):
|
||||
@@ -154,23 +138,6 @@ def gen_game(gen_options: dict, meta: Optional[Dict[str, Any]] = None, owner=Non
|
||||
ERmain(erargs, seed, baked_server_options=meta["server_options"])
|
||||
|
||||
return upload_to_db(target.name, sid, owner, race)
|
||||
thread_pool = concurrent.futures.ThreadPoolExecutor(max_workers=1)
|
||||
thread = thread_pool.submit(task)
|
||||
|
||||
try:
|
||||
return thread.result(app.config["JOB_TIME"])
|
||||
except concurrent.futures.TimeoutError as e:
|
||||
if sid:
|
||||
with db_session:
|
||||
gen = Generation.get(id=sid)
|
||||
if gen is not None:
|
||||
gen.state = STATE_ERROR
|
||||
meta = json.loads(gen.meta)
|
||||
meta["error"] = (
|
||||
"Allowed time for Generation exceeded, please consider generating locally instead. " +
|
||||
e.__class__.__name__ + ": " + str(e))
|
||||
gen.meta = json.dumps(meta)
|
||||
commit()
|
||||
except BaseException as e:
|
||||
if sid:
|
||||
with db_session:
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
from datetime import timedelta, datetime
|
||||
|
||||
from flask import render_template
|
||||
from pony.orm import count
|
||||
|
||||
from WebHostLib import app, cache
|
||||
from .models import Room, Seed
|
||||
|
||||
from .models import *
|
||||
from datetime import timedelta
|
||||
|
||||
@app.route('/', methods=['GET', 'POST'])
|
||||
@cache.cached(timeout=300) # cache has to appear under app route for caching to work
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
||||
class CommonLocker:
|
||||
"""Uses a file lock to signal that something is already running"""
|
||||
lock_folder = "file_locks"
|
||||
|
||||
def __init__(self, lockname: str, folder=None):
|
||||
if folder:
|
||||
self.lock_folder = folder
|
||||
os.makedirs(self.lock_folder, exist_ok=True)
|
||||
self.lockname = lockname
|
||||
self.lockfile = os.path.join(self.lock_folder, f"{self.lockname}.lck")
|
||||
|
||||
|
||||
class AlreadyRunningException(Exception):
|
||||
pass
|
||||
|
||||
|
||||
if sys.platform == 'win32':
|
||||
class Locker(CommonLocker):
|
||||
def __enter__(self):
|
||||
try:
|
||||
if os.path.exists(self.lockfile):
|
||||
os.unlink(self.lockfile)
|
||||
self.fp = os.open(
|
||||
self.lockfile, os.O_CREAT | os.O_EXCL | os.O_RDWR)
|
||||
except OSError as e:
|
||||
raise AlreadyRunningException() from e
|
||||
|
||||
def __exit__(self, _type, value, tb):
|
||||
fp = getattr(self, "fp", None)
|
||||
if fp:
|
||||
os.close(self.fp)
|
||||
os.unlink(self.lockfile)
|
||||
else: # unix
|
||||
import fcntl
|
||||
|
||||
|
||||
class Locker(CommonLocker):
|
||||
def __enter__(self):
|
||||
try:
|
||||
self.fp = open(self.lockfile, "wb")
|
||||
fcntl.flock(self.fp.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
except OSError as e:
|
||||
raise AlreadyRunningException() from e
|
||||
|
||||
def __exit__(self, _type, value, tb):
|
||||
fcntl.flock(self.fp.fileno(), fcntl.LOCK_UN)
|
||||
self.fp.close()
|
||||
@@ -32,7 +32,7 @@ def update_sprites_lttp():
|
||||
|
||||
spriteData = []
|
||||
|
||||
for file in (file for file in os.listdir(input_dir) if not file.startswith(".")):
|
||||
for file in os.listdir(input_dir):
|
||||
sprite = Sprite(os.path.join(input_dir, file))
|
||||
|
||||
if not sprite.name:
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
import datetime
|
||||
import os
|
||||
from typing import Any, IO, Dict, Iterator, List, Tuple, Union
|
||||
|
||||
import jinja2.exceptions
|
||||
from flask import request, redirect, url_for, render_template, Response, session, abort, send_from_directory
|
||||
from pony.orm import count, commit, db_session
|
||||
from werkzeug.utils import secure_filename
|
||||
|
||||
from .models import count, Seed, commit, Room, db_session, Command, UUID, uuid4
|
||||
from worlds.AutoWorld import AutoWorldRegister
|
||||
from . import app, cache
|
||||
from .models import Seed, Room, Command, UUID, uuid4
|
||||
|
||||
|
||||
def get_world_theme(game_name: str):
|
||||
@@ -18,6 +15,13 @@ def get_world_theme(game_name: str):
|
||||
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(jinja2.exceptions.TemplateNotFound)
|
||||
def page_not_found(err):
|
||||
@@ -26,21 +30,29 @@ def page_not_found(err):
|
||||
|
||||
# Start Playing Page
|
||||
@app.route('/start-playing')
|
||||
@cache.cached()
|
||||
def start_playing():
|
||||
return render_template(f"startPlaying.html")
|
||||
|
||||
|
||||
@app.route('/weighted-settings')
|
||||
def weighted_settings():
|
||||
return render_template(f"weighted-settings.html")
|
||||
|
||||
|
||||
# Player settings pages
|
||||
@app.route('/games/<string:game>/player-settings')
|
||||
def player_settings(game):
|
||||
return render_template(f"player-settings.html", game=game, theme=get_world_theme(game))
|
||||
|
||||
|
||||
# Game Info Pages
|
||||
@app.route('/games/<string:game>/info/<string:lang>')
|
||||
@cache.cached()
|
||||
def game_info(game, lang):
|
||||
return render_template('gameInfo.html', game=game, lang=lang, theme=get_world_theme(game))
|
||||
|
||||
|
||||
# List of supported games
|
||||
@app.route('/games')
|
||||
@cache.cached()
|
||||
def games():
|
||||
worlds = {}
|
||||
for game, world in AutoWorldRegister.world_types.items():
|
||||
@@ -50,53 +62,27 @@ def games():
|
||||
|
||||
|
||||
@app.route('/tutorial/<string:game>/<string:file>/<string:lang>')
|
||||
@cache.cached()
|
||||
def tutorial(game, file, lang):
|
||||
return render_template("tutorial.html", game=game, file=file, lang=lang, theme=get_world_theme(game))
|
||||
|
||||
|
||||
@app.route('/tutorial/')
|
||||
@cache.cached()
|
||||
def tutorial_landing():
|
||||
worlds = {}
|
||||
for game, world in AutoWorldRegister.world_types.items():
|
||||
if not world.hidden:
|
||||
worlds[game] = world
|
||||
return render_template("tutorialLanding.html")
|
||||
|
||||
|
||||
@app.route('/faq/<string:lang>/')
|
||||
@cache.cached()
|
||||
def faq(lang: str):
|
||||
import markdown
|
||||
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}
|
||||
}
|
||||
),
|
||||
)
|
||||
def faq(lang):
|
||||
return render_template("faq.html", lang=lang)
|
||||
|
||||
|
||||
@app.route('/glossary/<string:lang>/')
|
||||
@cache.cached()
|
||||
def glossary(lang: str):
|
||||
import markdown
|
||||
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}
|
||||
}
|
||||
),
|
||||
)
|
||||
def terms(lang):
|
||||
return render_template("glossary.html", lang=lang)
|
||||
|
||||
|
||||
@app.route('/seed/<suuid:seed>')
|
||||
@@ -117,102 +103,55 @@ def new_room(seed: UUID):
|
||||
return redirect(url_for("host_room", room=room.id))
|
||||
|
||||
|
||||
def _read_log(log: IO[Any], offset: int = 0) -> Iterator[bytes]:
|
||||
marker = log.read(3) # skip optional BOM
|
||||
if marker != b'\xEF\xBB\xBF':
|
||||
log.seek(0, os.SEEK_SET)
|
||||
log.seek(offset, os.SEEK_CUR)
|
||||
yield from log
|
||||
log.close() # free file handle as soon as possible
|
||||
def _read_log(path: str):
|
||||
if os.path.exists(path):
|
||||
with open(path, encoding="utf-8-sig") as log:
|
||||
yield from log
|
||||
else:
|
||||
yield f"Logfile {path} does not exist. " \
|
||||
f"Likely a crash during spinup of multiworld instance or it is still spinning up."
|
||||
|
||||
|
||||
@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)
|
||||
if room is None:
|
||||
return abort(404)
|
||||
if room.owner == session["_id"]:
|
||||
file_path = os.path.join("logs", str(room.id) + ".txt")
|
||||
try:
|
||||
log = open(file_path, "rb")
|
||||
range_header = request.headers.get("Range")
|
||||
if range_header:
|
||||
range_type, range_values = range_header.split('=')
|
||||
start, end = map(str.strip, range_values.split('-', 1))
|
||||
if range_type != "bytes" or end != "":
|
||||
return "Unsupported range", 500
|
||||
# NOTE: we skip Content-Range in the response here, which isn't great but works for our JS
|
||||
return Response(_read_log(log, int(start)), mimetype="text/plain", status=206)
|
||||
return Response(_read_log(log), mimetype="text/plain")
|
||||
except FileNotFoundError:
|
||||
return Response(f"Logfile {file_path} does not exist. "
|
||||
f"Likely a crash during spinup of multiworld instance or it is still spinning up.",
|
||||
mimetype="text/plain")
|
||||
|
||||
return Response(_read_log(os.path.join("logs", str(room.id) + ".txt")), mimetype="text/plain;charset=UTF-8")
|
||||
return "Access Denied", 403
|
||||
|
||||
|
||||
@app.post("/room/<suuid:room>")
|
||||
def host_room_command(room: UUID):
|
||||
room: Room = Room.get(id=room)
|
||||
if room is None:
|
||||
return abort(404)
|
||||
|
||||
if room.owner == session["_id"]:
|
||||
cmd = request.form["cmd"]
|
||||
if cmd:
|
||||
Command(room=room, commandtext=cmd)
|
||||
commit()
|
||||
return redirect(url_for("host_room", room=room.id))
|
||||
|
||||
|
||||
@app.get("/room/<suuid:room>")
|
||||
@app.route('/room/<suuid:room>', methods=['GET', 'POST'])
|
||||
def host_room(room: UUID):
|
||||
room: Room = Room.get(id=room)
|
||||
if room is None:
|
||||
return abort(404)
|
||||
if request.method == "POST":
|
||||
if room.owner == session["_id"]:
|
||||
cmd = request.form["cmd"]
|
||||
if cmd:
|
||||
Command(room=room, commandtext=cmd)
|
||||
commit()
|
||||
|
||||
now = datetime.datetime.utcnow()
|
||||
# indicate that the page should reload to get the assigned port
|
||||
should_refresh = ((not room.last_port and now - room.creation_time < datetime.timedelta(seconds=3))
|
||||
or room.last_activity < now - datetime.timedelta(seconds=room.timeout))
|
||||
should_refresh = not room.last_port and now - room.creation_time < datetime.timedelta(seconds=3)
|
||||
with db_session:
|
||||
room.last_activity = now # will trigger a spinup, if it's not already running
|
||||
|
||||
browser_tokens = "Mozilla", "Chrome", "Safari"
|
||||
automated = ("update" in request.args
|
||||
or "Discordbot" in request.user_agent.string
|
||||
or not any(browser_token in request.user_agent.string for browser_token in browser_tokens))
|
||||
|
||||
def get_log(max_size: int = 0 if automated else 1024000) -> 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)
|
||||
return render_template("hostRoom.html", room=room, should_refresh=should_refresh)
|
||||
|
||||
|
||||
@app.route('/favicon.ico')
|
||||
def favicon():
|
||||
return send_from_directory(os.path.join(app.root_path, "static", "static"),
|
||||
return send_from_directory(os.path.join(app.root_path, 'static/static'),
|
||||
'favicon.ico', mimetype='image/vnd.microsoft.icon')
|
||||
|
||||
|
||||
@app.route('/discord')
|
||||
def discord():
|
||||
return redirect("https://discord.gg/8Z65BR2")
|
||||
return redirect("https://discord.gg/archipelago")
|
||||
|
||||
|
||||
@app.route('/datapackage')
|
||||
@@ -226,11 +165,9 @@ def get_datapackage():
|
||||
|
||||
@app.route('/index')
|
||||
@app.route('/sitemap')
|
||||
@cache.cached()
|
||||
def get_sitemap():
|
||||
available_games: List[Dict[str, Union[str, bool]]] = []
|
||||
available_games = []
|
||||
for game, world in AutoWorldRegister.world_types.items():
|
||||
if not world.hidden:
|
||||
has_settings: bool = isinstance(world.web.options_page, bool) and world.web.options_page
|
||||
available_games.append({ 'title': game, 'has_settings': has_settings })
|
||||
available_games.append(game)
|
||||
return render_template("siteMap.html", games=available_games)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from datetime import datetime
|
||||
from uuid import UUID, uuid4
|
||||
from pony.orm import Database, PrimaryKey, Required, Set, Optional, buffer, LongStr
|
||||
from pony.orm import *
|
||||
|
||||
db = Database()
|
||||
|
||||
@@ -21,7 +21,7 @@ class Slot(db.Entity):
|
||||
class Room(db.Entity):
|
||||
id = PrimaryKey(UUID, default=uuid4)
|
||||
last_activity = Required(datetime, default=lambda: datetime.utcnow(), index=True)
|
||||
creation_time = Required(datetime, default=lambda: datetime.utcnow(), index=True) # index used by landing page
|
||||
creation_time = Required(datetime, default=lambda: datetime.utcnow())
|
||||
owner = Required(UUID, index=True)
|
||||
commands = Set('Command')
|
||||
seed = Required('Seed', index=True)
|
||||
@@ -29,7 +29,6 @@ class Room(db.Entity):
|
||||
show_spoiler = Required(int, default=0) # 0 -> never, 1 -> after completion, -> 2 always
|
||||
timeout = Required(int, default=lambda: 2 * 60 * 60) # seconds since last activity to shutdown
|
||||
tracker = Optional(UUID, index=True)
|
||||
# Port special value -1 means the server errored out. Another attempt can be made with a page refresh
|
||||
last_port = Optional(int, default=lambda: 0)
|
||||
|
||||
|
||||
@@ -38,7 +37,7 @@ class Seed(db.Entity):
|
||||
rooms = Set(Room)
|
||||
multidata = Required(bytes, lazy=True)
|
||||
owner = Required(UUID, index=True)
|
||||
creation_time = Required(datetime, default=lambda: datetime.utcnow(), index=True) # index used by landing page
|
||||
creation_time = Required(datetime, default=lambda: datetime.utcnow())
|
||||
slots = Set(Slot)
|
||||
spoiler = Optional(LongStr, lazy=True)
|
||||
meta = Required(LongStr, default=lambda: "{\"race\": false}") # additional meta information/tags
|
||||
@@ -56,8 +55,3 @@ class Generation(db.Entity):
|
||||
options = Required(buffer, lazy=True)
|
||||
meta = Required(LongStr, default=lambda: "{\"race\": false}")
|
||||
state = Required(int, default=0, index=True)
|
||||
|
||||
|
||||
class GameDataPackage(db.Entity):
|
||||
checksum = PrimaryKey(str)
|
||||
data = Required(bytes)
|
||||
|
||||
@@ -1,281 +1,179 @@
|
||||
import collections.abc
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from textwrap import dedent
|
||||
from typing import Dict, Union
|
||||
from docutils.core import publish_parts
|
||||
|
||||
from Utils import __version__, local_path
|
||||
from jinja2 import Template
|
||||
import yaml
|
||||
from flask import redirect, render_template, request, Response
|
||||
import json
|
||||
import typing
|
||||
|
||||
import Options
|
||||
from Utils import local_path
|
||||
from worlds.AutoWorld import AutoWorldRegister
|
||||
from . import app, cache
|
||||
from .generate import get_meta
|
||||
import Options
|
||||
|
||||
handled_in_js = {"start_inventory", "local_items", "non_local_items", "start_hints", "start_location_hints",
|
||||
"exclude_locations"}
|
||||
|
||||
|
||||
def create() -> None:
|
||||
def create():
|
||||
target_folder = local_path("WebHostLib", "static", "generated")
|
||||
yaml_folder = os.path.join(target_folder, "configs")
|
||||
os.makedirs(os.path.join(target_folder, "configs"), exist_ok=True)
|
||||
|
||||
Options.generate_yaml_templates(yaml_folder)
|
||||
|
||||
|
||||
def get_world_theme(game_name: str) -> str:
|
||||
if game_name in AutoWorldRegister.world_types:
|
||||
return AutoWorldRegister.world_types[game_name].web.theme
|
||||
return 'grass'
|
||||
|
||||
|
||||
def render_options_page(template: str, world_name: str, is_complex: bool = False) -> Union[Response, str]:
|
||||
world = AutoWorldRegister.world_types[world_name]
|
||||
if world.hidden or world.web.options_page is False:
|
||||
return redirect("games")
|
||||
visibility_flag = Options.Visibility.complex_ui if is_complex else Options.Visibility.simple_ui
|
||||
|
||||
start_collapsed = {"Game Options": False}
|
||||
for group in world.web.option_groups:
|
||||
start_collapsed[group.name] = group.start_collapsed
|
||||
|
||||
return render_template(
|
||||
template,
|
||||
world_name=world_name,
|
||||
world=world,
|
||||
option_groups=Options.get_option_groups(world, visibility_level=visibility_flag),
|
||||
start_collapsed=start_collapsed,
|
||||
issubclass=issubclass,
|
||||
Options=Options,
|
||||
theme=get_world_theme(world_name),
|
||||
)
|
||||
|
||||
|
||||
def generate_game(options: Dict[str, Union[dict, str]]) -> Union[Response, str]:
|
||||
from .generate import start_generation
|
||||
return start_generation(options, get_meta({}))
|
||||
|
||||
|
||||
def send_yaml(player_name: str, formatted_options: dict) -> Response:
|
||||
response = Response(yaml.dump(formatted_options, sort_keys=False))
|
||||
response.headers["Content-Type"] = "text/yaml"
|
||||
response.headers["Content-Disposition"] = f"attachment; filename={player_name}.yaml"
|
||||
return response
|
||||
|
||||
|
||||
@app.template_filter("dedent")
|
||||
def filter_dedent(text: str) -> str:
|
||||
return dedent(text).strip("\n ")
|
||||
|
||||
|
||||
@app.template_filter("rst_to_html")
|
||||
def filter_rst_to_html(text: str) -> str:
|
||||
"""Converts reStructuredText (such as a Python docstring) to HTML."""
|
||||
if text.startswith(" ") or text.startswith("\t"):
|
||||
text = dedent(text)
|
||||
elif "\n" in text:
|
||||
lines = text.splitlines()
|
||||
text = lines[0] + "\n" + dedent("\n".join(lines[1:]))
|
||||
|
||||
return publish_parts(text, writer_name='html', settings=None, settings_overrides={
|
||||
'raw_enable': False,
|
||||
'file_insertion_enabled': False,
|
||||
'output_encoding': 'unicode'
|
||||
})['body']
|
||||
|
||||
|
||||
@app.template_test("ordered")
|
||||
def test_ordered(obj):
|
||||
return isinstance(obj, collections.abc.Sequence)
|
||||
|
||||
|
||||
@app.route("/games/<string:game>/option-presets", methods=["GET"])
|
||||
@cache.cached()
|
||||
def option_presets(game: str) -> Response:
|
||||
world = AutoWorldRegister.world_types[game]
|
||||
|
||||
presets = {}
|
||||
for preset_name, preset in world.web.options_presets.items():
|
||||
presets[preset_name] = {}
|
||||
for preset_option_name, preset_option in preset.items():
|
||||
if preset_option == "random":
|
||||
presets[preset_name][preset_option_name] = preset_option
|
||||
continue
|
||||
|
||||
option = world.options_dataclass.type_hints[preset_option_name].from_any(preset_option)
|
||||
if isinstance(option, Options.NamedRange) and isinstance(preset_option, str):
|
||||
assert preset_option in option.special_range_names, \
|
||||
f"Invalid preset value '{preset_option}' for '{preset_option_name}' in '{preset_name}'. " \
|
||||
f"Expected {option.special_range_names.keys()} or {option.range_start}-{option.range_end}."
|
||||
|
||||
presets[preset_name][preset_option_name] = option.value
|
||||
elif isinstance(option, (Options.Range, Options.OptionSet, Options.OptionList, Options.ItemDict)):
|
||||
presets[preset_name][preset_option_name] = option.value
|
||||
elif isinstance(preset_option, str):
|
||||
# Ensure the option value is valid for Choice and Toggle options
|
||||
assert option.name_lookup[option.value] == preset_option, \
|
||||
f"Invalid option value '{preset_option}' for '{preset_option_name}' in preset '{preset_name}'. " \
|
||||
f"Values must not be resolved to a different option via option.from_text (or an alias)."
|
||||
# Use the name of the option
|
||||
presets[preset_name][preset_option_name] = option.current_key
|
||||
else:
|
||||
# Use the name of the option
|
||||
presets[preset_name][preset_option_name] = option.current_key
|
||||
|
||||
class SetEncoder(json.JSONEncoder):
|
||||
def default(self, obj):
|
||||
from collections.abc import Set
|
||||
if isinstance(obj, Set):
|
||||
return list(obj)
|
||||
return json.JSONEncoder.default(self, obj)
|
||||
|
||||
json_data = json.dumps(presets, cls=SetEncoder)
|
||||
response = Response(json_data)
|
||||
response.headers["Content-Type"] = "application/json"
|
||||
return response
|
||||
|
||||
|
||||
@app.route("/weighted-options")
|
||||
def weighted_options_old():
|
||||
return redirect("games", 301)
|
||||
|
||||
|
||||
@app.route("/games/<string:game>/weighted-options")
|
||||
@cache.cached()
|
||||
def weighted_options(game: str):
|
||||
return render_options_page("weightedOptions/weightedOptions.html", game, is_complex=True)
|
||||
|
||||
|
||||
@app.route("/games/<string:game>/generate-weighted-yaml", methods=["POST"])
|
||||
def generate_weighted_yaml(game: str):
|
||||
if request.method == "POST":
|
||||
intent_generate = False
|
||||
options = {}
|
||||
|
||||
for key, val in request.form.items():
|
||||
if "||" not in key:
|
||||
if len(str(val)) == 0:
|
||||
continue
|
||||
|
||||
options[key] = val
|
||||
else:
|
||||
if int(val) == 0:
|
||||
continue
|
||||
|
||||
[option, setting] = key.split("||")
|
||||
options.setdefault(option, {})[setting] = int(val)
|
||||
|
||||
# Error checking
|
||||
if "name" not in options:
|
||||
return "Player name is required."
|
||||
|
||||
# Remove POST data irrelevant to YAML
|
||||
if "intent-generate" in options:
|
||||
intent_generate = True
|
||||
del options["intent-generate"]
|
||||
if "intent-export" in options:
|
||||
del options["intent-export"]
|
||||
|
||||
# Properly format YAML output
|
||||
player_name = options["name"]
|
||||
del options["name"]
|
||||
|
||||
formatted_options = {
|
||||
"name": player_name,
|
||||
"game": game,
|
||||
"description": f"Generated by https://archipelago.gg/ for {game}",
|
||||
game: options,
|
||||
def dictify_range(option: typing.Union[Options.Range, Options.SpecialRange]):
|
||||
data = {}
|
||||
special = getattr(option, "special_range_cutoff", None)
|
||||
if special is not None:
|
||||
data[special] = 0
|
||||
data.update({
|
||||
option.range_start: 0,
|
||||
option.range_end: 0,
|
||||
"random": 0, "random-low": 0, "random-high": 0,
|
||||
option.default: 50
|
||||
})
|
||||
notes = {
|
||||
special: "minimum value without special meaning",
|
||||
option.range_start: "minimum value",
|
||||
option.range_end: "maximum value"
|
||||
}
|
||||
|
||||
if intent_generate:
|
||||
return generate_game({player_name: formatted_options})
|
||||
|
||||
else:
|
||||
return send_yaml(player_name, formatted_options)
|
||||
|
||||
|
||||
# Player options pages
|
||||
@app.route("/games/<string:game>/player-options")
|
||||
@cache.cached()
|
||||
def player_options(game: str):
|
||||
return render_options_page("playerOptions/playerOptions.html", game, is_complex=False)
|
||||
|
||||
|
||||
# YAML generator for player-options
|
||||
@app.route("/games/<string:game>/generate-yaml", methods=["POST"])
|
||||
def generate_yaml(game: str):
|
||||
if request.method == "POST":
|
||||
options = {}
|
||||
intent_generate = False
|
||||
for key, val in request.form.items(multi=True):
|
||||
if key in options:
|
||||
if not isinstance(options[key], list):
|
||||
options[key] = [options[key]]
|
||||
options[key].append(val)
|
||||
for name, number in getattr(option, "special_range_names", {}).items():
|
||||
if number in data:
|
||||
data[name] = data[number]
|
||||
del data[number]
|
||||
else:
|
||||
options[key] = val
|
||||
data[name] = 0
|
||||
|
||||
for key, val in options.copy().items():
|
||||
key_parts = key.rsplit("||", 2)
|
||||
# Detect and build ItemDict options from their name pattern
|
||||
if key_parts[-1] == "qty":
|
||||
if key_parts[0] not in options:
|
||||
options[key_parts[0]] = {}
|
||||
if val != "0":
|
||||
options[key_parts[0]][key_parts[1]] = int(val)
|
||||
del options[key]
|
||||
return data, notes
|
||||
|
||||
# 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
|
||||
def default_converter(default_value):
|
||||
if isinstance(default_value, (set, frozenset)):
|
||||
return list(default_value)
|
||||
return default_value
|
||||
|
||||
del options[key]
|
||||
def get_html_doc(option_type: type(Options.Option)) -> str:
|
||||
if not option_type.__doc__:
|
||||
return "Please document me!"
|
||||
return "\n".join(line.strip() for line in option_type.__doc__.split("\n")).strip()
|
||||
|
||||
# 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
|
||||
weighted_settings = {
|
||||
"baseOptions": {
|
||||
"description": "Generated by https://archipelago.gg/",
|
||||
"name": "Player",
|
||||
"game": {},
|
||||
},
|
||||
"games": {},
|
||||
}
|
||||
|
||||
del options[key]
|
||||
for game_name, world in AutoWorldRegister.world_types.items():
|
||||
|
||||
# Detect random-* keys and set their options accordingly
|
||||
for key, val in options.copy().items():
|
||||
if key.startswith("random-"):
|
||||
options[key.removeprefix("random-")] = "random"
|
||||
del options[key]
|
||||
all_options = {**Options.per_game_common_options, **world.option_definitions}
|
||||
with open(local_path("WebHostLib", "templates", "options.yaml")) as f:
|
||||
file_data = f.read()
|
||||
res = Template(file_data).render(
|
||||
options=all_options,
|
||||
__version__=__version__, game=game_name, yaml_dump=yaml.dump,
|
||||
dictify_range=dictify_range, default_converter=default_converter,
|
||||
)
|
||||
|
||||
# Error checking
|
||||
if not options["name"]:
|
||||
return "Player name is required."
|
||||
del file_data
|
||||
|
||||
# Remove POST data irrelevant to YAML
|
||||
preset_name = 'default'
|
||||
if "intent-generate" in options:
|
||||
intent_generate = True
|
||||
del options["intent-generate"]
|
||||
if "intent-export" in options:
|
||||
del options["intent-export"]
|
||||
if "game-options-preset" in options:
|
||||
preset_name = options["game-options-preset"]
|
||||
del options["game-options-preset"]
|
||||
with open(os.path.join(target_folder, 'configs', game_name + ".yaml"), "w") as f:
|
||||
f.write(res)
|
||||
|
||||
# Properly format YAML output
|
||||
player_name = options["name"]
|
||||
del options["name"]
|
||||
|
||||
description = f"Generated by https://archipelago.gg/ for {game}"
|
||||
if preset_name != 'default' and preset_name != 'custom':
|
||||
description += f" using {preset_name} preset"
|
||||
|
||||
formatted_options = {
|
||||
"name": player_name,
|
||||
"game": game,
|
||||
"description": description,
|
||||
game: options,
|
||||
# Generate JSON files for player-settings pages
|
||||
player_settings = {
|
||||
"baseOptions": {
|
||||
"description": "Generated by https://archipelago.gg/",
|
||||
"game": game_name,
|
||||
"name": "Player",
|
||||
},
|
||||
}
|
||||
|
||||
if intent_generate:
|
||||
return generate_game({player_name: formatted_options})
|
||||
game_options = {}
|
||||
for option_name, option in all_options.items():
|
||||
if option_name in handled_in_js:
|
||||
pass
|
||||
|
||||
else:
|
||||
return send_yaml(player_name, formatted_options)
|
||||
elif option.options:
|
||||
game_options[option_name] = this_option = {
|
||||
"type": "select",
|
||||
"displayName": option.display_name if hasattr(option, "display_name") else option_name,
|
||||
"description": get_html_doc(option),
|
||||
"defaultValue": None,
|
||||
"options": []
|
||||
}
|
||||
|
||||
for sub_option_id, sub_option_name in option.name_lookup.items():
|
||||
this_option["options"].append({
|
||||
"name": option.get_option_name(sub_option_id),
|
||||
"value": sub_option_name,
|
||||
})
|
||||
|
||||
if sub_option_id == option.default:
|
||||
this_option["defaultValue"] = sub_option_name
|
||||
|
||||
this_option["options"].append({
|
||||
"name": "Random",
|
||||
"value": "random",
|
||||
})
|
||||
|
||||
if option.default == "random":
|
||||
this_option["defaultValue"] = "random"
|
||||
|
||||
elif issubclass(option, Options.Range):
|
||||
game_options[option_name] = {
|
||||
"type": "range",
|
||||
"displayName": option.display_name if hasattr(option, "display_name") else option_name,
|
||||
"description": get_html_doc(option),
|
||||
"defaultValue": option.default if hasattr(
|
||||
option, "default") and option.default != "random" else option.range_start,
|
||||
"min": option.range_start,
|
||||
"max": option.range_end,
|
||||
}
|
||||
|
||||
if issubclass(option, Options.SpecialRange):
|
||||
game_options[option_name]["type"] = 'special_range'
|
||||
game_options[option_name]["value_names"] = {}
|
||||
for key, val in option.special_range_names.items():
|
||||
game_options[option_name]["value_names"][key] = val
|
||||
|
||||
elif getattr(option, "verify_item_name", False):
|
||||
game_options[option_name] = {
|
||||
"type": "items-list",
|
||||
"displayName": option.display_name if hasattr(option, "display_name") else option_name,
|
||||
"description": get_html_doc(option),
|
||||
}
|
||||
|
||||
elif getattr(option, "verify_location_name", False):
|
||||
game_options[option_name] = {
|
||||
"type": "locations-list",
|
||||
"displayName": option.display_name if hasattr(option, "display_name") else option_name,
|
||||
"description": get_html_doc(option),
|
||||
}
|
||||
|
||||
elif issubclass(option, Options.OptionList) or issubclass(option, Options.OptionSet):
|
||||
if option.valid_keys:
|
||||
game_options[option_name] = {
|
||||
"type": "custom-list",
|
||||
"displayName": option.display_name if hasattr(option, "display_name") else option_name,
|
||||
"description": get_html_doc(option),
|
||||
"options": list(option.valid_keys),
|
||||
}
|
||||
|
||||
else:
|
||||
logging.debug(f"{option} not exported to Web Settings.")
|
||||
|
||||
player_settings["gameOptions"] = game_options
|
||||
|
||||
os.makedirs(os.path.join(target_folder, 'player-settings'), exist_ok=True)
|
||||
|
||||
with open(os.path.join(target_folder, 'player-settings', game_name + ".json"), "w") as f:
|
||||
json.dump(player_settings, f, indent=2, separators=(',', ': '))
|
||||
|
||||
if not world.hidden and world.web.settings_page is True:
|
||||
weighted_settings["baseOptions"]["game"][game_name] = 0
|
||||
weighted_settings["games"][game_name] = {}
|
||||
weighted_settings["games"][game_name]["gameSettings"] = game_options
|
||||
weighted_settings["games"][game_name]["gameItems"] = tuple(world.item_names)
|
||||
weighted_settings["games"][game_name]["gameLocations"] = tuple(world.location_names)
|
||||
|
||||
with open(os.path.join(target_folder, 'weighted-settings.json'), "w") as f:
|
||||
json.dump(weighted_settings, f, indent=2, separators=(',', ': '))
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
flask>=3.0.3
|
||||
werkzeug>=3.0.6
|
||||
pony>=0.7.19
|
||||
waitress>=3.0.0
|
||||
Flask-Caching>=2.3.0
|
||||
Flask-Compress>=1.15
|
||||
Flask-Limiter>=3.8.0
|
||||
bokeh>=3.5.2
|
||||
markupsafe>=2.1.5
|
||||
Markdown>=3.7
|
||||
mdx-breakless-lists>=1.0.1
|
||||
flask>=2.2.2
|
||||
pony>=0.7.16
|
||||
waitress>=2.1.2
|
||||
Flask-Caching>=2.0.1
|
||||
Flask-Compress>=1.12
|
||||
Flask-Limiter>=2.6.2
|
||||
bokeh>=2.4.3
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
from WebHostLib import app
|
||||
from flask import abort
|
||||
from . import cache
|
||||
|
||||
|
||||
@cache.cached()
|
||||
@app.route('/robots.txt')
|
||||
def robots():
|
||||
# If this host is not official, do not allow search engine crawling
|
||||
if not app.config["ASSET_RIGHTS"]:
|
||||
# filename changed in case the path is intercepted and served by an outside service
|
||||
return app.send_static_file('robots_file.txt')
|
||||
|
||||
# Send 404 if the host has affirmed this to be the official WebHost
|
||||
abort(404)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -4,7 +4,6 @@ window.addEventListener('load', () => {
|
||||
"ordering": true,
|
||||
"info": false,
|
||||
"dom": "t",
|
||||
"stateSave": true,
|
||||
});
|
||||
console.log(tables);
|
||||
});
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
window.addEventListener('load', () => {
|
||||
// Mobile menu handling
|
||||
const menuButton = document.getElementById('base-header-mobile-menu-button');
|
||||
const mobileMenu = document.getElementById('base-header-mobile-menu');
|
||||
|
||||
menuButton.addEventListener('click', (evt) => {
|
||||
evt.preventDefault();
|
||||
evt.stopPropagation();
|
||||
|
||||
if (!mobileMenu.style.display || mobileMenu.style.display === 'none') {
|
||||
return mobileMenu.style.display = 'flex';
|
||||
}
|
||||
|
||||
mobileMenu.style.display = 'none';
|
||||
});
|
||||
|
||||
window.addEventListener('resize', () => {
|
||||
mobileMenu.style.display = 'none';
|
||||
});
|
||||
|
||||
// Popover handling
|
||||
const popoverText = document.getElementById('base-header-popover-text');
|
||||
const popoverMenu = document.getElementById('base-header-popover-menu');
|
||||
|
||||
popoverText.addEventListener('click', (evt) => {
|
||||
evt.preventDefault();
|
||||
evt.stopPropagation();
|
||||
|
||||
if (!popoverMenu.style.display || popoverMenu.style.display === 'none') {
|
||||
return popoverMenu.style.display = 'flex';
|
||||
}
|
||||
|
||||
popoverMenu.style.display = 'none';
|
||||
});
|
||||
|
||||
document.body.addEventListener('click', () => {
|
||||
mobileMenu.style.display = 'none';
|
||||
popoverMenu.style.display = 'none';
|
||||
});
|
||||
});
|
||||
@@ -1,49 +0,0 @@
|
||||
window.addEventListener('load', () => {
|
||||
// Reload tracker every 60 seconds
|
||||
const url = window.location;
|
||||
setInterval(() => {
|
||||
const ajax = new XMLHttpRequest();
|
||||
ajax.onreadystatechange = () => {
|
||||
if (ajax.readyState !== 4) { return; }
|
||||
|
||||
// Create a fake DOM using the returned HTML
|
||||
const domParser = new DOMParser();
|
||||
const fakeDOM = domParser.parseFromString(ajax.responseText, 'text/html');
|
||||
|
||||
// Update item tracker
|
||||
document.getElementById('inventory-table').innerHTML = fakeDOM.getElementById('inventory-table').innerHTML;
|
||||
// Update only counters in the location-table
|
||||
let counters = document.getElementsByClassName('counter');
|
||||
const fakeCounters = fakeDOM.getElementsByClassName('counter');
|
||||
for (let i = 0; i < counters.length; i++) {
|
||||
counters[i].innerHTML = fakeCounters[i].innerHTML;
|
||||
}
|
||||
};
|
||||
ajax.open('GET', url);
|
||||
ajax.send();
|
||||
}, 60000)
|
||||
|
||||
// Collapsible advancement sections
|
||||
const categories = document.getElementsByClassName("location-category");
|
||||
for (let i = 0; i < categories.length; i++) {
|
||||
let hide_id = categories[i].id.split('-')[0];
|
||||
if (hide_id == 'Total') {
|
||||
continue;
|
||||
}
|
||||
categories[i].addEventListener('click', function() {
|
||||
// Toggle the advancement list
|
||||
document.getElementById(hide_id).classList.toggle("hide");
|
||||
// Change text of the header
|
||||
const tab_header = document.getElementById(hide_id+'-header').children[0];
|
||||
const orig_text = tab_header.innerHTML;
|
||||
let new_text;
|
||||
if (orig_text.includes("▼")) {
|
||||
new_text = orig_text.replace("▼", "▲");
|
||||
}
|
||||
else {
|
||||
new_text = orig_text.replace("▲", "▼");
|
||||
}
|
||||
tab_header.innerHTML = new_text;
|
||||
});
|
||||
}
|
||||
});
|
||||
53
WebHostLib/static/assets/faq.js
Normal file
@@ -0,0 +1,53 @@
|
||||
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
|
||||
const headers = Array.from(document.querySelectorAll('h1, h2, h3, h4, h5, h6'));
|
||||
const scrollTargetIndex = window.location.href.search(/#[A-z0-9-_]*$/);
|
||||
for (let i=0; i < headers.length; i++){
|
||||
const headerId = headers[i].innerText.replace(/[ ]/g,'-').toLowerCase()
|
||||
headers[i].setAttribute('id', headerId);
|
||||
headers[i].addEventListener('click', () =>
|
||||
window.location.href = window.location.href.substring(0, scrollTargetIndex) + `#${headerId}`);
|
||||
}
|
||||
|
||||
// Manually scroll the user to the appropriate header if anchor navigation is used
|
||||
if (scrollTargetIndex > -1) {
|
||||
try{
|
||||
const scrollTarget = window.location.href.substring(scrollTargetIndex + 1);
|
||||
document.getElementById(scrollTarget).scrollIntoView({ behavior: "smooth" });
|
||||
} catch(error) {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
}).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>`;
|
||||
});
|
||||
});
|
||||
@@ -1,80 +0,0 @@
|
||||
# Frequently Asked Questions
|
||||
|
||||
## What is a randomizer?
|
||||
|
||||
A randomizer is a modification of a game which reorganizes the items required to progress through that game. A
|
||||
normal play-through might require you to use item A to unlock item B, then C, and so forth. In a randomized
|
||||
game, you might first find item C, then A, then B.
|
||||
|
||||
This transforms the game from a linear experience into a puzzle, presenting players with a new challenge each time they
|
||||
play. Putting items in non-standard locations can require the player to think about the game world and the items they
|
||||
encounter in new and interesting ways.
|
||||
|
||||
## What is a multiworld?
|
||||
|
||||
While a randomizer shuffles a game, a multiworld randomizer shuffles that game for multiple players. For example, in a
|
||||
two player multiworld, players A and B each get their own randomized version of a game, called a world. In each
|
||||
player's game, they may find items which belong to the other player. If player A finds an item which belongs to
|
||||
player B, the item will be sent to player B's world over the internet. This creates a cooperative experience, requiring
|
||||
players to rely upon each other to complete their game.
|
||||
|
||||
## What does multi-game mean?
|
||||
|
||||
While a multiworld game traditionally requires all players to be playing the same game, a multi-game multiworld allows
|
||||
players to randomize any of the supported games, and send items between them. This allows players of different
|
||||
games to interact with one another in a single multiplayer environment. Archipelago supports multi-game multiworlds.
|
||||
Here is a list of our [Supported Games](https://archipelago.gg/games).
|
||||
|
||||
## Can I generate a single-player game with Archipelago?
|
||||
|
||||
Yes. All of our supported games can be generated as single-player experiences both on the website and by installing
|
||||
the Archipelago generator software. The fastest way to do this is on the website. Find the Supported Game you wish to
|
||||
play, open the Settings Page, pick your settings, and click Generate Game.
|
||||
|
||||
## How do I get started?
|
||||
|
||||
We have a [Getting Started](https://archipelago.gg/tutorial/Archipelago/setup/en) guide that will help you get the
|
||||
software set up. You can use that guide to learn how to generate multiworlds. There are also basic instructions for
|
||||
including multiple games, and hosting multiworlds on the website for ease and convenience.
|
||||
|
||||
If you are ready to start randomizing games, or want to start playing your favorite randomizer with others, please join
|
||||
our discord server at the [Archipelago Discord](https://discord.gg/8Z65BR2). There are always people ready to answer
|
||||
any questions you might have.
|
||||
|
||||
## What are some common terms I should know?
|
||||
|
||||
As randomizers and multiworld randomizers have been around for a while now, there are quite a few common terms used
|
||||
by the communities surrounding them. A list of Archipelago jargon and terms commonly used by the community can be
|
||||
found in the [Glossary](/glossary/en).
|
||||
|
||||
## Does everyone need to be connected at the same time?
|
||||
|
||||
There are two different play-styles that are common for Archipelago multiworld sessions. These sessions can either
|
||||
be considered synchronous (or "sync"), where everyone connects and plays at the same time, or asynchronous (or "async"),
|
||||
where players connect and play at their own pace. The setup for both is identical. The difference in play-style is how
|
||||
you and your friends choose to organize and play your multiworld. Most groups decide on the format before creating
|
||||
their multiworld.
|
||||
|
||||
If a player must leave early, they can use Archipelago's release system. When a player releases their game, all items
|
||||
in that game belonging to other players are sent out automatically. This allows other players to continue to play
|
||||
uninterrupted. Here is a list of all of our [Server Commands](https://archipelago.gg/tutorial/Archipelago/commands/en).
|
||||
|
||||
## What happens if an item is placed somewhere it is impossible to get?
|
||||
|
||||
The randomizer has many strict sets of rules it must follow when generating a game. One of the functions of these rules
|
||||
is to ensure items necessary to complete the game will be accessible to the player. Many games also have a subset of
|
||||
rules allowing certain items to be placed in normally unreachable locations, provided the player has indicated they are
|
||||
comfortable exploiting certain glitches in the game.
|
||||
|
||||
## I want to add a game to the Archipelago randomizer. How do I do that?
|
||||
|
||||
The best way to get started is to take a look at our code on GitHub:
|
||||
[Archipelago GitHub Page](https://github.com/ArchipelagoMW/Archipelago).
|
||||
|
||||
There, you will find examples of games in the `worlds` folder:
|
||||
[/worlds Folder in Archipelago Code](https://github.com/ArchipelagoMW/Archipelago/tree/main/worlds).
|
||||
|
||||
You may also find developer documentation in the `docs` folder:
|
||||
[/docs Folder in Archipelago Code](https://github.com/ArchipelagoMW/Archipelago/tree/main/docs).
|
||||
|
||||
If you have more questions, feel free to ask in the **#ap-world-dev** channel on our Discord.
|
||||
69
WebHostLib/static/assets/faq/faq_en.md
Normal file
@@ -0,0 +1,69 @@
|
||||
# Frequently Asked Questions
|
||||
|
||||
## What is a randomizer?
|
||||
|
||||
A randomizer is a modification of a video game which reorganizes the items required to progress through the game. A
|
||||
normal play-through of a game might require you to use item A to unlock item B, then C, and so forth. In a randomized
|
||||
game, you might first find item C, then A, then B.
|
||||
|
||||
This transforms games from a linear experience into a puzzle, presenting players with a new challenge each time they
|
||||
play a randomized game. Putting items in non-standard locations can require the player to think about the game world and
|
||||
the items they encounter in new and interesting ways.
|
||||
|
||||
## What happens if an item is placed somewhere it is impossible to get?
|
||||
|
||||
The randomizer has many strict sets of rules it must follow when generating a game. One of the functions of these rules
|
||||
is to ensure items necessary to complete the game will be accessible to the player. Many games also have a subset of
|
||||
rules allowing certain items to be placed in normally unreachable locations, provided the player has indicated they are
|
||||
comfortable exploiting certain glitches in the game.
|
||||
|
||||
## What is a multi-world?
|
||||
|
||||
While a randomizer shuffles a game, a multi-world randomizer shuffles that game for multiple players. For example, in a
|
||||
two player multi-world, players A and B each get their own randomized version of a game, called seeds. In each player's
|
||||
game, they may find items which belong to the other player. If player A finds an item which belongs to player B, the
|
||||
item will be sent to player B's world over the internet.
|
||||
|
||||
This creates a cooperative experience during multi-world games, requiring players to rely upon each other to complete
|
||||
their game.
|
||||
|
||||
## What happens if a person has to leave early?
|
||||
|
||||
If a player must leave early, they can use Archipelago's forfeit system. When a player forfeits their game, all the
|
||||
items in that game which belong to other players are sent out automatically, so other players can continue to play.
|
||||
|
||||
## What does multi-game mean?
|
||||
|
||||
While a multi-world game traditionally requires all players to be playing the same game, a multi-game multi-world allows
|
||||
players to randomize any of a number of supported games, and send items between them. This allows players of different
|
||||
games to interact with one another in a single multiplayer environment.
|
||||
|
||||
## Can I generate a single-player game with Archipelago?
|
||||
|
||||
Yes. All our supported games can be generated as single-player experiences, and so long as you download the software,
|
||||
the website is not required to generate them.
|
||||
|
||||
## How do I get started?
|
||||
|
||||
If you are ready to start randomizing games, or want to start playing your favorite randomizer with others, please join
|
||||
our discord server at the [Archipelago Discord](https://discord.gg/archipelago). There are always people ready to answer
|
||||
any questions you might have.
|
||||
|
||||
## What are some common terms I should know?
|
||||
|
||||
As randomizers and multiworld randomizers have been around for a while now there are quite a lot of common terms
|
||||
and jargon that is used in conjunction by the communities surrounding them. For a lot of the terms that are more common
|
||||
to Archipelago and its specific systems please see the [Glossary](/glossary/en).
|
||||
|
||||
## I want to add a game to the Archipelago randomizer. How do I do that?
|
||||
|
||||
The best way to get started is to take a look at our code on GitHub
|
||||
at [Archipelago GitHub Page](https://github.com/ArchipelagoMW/Archipelago).
|
||||
|
||||
There you will find examples of games in the worlds folder
|
||||
at [/worlds Folder in Archipelago Code](https://github.com/ArchipelagoMW/Archipelago/tree/main/worlds).
|
||||
|
||||
You may also find developer documentation in the docs folder
|
||||
at [/docs Folder in Archipelago Code](https://github.com/ArchipelagoMW/Archipelago/tree/main/docs).
|
||||
|
||||
If you have more questions, feel free to ask in the **#archipelago-dev** channel on our Discord.
|
||||
@@ -26,22 +26,24 @@ window.addEventListener('load', () => {
|
||||
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();
|
||||
});
|
||||
const headers = Array.from(document.querySelectorAll('h1, h2, h3, h4, h5, h6'));
|
||||
const scrollTargetIndex = window.location.href.search(/#[A-z0-9-_]*$/);
|
||||
for (let i=0; i < headers.length; i++){
|
||||
const headerId = headers[i].innerText.replace(/[ ]/g,'-').toLowerCase()
|
||||
headers[i].setAttribute('id', headerId);
|
||||
headers[i].addEventListener('click', () =>
|
||||
window.location.href = window.location.href.substring(0, scrollTargetIndex) + `#${headerId}`);
|
||||
}
|
||||
|
||||
// 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();
|
||||
if (scrollTargetIndex > -1) {
|
||||
try{
|
||||
const scrollTarget = window.location.href.substring(scrollTargetIndex + 1);
|
||||
document.getElementById(scrollTarget).scrollIntoView({ behavior: "smooth" });
|
||||
} catch(error) {
|
||||
console.error(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
}).catch((error) => {
|
||||
console.error(error);
|
||||
gameInfo.innerHTML =
|
||||
|
||||
53
WebHostLib/static/assets/glossary.js
Normal file
@@ -0,0 +1,53 @@
|
||||
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
|
||||
const headers = Array.from(document.querySelectorAll('h1, h2, h3, h4, h5, h6'));
|
||||
const scrollTargetIndex = window.location.href.search(/#[A-z0-9-_]*$/);
|
||||
for (let i=0; i < headers.length; i++){
|
||||
const headerId = headers[i].innerText.replace(/[ ]/g,'-').toLowerCase()
|
||||
headers[i].setAttribute('id', headerId);
|
||||
headers[i].addEventListener('click', () =>
|
||||
window.location.href = window.location.href.substring(0, scrollTargetIndex) + `#${headerId}`);
|
||||
}
|
||||
|
||||
// Manually scroll the user to the appropriate header if anchor navigation is used
|
||||
if (scrollTargetIndex > -1) {
|
||||
try{
|
||||
const scrollTarget = window.location.href.substring(scrollTargetIndex + 1);
|
||||
document.getElementById(scrollTarget).scrollIntoView({ behavior: "smooth" });
|
||||
} catch(error) {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
}).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>`;
|
||||
});
|
||||
});
|
||||
20
WebHostLib/static/assets/lttp-tracker.js
Normal file
@@ -0,0 +1,20 @@
|
||||
window.addEventListener('load', () => {
|
||||
const url = window.location;
|
||||
setInterval(() => {
|
||||
const ajax = new XMLHttpRequest();
|
||||
ajax.onreadystatechange = () => {
|
||||
if (ajax.readyState !== 4) { return; }
|
||||
|
||||
// Create a fake DOM using the returned HTML
|
||||
const domParser = new DOMParser();
|
||||
const fakeDOM = domParser.parseFromString(ajax.responseText, 'text/html');
|
||||
|
||||
// Update item and location trackers
|
||||
document.getElementById('inventory-table').innerHTML = fakeDOM.getElementById('inventory-table').innerHTML;
|
||||
document.getElementById('location-table').innerHTML = fakeDOM.getElementById('location-table').innerHTML;
|
||||
|
||||
};
|
||||
ajax.open('GET', url);
|
||||
ajax.send();
|
||||
}, 15000)
|
||||
});
|
||||
@@ -1,6 +0,0 @@
|
||||
window.addEventListener('load', () => {
|
||||
$(".table-wrapper").scrollsync({
|
||||
y_sync: true,
|
||||
x_sync: true
|
||||
});
|
||||
});
|
||||
318
WebHostLib/static/assets/player-settings.js
Normal file
@@ -0,0 +1,318 @@
|
||||
let gameName = null;
|
||||
|
||||
window.addEventListener('load', () => {
|
||||
gameName = document.getElementById('player-settings').getAttribute('data-game');
|
||||
|
||||
// Update game name on page
|
||||
document.getElementById('game-name').innerText = gameName;
|
||||
|
||||
fetchSettingData().then((results) => {
|
||||
let settingHash = localStorage.getItem(`${gameName}-hash`);
|
||||
if (!settingHash) {
|
||||
// If no hash data has been set before, set it now
|
||||
settingHash = md5(JSON.stringify(results));
|
||||
localStorage.setItem(`${gameName}-hash`, settingHash);
|
||||
localStorage.removeItem(gameName);
|
||||
}
|
||||
|
||||
if (settingHash !== md5(JSON.stringify(results))) {
|
||||
showUserMessage("Your settings are out of date! Click here to update them! Be aware this will reset " +
|
||||
"them all to default.");
|
||||
document.getElementById('user-message').addEventListener('click', resetSettings);
|
||||
}
|
||||
|
||||
// Page setup
|
||||
createDefaultSettings(results);
|
||||
buildUI(results);
|
||||
adjustHeaderWidth();
|
||||
|
||||
// Event listeners
|
||||
document.getElementById('export-settings').addEventListener('click', () => exportSettings());
|
||||
document.getElementById('generate-race').addEventListener('click', () => generateGame(true));
|
||||
document.getElementById('generate-game').addEventListener('click', () => generateGame());
|
||||
|
||||
// Name input field
|
||||
const playerSettings = JSON.parse(localStorage.getItem(gameName));
|
||||
const nameInput = document.getElementById('player-name');
|
||||
nameInput.addEventListener('keyup', (event) => updateBaseSetting(event));
|
||||
nameInput.value = playerSettings.name;
|
||||
}).catch((e) => {
|
||||
console.error(e);
|
||||
const url = new URL(window.location.href);
|
||||
window.location.replace(`${url.protocol}//${url.hostname}/page-not-found`);
|
||||
})
|
||||
});
|
||||
|
||||
const resetSettings = () => {
|
||||
localStorage.removeItem(gameName);
|
||||
localStorage.removeItem(`${gameName}-hash`)
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
const fetchSettingData = () => new Promise((resolve, reject) => {
|
||||
const ajax = new XMLHttpRequest();
|
||||
ajax.onreadystatechange = () => {
|
||||
if (ajax.readyState !== 4) { return; }
|
||||
if (ajax.status !== 200) {
|
||||
reject(ajax.responseText);
|
||||
return;
|
||||
}
|
||||
try{ resolve(JSON.parse(ajax.responseText)); }
|
||||
catch(error){ reject(error); }
|
||||
};
|
||||
ajax.open('GET', `${window.location.origin}/static/generated/player-settings/${gameName}.json`, true);
|
||||
ajax.send();
|
||||
});
|
||||
|
||||
const createDefaultSettings = (settingData) => {
|
||||
if (!localStorage.getItem(gameName)) {
|
||||
const newSettings = {
|
||||
[gameName]: {},
|
||||
};
|
||||
for (let baseOption of Object.keys(settingData.baseOptions)){
|
||||
newSettings[baseOption] = settingData.baseOptions[baseOption];
|
||||
}
|
||||
for (let gameOption of Object.keys(settingData.gameOptions)){
|
||||
newSettings[gameName][gameOption] = settingData.gameOptions[gameOption].defaultValue;
|
||||
}
|
||||
localStorage.setItem(gameName, JSON.stringify(newSettings));
|
||||
}
|
||||
};
|
||||
|
||||
const buildUI = (settingData) => {
|
||||
// Game Options
|
||||
const leftGameOpts = {};
|
||||
const rightGameOpts = {};
|
||||
Object.keys(settingData.gameOptions).forEach((key, index) => {
|
||||
if (index < Object.keys(settingData.gameOptions).length / 2) { leftGameOpts[key] = settingData.gameOptions[key]; }
|
||||
else { rightGameOpts[key] = settingData.gameOptions[key]; }
|
||||
});
|
||||
document.getElementById('game-options-left').appendChild(buildOptionsTable(leftGameOpts));
|
||||
document.getElementById('game-options-right').appendChild(buildOptionsTable(rightGameOpts));
|
||||
};
|
||||
|
||||
const buildOptionsTable = (settings, romOpts = false) => {
|
||||
const currentSettings = JSON.parse(localStorage.getItem(gameName));
|
||||
const table = document.createElement('table');
|
||||
const tbody = document.createElement('tbody');
|
||||
|
||||
Object.keys(settings).forEach((setting) => {
|
||||
const tr = document.createElement('tr');
|
||||
|
||||
// td Left
|
||||
const tdl = document.createElement('td');
|
||||
const label = document.createElement('label');
|
||||
label.textContent = `${settings[setting].displayName}: `;
|
||||
label.setAttribute('for', setting);
|
||||
|
||||
const questionSpan = document.createElement('span');
|
||||
questionSpan.classList.add('interactive');
|
||||
questionSpan.setAttribute('data-tooltip', settings[setting].description);
|
||||
questionSpan.innerText = '(?)';
|
||||
|
||||
label.appendChild(questionSpan);
|
||||
tdl.appendChild(label);
|
||||
tr.appendChild(tdl);
|
||||
|
||||
// td Right
|
||||
const tdr = document.createElement('td');
|
||||
let element = null;
|
||||
|
||||
switch(settings[setting].type){
|
||||
case 'select':
|
||||
element = document.createElement('div');
|
||||
element.classList.add('select-container');
|
||||
let select = document.createElement('select');
|
||||
select.setAttribute('id', setting);
|
||||
select.setAttribute('data-key', setting);
|
||||
if (romOpts) { select.setAttribute('data-romOpt', '1'); }
|
||||
settings[setting].options.forEach((opt) => {
|
||||
const option = document.createElement('option');
|
||||
option.setAttribute('value', opt.value);
|
||||
option.innerText = opt.name;
|
||||
if ((isNaN(currentSettings[gameName][setting]) &&
|
||||
(parseInt(opt.value, 10) === parseInt(currentSettings[gameName][setting]))) ||
|
||||
(opt.value === currentSettings[gameName][setting]))
|
||||
{
|
||||
option.selected = true;
|
||||
}
|
||||
select.appendChild(option);
|
||||
});
|
||||
select.addEventListener('change', (event) => updateGameSetting(event));
|
||||
element.appendChild(select);
|
||||
break;
|
||||
|
||||
case 'range':
|
||||
element = document.createElement('div');
|
||||
element.classList.add('range-container');
|
||||
|
||||
let range = document.createElement('input');
|
||||
range.setAttribute('type', 'range');
|
||||
range.setAttribute('data-key', setting);
|
||||
range.setAttribute('min', settings[setting].min);
|
||||
range.setAttribute('max', settings[setting].max);
|
||||
range.value = currentSettings[gameName][setting];
|
||||
range.addEventListener('change', (event) => {
|
||||
document.getElementById(`${setting}-value`).innerText = event.target.value;
|
||||
updateGameSetting(event);
|
||||
});
|
||||
element.appendChild(range);
|
||||
|
||||
let rangeVal = document.createElement('span');
|
||||
rangeVal.classList.add('range-value');
|
||||
rangeVal.setAttribute('id', `${setting}-value`);
|
||||
rangeVal.innerText = currentSettings[gameName][setting] ?? settings[setting].defaultValue;
|
||||
element.appendChild(rangeVal);
|
||||
break;
|
||||
|
||||
case 'special_range':
|
||||
element = document.createElement('div');
|
||||
element.classList.add('special-range-container');
|
||||
|
||||
// Build the select element
|
||||
let specialRangeSelect = document.createElement('select');
|
||||
specialRangeSelect.setAttribute('data-key', setting);
|
||||
Object.keys(settings[setting].value_names).forEach((presetName) => {
|
||||
let presetOption = document.createElement('option');
|
||||
presetOption.innerText = presetName;
|
||||
presetOption.value = settings[setting].value_names[presetName];
|
||||
specialRangeSelect.appendChild(presetOption);
|
||||
});
|
||||
let customOption = document.createElement('option');
|
||||
customOption.innerText = 'Custom';
|
||||
customOption.value = 'custom';
|
||||
customOption.selected = true;
|
||||
specialRangeSelect.appendChild(customOption);
|
||||
if (Object.values(settings[setting].value_names).includes(Number(currentSettings[gameName][setting]))) {
|
||||
specialRangeSelect.value = Number(currentSettings[gameName][setting]);
|
||||
}
|
||||
|
||||
// Build range element
|
||||
let specialRangeWrapper = document.createElement('div');
|
||||
specialRangeWrapper.classList.add('special-range-wrapper');
|
||||
let specialRange = document.createElement('input');
|
||||
specialRange.setAttribute('type', 'range');
|
||||
specialRange.setAttribute('data-key', setting);
|
||||
specialRange.setAttribute('min', settings[setting].min);
|
||||
specialRange.setAttribute('max', settings[setting].max);
|
||||
specialRange.value = currentSettings[gameName][setting];
|
||||
|
||||
// Build rage value element
|
||||
let specialRangeVal = document.createElement('span');
|
||||
specialRangeVal.classList.add('range-value');
|
||||
specialRangeVal.setAttribute('id', `${setting}-value`);
|
||||
specialRangeVal.innerText = currentSettings[gameName][setting] ?? settings[setting].defaultValue;
|
||||
|
||||
// Configure select event listener
|
||||
specialRangeSelect.addEventListener('change', (event) => {
|
||||
if (event.target.value === 'custom') { return; }
|
||||
|
||||
// Update range slider
|
||||
specialRange.value = event.target.value;
|
||||
document.getElementById(`${setting}-value`).innerText = event.target.value;
|
||||
updateGameSetting(event);
|
||||
});
|
||||
|
||||
// Configure range event handler
|
||||
specialRange.addEventListener('change', (event) => {
|
||||
// Update select element
|
||||
specialRangeSelect.value =
|
||||
(Object.values(settings[setting].value_names).includes(parseInt(event.target.value))) ?
|
||||
parseInt(event.target.value) : 'custom';
|
||||
document.getElementById(`${setting}-value`).innerText = event.target.value;
|
||||
updateGameSetting(event);
|
||||
});
|
||||
|
||||
element.appendChild(specialRangeSelect);
|
||||
specialRangeWrapper.appendChild(specialRange);
|
||||
specialRangeWrapper.appendChild(specialRangeVal);
|
||||
element.appendChild(specialRangeWrapper);
|
||||
break;
|
||||
|
||||
default:
|
||||
console.error(`Ignoring unknown setting type: ${settings[setting].type} with name ${setting}`);
|
||||
return;
|
||||
}
|
||||
|
||||
tdr.appendChild(element);
|
||||
tr.appendChild(tdr);
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
|
||||
table.appendChild(tbody);
|
||||
return table;
|
||||
};
|
||||
|
||||
const updateBaseSetting = (event) => {
|
||||
const options = JSON.parse(localStorage.getItem(gameName));
|
||||
options[event.target.getAttribute('data-key')] = isNaN(event.target.value) ?
|
||||
event.target.value : parseInt(event.target.value);
|
||||
localStorage.setItem(gameName, JSON.stringify(options));
|
||||
};
|
||||
|
||||
const updateGameSetting = (event) => {
|
||||
const options = JSON.parse(localStorage.getItem(gameName));
|
||||
options[gameName][event.target.getAttribute('data-key')] = isNaN(event.target.value) ?
|
||||
event.target.value : parseInt(event.target.value, 10);
|
||||
localStorage.setItem(gameName, JSON.stringify(options));
|
||||
};
|
||||
|
||||
const exportSettings = () => {
|
||||
const settings = JSON.parse(localStorage.getItem(gameName));
|
||||
if (!settings.name || settings.name.toLowerCase() === 'player' || settings.name.trim().length === 0) {
|
||||
return showUserMessage('You must enter a player name!');
|
||||
}
|
||||
const yamlText = jsyaml.safeDump(settings, { noCompatMode: true }).replaceAll(/'(\d+)':/g, (x, y) => `${y}:`);
|
||||
download(`${document.getElementById('player-name').value}.yaml`, yamlText);
|
||||
};
|
||||
|
||||
/** Create an anchor and trigger a download of a text file. */
|
||||
const download = (filename, text) => {
|
||||
const downloadLink = document.createElement('a');
|
||||
downloadLink.setAttribute('href','data:text/yaml;charset=utf-8,'+ encodeURIComponent(text))
|
||||
downloadLink.setAttribute('download', filename);
|
||||
downloadLink.style.display = 'none';
|
||||
document.body.appendChild(downloadLink);
|
||||
downloadLink.click();
|
||||
document.body.removeChild(downloadLink);
|
||||
};
|
||||
|
||||
const generateGame = (raceMode = false) => {
|
||||
const settings = JSON.parse(localStorage.getItem(gameName));
|
||||
if (!settings.name || settings.name.toLowerCase() === 'player' || settings.name.trim().length === 0) {
|
||||
return showUserMessage('You must enter a player name!');
|
||||
}
|
||||
|
||||
axios.post('/api/generate', {
|
||||
weights: { player: settings },
|
||||
presetData: { player: settings },
|
||||
playerCount: 1,
|
||||
race: raceMode ? '1' : '0',
|
||||
}).then((response) => {
|
||||
window.location.href = response.data.url;
|
||||
}).catch((error) => {
|
||||
let userMessage = 'Something went wrong and your game could not be generated.';
|
||||
if (error.response.data.text) {
|
||||
userMessage += ' ' + error.response.data.text;
|
||||
}
|
||||
showUserMessage(userMessage);
|
||||
console.error(error);
|
||||
});
|
||||
};
|
||||
|
||||
const showUserMessage = (message) => {
|
||||
const userMessage = document.getElementById('user-message');
|
||||
userMessage.innerText = message;
|
||||
userMessage.classList.add('visible');
|
||||
window.scrollTo(0, 0);
|
||||
userMessage.addEventListener('click', () => {
|
||||
userMessage.classList.remove('visible');
|
||||
userMessage.addEventListener('click', hideUserMessage);
|
||||
});
|
||||
};
|
||||
|
||||
const hideUserMessage = () => {
|
||||
const userMessage = document.getElementById('user-message');
|
||||
userMessage.classList.remove('visible');
|
||||
userMessage.removeEventListener('click', hideUserMessage);
|
||||
};
|
||||
@@ -1,340 +0,0 @@
|
||||
let presets = {};
|
||||
|
||||
window.addEventListener('load', async () => {
|
||||
// Load settings from localStorage, if available
|
||||
loadSettings();
|
||||
|
||||
// Fetch presets if available
|
||||
await fetchPresets();
|
||||
|
||||
// Handle changes to range inputs
|
||||
document.querySelectorAll('input[type=range]').forEach((range) => {
|
||||
const optionName = range.getAttribute('id');
|
||||
range.addEventListener('change', () => {
|
||||
document.getElementById(`${optionName}-value`).innerText = range.value;
|
||||
|
||||
// Handle updating named range selects to "custom" if appropriate
|
||||
const select = document.querySelector(`select[data-option-name=${optionName}]`);
|
||||
if (select) {
|
||||
let updated = false;
|
||||
select?.childNodes.forEach((option) => {
|
||||
if (option.value === range.value) {
|
||||
select.value = range.value;
|
||||
updated = true;
|
||||
}
|
||||
});
|
||||
if (!updated) {
|
||||
select.value = 'custom';
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Handle changes to named range selects
|
||||
document.querySelectorAll('.named-range-container select').forEach((select) => {
|
||||
const optionName = select.getAttribute('data-option-name');
|
||||
select.addEventListener('change', (evt) => {
|
||||
document.getElementById(optionName).value = evt.target.value;
|
||||
document.getElementById(`${optionName}-value`).innerText = evt.target.value;
|
||||
});
|
||||
});
|
||||
|
||||
// Handle changes to randomize checkboxes
|
||||
document.querySelectorAll('.randomize-checkbox').forEach((checkbox) => {
|
||||
const optionName = checkbox.getAttribute('data-option-name');
|
||||
checkbox.addEventListener('change', () => {
|
||||
const optionInput = document.getElementById(optionName);
|
||||
const namedRangeSelect = document.querySelector(`select[data-option-name=${optionName}]`);
|
||||
const customInput = document.getElementById(`${optionName}-custom`);
|
||||
if (checkbox.checked) {
|
||||
optionInput.setAttribute('disabled', '1');
|
||||
namedRangeSelect?.setAttribute('disabled', '1');
|
||||
if (customInput) {
|
||||
customInput.setAttribute('disabled', '1');
|
||||
}
|
||||
} else {
|
||||
optionInput.removeAttribute('disabled');
|
||||
namedRangeSelect?.removeAttribute('disabled');
|
||||
if (customInput) {
|
||||
customInput.removeAttribute('disabled');
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Handle changes to TextChoice input[type=text]
|
||||
document.querySelectorAll('.text-choice-container input[type=text]').forEach((input) => {
|
||||
const optionName = input.getAttribute('data-option-name');
|
||||
input.addEventListener('input', () => {
|
||||
const select = document.getElementById(optionName);
|
||||
const optionValues = [];
|
||||
select.childNodes.forEach((option) => optionValues.push(option.value));
|
||||
select.value = (optionValues.includes(input.value)) ? input.value : 'custom';
|
||||
});
|
||||
});
|
||||
|
||||
// Handle changes to TextChoice select
|
||||
document.querySelectorAll('.text-choice-container select').forEach((select) => {
|
||||
const optionName = select.getAttribute('id');
|
||||
select.addEventListener('change', () => {
|
||||
document.getElementById(`${optionName}-custom`).value = '';
|
||||
});
|
||||
});
|
||||
|
||||
// Update the "Option Preset" select to read "custom" when changes are made to relevant inputs
|
||||
const presetSelect = document.getElementById('game-options-preset');
|
||||
document.querySelectorAll('input, select').forEach((input) => {
|
||||
if ( // Ignore inputs which have no effect on yaml generation
|
||||
(input.id === 'player-name') ||
|
||||
(input.id === 'game-options-preset') ||
|
||||
(input.classList.contains('group-toggle')) ||
|
||||
(input.type === 'submit')
|
||||
) {
|
||||
return;
|
||||
}
|
||||
input.addEventListener('change', () => {
|
||||
presetSelect.value = 'custom';
|
||||
});
|
||||
});
|
||||
|
||||
// Handle changes to presets select
|
||||
document.getElementById('game-options-preset').addEventListener('change', choosePreset);
|
||||
|
||||
// Save settings to localStorage when form is submitted
|
||||
document.getElementById('options-form').addEventListener('submit', (evt) => {
|
||||
const playerName = document.getElementById('player-name');
|
||||
if (!playerName.value.trim()) {
|
||||
evt.preventDefault();
|
||||
window.scrollTo(0, 0);
|
||||
showUserMessage('You must enter a player name!');
|
||||
}
|
||||
|
||||
saveSettings();
|
||||
});
|
||||
});
|
||||
|
||||
// Save all settings to localStorage
|
||||
const saveSettings = () => {
|
||||
const options = {
|
||||
inputs: {},
|
||||
checkboxes: {},
|
||||
};
|
||||
document.querySelectorAll('input, select').forEach((input) => {
|
||||
if (input.type === 'submit') {
|
||||
// Ignore submit inputs
|
||||
}
|
||||
else if (input.type === 'checkbox') {
|
||||
options.checkboxes[input.id] = input.checked;
|
||||
}
|
||||
else {
|
||||
options.inputs[input.id] = input.value
|
||||
}
|
||||
});
|
||||
const game = document.getElementById('player-options').getAttribute('data-game');
|
||||
localStorage.setItem(game, JSON.stringify(options));
|
||||
};
|
||||
|
||||
// Load all options from localStorage
|
||||
const loadSettings = () => {
|
||||
const game = document.getElementById('player-options').getAttribute('data-game');
|
||||
|
||||
const options = JSON.parse(localStorage.getItem(game));
|
||||
if (options) {
|
||||
if (!options.inputs || !options.checkboxes) {
|
||||
localStorage.removeItem(game);
|
||||
return;
|
||||
}
|
||||
|
||||
// Restore value-based inputs and selects
|
||||
Object.keys(options.inputs).forEach((key) => {
|
||||
try{
|
||||
document.getElementById(key).value = options.inputs[key];
|
||||
const rangeValue = document.getElementById(`${key}-value`);
|
||||
if (rangeValue) {
|
||||
rangeValue.innerText = options.inputs[key];
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Unable to restore value to input with id ${key}`);
|
||||
}
|
||||
});
|
||||
|
||||
// Restore checkboxes
|
||||
Object.keys(options.checkboxes).forEach((key) => {
|
||||
try{
|
||||
if (options.checkboxes[key]) {
|
||||
document.getElementById(key).setAttribute('checked', '1');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Unable to restore value to input with id ${key}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Ensure any input for which the randomize checkbox is checked by default, the relevant inputs are disabled
|
||||
document.querySelectorAll('.randomize-checkbox').forEach((checkbox) => {
|
||||
const optionName = checkbox.getAttribute('data-option-name');
|
||||
if (checkbox.checked) {
|
||||
const input = document.getElementById(optionName);
|
||||
if (input) {
|
||||
input.setAttribute('disabled', '1');
|
||||
}
|
||||
const customInput = document.getElementById(`${optionName}-custom`);
|
||||
if (customInput) {
|
||||
customInput.setAttribute('disabled', '1');
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetch the preset data for this game and apply the presets if localStorage indicates one was previously chosen
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const fetchPresets = async () => {
|
||||
const response = await fetch('option-presets');
|
||||
presets = await response.json();
|
||||
const presetSelect = document.getElementById('game-options-preset');
|
||||
presetSelect.removeAttribute('disabled');
|
||||
|
||||
const game = document.getElementById('player-options').getAttribute('data-game');
|
||||
const presetToApply = localStorage.getItem(`${game}-preset`);
|
||||
const playerName = localStorage.getItem(`${game}-player`);
|
||||
if (presetToApply) {
|
||||
localStorage.removeItem(`${game}-preset`);
|
||||
presetSelect.value = presetToApply;
|
||||
applyPresets(presetToApply);
|
||||
}
|
||||
|
||||
if (playerName) {
|
||||
document.getElementById('player-name').value = playerName;
|
||||
localStorage.removeItem(`${game}-player`);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Clear the localStorage for this game and set a preset to be loaded upon page reload
|
||||
* @param evt
|
||||
*/
|
||||
const choosePreset = (evt) => {
|
||||
if (evt.target.value === 'custom') { return; }
|
||||
|
||||
const game = document.getElementById('player-options').getAttribute('data-game');
|
||||
localStorage.removeItem(game);
|
||||
|
||||
localStorage.setItem(`${game}-player`, document.getElementById('player-name').value);
|
||||
if (evt.target.value !== 'default') {
|
||||
localStorage.setItem(`${game}-preset`, evt.target.value);
|
||||
}
|
||||
|
||||
document.querySelectorAll('#options-form input, #options-form select').forEach((input) => {
|
||||
if (input.id === 'player-name') { return; }
|
||||
input.removeAttribute('value');
|
||||
});
|
||||
|
||||
window.location.replace(window.location.href);
|
||||
};
|
||||
|
||||
const applyPresets = (presetName) => {
|
||||
// Ignore the "default" preset, because it gets set automatically by Jinja
|
||||
if (presetName === 'default') {
|
||||
saveSettings();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!presets[presetName]) {
|
||||
console.error(`Unknown preset ${presetName} chosen`);
|
||||
return;
|
||||
}
|
||||
|
||||
const preset = presets[presetName];
|
||||
Object.keys(preset).forEach((optionName) => {
|
||||
const optionValue = preset[optionName];
|
||||
|
||||
// Handle List and Set options
|
||||
if (Array.isArray(optionValue)) {
|
||||
document.querySelectorAll(`input[type=checkbox][name=${optionName}]`).forEach((checkbox) => {
|
||||
if (optionValue.includes(checkbox.value)) {
|
||||
checkbox.setAttribute('checked', '1');
|
||||
} else {
|
||||
checkbox.removeAttribute('checked');
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle Dict options
|
||||
if (typeof(optionValue) === 'object' && optionValue !== null) {
|
||||
const itemNames = Object.keys(optionValue);
|
||||
document.querySelectorAll(`input[type=number][data-option-name=${optionName}]`).forEach((input) => {
|
||||
const itemName = input.getAttribute('data-item-name');
|
||||
input.value = (itemNames.includes(itemName)) ? optionValue[itemName] : 0
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Identify all possible elements
|
||||
const normalInput = document.getElementById(optionName);
|
||||
const customInput = document.getElementById(`${optionName}-custom`);
|
||||
const rangeValue = document.getElementById(`${optionName}-value`);
|
||||
const randomizeInput = document.getElementById(`random-${optionName}`);
|
||||
const namedRangeSelect = document.getElementById(`${optionName}-select`);
|
||||
|
||||
// It is possible for named ranges to use name of a value rather than the value itself. This is accounted for here
|
||||
let trueValue = optionValue;
|
||||
if (namedRangeSelect) {
|
||||
namedRangeSelect.querySelectorAll('option').forEach((opt) => {
|
||||
if (opt.innerText.startsWith(optionValue)) {
|
||||
trueValue = opt.value;
|
||||
}
|
||||
});
|
||||
namedRangeSelect.value = trueValue;
|
||||
// It is also possible for a preset to use an unnamed value. If this happens, set the dropdown to "Custom"
|
||||
if (namedRangeSelect.selectedIndex == -1)
|
||||
{
|
||||
namedRangeSelect.value = "custom";
|
||||
}
|
||||
}
|
||||
|
||||
// Handle options whose presets are "random"
|
||||
if (optionValue === 'random') {
|
||||
normalInput.setAttribute('disabled', '1');
|
||||
randomizeInput.setAttribute('checked', '1');
|
||||
if (customInput) {
|
||||
customInput.setAttribute('disabled', '1');
|
||||
}
|
||||
if (rangeValue) {
|
||||
rangeValue.innerText = normalInput.value;
|
||||
}
|
||||
if (namedRangeSelect) {
|
||||
namedRangeSelect.setAttribute('disabled', '1');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle normal (text, number, select, etc.) and custom inputs (custom inputs exist with TextChoice only)
|
||||
normalInput.value = trueValue;
|
||||
normalInput.removeAttribute('disabled');
|
||||
randomizeInput.removeAttribute('checked');
|
||||
if (customInput) {
|
||||
document.getElementById(`${optionName}-custom`).removeAttribute('disabled');
|
||||
}
|
||||
if (rangeValue) {
|
||||
rangeValue.innerText = trueValue;
|
||||
}
|
||||
});
|
||||
|
||||
saveSettings();
|
||||
};
|
||||
|
||||
const showUserMessage = (text) => {
|
||||
const userMessage = document.getElementById('user-message');
|
||||
userMessage.innerText = text;
|
||||
userMessage.addEventListener('click', hideUserMessage);
|
||||
userMessage.style.display = 'block';
|
||||
};
|
||||
|
||||
const hideUserMessage = () => {
|
||||
const userMessage = document.getElementById('user-message');
|
||||
userMessage.removeEventListener('click', hideUserMessage);
|
||||
userMessage.style.display = 'none';
|
||||
};
|
||||
@@ -1,49 +0,0 @@
|
||||
window.addEventListener('load', () => {
|
||||
// Reload tracker every 15 seconds
|
||||
const url = window.location;
|
||||
setInterval(() => {
|
||||
const ajax = new XMLHttpRequest();
|
||||
ajax.onreadystatechange = () => {
|
||||
if (ajax.readyState !== 4) { return; }
|
||||
|
||||
// Create a fake DOM using the returned HTML
|
||||
const domParser = new DOMParser();
|
||||
const fakeDOM = domParser.parseFromString(ajax.responseText, 'text/html');
|
||||
|
||||
// Update item tracker
|
||||
document.getElementById('inventory-table').innerHTML = fakeDOM.getElementById('inventory-table').innerHTML;
|
||||
// Update only counters in the location-table
|
||||
let counters = document.getElementsByClassName('counter');
|
||||
const fakeCounters = fakeDOM.getElementsByClassName('counter');
|
||||
for (let i = 0; i < counters.length; i++) {
|
||||
counters[i].innerHTML = fakeCounters[i].innerHTML;
|
||||
}
|
||||
};
|
||||
ajax.open('GET', url);
|
||||
ajax.send();
|
||||
}, 15000)
|
||||
|
||||
// Collapsible advancement sections
|
||||
const categories = document.getElementsByClassName("location-category");
|
||||
for (let category of categories) {
|
||||
let hide_id = category.id.split('_')[0];
|
||||
if (hide_id === 'Total') {
|
||||
continue;
|
||||
}
|
||||
category.addEventListener('click', function() {
|
||||
// Toggle the advancement list
|
||||
document.getElementById(hide_id).classList.toggle("hide");
|
||||
// Change text of the header
|
||||
const tab_header = document.getElementById(hide_id+'_header').children[0];
|
||||
const orig_text = tab_header.innerHTML;
|
||||
let new_text;
|
||||
if (orig_text.includes("▼")) {
|
||||
new_text = orig_text.replace("▼", "▲");
|
||||
}
|
||||
else {
|
||||
new_text = orig_text.replace("▲", "▼");
|
||||
}
|
||||
tab_header.innerHTML = new_text;
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -1,44 +0,0 @@
|
||||
window.addEventListener('load', () => {
|
||||
// Add toggle listener to all elements with .collapse-toggle
|
||||
const toggleButtons = document.querySelectorAll('details');
|
||||
|
||||
// Handle game filter input
|
||||
const gameSearch = document.getElementById('game-search');
|
||||
gameSearch.value = '';
|
||||
gameSearch.addEventListener('input', (evt) => {
|
||||
if (!evt.target.value.trim()) {
|
||||
// If input is empty, display all games as collapsed
|
||||
return toggleButtons.forEach((header) => {
|
||||
header.style.display = null;
|
||||
header.removeAttribute('open');
|
||||
});
|
||||
}
|
||||
|
||||
// Loop over all the games
|
||||
toggleButtons.forEach((header) => {
|
||||
// If the game name includes the search string, display the game. If not, hide it
|
||||
if (header.getAttribute('data-game').toLowerCase().includes(evt.target.value.toLowerCase())) {
|
||||
header.style.display = null;
|
||||
header.setAttribute('open', '1');
|
||||
} else {
|
||||
header.style.display = 'none';
|
||||
header.removeAttribute('open');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById('expand-all').addEventListener('click', expandAll);
|
||||
document.getElementById('collapse-all').addEventListener('click', collapseAll);
|
||||
});
|
||||
|
||||
const expandAll = () => {
|
||||
document.querySelectorAll('details').forEach((detail) => {
|
||||
detail.setAttribute('open', '1');
|
||||
});
|
||||
};
|
||||
|
||||
const collapseAll = () => {
|
||||
document.querySelectorAll('details').forEach((detail) => {
|
||||
detail.removeAttribute('open');
|
||||
});
|
||||
};
|
||||
108
WebHostLib/static/assets/tracker.js
Normal file
@@ -0,0 +1,108 @@
|
||||
const adjustTableHeight = () => {
|
||||
const tablesContainer = document.getElementById('tables-container');
|
||||
const upperDistance = tablesContainer.getBoundingClientRect().top;
|
||||
|
||||
const containerHeight = window.innerHeight - upperDistance;
|
||||
tablesContainer.style.maxHeight = `calc(${containerHeight}px - 1rem)`;
|
||||
|
||||
const tableWrappers = document.getElementsByClassName('table-wrapper');
|
||||
for(let i=0; i < tableWrappers.length; i++){
|
||||
const maxHeight = (window.innerHeight - upperDistance) / 2;
|
||||
tableWrappers[i].style.maxHeight = `calc(${maxHeight}px - 1rem)`;
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('load', () => {
|
||||
const tables = $(".table").DataTable({
|
||||
paging: false,
|
||||
info: false,
|
||||
dom: "t",
|
||||
columnDefs: [
|
||||
{
|
||||
targets: 'hours',
|
||||
render: function (data, type, row) {
|
||||
if (type === "sort" || type === 'type') {
|
||||
if (data === "None")
|
||||
return -1;
|
||||
|
||||
return parseInt(data);
|
||||
}
|
||||
if (data === "None")
|
||||
return data;
|
||||
|
||||
let hours = Math.floor(data / 3600);
|
||||
let minutes = Math.floor((data - (hours * 3600)) / 60);
|
||||
|
||||
if (minutes < 10) {minutes = "0"+minutes;}
|
||||
return hours+':'+minutes;
|
||||
}
|
||||
},
|
||||
{
|
||||
targets: 'number',
|
||||
render: function (data, type, row) {
|
||||
if (type === "sort" || type === 'type') {
|
||||
return parseFloat(data);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
},
|
||||
{
|
||||
targets: 'fraction',
|
||||
render: function (data, type, row) {
|
||||
let splitted = data.split("/", 1);
|
||||
let current = splitted[0]
|
||||
if (type === "sort" || type === 'type') {
|
||||
return parseInt(current);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
},
|
||||
],
|
||||
|
||||
// DO NOT use the scrollX or scrollY options. They cause DataTables to split the thead from
|
||||
// the tbody and render two separate tables.
|
||||
});
|
||||
|
||||
document.getElementById('search').addEventListener('keyup', (event) => {
|
||||
tables.search(event.target.value);
|
||||
console.info(tables.search());
|
||||
tables.draw();
|
||||
});
|
||||
|
||||
const update = () => {
|
||||
const target = $("<div></div>");
|
||||
const tracker = document.getElementById('tracker-wrapper').getAttribute('data-tracker');
|
||||
target.load("/tracker/" + tracker, function (response, status) {
|
||||
if (status === "success") {
|
||||
target.find(".table").each(function (i, new_table) {
|
||||
const new_trs = $(new_table).find("tbody>tr");
|
||||
const old_table = tables.eq(i);
|
||||
const topscroll = $(old_table.settings()[0].nScrollBody).scrollTop();
|
||||
const leftscroll = $(old_table.settings()[0].nScrollBody).scrollLeft();
|
||||
old_table.clear();
|
||||
old_table.rows.add(new_trs).draw();
|
||||
$(old_table.settings()[0].nScrollBody).scrollTop(topscroll);
|
||||
$(old_table.settings()[0].nScrollBody).scrollLeft(leftscroll);
|
||||
});
|
||||
$("#multi-stream-link").replaceWith(target.find("#multi-stream-link"));
|
||||
} else {
|
||||
console.log("Failed to connect to Server, in order to update Table Data.");
|
||||
console.log(response);
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
setInterval(update, 30000);
|
||||
|
||||
window.addEventListener('resize', () => {
|
||||
adjustTableHeight();
|
||||
tables.draw();
|
||||
});
|
||||
|
||||
$(".table-wrapper").scrollsync({
|
||||
y_sync: true,
|
||||
x_sync: true
|
||||
});
|
||||
|
||||
adjustTableHeight();
|
||||
});
|
||||
@@ -1,186 +0,0 @@
|
||||
const adjustTableHeight = () => {
|
||||
const tablesContainer = document.getElementById('tables-container');
|
||||
if (!tablesContainer)
|
||||
return;
|
||||
const upperDistance = tablesContainer.getBoundingClientRect().top;
|
||||
|
||||
const tableWrappers = document.getElementsByClassName('table-wrapper');
|
||||
for (let i = 0; i < tableWrappers.length; i++) {
|
||||
// Ensure we are starting from maximum size prior to calculation.
|
||||
tableWrappers[i].style.height = null;
|
||||
tableWrappers[i].style.maxHeight = null;
|
||||
|
||||
// Set as a reasonable height, but still allows the user to resize element if they desire.
|
||||
const currentHeight = tableWrappers[i].offsetHeight;
|
||||
const maxHeight = (window.innerHeight - upperDistance) / Math.min(tableWrappers.length, 4);
|
||||
if (currentHeight > maxHeight) {
|
||||
tableWrappers[i].style.height = `calc(${maxHeight}px - 1rem)`;
|
||||
}
|
||||
|
||||
tableWrappers[i].style.maxHeight = `${currentHeight}px`;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Convert an integer number of seconds into a human readable HH:MM format
|
||||
* @param {Number} seconds
|
||||
* @returns {string}
|
||||
*/
|
||||
const secondsToHours = (seconds) => {
|
||||
let hours = Math.floor(seconds / 3600);
|
||||
let minutes = Math.floor((seconds - (hours * 3600)) / 60).toString().padStart(2, '0');
|
||||
return `${hours}:${minutes}`;
|
||||
};
|
||||
|
||||
window.addEventListener('load', () => {
|
||||
const tables = $(".table").DataTable({
|
||||
paging: false,
|
||||
info: false,
|
||||
dom: "t",
|
||||
stateSave: true,
|
||||
stateSaveCallback: function (settings, data) {
|
||||
delete data.search;
|
||||
localStorage.setItem(`DataTables_${settings.sInstance}_/tracker`, JSON.stringify(data));
|
||||
},
|
||||
stateLoadCallback: function (settings) {
|
||||
return JSON.parse(localStorage.getItem(`DataTables_${settings.sInstance}_/tracker`));
|
||||
},
|
||||
footerCallback: function (tfoot, data, start, end, display) {
|
||||
if (tfoot) {
|
||||
const activityData = this.api().column('lastActivity:name').data().toArray().filter(x => !isNaN(x));
|
||||
Array.from(tfoot?.children).find(td => td.classList.contains('last-activity')).innerText =
|
||||
(activityData.length) ? secondsToHours(Math.min(...activityData)) : 'None';
|
||||
}
|
||||
},
|
||||
columnDefs: [
|
||||
{
|
||||
targets: 'last-activity',
|
||||
name: 'lastActivity'
|
||||
},
|
||||
{
|
||||
targets: 'hours',
|
||||
render: function (data, type, row) {
|
||||
if (type === "sort" || type === 'type') {
|
||||
if (data === "None")
|
||||
return Number.MAX_VALUE;
|
||||
|
||||
return parseInt(data);
|
||||
}
|
||||
if (data === "None")
|
||||
return data;
|
||||
|
||||
return secondsToHours(data);
|
||||
}
|
||||
},
|
||||
{
|
||||
targets: 'number',
|
||||
render: function (data, type, row) {
|
||||
if (type === "sort" || type === 'type') {
|
||||
return parseFloat(data);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
},
|
||||
{
|
||||
targets: 'fraction',
|
||||
render: function (data, type, row) {
|
||||
let splitted = data.split("/", 1);
|
||||
let current = splitted[0]
|
||||
if (type === "sort" || type === 'type') {
|
||||
return parseInt(current);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
},
|
||||
],
|
||||
|
||||
// DO NOT use the scrollX or scrollY options. They cause DataTables to split the thead from
|
||||
// the tbody and render two separate tables.
|
||||
});
|
||||
|
||||
const searchBox = document.getElementById("search");
|
||||
searchBox.value = tables.search();
|
||||
searchBox.focus();
|
||||
searchBox.select();
|
||||
const doSearch = () => {
|
||||
tables.search(searchBox.value);
|
||||
tables.draw();
|
||||
};
|
||||
searchBox.addEventListener("keyup", doSearch);
|
||||
window.addEventListener("keydown", (event) => {
|
||||
if (!event.ctrlKey && !event.altKey && event.key.length === 1 && document.activeElement !== searchBox) {
|
||||
searchBox.focus();
|
||||
searchBox.select();
|
||||
}
|
||||
if (!event.ctrlKey && !event.altKey && !event.shiftKey && event.key === "Escape") {
|
||||
if (searchBox.value !== "") {
|
||||
searchBox.value = "";
|
||||
doSearch();
|
||||
}
|
||||
searchBox.blur();
|
||||
if (!document.getElementById("tables-container"))
|
||||
window.scroll(0, 0);
|
||||
event.preventDefault();
|
||||
}
|
||||
});
|
||||
const target_second = parseInt(document.getElementById('tracker-wrapper').getAttribute('data-second')) + 3;
|
||||
console.log("Target second of refresh: " + target_second);
|
||||
|
||||
function getSleepTimeSeconds() {
|
||||
// -40 % 60 is -40, which is absolutely wrong and should burn
|
||||
var sleepSeconds = (((target_second - new Date().getSeconds()) % 60) + 60) % 60;
|
||||
return sleepSeconds || 60;
|
||||
}
|
||||
|
||||
let update_on_view = false;
|
||||
const update = () => {
|
||||
if (document.hidden) {
|
||||
console.log("Document reporting as not visible, not updating Tracker...");
|
||||
update_on_view = true;
|
||||
} else {
|
||||
update_on_view = false;
|
||||
const target = $("<div></div>");
|
||||
console.log("Updating Tracker...");
|
||||
target.load(location.href, function (response, status) {
|
||||
if (status === "success") {
|
||||
target.find(".table").each(function (i, new_table) {
|
||||
const new_trs = $(new_table).find("tbody>tr");
|
||||
const footer_tr = $(new_table).find("tfoot>tr");
|
||||
const old_table = tables.eq(i);
|
||||
const topscroll = $(old_table.settings()[0].nScrollBody).scrollTop();
|
||||
const leftscroll = $(old_table.settings()[0].nScrollBody).scrollLeft();
|
||||
old_table.clear();
|
||||
if (footer_tr.length) {
|
||||
$(old_table.table).find("tfoot").html(footer_tr);
|
||||
}
|
||||
old_table.rows.add(new_trs);
|
||||
old_table.draw();
|
||||
$(old_table.settings()[0].nScrollBody).scrollTop(topscroll);
|
||||
$(old_table.settings()[0].nScrollBody).scrollLeft(leftscroll);
|
||||
});
|
||||
$("#multi-stream-link").replaceWith(target.find("#multi-stream-link"));
|
||||
} else {
|
||||
console.log("Failed to connect to Server, in order to update Table Data.");
|
||||
console.log(response);
|
||||
}
|
||||
})
|
||||
}
|
||||
updater = setTimeout(update, getSleepTimeSeconds() * 1000);
|
||||
}
|
||||
let updater = setTimeout(update, getSleepTimeSeconds() * 1000);
|
||||
|
||||
window.addEventListener('resize', () => {
|
||||
adjustTableHeight();
|
||||
tables.draw();
|
||||
});
|
||||
|
||||
window.addEventListener('visibilitychange', () => {
|
||||
if (!document.hidden && update_on_view) {
|
||||
console.log("Page became visible, tracker should be refreshed.");
|
||||
clearTimeout(updater);
|
||||
update();
|
||||
}
|
||||
});
|
||||
|
||||
adjustTableHeight();
|
||||
});
|
||||
@@ -27,28 +27,25 @@ window.addEventListener('load', () => {
|
||||
tutorialWrapper.innerHTML += (new showdown.Converter()).makeHtml(results);
|
||||
adjustHeaderWidth();
|
||||
|
||||
const title = document.querySelector('h1')
|
||||
if (title) {
|
||||
document.title = title.textContent;
|
||||
}
|
||||
|
||||
// Reset the id of all header divs to something nicer
|
||||
for (const header of document.querySelectorAll('h1, h2, h3, h4, h5, h6')) {
|
||||
const headerId = header.innerText.replace(/\s+/g, '-').toLowerCase();
|
||||
header.setAttribute('id', headerId);
|
||||
header.addEventListener('click', () => {
|
||||
window.location.hash = `#${headerId}`;
|
||||
header.scrollIntoView();
|
||||
});
|
||||
const headers = Array.from(document.querySelectorAll('h1, h2, h3, h4, h5, h6'));
|
||||
const scrollTargetIndex = window.location.href.search(/#[A-z0-9-_]*$/);
|
||||
for (let i=0; i < headers.length; i++){
|
||||
const headerId = headers[i].innerText.replace(/[ ]/g,'-').toLowerCase()
|
||||
headers[i].setAttribute('id', headerId);
|
||||
headers[i].addEventListener('click', () =>
|
||||
window.location.href = window.location.href.substring(0, scrollTargetIndex) + `#${headerId}`);
|
||||
}
|
||||
|
||||
// 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();
|
||||
if (scrollTargetIndex > -1) {
|
||||
try{
|
||||
const scrollTarget = window.location.href.substring(scrollTargetIndex + 1);
|
||||
document.getElementById(scrollTarget).scrollIntoView({ behavior: "smooth" });
|
||||
} catch(error) {
|
||||
console.error(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
}).catch((error) => {
|
||||
console.error(error);
|
||||
tutorialWrapper.innerHTML =
|
||||
|
||||
@@ -6,7 +6,6 @@ window.addEventListener('load', () => {
|
||||
"order": [[ 3, "desc" ]],
|
||||
"info": false,
|
||||
"dom": "t",
|
||||
"stateSave": true,
|
||||
});
|
||||
$("#seeds-table").DataTable({
|
||||
"paging": false,
|
||||
@@ -14,6 +13,5 @@ window.addEventListener('load', () => {
|
||||
"order": [[ 2, "desc" ]],
|
||||
"info": false,
|
||||
"dom": "t",
|
||||
"stateSave": true,
|
||||
});
|
||||
});
|
||||
|
||||
1032
WebHostLib/static/assets/weighted-settings.js
Normal file
@@ -1,223 +0,0 @@
|
||||
let deletedOptions = {};
|
||||
|
||||
window.addEventListener('load', () => {
|
||||
const worldName = document.querySelector('#weighted-options').getAttribute('data-game');
|
||||
|
||||
// Generic change listener. Detecting unique qualities and acting on them here reduces initial JS initialisation time
|
||||
// and handles dynamically created elements
|
||||
document.addEventListener('change', (evt) => {
|
||||
// Handle updates to range inputs
|
||||
if (evt.target.type === 'range') {
|
||||
// Update span containing range value. All ranges have a corresponding `{rangeId}-value` span
|
||||
document.getElementById(`${evt.target.id}-value`).innerText = evt.target.value;
|
||||
|
||||
// If the changed option was the name of a game, determine whether to show or hide that game's div
|
||||
if (evt.target.id.startsWith('game||')) {
|
||||
const gameName = evt.target.id.split('||')[1];
|
||||
const gameDiv = document.getElementById(`${gameName}-container`);
|
||||
if (evt.target.value > 0) {
|
||||
gameDiv.classList.remove('hidden');
|
||||
} else {
|
||||
gameDiv.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Generic click listener
|
||||
document.addEventListener('click', (evt) => {
|
||||
// Handle creating new rows for Range options
|
||||
if (evt.target.classList.contains('add-range-option-button')) {
|
||||
const optionName = evt.target.getAttribute('data-option');
|
||||
addRangeRow(optionName);
|
||||
}
|
||||
|
||||
// Handle deleting range rows
|
||||
if (evt.target.classList.contains('range-option-delete')) {
|
||||
const targetRow = document.querySelector(`tr[data-row="${evt.target.getAttribute('data-target')}"]`);
|
||||
setDeletedOption(
|
||||
targetRow.getAttribute('data-option-name'),
|
||||
targetRow.getAttribute('data-value'),
|
||||
);
|
||||
targetRow.parentElement.removeChild(targetRow);
|
||||
}
|
||||
});
|
||||
|
||||
// Listen for enter presses on inputs intended to add range rows
|
||||
document.addEventListener('keydown', (evt) => {
|
||||
if (evt.key === 'Enter') {
|
||||
evt.preventDefault();
|
||||
}
|
||||
|
||||
if (evt.key === 'Enter' && evt.target.classList.contains('range-option-value')) {
|
||||
const optionName = evt.target.getAttribute('data-option');
|
||||
addRangeRow(optionName);
|
||||
}
|
||||
});
|
||||
|
||||
// Detect form submission
|
||||
document.getElementById('weighted-options-form').addEventListener('submit', (evt) => {
|
||||
// Save data to localStorage
|
||||
const weightedOptions = {};
|
||||
document.querySelectorAll('input[name]').forEach((input) => {
|
||||
const keys = input.getAttribute('name').split('||');
|
||||
|
||||
// Determine keys
|
||||
const optionName = keys[0] ?? null;
|
||||
const subOption = keys[1] ?? null;
|
||||
|
||||
// Ensure keys exist
|
||||
if (!weightedOptions[optionName]) { weightedOptions[optionName] = {}; }
|
||||
if (subOption && !weightedOptions[optionName][subOption]) {
|
||||
weightedOptions[optionName][subOption] = null;
|
||||
}
|
||||
|
||||
if (subOption) { return weightedOptions[optionName][subOption] = determineValue(input); }
|
||||
if (optionName) { return weightedOptions[optionName] = determineValue(input); }
|
||||
});
|
||||
|
||||
localStorage.setItem(`${worldName}-weights`, JSON.stringify(weightedOptions));
|
||||
localStorage.setItem(`${worldName}-deletedOptions`, JSON.stringify(deletedOptions));
|
||||
});
|
||||
|
||||
// Remove all deleted values as specified by localStorage
|
||||
deletedOptions = JSON.parse(localStorage.getItem(`${worldName}-deletedOptions`) || '{}');
|
||||
Object.keys(deletedOptions).forEach((optionName) => {
|
||||
deletedOptions[optionName].forEach((value) => {
|
||||
const targetRow = document.querySelector(`tr[data-row="${value}-row"]`);
|
||||
targetRow.parentElement.removeChild(targetRow);
|
||||
});
|
||||
});
|
||||
|
||||
// Populate all settings from localStorage on page initialisation
|
||||
const previousSettingsJson = localStorage.getItem(`${worldName}-weights`);
|
||||
if (previousSettingsJson) {
|
||||
const previousSettings = JSON.parse(previousSettingsJson);
|
||||
Object.keys(previousSettings).forEach((option) => {
|
||||
if (typeof previousSettings[option] === 'string') {
|
||||
return document.querySelector(`input[name="${option}"]`).value = previousSettings[option];
|
||||
}
|
||||
|
||||
Object.keys(previousSettings[option]).forEach((value) => {
|
||||
const input = document.querySelector(`input[name="${option}||${value}"]`);
|
||||
if (!input?.type) {
|
||||
return console.error(`Unable to populate option with name ${option}||${value}.`);
|
||||
}
|
||||
|
||||
switch (input.type) {
|
||||
case 'checkbox':
|
||||
input.checked = (parseInt(previousSettings[option][value], 10) === 1);
|
||||
break;
|
||||
case 'range':
|
||||
input.value = parseInt(previousSettings[option][value], 10);
|
||||
break;
|
||||
case 'number':
|
||||
input.value = previousSettings[option][value].toString();
|
||||
break;
|
||||
default:
|
||||
console.error(`Found unsupported input type: ${input.type}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const addRangeRow = (optionName) => {
|
||||
const inputQuery = `input[type=number][data-option="${optionName}"].range-option-value`;
|
||||
const inputTarget = document.querySelector(inputQuery);
|
||||
const newValue = inputTarget.value;
|
||||
if (!/^-?\d+$/.test(newValue)) {
|
||||
alert('Range values must be a positive or negative integer!');
|
||||
return;
|
||||
}
|
||||
inputTarget.value = '';
|
||||
const tBody = document.querySelector(`table[data-option="${optionName}"].range-rows tbody`);
|
||||
const tr = document.createElement('tr');
|
||||
tr.setAttribute('data-row', `${optionName}-${newValue}-row`);
|
||||
tr.setAttribute('data-option-name', optionName);
|
||||
tr.setAttribute('data-value', newValue);
|
||||
const tdLeft = document.createElement('td');
|
||||
tdLeft.classList.add('td-left');
|
||||
const label = document.createElement('label');
|
||||
label.setAttribute('for', `${optionName}||${newValue}`);
|
||||
label.innerText = newValue.toString();
|
||||
tdLeft.appendChild(label);
|
||||
tr.appendChild(tdLeft);
|
||||
const tdMiddle = document.createElement('td');
|
||||
tdMiddle.classList.add('td-middle');
|
||||
const range = document.createElement('input');
|
||||
range.setAttribute('type', 'range');
|
||||
range.setAttribute('min', '0');
|
||||
range.setAttribute('max', '50');
|
||||
range.setAttribute('value', '0');
|
||||
range.setAttribute('id', `${optionName}||${newValue}`);
|
||||
range.setAttribute('name', `${optionName}||${newValue}`);
|
||||
tdMiddle.appendChild(range);
|
||||
tr.appendChild(tdMiddle);
|
||||
const tdRight = document.createElement('td');
|
||||
tdRight.classList.add('td-right');
|
||||
const valueSpan = document.createElement('span');
|
||||
valueSpan.setAttribute('id', `${optionName}||${newValue}-value`);
|
||||
valueSpan.innerText = '0';
|
||||
tdRight.appendChild(valueSpan);
|
||||
tr.appendChild(tdRight);
|
||||
const tdDelete = document.createElement('td');
|
||||
const deleteSpan = document.createElement('span');
|
||||
deleteSpan.classList.add('range-option-delete');
|
||||
deleteSpan.classList.add('js-required');
|
||||
deleteSpan.setAttribute('data-target', `${optionName}-${newValue}-row`);
|
||||
deleteSpan.innerText = '❌';
|
||||
tdDelete.appendChild(deleteSpan);
|
||||
tr.appendChild(tdDelete);
|
||||
tBody.appendChild(tr);
|
||||
|
||||
// Remove this option from the set of deleted options if it exists
|
||||
unsetDeletedOption(optionName, newValue);
|
||||
};
|
||||
|
||||
/**
|
||||
* Determines the value of an input element, or returns a 1 or 0 if the element is a checkbox
|
||||
*
|
||||
* @param {object} input - The input element.
|
||||
* @returns {number} The value of the input element.
|
||||
*/
|
||||
const determineValue = (input) => {
|
||||
switch (input.type) {
|
||||
case 'checkbox':
|
||||
return (input.checked ? 1 : 0);
|
||||
case 'range':
|
||||
return parseInt(input.value, 10);
|
||||
default:
|
||||
return input.value;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Sets the deleted option value for a given world and option name.
|
||||
* If the world or option does not exist, it creates the necessary entries.
|
||||
*
|
||||
* @param {string} optionName - The name of the option.
|
||||
* @param {*} value - The value to be set for the deleted option.
|
||||
* @returns {void}
|
||||
*/
|
||||
const setDeletedOption = (optionName, value) => {
|
||||
deletedOptions[optionName] = deletedOptions[optionName] || [];
|
||||
deletedOptions[optionName].push(`${optionName}-${value}`);
|
||||
};
|
||||
|
||||
/**
|
||||
* Removes a specific value from the deletedOptions object.
|
||||
*
|
||||
* @param {string} optionName - The name of the option.
|
||||
* @param {*} value - The value to be removed
|
||||
* @returns {void}
|
||||
*/
|
||||
const unsetDeletedOption = (optionName, value) => {
|
||||
if (!deletedOptions.hasOwnProperty(optionName)) { return; }
|
||||
if (deletedOptions[optionName].includes(`${optionName}-${value}`)) {
|
||||
deletedOptions[optionName].splice(deletedOptions[optionName].indexOf(`${optionName}-${value}`), 1);
|
||||
}
|
||||
if (deletedOptions[optionName].length === 0) {
|
||||
delete deletedOptions[optionName];
|
||||
}
|
||||
};
|
||||
@@ -1,20 +0,0 @@
|
||||
User-agent: Googlebot
|
||||
Disallow: /
|
||||
|
||||
User-agent: APIs-Google
|
||||
Disallow: /
|
||||
|
||||
User-agent: AdsBot-Google-Mobile
|
||||
Disallow: /
|
||||
|
||||
User-agent: AdsBot-Google-Mobile
|
||||
Disallow: /
|
||||
|
||||
User-agent: Mediapartners-Google
|
||||
Disallow: /
|
||||
|
||||
User-agent: Google-Safety
|
||||
Disallow: /
|
||||
|
||||
User-agent: *
|
||||
Disallow: /
|
||||
|
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 |