Compare commits
16 Commits
main
...
use_self.o
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2524ddc075 | ||
|
|
d545b78803 | ||
|
|
88b1c94eb2 | ||
|
|
7742d5d804 | ||
|
|
d3e148dcc6 | ||
|
|
b5fccde913 | ||
|
|
55e9b0687a | ||
|
|
79e1bf351e | ||
|
|
fcfea9d9aa | ||
|
|
cfc5508f06 | ||
|
|
62cb5f1fc2 | ||
|
|
7e70b16656 | ||
|
|
7b486b3380 | ||
|
|
09cac0a685 | ||
|
|
12c583533d | ||
|
|
c5af28a649 |
@@ -1,5 +0,0 @@
|
||||
[report]
|
||||
exclude_lines =
|
||||
pragma: no cover
|
||||
if TYPE_CHECKING:
|
||||
if typing.TYPE_CHECKING:
|
||||
210
.dockerignore
@@ -1,210 +0,0 @@
|
||||
.git
|
||||
.github
|
||||
.run
|
||||
docs
|
||||
test
|
||||
typings
|
||||
*Client.py
|
||||
|
||||
.idea
|
||||
.vscode
|
||||
|
||||
*_Spoiler.txt
|
||||
*.bmbp
|
||||
*.apbp
|
||||
*.apl2ac
|
||||
*.apm3
|
||||
*.apmc
|
||||
*.apz5
|
||||
*.aptloz
|
||||
*.apemerald
|
||||
*.pyc
|
||||
*.pyd
|
||||
*.sfc
|
||||
*.z64
|
||||
*.n64
|
||||
*.nes
|
||||
*.smc
|
||||
*.sms
|
||||
*.gb
|
||||
*.gbc
|
||||
*.gba
|
||||
*.wixobj
|
||||
*.lck
|
||||
*.db3
|
||||
*multidata
|
||||
*multisave
|
||||
*.archipelago
|
||||
*.apsave
|
||||
*.BIN
|
||||
*.puml
|
||||
|
||||
setups
|
||||
build
|
||||
bundle/components.wxs
|
||||
dist
|
||||
/prof/
|
||||
README.html
|
||||
.vs/
|
||||
EnemizerCLI/
|
||||
/Players/
|
||||
/SNI/
|
||||
/sni-*/
|
||||
/appimagetool*
|
||||
/host.yaml
|
||||
/options.yaml
|
||||
/config.yaml
|
||||
/logs/
|
||||
_persistent_storage.yaml
|
||||
mystery_result_*.yaml
|
||||
*-errors.txt
|
||||
success.txt
|
||||
output/
|
||||
Output Logs/
|
||||
/factorio/
|
||||
/Minecraft Forge Server/
|
||||
/WebHostLib/static/generated
|
||||
/freeze_requirements.txt
|
||||
/Archipelago.zip
|
||||
/setup.ini
|
||||
/installdelete.iss
|
||||
/data/user.kv
|
||||
/datapackage
|
||||
/custom_worlds
|
||||
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
|
||||
# C extensions
|
||||
*.so
|
||||
*.dll
|
||||
|
||||
# Distribution / packaging
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
share/python-wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
MANIFEST
|
||||
|
||||
# Installer logs
|
||||
pip-log.txt
|
||||
pip-delete-this-directory.txt
|
||||
installer.log
|
||||
|
||||
# Unit test / coverage reports
|
||||
htmlcov/
|
||||
.tox/
|
||||
.nox/
|
||||
.coverage
|
||||
.coverage.*
|
||||
.cache
|
||||
nosetests.xml
|
||||
coverage.xml
|
||||
*.cover
|
||||
*.py,cover
|
||||
.hypothesis/
|
||||
.pytest_cache/
|
||||
cover/
|
||||
|
||||
|
||||
# Flask stuff:
|
||||
instance/
|
||||
.webassets-cache
|
||||
|
||||
# Scrapy stuff:
|
||||
.scrapy
|
||||
|
||||
# Sphinx documentation
|
||||
docs/_build/
|
||||
|
||||
# PyBuilder
|
||||
.pybuilder/
|
||||
target/
|
||||
|
||||
# Jupyter Notebook
|
||||
.ipynb_checkpoints
|
||||
|
||||
# IPython
|
||||
profile_default/
|
||||
ipython_config.py
|
||||
|
||||
# vim editor
|
||||
*.swp
|
||||
|
||||
# SageMath parsed files
|
||||
*.sage.py
|
||||
|
||||
# Environments
|
||||
.env
|
||||
.venv*
|
||||
env/
|
||||
venv/
|
||||
/venv*/
|
||||
ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
*.code-workspace
|
||||
shell.nix
|
||||
|
||||
# Spyder project settings
|
||||
.spyderproject
|
||||
.spyproject
|
||||
|
||||
# Rope project settings
|
||||
.ropeproject
|
||||
|
||||
# mkdocs documentation
|
||||
/site
|
||||
|
||||
# mypy
|
||||
.mypy_cache/
|
||||
.dmypy.json
|
||||
dmypy.json
|
||||
|
||||
# Pyre type checker
|
||||
.pyre/
|
||||
|
||||
# pytype static type analyzer
|
||||
.pytype/
|
||||
|
||||
# Cython debug symbols
|
||||
cython_debug/
|
||||
|
||||
# Cython intermediates
|
||||
_speedups.c
|
||||
_speedups.cpp
|
||||
_speedups.html
|
||||
|
||||
# minecraft server stuff
|
||||
jdk*/
|
||||
minecraft*/
|
||||
minecraft_versions.json
|
||||
!worlds/minecraft/
|
||||
|
||||
# pyenv
|
||||
.python-version
|
||||
|
||||
#undertale stuff
|
||||
/Undertale/
|
||||
|
||||
# OS General Files
|
||||
.DS_Store
|
||||
.AppleDouble
|
||||
.LSOverride
|
||||
Thumbs.db
|
||||
[Dd]esktop.ini
|
||||
2
.gitattributes
vendored
@@ -1,2 +0,0 @@
|
||||
worlds/blasphemous/region_data.py linguist-generated=true
|
||||
worlds/yachtdice/YachtWeights.py linguist-generated=true
|
||||
30
.github/labeler.yml
vendored
@@ -1,30 +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/**'
|
||||
- '!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'
|
||||
44
.github/pyright-config.json
vendored
@@ -1,44 +0,0 @@
|
||||
{
|
||||
"include": [
|
||||
"../BizHawkClient.py",
|
||||
"../Patch.py",
|
||||
"../rule_builder/cached_world.py",
|
||||
"../rule_builder/options.py",
|
||||
"../rule_builder/rules.py",
|
||||
"../test/param.py",
|
||||
"../test/general/test_groups.py",
|
||||
"../test/general/test_helpers.py",
|
||||
"../test/general/test_memory.py",
|
||||
"../test/general/test_names.py",
|
||||
"../test/general/test_rule_builder.py",
|
||||
"../test/multiworld/__init__.py",
|
||||
"../test/multiworld/test_multiworlds.py",
|
||||
"../test/netutils/__init__.py",
|
||||
"../test/programs/__init__.py",
|
||||
"../test/programs/test_multi_server.py",
|
||||
"../test/utils/__init__.py",
|
||||
"../test/webhost/test_descriptions.py",
|
||||
"../worlds/AutoSNIClient.py",
|
||||
"type_check.py"
|
||||
],
|
||||
|
||||
"exclude": [
|
||||
"**/__pycache__"
|
||||
],
|
||||
|
||||
"stubPath": "../typings",
|
||||
|
||||
"typeCheckingMode": "strict",
|
||||
"reportImplicitOverride": "error",
|
||||
"reportMissingImports": true,
|
||||
"reportMissingTypeStubs": true,
|
||||
|
||||
"pythonVersion": "3.11",
|
||||
"pythonPlatform": "Windows",
|
||||
|
||||
"executionEnvironments": [
|
||||
{
|
||||
"root": ".."
|
||||
}
|
||||
]
|
||||
}
|
||||
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)
|
||||
10
.github/workflows/analyze-modified-files.yml
vendored
@@ -25,7 +25,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: "Determine modified files (pull_request)"
|
||||
if: github.event_name == 'pull_request'
|
||||
@@ -50,10 +50,10 @@ jobs:
|
||||
run: |
|
||||
echo "diff=." >> $GITHUB_ENV
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
- uses: actions/setup-python@v4
|
||||
if: env.diff != ''
|
||||
with:
|
||||
python-version: '3.11'
|
||||
python-version: 3.8
|
||||
|
||||
- name: "Install dependencies"
|
||||
if: env.diff != ''
|
||||
@@ -65,13 +65,13 @@ jobs:
|
||||
continue-on-error: false
|
||||
if: env.diff != '' && matrix.task == 'flake8'
|
||||
run: |
|
||||
flake8 --count --select=E9,F63,F7,F82 --ignore F824 --show-source --statistics ${{ env.diff }}
|
||||
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 }}
|
||||
flake8 --count --max-complexity=10 --max-doc-length=120 --max-line-length=120 --statistics ${{ env.diff }}
|
||||
|
||||
- name: "mypy: Type check modified files"
|
||||
continue-on-error: true
|
||||
|
||||
145
.github/workflows/build.yml
vendored
@@ -1,5 +1,4 @@
|
||||
# This workflow will build a release-like distribution when manually dispatched:
|
||||
# a Windows x64 7zip, a Windows x64 Installer, a Linux AppImage and a Linux binary .tar.gz.
|
||||
# This workflow will build a release-like distribution when manually dispatched
|
||||
|
||||
name: Build
|
||||
|
||||
@@ -9,57 +8,36 @@ on:
|
||||
- '.github/workflows/build.yml'
|
||||
- 'setup.py'
|
||||
- 'requirements.txt'
|
||||
- '*.iss'
|
||||
- 'worlds/*/archipelago.json'
|
||||
pull_request:
|
||||
paths:
|
||||
- '.github/workflows/build.yml'
|
||||
- 'setup.py'
|
||||
- 'requirements.txt'
|
||||
- '*.iss'
|
||||
- 'worlds/*/archipelago.json'
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
ENEMIZER_VERSION: 7.1
|
||||
# NOTE: since appimage/appimagetool and appimage/type2-runtime does not have tags anymore,
|
||||
# we check the sha256 and require manual intervention if it was updated.
|
||||
APPIMAGE_FORK: 'PopTracker'
|
||||
APPIMAGETOOL_VERSION: 'r-2025-11-18'
|
||||
APPIMAGETOOL_X86_64_HASH: '4577a452b30af2337123fbb383aea154b618e51ad5448c3b62085cbbbfbfd9a2'
|
||||
APPIMAGE_RUNTIME_VERSION: 'r-2025-11-07'
|
||||
APPIMAGE_RUNTIME_X86_64_HASH: '27ddd3f78e483fc5f7856e413d7c17092917f8c35bfe3318a0d378aa9435ad17'
|
||||
|
||||
permissions: # permissions required for attestation
|
||||
id-token: 'write'
|
||||
attestations: 'write'
|
||||
APPIMAGETOOL_VERSION: 13
|
||||
|
||||
jobs:
|
||||
# build-release-macos: # LF volunteer
|
||||
|
||||
build-win: # RCs and releases may still be built and signed by hand
|
||||
build-win-py38: # RCs will still be built and signed by hand
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
# - copy code below to release.yml -
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v3
|
||||
- name: Install python
|
||||
uses: actions/setup-python@v5
|
||||
uses: actions/setup-python@v4
|
||||
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/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.7.0 --allow-downgrade
|
||||
- name: Build
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
python setup.py build_exe --yes
|
||||
if ( $? -eq $false ) {
|
||||
Write-Error "setup.py failed!"
|
||||
exit 1
|
||||
}
|
||||
$NAME="$(ls build | Select-String -Pattern 'exe')".Split('.',2)[1]
|
||||
$ZIP_NAME="Archipelago_$NAME.7z"
|
||||
echo "$NAME -> $ZIP_NAME"
|
||||
@@ -68,88 +46,34 @@ jobs:
|
||||
cd build
|
||||
Rename-Item "exe.$NAME" Archipelago
|
||||
7z a -mx=9 -mhe=on -ms "../dist/$ZIP_NAME" Archipelago
|
||||
Rename-Item Archipelago "exe.$NAME" # inno_setup.iss expects the original name
|
||||
- name: Build Setup
|
||||
run: |
|
||||
& "${env:ProgramFiles(x86)}\Inno Setup 6\iscc.exe" inno_setup.iss /DNO_SIGNTOOL
|
||||
if ( $? -eq $false ) {
|
||||
Write-Error "Building setup failed!"
|
||||
exit 1
|
||||
}
|
||||
$contents = Get-ChildItem -Path setups/*.exe -Force -Recurse
|
||||
$SETUP_NAME=$contents[0].Name
|
||||
echo "SETUP_NAME=$SETUP_NAME" >> $Env:GITHUB_ENV
|
||||
# - copy code above to release.yml -
|
||||
- name: Attest Build
|
||||
if: ${{ github.event_name == 'workflow_dispatch' }}
|
||||
uses: actions/attest-build-provenance@v2
|
||||
with:
|
||||
subject-path: |
|
||||
build/exe.*/ArchipelagoLauncher.exe
|
||||
build/exe.*/ArchipelagoLauncherDebug.exe
|
||||
build/exe.*/ArchipelagoGenerate.exe
|
||||
build/exe.*/ArchipelagoServer.exe
|
||||
dist/${{ env.ZIP_NAME }}
|
||||
setups/${{ env.SETUP_NAME }}
|
||||
- name: Check build loads expected worlds
|
||||
shell: bash
|
||||
run: |
|
||||
cd build/exe*
|
||||
mv Players/Templates/meta.yaml .
|
||||
ls -1 Players/Templates | sort > setup-player-templates.txt
|
||||
rm -R Players/Templates
|
||||
timeout 30 ./ArchipelagoLauncher "Generate Template Options" || true
|
||||
ls -1 Players/Templates | sort > generated-player-templates.txt
|
||||
cmp setup-player-templates.txt generated-player-templates.txt \
|
||||
|| diff setup-player-templates.txt generated-player-templates.txt
|
||||
mv meta.yaml Players/Templates/
|
||||
- name: Test Generate
|
||||
shell: bash
|
||||
run: |
|
||||
cd build/exe*
|
||||
cp Players/Templates/VVVVVV.yaml Players/
|
||||
timeout 30 ./ArchipelagoGenerate
|
||||
- name: Store 7z
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v3
|
||||
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-ubuntu2204:
|
||||
runs-on: ubuntu-22.04
|
||||
build-ubuntu2004:
|
||||
runs-on: ubuntu-20.04
|
||||
steps:
|
||||
# - copy code below to release.yml -
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v3
|
||||
- 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@v4
|
||||
with:
|
||||
python-version: '~3.12.7'
|
||||
check-latest: true
|
||||
python-version: '3.11'
|
||||
- name: Install build-time dependencies
|
||||
run: |
|
||||
echo "PYTHON=python3.12" >> $GITHUB_ENV
|
||||
wget -nv https://github.com/$APPIMAGE_FORK/appimagetool/releases/download/$APPIMAGETOOL_VERSION/appimagetool-x86_64.AppImage
|
||||
echo "$APPIMAGETOOL_X86_64_HASH appimagetool-x86_64.AppImage" | sha256sum -c
|
||||
wget -nv https://github.com/$APPIMAGE_FORK/type2-runtime/releases/download/$APPIMAGE_RUNTIME_VERSION/runtime-x86_64
|
||||
echo "$APPIMAGE_RUNTIME_X86_64_HASH runtime-x86_64" | sha256sum -c
|
||||
echo "PYTHON=python3.11" >> $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
|
||||
echo -e '#/bin/sh\n./squashfs-root/AppRun --runtime-file runtime-x86_64 "$@"' > appimagetool
|
||||
echo -e '#/bin/sh\n./squashfs-root/AppRun "$@"' > appimagetool
|
||||
chmod a+rx appimagetool
|
||||
- name: Download run-time dependencies
|
||||
run: |
|
||||
@@ -161,60 +85,29 @@ jobs:
|
||||
# charset-normalizer was somehow incomplete in the github runner
|
||||
"${{ env.PYTHON }}" -m venv venv
|
||||
source venv/bin/activate
|
||||
"${{ env.PYTHON }}" -m pip install --upgrade pip "PyGObject<3.51.0" charset-normalizer
|
||||
"${{ env.PYTHON }}" -m pip install --upgrade pip PyGObject charset-normalizer
|
||||
python setup.py build_exe --yes bdist_appimage --yes
|
||||
echo -e "setup.py build output:\n `ls build`"
|
||||
echo -e "setup.py dist output:\n `ls dist`"
|
||||
cd dist && export APPIMAGE_NAME="`ls *.AppImage`" && cd ..
|
||||
export TAR_NAME="${APPIMAGE_NAME%.AppImage}.tar.gz"
|
||||
(cd build && DIR_NAME="`ls | grep exe`" && mv "$DIR_NAME" Archipelago && tar -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: Attest Build
|
||||
if: ${{ github.event_name == 'workflow_dispatch' }}
|
||||
uses: actions/attest-build-provenance@v2
|
||||
with:
|
||||
subject-path: |
|
||||
build/exe.*/ArchipelagoLauncher
|
||||
build/exe.*/ArchipelagoGenerate
|
||||
build/exe.*/ArchipelagoServer
|
||||
dist/${{ env.APPIMAGE_NAME }}*
|
||||
dist/${{ env.TAR_NAME }}
|
||||
- name: Build Again
|
||||
run: |
|
||||
source venv/bin/activate
|
||||
python setup.py build_exe --yes
|
||||
- name: Check build loads expected worlds
|
||||
shell: bash
|
||||
run: |
|
||||
cd build/exe*
|
||||
mv Players/Templates/meta.yaml .
|
||||
ls -1 Players/Templates | sort > setup-player-templates.txt
|
||||
rm -R Players/Templates
|
||||
timeout 30 ./ArchipelagoLauncher "Generate Template Options" || true
|
||||
ls -1 Players/Templates | sort > generated-player-templates.txt
|
||||
cmp setup-player-templates.txt generated-player-templates.txt \
|
||||
|| diff setup-player-templates.txt generated-player-templates.txt
|
||||
mv meta.yaml Players/Templates/
|
||||
- name: Test Generate
|
||||
shell: bash
|
||||
run: |
|
||||
cd build/exe*
|
||||
cp Players/Templates/VVVVVV.yaml Players/
|
||||
timeout 30 ./ArchipelagoGenerate
|
||||
- name: Store AppImage
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v3
|
||||
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@v3
|
||||
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
|
||||
|
||||
8
.github/workflows/codeql-analysis.yml
vendored
@@ -43,11 +43,11 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v3
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v3
|
||||
uses: github/codeql-action/init@v2
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
||||
@@ -58,7 +58,7 @@ jobs:
|
||||
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
|
||||
# If this step fails, then you should remove it and run the build manually (see below)
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@v3
|
||||
uses: github/codeql-action/autobuild@v2
|
||||
|
||||
# ℹ️ Command-line programs to run using the OS shell.
|
||||
# 📚 https://git.io/JvXDl
|
||||
@@ -72,4 +72,4 @@ jobs:
|
||||
# make release
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v3
|
||||
uses: github/codeql-action/analyze@v2
|
||||
|
||||
54
.github/workflows/ctest.yml
vendored
@@ -1,54 +0,0 @@
|
||||
# Run CMake / CTest C++ unit tests
|
||||
|
||||
name: ctest
|
||||
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- '**.cc?'
|
||||
- '**.cpp'
|
||||
- '**.cxx'
|
||||
- '**.hh?'
|
||||
- '**.hpp'
|
||||
- '**.hxx'
|
||||
- '**/CMakeLists.txt'
|
||||
- '.github/workflows/ctest.yml'
|
||||
pull_request:
|
||||
paths:
|
||||
- '**.cc?'
|
||||
- '**.cpp'
|
||||
- '**.cxx'
|
||||
- '**.hh?'
|
||||
- '**.hpp'
|
||||
- '**.hxx'
|
||||
- '**/CMakeLists.txt'
|
||||
- '.github/workflows/ctest.yml'
|
||||
|
||||
jobs:
|
||||
ctest:
|
||||
runs-on: ${{ matrix.os }}
|
||||
name: Test C++ ${{ matrix.os }}
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest, windows-latest]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: ilammy/msvc-dev-cmd@0b201ec74fa43914dc39ae48a89fd1d8cb592756
|
||||
if: startsWith(matrix.os,'windows')
|
||||
- uses: Bacondish2023/setup-googletest@49065d1f7a6d21f6134864dd65980fe5dbe06c73
|
||||
with:
|
||||
build-type: 'Release'
|
||||
- name: Build tests
|
||||
run: |
|
||||
cd test/cpp
|
||||
mkdir build
|
||||
cmake -S . -B build/ -DCMAKE_BUILD_TYPE=Release
|
||||
cmake --build build/ --config Release
|
||||
ls
|
||||
- name: Run tests
|
||||
run: |
|
||||
cd test/cpp
|
||||
ctest --test-dir build/ -C Release --output-on-failure
|
||||
154
.github/workflows/docker.yml
vendored
@@ -1,154 +0,0 @@
|
||||
name: Build and Publish Docker Images
|
||||
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- "**"
|
||||
- "!docs/**"
|
||||
- "!deploy/**"
|
||||
- "!setup.py"
|
||||
- "!.gitignore"
|
||||
- "!.github/workflows/**"
|
||||
- ".github/workflows/docker.yml"
|
||||
branches:
|
||||
- "main"
|
||||
tags:
|
||||
- "v?[0-9]+.[0-9]+.[0-9]*"
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
|
||||
jobs:
|
||||
prepare:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
image-name: ${{ steps.image.outputs.name }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
package-name: ${{ steps.package.outputs.name }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set lowercase image name
|
||||
id: image
|
||||
run: |
|
||||
echo "name=${GITHUB_REPOSITORY,,}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Set package name
|
||||
id: package
|
||||
run: |
|
||||
echo "name=$(basename ${GITHUB_REPOSITORY,,})" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Extract metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ steps.image.outputs.name }}
|
||||
tags: |
|
||||
type=ref,event=branch,enable={{is_not_default_branch}}
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=raw,value=nightly,enable={{is_default_branch}}
|
||||
|
||||
- name: Compute final tags
|
||||
id: final-tags
|
||||
run: |
|
||||
readarray -t tags <<< "${{ steps.meta.outputs.tags }}"
|
||||
|
||||
if [[ "${{ github.ref_type }}" == "tag" ]]; then
|
||||
tag="${{ github.ref_name }}"
|
||||
if [[ "$tag" =~ ^v?[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
full_latest="${{ env.REGISTRY }}/${{ steps.image.outputs.name }}:latest"
|
||||
# Check if latest is already in tags to avoid duplicates
|
||||
if ! printf '%s\n' "${tags[@]}" | grep -q "^$full_latest$"; then
|
||||
tags+=("$full_latest")
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# Set multiline output
|
||||
echo "tags<<EOF" >> $GITHUB_OUTPUT
|
||||
printf '%s\n' "${tags[@]}" >> $GITHUB_OUTPUT
|
||||
echo "EOF" >> $GITHUB_OUTPUT
|
||||
|
||||
build:
|
||||
needs: prepare
|
||||
runs-on: ${{ matrix.runner }}
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- platform: amd64
|
||||
runner: ubuntu-latest
|
||||
suffix: amd64
|
||||
cache-scope: amd64
|
||||
- platform: arm64
|
||||
runner: ubuntu-24.04-arm
|
||||
suffix: arm64
|
||||
cache-scope: arm64
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Compute suffixed tags
|
||||
id: tags
|
||||
run: |
|
||||
readarray -t tags <<< "${{ needs.prepare.outputs.tags }}"
|
||||
suffixed=()
|
||||
for t in "${tags[@]}"; do
|
||||
suffixed+=("$t-${{ matrix.suffix }}")
|
||||
done
|
||||
echo "tags=$(IFS=','; echo "${suffixed[*]}")" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
platforms: linux/${{ matrix.platform }}
|
||||
push: true
|
||||
tags: ${{ steps.tags.outputs.tags }}
|
||||
labels: ${{ needs.prepare.outputs.labels }}
|
||||
cache-from: type=gha,scope=${{ matrix.cache-scope }}
|
||||
cache-to: type=gha,mode=max,scope=${{ matrix.cache-scope }}
|
||||
provenance: false
|
||||
|
||||
manifest:
|
||||
needs: [prepare, build]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Create and push multi-arch manifest
|
||||
run: |
|
||||
readarray -t tag_array <<< "${{ needs.prepare.outputs.tags }}"
|
||||
|
||||
for tag in "${tag_array[@]}"; do
|
||||
docker manifest create "$tag" \
|
||||
"$tag-amd64" \
|
||||
"$tag-arm64"
|
||||
|
||||
docker manifest push "$tag"
|
||||
done
|
||||
47
.github/workflows/label-pull-requests.yml
vendored
@@ -1,47 +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
|
||||
env:
|
||||
GH_REPO: ${{ github.repository }}
|
||||
|
||||
jobs:
|
||||
labeler:
|
||||
name: 'Apply content-based labels'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/labeler@v5
|
||||
with:
|
||||
sync-labels: false
|
||||
peer_review:
|
||||
name: 'Apply peer review label'
|
||||
needs: labeler
|
||||
if: >-
|
||||
(github.event.action == 'opened' || github.event.action == 'reopened' ||
|
||||
github.event.action == 'ready_for_review') && !github.event.pull_request.draft
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: 'Add label'
|
||||
run: "gh pr edit \"$PR_URL\" --add-label 'waiting-on: peer-review'"
|
||||
env:
|
||||
PR_URL: ${{ github.event.pull_request.html_url }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
unblock_draft_prs:
|
||||
name: 'Remove waiting-on labels'
|
||||
needs: labeler
|
||||
if: github.event.action == 'converted_to_draft' || github.event.action == 'closed'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: 'Remove labels'
|
||||
run: |-
|
||||
gh pr edit "$PR_URL" --remove-label 'waiting-on: peer-review' \
|
||||
--remove-label 'waiting-on: core-review' \
|
||||
--remove-label 'waiting-on: world-maintainer' \
|
||||
--remove-label 'waiting-on: author'
|
||||
env:
|
||||
PR_URL: ${{ github.event.pull_request.html_url }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
121
.github/workflows/release.yml
vendored
@@ -5,22 +5,11 @@ name: Release
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v?[0-9]+.[0-9]+.[0-9]*'
|
||||
- '*.*.*'
|
||||
|
||||
env:
|
||||
ENEMIZER_VERSION: 7.1
|
||||
# NOTE: since appimage/appimagetool and appimage/type2-runtime does not have tags anymore,
|
||||
# we check the sha256 and require manual intervention if it was updated.
|
||||
APPIMAGE_FORK: 'PopTracker'
|
||||
APPIMAGETOOL_VERSION: 'r-2025-11-18'
|
||||
APPIMAGETOOL_X86_64_HASH: '4577a452b30af2337123fbb383aea154b618e51ad5448c3b62085cbbbfbfd9a2'
|
||||
APPIMAGE_RUNTIME_VERSION: 'r-2025-11-07'
|
||||
APPIMAGE_RUNTIME_X86_64_HASH: '27ddd3f78e483fc5f7856e413d7c17092917f8c35bfe3318a0d378aa9435ad17'
|
||||
|
||||
permissions: # permissions required for attestation
|
||||
id-token: 'write'
|
||||
attestations: 'write'
|
||||
contents: 'write' # additionally required for release
|
||||
APPIMAGETOOL_VERSION: 13
|
||||
|
||||
jobs:
|
||||
create-release:
|
||||
@@ -29,7 +18,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
|
||||
@@ -37,104 +26,32 @@ jobs:
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# build-release-windows: # this is done by hand because of signing
|
||||
# build-release-macos: # LF volunteer
|
||||
|
||||
build-release-win:
|
||||
runs-on: windows-latest
|
||||
if: ${{ true }} # change to false to skip if release is built by hand
|
||||
needs: create-release
|
||||
steps:
|
||||
- name: Set env
|
||||
shell: bash
|
||||
run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV
|
||||
# - code below copied from build.yml -
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '~3.12.7'
|
||||
check-latest: true
|
||||
- name: Download run-time dependencies
|
||||
run: |
|
||||
Invoke-WebRequest -Uri https://github.com/Ijwu/Enemizer/releases/download/${Env:ENEMIZER_VERSION}/win-x64.zip -OutFile enemizer.zip
|
||||
Expand-Archive -Path enemizer.zip -DestinationPath EnemizerCLI -Force
|
||||
choco install innosetup --version=6.2.2 --allow-downgrade
|
||||
- name: Build
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
python setup.py build_exe --yes
|
||||
if ( $? -eq $false ) {
|
||||
Write-Error "setup.py failed!"
|
||||
exit 1
|
||||
}
|
||||
$NAME="$(ls build | Select-String -Pattern 'exe')".Split('.',2)[1]
|
||||
$ZIP_NAME="Archipelago_$NAME.7z"
|
||||
echo "$NAME -> $ZIP_NAME"
|
||||
echo "ZIP_NAME=$ZIP_NAME" >> $Env:GITHUB_ENV
|
||||
New-Item -Path dist -ItemType Directory -Force
|
||||
cd build
|
||||
Rename-Item "exe.$NAME" Archipelago
|
||||
7z a -mx=9 -mhe=on -ms "../dist/$ZIP_NAME" Archipelago
|
||||
Rename-Item Archipelago "exe.$NAME" # inno_setup.iss expects the original name
|
||||
- name: Build Setup
|
||||
run: |
|
||||
& "${env:ProgramFiles(x86)}\Inno Setup 6\iscc.exe" inno_setup.iss /DNO_SIGNTOOL
|
||||
if ( $? -eq $false ) {
|
||||
Write-Error "Building setup failed!"
|
||||
exit 1
|
||||
}
|
||||
$contents = Get-ChildItem -Path setups/*.exe -Force -Recurse
|
||||
$SETUP_NAME=$contents[0].Name
|
||||
echo "SETUP_NAME=$SETUP_NAME" >> $Env:GITHUB_ENV
|
||||
# - code above copied from build.yml -
|
||||
- name: Attest Build
|
||||
uses: actions/attest-build-provenance@v2
|
||||
with:
|
||||
subject-path: |
|
||||
build/exe.*/ArchipelagoLauncher.exe
|
||||
build/exe.*/ArchipelagoLauncherDebug.exe
|
||||
build/exe.*/ArchipelagoGenerate.exe
|
||||
build/exe.*/ArchipelagoServer.exe
|
||||
setups/*
|
||||
- name: Add to Release
|
||||
uses: softprops/action-gh-release@975c1b265e11dd76618af1c374e7981f9a6ff44a
|
||||
with:
|
||||
draft: true # see above
|
||||
prerelease: false
|
||||
name: Archipelago ${{ env.RELEASE_VERSION }}
|
||||
files: |
|
||||
setups/*
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
build-release-ubuntu2204:
|
||||
runs-on: ubuntu-22.04
|
||||
needs: create-release
|
||||
build-release-ubuntu2004:
|
||||
runs-on: ubuntu-20.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@v3
|
||||
- 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@v4
|
||||
with:
|
||||
python-version: '~3.12.7'
|
||||
check-latest: true
|
||||
python-version: '3.11'
|
||||
- name: Install build-time dependencies
|
||||
run: |
|
||||
echo "PYTHON=python3.12" >> $GITHUB_ENV
|
||||
wget -nv https://github.com/$APPIMAGE_FORK/appimagetool/releases/download/$APPIMAGETOOL_VERSION/appimagetool-x86_64.AppImage
|
||||
echo "$APPIMAGETOOL_X86_64_HASH appimagetool-x86_64.AppImage" | sha256sum -c
|
||||
wget -nv https://github.com/$APPIMAGE_FORK/type2-runtime/releases/download/$APPIMAGE_RUNTIME_VERSION/runtime-x86_64
|
||||
echo "$APPIMAGE_RUNTIME_X86_64_HASH runtime-x86_64" | sha256sum -c
|
||||
echo "PYTHON=python3.11" >> $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
|
||||
echo -e '#/bin/sh\n./squashfs-root/AppRun --runtime-file runtime-x86_64 "$@"' > appimagetool
|
||||
echo -e '#/bin/sh\n./squashfs-root/AppRun "$@"' > appimagetool
|
||||
chmod a+rx appimagetool
|
||||
- name: Download run-time dependencies
|
||||
run: |
|
||||
@@ -146,26 +63,18 @@ jobs:
|
||||
# charset-normalizer was somehow incomplete in the github runner
|
||||
"${{ env.PYTHON }}" -m venv venv
|
||||
source venv/bin/activate
|
||||
"${{ env.PYTHON }}" -m pip install --upgrade pip "PyGObject<3.51.0" charset-normalizer
|
||||
"${{ env.PYTHON }}" -m pip install --upgrade pip PyGObject charset-normalizer
|
||||
python setup.py build_exe --yes bdist_appimage --yes
|
||||
echo -e "setup.py build output:\n `ls build`"
|
||||
echo -e "setup.py dist output:\n `ls dist`"
|
||||
cd dist && export APPIMAGE_NAME="`ls *.AppImage`" && cd ..
|
||||
export TAR_NAME="${APPIMAGE_NAME%.AppImage}.tar.gz"
|
||||
(cd build && DIR_NAME="`ls | grep exe`" && mv "$DIR_NAME" Archipelago && tar -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: Attest Build
|
||||
uses: actions/attest-build-provenance@v2
|
||||
with:
|
||||
subject-path: |
|
||||
build/exe.*/ArchipelagoLauncher
|
||||
build/exe.*/ArchipelagoGenerate
|
||||
build/exe.*/ArchipelagoServer
|
||||
dist/*
|
||||
- name: Add to Release
|
||||
uses: softprops/action-gh-release@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
|
||||
56
.github/workflows/unittests.yml
vendored
@@ -8,29 +8,23 @@ on:
|
||||
paths:
|
||||
- '**'
|
||||
- '!docs/**'
|
||||
- '!deploy/**'
|
||||
- '!setup.py'
|
||||
- '!Dockerfile'
|
||||
- '!*.iss'
|
||||
- '!.gitignore'
|
||||
- '!.dockerignore'
|
||||
- '!.github/workflows/**'
|
||||
- '.github/workflows/unittests.yml'
|
||||
pull_request:
|
||||
paths:
|
||||
- '**'
|
||||
- '!docs/**'
|
||||
- '!deploy/**'
|
||||
- '!setup.py'
|
||||
- '!Dockerfile'
|
||||
- '!*.iss'
|
||||
- '!.gitignore'
|
||||
- '!.dockerignore'
|
||||
- '!.github/workflows/**'
|
||||
- '.github/workflows/unittests.yml'
|
||||
|
||||
jobs:
|
||||
unit:
|
||||
build:
|
||||
runs-on: ${{ matrix.os }}
|
||||
name: Test Python ${{ matrix.python.version }} ${{ matrix.os }}
|
||||
|
||||
@@ -39,58 +33,30 @@ jobs:
|
||||
matrix:
|
||||
os: [ubuntu-latest]
|
||||
python:
|
||||
- {version: '3.11.2'} # Change to '3.11' around 2026-06-10
|
||||
- {version: '3.12'}
|
||||
- {version: '3.13'}
|
||||
- {version: '3.8'}
|
||||
- {version: '3.9'}
|
||||
- {version: '3.10'}
|
||||
- {version: '3.11'}
|
||||
include:
|
||||
- python: {version: '3.11'} # old compat
|
||||
- python: {version: '3.8'} # win7 compat
|
||||
os: windows-latest
|
||||
- python: {version: '3.13'} # current
|
||||
- python: {version: '3.11'} # current
|
||||
os: windows-latest
|
||||
- python: {version: '3.13'} # current
|
||||
- python: {version: '3.11'} # current
|
||||
os: macos-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v3
|
||||
- name: Set up Python ${{ matrix.python.version }}
|
||||
uses: actions/setup-python@v5
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: ${{ matrix.python.version }}
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -r ci-requirements.txt
|
||||
pip install pytest pytest-subtests pytest-xdist
|
||||
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.13'} # current
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up Python ${{ matrix.python.version }}
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ matrix.python.version }}
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m venv venv
|
||||
source venv/bin/activate
|
||||
python -m pip install --upgrade pip
|
||||
python ModuleUpdate.py --yes --force --append "WebHostLib/requirements.txt"
|
||||
- name: Test hosting
|
||||
run: |
|
||||
source venv/bin/activate
|
||||
export PYTHONPATH=$(pwd)
|
||||
timeout 600 python test/hosting/__main__.py
|
||||
|
||||
16
.gitignore
vendored
@@ -4,13 +4,11 @@
|
||||
*_Spoiler.txt
|
||||
*.bmbp
|
||||
*.apbp
|
||||
*.apcivvi
|
||||
*.apl2ac
|
||||
*.apm3
|
||||
*.apmc
|
||||
*.apz5
|
||||
*.aptloz
|
||||
*.aptww
|
||||
*.apemerald
|
||||
*.pyc
|
||||
*.pyd
|
||||
@@ -56,6 +54,7 @@ success.txt
|
||||
output/
|
||||
Output Logs/
|
||||
/factorio/
|
||||
/Minecraft Forge Server/
|
||||
/WebHostLib/static/generated
|
||||
/freeze_requirements.txt
|
||||
/Archipelago.zip
|
||||
@@ -63,10 +62,6 @@ Output Logs/
|
||||
/installdelete.iss
|
||||
/data/user.kv
|
||||
/datapackage
|
||||
/datapackage_export.json
|
||||
/custom_worlds
|
||||
# stubgen output
|
||||
/out/
|
||||
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
@@ -154,7 +149,7 @@ venv/
|
||||
ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
*.code-workspace
|
||||
.code-workspace
|
||||
shell.nix
|
||||
|
||||
# Spyder project settings
|
||||
@@ -182,10 +177,15 @@ dmypy.json
|
||||
cython_debug/
|
||||
|
||||
# Cython intermediates
|
||||
_speedups.c
|
||||
_speedups.cpp
|
||||
_speedups.html
|
||||
|
||||
# minecraft server stuff
|
||||
jdk*/
|
||||
minecraft*/
|
||||
minecraft_versions.json
|
||||
!worlds/minecraft/
|
||||
|
||||
# pyenv
|
||||
.python-version
|
||||
|
||||
|
||||
@@ -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,24 +0,0 @@
|
||||
<component name="ProjectRunConfigurationManager">
|
||||
<configuration default="false" name="Build APWorlds" type="PythonConfigurationType" factoryName="Python">
|
||||
<module name="Archipelago" />
|
||||
<option name="ENV_FILES" value="" />
|
||||
<option name="INTERPRETER_OPTIONS" value="" />
|
||||
<option name="PARENT_ENVS" value="true" />
|
||||
<envs>
|
||||
<env name="PYTHONUNBUFFERED" value="1" />
|
||||
</envs>
|
||||
<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" />
|
||||
<option name="SCRIPT_NAME" value="$PROJECT_DIR$/Launcher.py" />
|
||||
<option name="PARAMETERS" value=""Build APWorlds"" />
|
||||
<option name="SHOW_COMMAND_LINE" value="false" />
|
||||
<option name="EMULATE_TERMINAL" value="false" />
|
||||
<option name="MODULE_MODE" value="false" />
|
||||
<option name="REDIRECT_INPUT" value="false" />
|
||||
<option name="INPUT_FILE" value="" />
|
||||
<method v="2" />
|
||||
</configuration>
|
||||
</component>
|
||||
@@ -1,9 +0,0 @@
|
||||
import sys
|
||||
from worlds.ahit.Client import launch
|
||||
import Utils
|
||||
import ModuleUpdate
|
||||
ModuleUpdate.update()
|
||||
|
||||
if __name__ == "__main__":
|
||||
Utils.init_logging("AHITClient", exception_logger="Client")
|
||||
launch(*sys.argv[1:])
|
||||
@@ -11,7 +11,6 @@ from typing import List
|
||||
|
||||
|
||||
import Utils
|
||||
from settings import get_settings
|
||||
from NetUtils import ClientStatus
|
||||
from Utils import async_start
|
||||
from CommonClient import CommonContext, server_loop, gui_enabled, ClientCommandProcessor, logger, \
|
||||
@@ -81,8 +80,8 @@ class AdventureContext(CommonContext):
|
||||
self.local_item_locations = {}
|
||||
self.dragon_speed_info = {}
|
||||
|
||||
options = get_settings().adventure_options
|
||||
self.display_msgs = options.display_msgs
|
||||
options = Utils.get_options()
|
||||
self.display_msgs = options["adventure_options"]["display_msgs"]
|
||||
|
||||
async def server_auth(self, password_requested: bool = False):
|
||||
if password_requested and not self.password:
|
||||
@@ -103,7 +102,7 @@ class AdventureContext(CommonContext):
|
||||
def on_package(self, cmd: str, args: dict):
|
||||
if cmd == 'Connected':
|
||||
self.locations_array = None
|
||||
if get_settings().adventure_options.as_dict().get("death_link", False):
|
||||
if Utils.get_options()["adventure_options"].get("death_link", False):
|
||||
self.set_deathlink = True
|
||||
async_start(self.get_freeincarnates_used())
|
||||
elif cmd == "RoomInfo":
|
||||
@@ -113,7 +112,7 @@ class AdventureContext(CommonContext):
|
||||
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']])}"
|
||||
msg = f"Received {', '.join([self.item_names[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"]:
|
||||
@@ -407,7 +406,6 @@ async def atari_sync_task(ctx: AdventureContext):
|
||||
except ConnectionRefusedError:
|
||||
logger.debug("Connection Refused, Trying Again")
|
||||
ctx.atari_status = CONNECTION_REFUSED_STATUS
|
||||
await asyncio.sleep(1)
|
||||
continue
|
||||
except CancelledError:
|
||||
pass
|
||||
@@ -417,9 +415,8 @@ async def atari_sync_task(ctx: AdventureContext):
|
||||
|
||||
|
||||
async def run_game(romfile):
|
||||
options = get_settings().adventure_options
|
||||
auto_start = options.rom_start
|
||||
rom_args = options.rom_args
|
||||
auto_start = Utils.get_options()["adventure_options"].get("rom_start", True)
|
||||
rom_args = Utils.get_options()["adventure_options"].get("rom_args")
|
||||
if auto_start is True:
|
||||
import webbrowser
|
||||
webbrowser.open(romfile)
|
||||
@@ -514,7 +511,7 @@ if __name__ == '__main__':
|
||||
|
||||
import colorama
|
||||
|
||||
colorama.just_fix_windows_console()
|
||||
colorama.init()
|
||||
|
||||
asyncio.run(main())
|
||||
colorama.deinit()
|
||||
|
||||
1173
BaseClasses.py
@@ -1,10 +1,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import ModuleUpdate
|
||||
ModuleUpdate.update()
|
||||
|
||||
from worlds._bizhawk.context import launch
|
||||
|
||||
if __name__ == "__main__":
|
||||
launch(*sys.argv[1:])
|
||||
launch()
|
||||
|
||||
497
CommonClient.py
Executable file → Normal file
@@ -1,6 +1,5 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import collections
|
||||
import copy
|
||||
import logging
|
||||
import asyncio
|
||||
@@ -9,7 +8,6 @@ import sys
|
||||
import typing
|
||||
import time
|
||||
import functools
|
||||
import warnings
|
||||
|
||||
import ModuleUpdate
|
||||
ModuleUpdate.update()
|
||||
@@ -21,20 +19,22 @@ import Utils
|
||||
if __name__ == "__main__":
|
||||
Utils.init_logging("TextClient", exception_logger="Client")
|
||||
|
||||
from MultiServer import CommandProcessor, mark_raw
|
||||
from NetUtils import (Endpoint, decode, NetworkItem, encode, JSONtoTextParser, ClientStatus, Permission, NetworkSlot,
|
||||
RawJSONtoTextParser, add_json_text, add_json_location, add_json_item, JSONTypes, HintStatus, SlotType)
|
||||
from Utils import gui_enabled, Version, stream_input, async_start
|
||||
from MultiServer import CommandProcessor
|
||||
from NetUtils import Endpoint, decode, NetworkItem, encode, JSONtoTextParser, \
|
||||
ClientStatus, Permission, NetworkSlot, RawJSONtoTextParser
|
||||
from Utils import Version, stream_input, async_start
|
||||
from worlds import network_data_package, AutoWorldRegister
|
||||
import os
|
||||
import ssl
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
import kvui
|
||||
import argparse
|
||||
|
||||
logger = logging.getLogger("Client")
|
||||
|
||||
# without terminal, we have to use gui mode
|
||||
gui_enabled = not sys.stdout or "--nogui" not in sys.argv
|
||||
|
||||
|
||||
@Utils.cache_argsless
|
||||
def get_ssl_context():
|
||||
@@ -43,27 +43,14 @@ def get_ssl_context():
|
||||
|
||||
|
||||
class ClientCommandProcessor(CommandProcessor):
|
||||
"""
|
||||
The Command Processor will parse every method of the class that starts with "_cmd_" as a command to be called
|
||||
when parsing user input, i.e. _cmd_exit will be called when the user sends the command "/exit".
|
||||
|
||||
The decorator @mark_raw can be imported from MultiServer and tells the parser to only split on the first
|
||||
space after the command i.e. "/exit one two three" will be passed in as method("one two three") with mark_raw
|
||||
and method("one", "two", "three") without.
|
||||
|
||||
In addition all docstrings for command methods will be displayed to the user on launch and when using "/help"
|
||||
"""
|
||||
def __init__(self, ctx: CommonContext):
|
||||
self.ctx = ctx
|
||||
|
||||
def output(self, text: str):
|
||||
"""Helper function to abstract logging to the CommonClient UI"""
|
||||
logger.info(text)
|
||||
|
||||
def _cmd_exit(self) -> bool:
|
||||
"""Close connections and client"""
|
||||
if self.ctx.ui:
|
||||
self.ctx.ui.stop()
|
||||
self.ctx.exit_event.set()
|
||||
return True
|
||||
|
||||
@@ -72,7 +59,6 @@ class ClientCommandProcessor(CommandProcessor):
|
||||
if address:
|
||||
self.ctx.server_address = None
|
||||
self.ctx.username = None
|
||||
self.ctx.password = None
|
||||
elif not self.ctx.server_address:
|
||||
self.output("Please specify an address.")
|
||||
return False
|
||||
@@ -86,16 +72,9 @@ class ClientCommandProcessor(CommandProcessor):
|
||||
|
||||
def _cmd_received(self) -> bool:
|
||||
"""List all received items"""
|
||||
item: NetworkItem
|
||||
self.output(f'{len(self.ctx.items_received)} received items, sorted by time:')
|
||||
self.output(f'{len(self.ctx.items_received)} received items:')
|
||||
for index, item in enumerate(self.ctx.items_received, 1):
|
||||
parts = []
|
||||
add_json_item(parts, item.item, self.ctx.slot, item.flags)
|
||||
add_json_text(parts, " from ")
|
||||
add_json_location(parts, item.location, item.player)
|
||||
add_json_text(parts, " by ")
|
||||
add_json_text(parts, item.player, type=JSONTypes.player_id)
|
||||
self.ctx.on_print_json({"data": parts, "cmd": "PrintJSON"})
|
||||
self.output(f"{self.ctx.item_names[item.item]} from {self.ctx.player_names[item.player]}")
|
||||
return True
|
||||
|
||||
def _cmd_missing(self, filter_text = "") -> bool:
|
||||
@@ -106,9 +85,7 @@ class ClientCommandProcessor(CommandProcessor):
|
||||
return False
|
||||
count = 0
|
||||
checked_count = 0
|
||||
|
||||
lookup = self.ctx.location_names[self.ctx.game]
|
||||
for location_id, location in lookup.items():
|
||||
for location, location_id in AutoWorldRegister.world_types[self.ctx.game].location_name_to_id.items():
|
||||
if filter_text and filter_text not in location:
|
||||
continue
|
||||
if location_id < 0:
|
||||
@@ -129,87 +106,25 @@ class ClientCommandProcessor(CommandProcessor):
|
||||
self.output("No missing location checks found.")
|
||||
return True
|
||||
|
||||
def output_datapackage_part(self, name: typing.Literal["Item Names", "Location Names"]) -> bool:
|
||||
"""
|
||||
Helper to digest a specific section of this game's datapackage.
|
||||
|
||||
:param name: Printed to the user as context for the part.
|
||||
|
||||
:return: Whether the process was successful.
|
||||
"""
|
||||
if not self.ctx.game:
|
||||
self.output(f"No game set, cannot determine {name}.")
|
||||
return False
|
||||
|
||||
lookup = self.ctx.item_names if name == "Item Names" else self.ctx.location_names
|
||||
lookup = lookup[self.ctx.game]
|
||||
self.output(f"{name} for {self.ctx.game}")
|
||||
for name in lookup.values():
|
||||
self.output(name)
|
||||
return True
|
||||
|
||||
def _cmd_items(self) -> bool:
|
||||
def _cmd_items(self):
|
||||
"""List all item names for the currently running game."""
|
||||
return self.output_datapackage_part("Item Names")
|
||||
|
||||
def _cmd_locations(self) -> bool:
|
||||
"""List all location names for the currently running game."""
|
||||
return self.output_datapackage_part("Location Names")
|
||||
|
||||
def output_group_part(self, group_key: typing.Literal["item_name_groups", "location_name_groups"],
|
||||
filter_key: str,
|
||||
name: str) -> bool:
|
||||
"""
|
||||
Logs an item or location group from the player's game's datapackage.
|
||||
|
||||
:param group_key: Either Item or Location group to be processed.
|
||||
:param filter_key: Which group key to filter to. If an empty string is passed will log all item/location groups.
|
||||
:param name: Printed to the user as context for the part.
|
||||
|
||||
:return: Whether the process was successful.
|
||||
"""
|
||||
if not self.ctx.game:
|
||||
self.output(f"No game set, cannot determine existing {name} Groups.")
|
||||
self.output("No game set, cannot determine existing items.")
|
||||
return False
|
||||
lookup = Utils.persistent_load().get("groups_by_checksum", {}).get(self.ctx.checksums[self.ctx.game], {})\
|
||||
.get(self.ctx.game, {}).get(group_key, {})
|
||||
if lookup is None:
|
||||
self.output("datapackage not yet loaded, try again")
|
||||
self.output(f"Item Names for {self.ctx.game}")
|
||||
for item_name in AutoWorldRegister.world_types[self.ctx.game].item_name_to_id:
|
||||
self.output(item_name)
|
||||
|
||||
def _cmd_locations(self):
|
||||
"""List all location names for the currently running game."""
|
||||
if not self.ctx.game:
|
||||
self.output("No game set, cannot determine existing locations.")
|
||||
return False
|
||||
self.output(f"Location Names for {self.ctx.game}")
|
||||
for location_name in AutoWorldRegister.world_types[self.ctx.game].location_name_to_id:
|
||||
self.output(location_name)
|
||||
|
||||
if filter_key:
|
||||
if filter_key not in lookup:
|
||||
self.output(f"Unknown {name} Group {filter_key}")
|
||||
return False
|
||||
|
||||
self.output(f"{name}s for {name} Group \"{filter_key}\"")
|
||||
for entry in lookup[filter_key]:
|
||||
self.output(entry)
|
||||
else:
|
||||
self.output(f"{name} Groups for {self.ctx.game}")
|
||||
for group in lookup:
|
||||
self.output(group)
|
||||
return True
|
||||
|
||||
@mark_raw
|
||||
def _cmd_item_groups(self, key: str = "") -> bool:
|
||||
"""
|
||||
List all item group names for the currently running game.
|
||||
|
||||
:param key: Which item group to filter to. Will log all groups if empty.
|
||||
"""
|
||||
return self.output_group_part("item_name_groups", key, "Item")
|
||||
|
||||
@mark_raw
|
||||
def _cmd_location_groups(self, key: str = "") -> bool:
|
||||
"""
|
||||
List all location group names for the currently running game.
|
||||
|
||||
:param key: Which item group to filter to. Will log all groups if empty.
|
||||
"""
|
||||
return self.output_group_part("location_name_groups", key, "Location")
|
||||
|
||||
def _cmd_ready(self) -> bool:
|
||||
def _cmd_ready(self):
|
||||
"""Send ready status to server."""
|
||||
self.ctx.ready = not self.ctx.ready
|
||||
if self.ctx.ready:
|
||||
@@ -219,85 +134,30 @@ class ClientCommandProcessor(CommandProcessor):
|
||||
state = ClientStatus.CLIENT_CONNECTED
|
||||
self.output("Unreadied.")
|
||||
async_start(self.ctx.send_msgs([{"cmd": "StatusUpdate", "status": state}]), name="send StatusUpdate")
|
||||
return True
|
||||
|
||||
def default(self, raw: str):
|
||||
"""The default message parser to be used when parsing any messages that do not match a command"""
|
||||
raw = self.ctx.on_user_say(raw)
|
||||
if raw:
|
||||
async_start(self.ctx.send_msgs([{"cmd": "Say", "text": raw}]), name="send Say")
|
||||
|
||||
|
||||
class CommonContext:
|
||||
# The following attributes are used to Connect and should be adjusted as needed in subclasses
|
||||
# Should be adjusted as needed in subclasses
|
||||
tags: typing.Set[str] = {"AP"}
|
||||
game: typing.Optional[str] = None
|
||||
items_handling: typing.Optional[int] = None
|
||||
want_slot_data: bool = True # should slot_data be retrieved via Connect
|
||||
|
||||
class NameLookupDict:
|
||||
"""A specialized dict, with helper methods, for id -> name item/location data package lookups by game."""
|
||||
def __init__(self, ctx: CommonContext, lookup_type: typing.Literal["item", "location"]):
|
||||
self.ctx: CommonContext = ctx
|
||||
self.lookup_type: typing.Literal["item", "location"] = lookup_type
|
||||
self._unknown_item: typing.Callable[[int], str] = lambda key: f"Unknown {lookup_type} (ID: {key})"
|
||||
self._archipelago_lookup: typing.Dict[int, str] = {}
|
||||
self._game_store: typing.Dict[str, typing.ChainMap[int, str]] = collections.defaultdict(
|
||||
lambda: collections.ChainMap(self._archipelago_lookup, Utils.KeyedDefaultDict(self._unknown_item)))
|
||||
|
||||
# noinspection PyTypeChecker
|
||||
def __getitem__(self, key: str) -> typing.Mapping[int, str]:
|
||||
assert isinstance(key, str), f"ctx.{self.lookup_type}_names used with an id, use the lookup_in_ helpers instead"
|
||||
return self._game_store[key]
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self._game_store)
|
||||
|
||||
def __iter__(self) -> typing.Iterator[str]:
|
||||
return iter(self._game_store)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return repr(self._game_store)
|
||||
|
||||
def lookup_in_game(self, code: int, game_name: typing.Optional[str] = None) -> str:
|
||||
"""Returns the name for an item/location id in the context of a specific game or own game if `game` is
|
||||
omitted.
|
||||
"""
|
||||
if game_name is None:
|
||||
game_name = self.ctx.game
|
||||
assert game_name is not None, f"Attempted to lookup {self.lookup_type} with no game name available."
|
||||
|
||||
return self._game_store[game_name][code]
|
||||
|
||||
def lookup_in_slot(self, code: int, slot: typing.Optional[int] = None) -> str:
|
||||
"""Returns the name for an item/location id in the context of a specific slot or own slot if `slot` is
|
||||
omitted.
|
||||
|
||||
Use of `lookup_in_slot` should not be used when not connected to a server. If looking in own game, set
|
||||
`ctx.game` and use `lookup_in_game` method instead.
|
||||
"""
|
||||
if slot is None:
|
||||
slot = self.ctx.slot
|
||||
assert slot is not None, f"Attempted to lookup {self.lookup_type} with no slot info available."
|
||||
|
||||
return self.lookup_in_game(code, self.ctx.slot_info[slot].game)
|
||||
|
||||
def update_game(self, game: str, name_to_id_lookup_table: typing.Dict[str, int]) -> None:
|
||||
"""Overrides existing lookup tables for a particular game."""
|
||||
id_to_name_lookup_table = Utils.KeyedDefaultDict(self._unknown_item)
|
||||
id_to_name_lookup_table.update({code: name for name, code in name_to_id_lookup_table.items()})
|
||||
self._game_store[game] = collections.ChainMap(self._archipelago_lookup, id_to_name_lookup_table)
|
||||
if game == "Archipelago":
|
||||
# Keep track of the Archipelago data package separately so if it gets updated in a custom datapackage,
|
||||
# it updates in all chain maps automatically.
|
||||
self._archipelago_lookup.clear()
|
||||
self._archipelago_lookup.update(id_to_name_lookup_table)
|
||||
# data package
|
||||
# Contents in flux until connection to server is made, to download correct data for this multiworld.
|
||||
item_names: typing.Dict[int, str] = Utils.KeyedDefaultDict(lambda code: f'Unknown item (ID:{code})')
|
||||
location_names: typing.Dict[int, str] = Utils.KeyedDefaultDict(lambda code: f'Unknown location (ID:{code})')
|
||||
|
||||
# defaults
|
||||
starting_reconnect_delay: int = 5
|
||||
current_reconnect_delay: int = starting_reconnect_delay
|
||||
command_processor: typing.Type[CommandProcessor] = ClientCommandProcessor
|
||||
ui: typing.Optional["kvui.GameManager"] = None
|
||||
ui = None
|
||||
ui_task: typing.Optional["asyncio.Task[None]"] = None
|
||||
input_task: typing.Optional["asyncio.Task[None]"] = None
|
||||
keep_alive_task: typing.Optional["asyncio.Task[None]"] = None
|
||||
@@ -308,78 +168,42 @@ class CommonContext:
|
||||
server_version: Version = Version(0, 0, 0)
|
||||
generator_version: Version = Version(0, 0, 0)
|
||||
current_energy_link_value: typing.Optional[int] = None # to display in UI, gets set by server
|
||||
max_size: int = 16*1024*1024 # 16 MB of max incoming packet size
|
||||
|
||||
last_death_link: float = time.time() # last send/received death link on AP layer
|
||||
|
||||
# remaining type info
|
||||
slot_info: dict[int, NetworkSlot]
|
||||
"""Slot Info from the server for the current connection"""
|
||||
server_address: str | None
|
||||
"""Autoconnect address provided by the ctx constructor"""
|
||||
password: str | None
|
||||
"""Password used for Connecting, expected by server_auth"""
|
||||
hint_cost: int | None
|
||||
"""Current Hint Cost per Hint from the server"""
|
||||
hint_points: int | None
|
||||
"""Current available Hint Points from the server"""
|
||||
player_names: dict[int, str]
|
||||
"""Current lookup of slot number to player display name from server (includes aliases)"""
|
||||
slot_info: typing.Dict[int, NetworkSlot]
|
||||
server_address: typing.Optional[str]
|
||||
password: typing.Optional[str]
|
||||
hint_cost: typing.Optional[int]
|
||||
hint_points: typing.Optional[int]
|
||||
player_names: typing.Dict[int, str]
|
||||
|
||||
finished_game: bool
|
||||
"""
|
||||
Bool to signal that status should be updated to Goal after reconnecting
|
||||
to be used to ensure that a StatusUpdate packet does not get lost when disconnected
|
||||
"""
|
||||
ready: bool
|
||||
"""Bool to keep track of state for the /ready command"""
|
||||
team: int | None
|
||||
"""Team number of currently connected slot"""
|
||||
slot: int | None
|
||||
"""Slot number of currently connected slot"""
|
||||
auth: str | None
|
||||
"""Name used in Connect packet"""
|
||||
seed_name: str | None
|
||||
"""Seed name that will be validated on opening a socket if present"""
|
||||
auth: typing.Optional[str]
|
||||
seed_name: typing.Optional[str]
|
||||
|
||||
# locations
|
||||
locations_checked: set[int]
|
||||
"""
|
||||
Local container of location ids checked to signal that LocationChecks should be resent after reconnecting
|
||||
to be used to ensure that a LocationChecks packet does not get lost when disconnected
|
||||
"""
|
||||
locations_scouted: set[int]
|
||||
"""
|
||||
Local container of location ids scouted to signal that LocationScouts should be resent after reconnecting
|
||||
to be used to ensure that a LocationScouts packet does not get lost when disconnected
|
||||
"""
|
||||
items_received: list[NetworkItem]
|
||||
"""List of NetworkItems recieved from the server"""
|
||||
missing_locations: set[int]
|
||||
"""Container of Locations that are unchecked per server state"""
|
||||
checked_locations: set[int]
|
||||
"""Container of Locations that are checked per server state"""
|
||||
server_locations: set[int]
|
||||
"""Container of Locations that exist per server state; a combination between missing and checked locations"""
|
||||
locations_info: dict[int, NetworkItem]
|
||||
"""Dict of location id: NetworkItem info from LocationScouts request"""
|
||||
locations_checked: typing.Set[int] # local state
|
||||
locations_scouted: typing.Set[int]
|
||||
items_received: typing.List[NetworkItem]
|
||||
missing_locations: typing.Set[int] # server state
|
||||
checked_locations: typing.Set[int] # server state
|
||||
server_locations: typing.Set[int] # all locations the server knows of, missing_location | checked_locations
|
||||
locations_info: typing.Dict[int, NetworkItem]
|
||||
|
||||
# data storage
|
||||
stored_data: dict[str, typing.Any]
|
||||
"""
|
||||
Data Storage values by key that were retrieved from the server
|
||||
any keys subscribed to with SetNotify will be kept up to date
|
||||
"""
|
||||
stored_data_notification_keys: set[str]
|
||||
"""Current container of watched Data Storage keys, managed by ctx.set_notify"""
|
||||
stored_data: typing.Dict[str, typing.Any]
|
||||
stored_data_notification_keys: typing.Set[str]
|
||||
|
||||
# internals
|
||||
# current message box through kvui
|
||||
_messagebox: typing.Optional["kvui.MessageBox"] = None
|
||||
"""Current message box through kvui"""
|
||||
# message box reporting a loss of connection
|
||||
_messagebox_connection_loss: typing.Optional["kvui.MessageBox"] = None
|
||||
"""Message box reporting a loss of connection"""
|
||||
|
||||
def __init__(self, server_address: typing.Optional[str] = None, password: typing.Optional[str] = None) -> None:
|
||||
def __init__(self, server_address: typing.Optional[str], password: typing.Optional[str]) -> None:
|
||||
# server state
|
||||
self.server_address = server_address
|
||||
self.username = None
|
||||
@@ -419,14 +243,8 @@ class CommonContext:
|
||||
self.exit_event = asyncio.Event()
|
||||
self.watcher_event = asyncio.Event()
|
||||
|
||||
self.item_names = self.NameLookupDict(self, "item")
|
||||
self.location_names = self.NameLookupDict(self, "location")
|
||||
self.checksums = {}
|
||||
|
||||
self.jsontotextparser = JSONtoTextParser(self)
|
||||
self.rawjsontotextparser = RawJSONtoTextParser(self)
|
||||
if self.game:
|
||||
self.checksums[self.game] = network_data_package["games"][self.game]["checksum"]
|
||||
self.update_data_package(network_data_package)
|
||||
|
||||
# execution
|
||||
@@ -479,8 +297,6 @@ class CommonContext:
|
||||
await self.server.socket.close()
|
||||
if self.server_task is not None:
|
||||
await self.server_task
|
||||
if self.ui:
|
||||
self.ui.update_hints()
|
||||
|
||||
async def send_msgs(self, msgs: typing.List[typing.Any]) -> None:
|
||||
""" `msgs` JSON serializable """
|
||||
@@ -512,10 +328,7 @@ class CommonContext:
|
||||
self.auth = await self.console_input()
|
||||
|
||||
async def send_connect(self, **kwargs: typing.Any) -> None:
|
||||
"""
|
||||
Send a `Connect` packet to log in to the server,
|
||||
additional keyword args can override any value in the connection packet
|
||||
"""
|
||||
""" send `Connect` packet to log in to server """
|
||||
payload = {
|
||||
'cmd': 'Connect',
|
||||
'password': self.password, 'name': self.auth, 'version': Utils.version_tuple,
|
||||
@@ -525,14 +338,6 @@ class CommonContext:
|
||||
if kwargs:
|
||||
payload.update(kwargs)
|
||||
await self.send_msgs([payload])
|
||||
await self.send_msgs([{"cmd": "Get", "keys": ["_read_race_mode"]}])
|
||||
|
||||
async def check_locations(self, locations: typing.Collection[int]) -> set[int]:
|
||||
"""Send new location checks to the server. Returns the set of actually new locations that were sent."""
|
||||
locations = set(locations) & self.missing_locations
|
||||
if locations:
|
||||
await self.send_msgs([{"cmd": 'LocationChecks', "locations": tuple(locations)}])
|
||||
return locations
|
||||
|
||||
async def console_input(self) -> str:
|
||||
if self.ui:
|
||||
@@ -553,7 +358,6 @@ class CommonContext:
|
||||
return False
|
||||
|
||||
def slot_concerns_self(self, slot) -> bool:
|
||||
"""Helper function to abstract player groups, should be used instead of checking slot == self.slot directly."""
|
||||
if slot == self.slot:
|
||||
return True
|
||||
if slot in self.slot_info:
|
||||
@@ -561,7 +365,6 @@ class CommonContext:
|
||||
return False
|
||||
|
||||
def is_echoed_chat(self, print_json_packet: dict) -> bool:
|
||||
"""Helper function for filtering out messages sent by self."""
|
||||
return print_json_packet.get("type", "") == "Chat" \
|
||||
and print_json_packet.get("team", None) == self.team \
|
||||
and print_json_packet.get("slot", None) == self.slot
|
||||
@@ -571,10 +374,6 @@ class CommonContext:
|
||||
return print_json_packet.get("type", "") == "ItemSend" \
|
||||
and not self.slot_concerns_self(print_json_packet["receiving"]) \
|
||||
and not self.slot_concerns_self(print_json_packet["item"].player)
|
||||
|
||||
def is_connection_change(self, print_json_packet: dict) -> bool:
|
||||
"""Helper function for filtering out connection changes."""
|
||||
return print_json_packet.get("type", "") in ["Join","Part"]
|
||||
|
||||
def on_print(self, args: dict):
|
||||
logger.info(args["text"])
|
||||
@@ -598,13 +397,7 @@ class CommonContext:
|
||||
Returned text is sent, or sending is aborted if None is returned."""
|
||||
return text
|
||||
|
||||
def on_ui_command(self, text: str) -> None:
|
||||
"""Gets called by kivy when the user executes a command starting with `/` or `!`.
|
||||
The command processor is still called; this is just intended for command echoing."""
|
||||
self.ui.print_json([{"text": text, "type": "color", "color": "orange"}])
|
||||
|
||||
def update_permissions(self, permissions: typing.Dict[str, int]):
|
||||
"""Internal method to parse and save server permissions from RoomInfo"""
|
||||
for permission_name, permission_flag in permissions.items():
|
||||
try:
|
||||
flag = Permission(permission_flag)
|
||||
@@ -616,7 +409,6 @@ class CommonContext:
|
||||
async def shutdown(self):
|
||||
self.server_address = ""
|
||||
self.username = None
|
||||
self.password = None
|
||||
self.cancel_autoreconnect()
|
||||
if self.server and not self.server.socket.closed:
|
||||
await self.server.socket.close()
|
||||
@@ -631,16 +423,10 @@ class CommonContext:
|
||||
await self.ui_task
|
||||
if self.input_task:
|
||||
self.input_task.cancel()
|
||||
|
||||
# Hints
|
||||
def update_hint(self, location: int, finding_player: int, status: typing.Optional[HintStatus]) -> None:
|
||||
msg = {"cmd": "UpdateHint", "location": location, "player": finding_player}
|
||||
if status is not None:
|
||||
msg["status"] = status
|
||||
async_start(self.send_msgs([msg]), name="update_hint")
|
||||
|
||||
|
||||
# DataPackage
|
||||
async def prepare_data_package(self, relevant_games: typing.Set[str],
|
||||
remote_date_package_versions: typing.Dict[str, int],
|
||||
remote_data_package_checksums: typing.Dict[str, str]):
|
||||
"""Validate that all data is present for the current multiworld.
|
||||
Download, assimilate and cache missing data from the server."""
|
||||
@@ -649,65 +435,51 @@ class CommonContext:
|
||||
|
||||
needed_updates: typing.Set[str] = set()
|
||||
for game in relevant_games:
|
||||
if game not in remote_data_package_checksums:
|
||||
if game not in remote_date_package_versions and game not in remote_data_package_checksums:
|
||||
continue
|
||||
|
||||
remote_version: int = remote_date_package_versions.get(game, 0)
|
||||
remote_checksum: typing.Optional[str] = remote_data_package_checksums.get(game)
|
||||
|
||||
if not remote_checksum: # custom data package and no checksum for this game
|
||||
if remote_version == 0 and not remote_checksum: # custom data package and no checksum for this game
|
||||
needed_updates.add(game)
|
||||
continue
|
||||
|
||||
cached_checksum: typing.Optional[str] = self.checksums.get(game)
|
||||
# no action required if cached version is new enough
|
||||
if remote_checksum != cached_checksum:
|
||||
local_checksum: typing.Optional[str] = network_data_package["games"].get(game, {}).get("checksum")
|
||||
if remote_checksum == local_checksum:
|
||||
self.update_game(network_data_package["games"][game], game)
|
||||
local_version: int = network_data_package["games"].get(game, {}).get("version", 0)
|
||||
local_checksum: typing.Optional[str] = network_data_package["games"].get(game, {}).get("checksum")
|
||||
# no action required if local version is new enough
|
||||
if (not remote_checksum and (remote_version > local_version or remote_version == 0)) \
|
||||
or remote_checksum != local_checksum:
|
||||
cached_game = Utils.load_data_package_for_checksum(game, remote_checksum)
|
||||
cache_version: int = cached_game.get("version", 0)
|
||||
cache_checksum: typing.Optional[str] = cached_game.get("checksum")
|
||||
# download remote version if cache is not new enough
|
||||
if (not remote_checksum and (remote_version > cache_version or remote_version == 0)) \
|
||||
or remote_checksum != cache_checksum:
|
||||
needed_updates.add(game)
|
||||
else:
|
||||
cached_game = Utils.load_data_package_for_checksum(game, remote_checksum)
|
||||
cache_checksum: typing.Optional[str] = cached_game.get("checksum")
|
||||
# download remote version if cache is not new enough
|
||||
if remote_checksum != cache_checksum:
|
||||
needed_updates.add(game)
|
||||
else:
|
||||
self.update_game(cached_game, game)
|
||||
self.update_game(cached_game)
|
||||
if needed_updates:
|
||||
await self.send_msgs([{"cmd": "GetDataPackage", "games": [game_name]} for game_name in needed_updates])
|
||||
await self.send_msgs([{"cmd": "GetDataPackage", "games": list(needed_updates)}])
|
||||
|
||||
def update_game(self, game_package: dict, game: str):
|
||||
self.item_names.update_game(game, game_package["item_name_to_id"])
|
||||
self.location_names.update_game(game, game_package["location_name_to_id"])
|
||||
self.checksums[game] = game_package.get("checksum")
|
||||
def update_game(self, game_package: dict):
|
||||
for item_name, item_id in game_package["item_name_to_id"].items():
|
||||
self.item_names[item_id] = item_name
|
||||
for location_name, location_id in game_package["location_name_to_id"].items():
|
||||
self.location_names[location_id] = location_name
|
||||
|
||||
def update_data_package(self, data_package: dict):
|
||||
for game, game_data in data_package["games"].items():
|
||||
self.update_game(game_data, game)
|
||||
self.update_game(game_data)
|
||||
|
||||
def consume_network_data_package(self, data_package: dict):
|
||||
self.update_data_package(data_package)
|
||||
logger.info(f"Got new ID/Name DataPackage for {', '.join(data_package['games'])}")
|
||||
current_cache = Utils.persistent_load().get("datapackage", {}).get("games", {})
|
||||
current_cache.update(data_package["games"])
|
||||
Utils.persistent_store("datapackage", "games", current_cache)
|
||||
for game, game_data in data_package["games"].items():
|
||||
Utils.store_data_package_for_checksum(game, game_data)
|
||||
|
||||
def consume_network_item_groups(self):
|
||||
data = {"item_name_groups": self.stored_data[f"_read_item_name_groups_{self.game}"]}
|
||||
current_cache = Utils.persistent_load().get("groups_by_checksum", {}).get(self.checksums[self.game], {})
|
||||
if self.game in current_cache:
|
||||
current_cache[self.game].update(data)
|
||||
else:
|
||||
current_cache[self.game] = data
|
||||
Utils.persistent_store("groups_by_checksum", self.checksums[self.game], current_cache)
|
||||
|
||||
def consume_network_location_groups(self):
|
||||
data = {"location_name_groups": self.stored_data[f"_read_location_name_groups_{self.game}"]}
|
||||
current_cache = Utils.persistent_load().get("groups_by_checksum", {}).get(self.checksums[self.game], {})
|
||||
if self.game in current_cache:
|
||||
current_cache[self.game].update(data)
|
||||
else:
|
||||
current_cache[self.game] = data
|
||||
Utils.persistent_store("groups_by_checksum", self.checksums[self.game], current_cache)
|
||||
|
||||
# data storage
|
||||
|
||||
def set_notify(self, *keys: str) -> None:
|
||||
@@ -735,7 +507,6 @@ class CommonContext:
|
||||
logger.info(f"DeathLink: Received from {data['source']}")
|
||||
|
||||
async def send_death(self, death_text: str = ""):
|
||||
"""Helper function to send a deathlink using death_text as the unique death cause string."""
|
||||
if self.server and self.server.socket:
|
||||
logger.info("DeathLink: Sending death to your friends...")
|
||||
self.last_death_link = time.time()
|
||||
@@ -749,7 +520,6 @@ class CommonContext:
|
||||
}])
|
||||
|
||||
async def update_death_link(self, death_link: bool):
|
||||
"""Helper function to set Death Link connection tag on/off and update the connection if already connected."""
|
||||
old_tags = self.tags.copy()
|
||||
if death_link:
|
||||
self.tags.add("DeathLink")
|
||||
@@ -759,7 +529,7 @@ class CommonContext:
|
||||
await self.send_msgs([{"cmd": "ConnectUpdate", "tags": self.tags}])
|
||||
|
||||
def gui_error(self, title: str, text: typing.Union[Exception, str]) -> typing.Optional["kvui.MessageBox"]:
|
||||
"""Displays an error messagebox in the loaded Kivy UI. Override if using a different UI framework"""
|
||||
"""Displays an error messagebox"""
|
||||
if not self.ui:
|
||||
return None
|
||||
title = title or "Error"
|
||||
@@ -786,36 +556,21 @@ class CommonContext:
|
||||
logger.exception(msg, exc_info=exc_info, extra={'compact_gui': True})
|
||||
self._messagebox_connection_loss = self.gui_error(msg, exc_info[1])
|
||||
|
||||
def make_gui(self) -> "type[kvui.GameManager]":
|
||||
"""
|
||||
To return the Kivy `App` class needed for `run_gui` so it can be overridden before being built
|
||||
|
||||
Common changes are changing `base_title` to update the window title of the client and
|
||||
updating `logging_pairs` to automatically make new tabs that can be filled with their respective logger.
|
||||
|
||||
ex. `logging_pairs.append(("Foo", "Bar"))`
|
||||
will add a "Bar" tab which follows the logger returned from `logging.getLogger("Foo")`
|
||||
"""
|
||||
def run_gui(self):
|
||||
"""Import kivy UI system and start running it as self.ui_task."""
|
||||
from kvui import GameManager
|
||||
|
||||
class TextManager(GameManager):
|
||||
logging_pairs = [
|
||||
("Client", "Archipelago")
|
||||
]
|
||||
base_title = "Archipelago Text Client"
|
||||
|
||||
return TextManager
|
||||
|
||||
def run_gui(self):
|
||||
"""Import kivy UI system from make_gui() and start running it as self.ui_task."""
|
||||
ui_class = self.make_gui()
|
||||
self.ui = ui_class(self)
|
||||
self.ui = TextManager(self)
|
||||
self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI")
|
||||
|
||||
def run_cli(self):
|
||||
if sys.stdin:
|
||||
if sys.stdin.fileno() != 0:
|
||||
from multiprocessing import parent_process
|
||||
if parent_process():
|
||||
return # ignore MultiProcessing pipe
|
||||
|
||||
# steam overlay breaks when starting console_loop
|
||||
if 'gameoverlayrenderer' in os.environ.get('LD_PRELOAD', ''):
|
||||
logger.info("Skipping terminal input, due to conflicting Steam Overlay detected. Please use GUI only.")
|
||||
@@ -859,19 +614,18 @@ async def server_loop(ctx: CommonContext, address: typing.Optional[str] = None)
|
||||
|
||||
server_url = urllib.parse.urlparse(address)
|
||||
if server_url.username:
|
||||
ctx.username = urllib.parse.unquote(server_url.username)
|
||||
ctx.username = server_url.username
|
||||
if server_url.password:
|
||||
ctx.password = urllib.parse.unquote(server_url.password)
|
||||
ctx.password = server_url.password
|
||||
port = server_url.port or 38281
|
||||
|
||||
def reconnect_hint() -> str:
|
||||
return ", type /connect to reconnect" if ctx.server_address else ""
|
||||
|
||||
logger.info(f'Connecting to Archipelago server at {address}')
|
||||
try:
|
||||
port = server_url.port or 38281 # raises ValueError if invalid
|
||||
socket = await websockets.connect(address, port=port, ping_timeout=None, ping_interval=None,
|
||||
ssl=get_ssl_context() if address.startswith("wss://") else None,
|
||||
max_size=ctx.max_size)
|
||||
ssl=get_ssl_context() if address.startswith("wss://") else None)
|
||||
if ctx.ui is not None:
|
||||
ctx.ui.update_address_bar(server_url.netloc)
|
||||
ctx.server = Endpoint(socket)
|
||||
@@ -966,24 +720,23 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
|
||||
logger.info(' %s (Player %d)' % (network_player.alias, network_player.slot))
|
||||
|
||||
# update data package
|
||||
data_package_versions = args.get("datapackage_versions", {})
|
||||
data_package_checksums = args.get("datapackage_checksums", {})
|
||||
await ctx.prepare_data_package(set(args["games"]), data_package_checksums)
|
||||
await ctx.prepare_data_package(set(args["games"]), data_package_versions, data_package_checksums)
|
||||
|
||||
await ctx.server_auth(args['password'])
|
||||
|
||||
elif cmd == 'DataPackage':
|
||||
logger.info("Got new ID/Name DataPackage")
|
||||
ctx.consume_network_data_package(args['data'])
|
||||
|
||||
elif cmd == 'ConnectionRefused':
|
||||
errors = args["errors"]
|
||||
if 'InvalidSlot' in errors:
|
||||
ctx.disconnected_intentionally = True
|
||||
ctx.event_invalid_slot()
|
||||
elif 'InvalidGame' in errors:
|
||||
ctx.disconnected_intentionally = True
|
||||
ctx.event_invalid_game()
|
||||
elif 'IncompatibleVersion' in errors:
|
||||
ctx.disconnected_intentionally = True
|
||||
raise Exception('Server reported your client version as incompatible. '
|
||||
'This probably means you have to update.')
|
||||
elif 'InvalidItemsHandling' in errors:
|
||||
@@ -1003,17 +756,10 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
|
||||
ctx.team = args["team"]
|
||||
ctx.slot = args["slot"]
|
||||
# int keys get lost in JSON transfer
|
||||
ctx.slot_info = {0: NetworkSlot("Archipelago", "Archipelago", SlotType.player)}
|
||||
ctx.slot_info.update({int(pid): data for pid, data in args["slot_info"].items()})
|
||||
ctx.slot_info = {int(pid): data for pid, data in args["slot_info"].items()}
|
||||
ctx.hint_points = args.get("hint_points", 0)
|
||||
ctx.consume_players_package(args["players"])
|
||||
ctx.stored_data_notification_keys.add(f"_read_hints_{ctx.team}_{ctx.slot}")
|
||||
if ctx.game:
|
||||
game = ctx.game
|
||||
else:
|
||||
game = ctx.slot_info[ctx.slot][1]
|
||||
ctx.stored_data_notification_keys.add(f"_read_item_name_groups_{game}")
|
||||
ctx.stored_data_notification_keys.add(f"_read_location_name_groups_{game}")
|
||||
msgs = []
|
||||
if ctx.locations_checked:
|
||||
msgs.append({"cmd": "LocationChecks",
|
||||
@@ -1094,19 +840,11 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
|
||||
ctx.stored_data.update(args["keys"])
|
||||
if ctx.ui and f"_read_hints_{ctx.team}_{ctx.slot}" in args["keys"]:
|
||||
ctx.ui.update_hints()
|
||||
if f"_read_item_name_groups_{ctx.game}" in args["keys"]:
|
||||
ctx.consume_network_item_groups()
|
||||
if f"_read_location_name_groups_{ctx.game}" in args["keys"]:
|
||||
ctx.consume_network_location_groups()
|
||||
|
||||
elif cmd == "SetReply":
|
||||
ctx.stored_data[args["key"]] = args["value"]
|
||||
if ctx.ui and f"_read_hints_{ctx.team}_{ctx.slot}" == args["key"]:
|
||||
ctx.ui.update_hints()
|
||||
elif f"_read_item_name_groups_{ctx.game}" == args["key"]:
|
||||
ctx.consume_network_item_groups()
|
||||
elif f"_read_location_name_groups_{ctx.game}" == args["key"]:
|
||||
ctx.consume_network_location_groups()
|
||||
elif args["key"].startswith("EnergyLink"):
|
||||
ctx.current_energy_link_value = args["value"]
|
||||
if ctx.ui:
|
||||
@@ -1138,7 +876,6 @@ async def console_loop(ctx: CommonContext):
|
||||
|
||||
|
||||
def get_base_parser(description: typing.Optional[str] = None):
|
||||
"""Base argument parser to be reused for components subclassing off of CommonClient"""
|
||||
import argparse
|
||||
parser = argparse.ArgumentParser(description=description)
|
||||
parser.add_argument('--connect', default=None, help='Address of the multiworld host.')
|
||||
@@ -1148,33 +885,7 @@ def get_base_parser(description: typing.Optional[str] = None):
|
||||
return parser
|
||||
|
||||
|
||||
def handle_url_arg(args: "argparse.Namespace",
|
||||
parser: "typing.Optional[argparse.ArgumentParser]" = None) -> "argparse.Namespace":
|
||||
"""
|
||||
Parse the url arg "archipelago://name:pass@host:port" from launcher into correct launch args for CommonClient
|
||||
If alternate data is required the urlparse response is saved back to args.url if valid
|
||||
"""
|
||||
if not args.url:
|
||||
return args
|
||||
|
||||
url = urllib.parse.urlparse(args.url)
|
||||
if url.scheme != "archipelago":
|
||||
if not parser:
|
||||
parser = get_base_parser()
|
||||
parser.error(f"bad url, found {args.url}, expected url in form of archipelago://archipelago.gg:38281")
|
||||
return args
|
||||
|
||||
args.url = url
|
||||
args.connect = url.netloc
|
||||
if url.username:
|
||||
args.name = urllib.parse.unquote(url.username)
|
||||
if url.password:
|
||||
args.password = urllib.parse.unquote(url.password)
|
||||
|
||||
return args
|
||||
|
||||
|
||||
def run_as_textclient(*args):
|
||||
def run_as_textclient():
|
||||
class TextContext(CommonContext):
|
||||
# Text Mode to use !hint and such with games that have no text entry
|
||||
tags = CommonContext.tags | {"TextOnly"}
|
||||
@@ -1186,7 +897,7 @@ def run_as_textclient(*args):
|
||||
if password_requested and not self.password:
|
||||
await super(TextContext, self).server_auth(password_requested)
|
||||
await self.get_username()
|
||||
await self.send_connect(game="")
|
||||
await self.send_connect()
|
||||
|
||||
def on_package(self, cmd: str, args: dict):
|
||||
if cmd == "Connected":
|
||||
@@ -1213,17 +924,21 @@ def run_as_textclient(*args):
|
||||
parser = get_base_parser(description="Gameless Archipelago Client, for text interfacing.")
|
||||
parser.add_argument('--name', default=None, help="Slot Name to connect as.")
|
||||
parser.add_argument("url", nargs="?", help="Archipelago connection url")
|
||||
args = parser.parse_args(args)
|
||||
args = parser.parse_args()
|
||||
|
||||
args = handle_url_arg(args, parser=parser)
|
||||
if args.url:
|
||||
url = urllib.parse.urlparse(args.url)
|
||||
args.connect = url.netloc
|
||||
if url.username:
|
||||
args.name = urllib.parse.unquote(url.username)
|
||||
if url.password:
|
||||
args.password = urllib.parse.unquote(url.password)
|
||||
|
||||
# use colorama to display colored text highlighting on windows
|
||||
colorama.just_fix_windows_console()
|
||||
colorama.init()
|
||||
|
||||
asyncio.run(main(args))
|
||||
colorama.deinit()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
logging.getLogger().setLevel(logging.INFO) # force log-level to work around log level resetting to WARNING
|
||||
run_as_textclient(*sys.argv[1:]) # default value for parse_args
|
||||
run_as_textclient()
|
||||
|
||||
100
Dockerfile
@@ -1,100 +0,0 @@
|
||||
# hadolint global ignore=SC1090,SC1091
|
||||
|
||||
# Source
|
||||
FROM scratch AS release
|
||||
WORKDIR /release
|
||||
ADD https://github.com/Ijwu/Enemizer/releases/latest/download/ubuntu.16.04-x64.zip Enemizer.zip
|
||||
|
||||
# Enemizer
|
||||
FROM alpine:3.21 AS enemizer
|
||||
ARG TARGETARCH
|
||||
WORKDIR /release
|
||||
COPY --from=release /release/Enemizer.zip .
|
||||
|
||||
# No release for arm architecture. Skip.
|
||||
RUN if [ "$TARGETARCH" = "amd64" ]; then \
|
||||
apk add unzip=6.0-r15 --no-cache && \
|
||||
unzip -u Enemizer.zip -d EnemizerCLI && \
|
||||
chmod -R 777 EnemizerCLI; \
|
||||
else touch EnemizerCLI; fi
|
||||
|
||||
# Cython builder stage
|
||||
FROM python:3.12 AS cython-builder
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
# Copy and install requirements first (better caching)
|
||||
COPY requirements.txt WebHostLib/requirements.txt
|
||||
|
||||
RUN pip install --no-cache-dir -r \
|
||||
WebHostLib/requirements.txt \
|
||||
"setuptools>=75,<81"
|
||||
|
||||
COPY _speedups.pyx .
|
||||
COPY intset.h .
|
||||
|
||||
RUN cythonize -b -i _speedups.pyx
|
||||
|
||||
# Archipelago
|
||||
FROM python:3.12-slim-bookworm AS archipelago
|
||||
ARG TARGETARCH
|
||||
ENV VIRTUAL_ENV=/opt/venv
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
WORKDIR /app
|
||||
|
||||
# Install requirements
|
||||
# hadolint ignore=DL3008
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
git \
|
||||
gcc=4:12.2.0-3 \
|
||||
libc6-dev \
|
||||
libtk8.6=8.6.13-2 \
|
||||
g++=4:12.2.0-3 \
|
||||
curl && \
|
||||
apt-get clean && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Create and activate venv
|
||||
RUN python -m venv $VIRTUAL_ENV; \
|
||||
. $VIRTUAL_ENV/bin/activate
|
||||
|
||||
# Copy and install requirements first (better caching)
|
||||
COPY WebHostLib/requirements.txt WebHostLib/requirements.txt
|
||||
|
||||
RUN pip install --no-cache-dir -r \
|
||||
WebHostLib/requirements.txt \
|
||||
gunicorn==23.0.0
|
||||
|
||||
COPY . .
|
||||
|
||||
COPY --from=cython-builder /build/*.so ./
|
||||
|
||||
# Run ModuleUpdate
|
||||
RUN python ModuleUpdate.py -y
|
||||
|
||||
# Purge unneeded packages
|
||||
RUN apt-get purge -y \
|
||||
git \
|
||||
gcc \
|
||||
libc6-dev \
|
||||
g++ && \
|
||||
apt-get autoremove -y
|
||||
|
||||
# Copy necessary components
|
||||
COPY --from=enemizer /release/EnemizerCLI /tmp/EnemizerCLI
|
||||
|
||||
# No release for arm architecture. Skip.
|
||||
RUN if [ "$TARGETARCH" = "amd64" ]; then \
|
||||
cp -r /tmp/EnemizerCLI EnemizerCLI; \
|
||||
fi; \
|
||||
rm -rf /tmp/EnemizerCLI
|
||||
|
||||
# Define health check
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
|
||||
CMD curl -f http://localhost:${PORT:-80} || exit 1
|
||||
|
||||
# Ensure no runtime ModuleUpdate.
|
||||
ENV SKIP_REQUIREMENTS_UPDATE=true
|
||||
|
||||
ENTRYPOINT [ "python", "WebHost.py" ]
|
||||
267
FF1Client.py
Normal file
@@ -0,0 +1,267 @@
|
||||
import asyncio
|
||||
import copy
|
||||
import json
|
||||
import time
|
||||
from asyncio import StreamReader, StreamWriter
|
||||
from typing import List
|
||||
|
||||
|
||||
import Utils
|
||||
from Utils import async_start
|
||||
from CommonClient import CommonContext, server_loop, gui_enabled, ClientCommandProcessor, logger, \
|
||||
get_base_parser
|
||||
|
||||
SYSTEM_MESSAGE_ID = 0
|
||||
|
||||
CONNECTION_TIMING_OUT_STATUS = "Connection timing out. Please restart your emulator, then restart connector_ff1.lua"
|
||||
CONNECTION_REFUSED_STATUS = "Connection Refused. Please start your emulator and make sure connector_ff1.lua is running"
|
||||
CONNECTION_RESET_STATUS = "Connection was reset. Please restart your emulator, then restart connector_ff1.lua"
|
||||
CONNECTION_TENTATIVE_STATUS = "Initial Connection Made"
|
||||
CONNECTION_CONNECTED_STATUS = "Connected"
|
||||
CONNECTION_INITIAL_STATUS = "Connection has not been initiated"
|
||||
|
||||
DISPLAY_MSGS = True
|
||||
|
||||
|
||||
class FF1CommandProcessor(ClientCommandProcessor):
|
||||
def __init__(self, ctx: CommonContext):
|
||||
super().__init__(ctx)
|
||||
|
||||
def _cmd_nes(self):
|
||||
"""Check NES Connection State"""
|
||||
if isinstance(self.ctx, FF1Context):
|
||||
logger.info(f"NES Status: {self.ctx.nes_status}")
|
||||
|
||||
def _cmd_toggle_msgs(self):
|
||||
"""Toggle displaying messages in EmuHawk"""
|
||||
global DISPLAY_MSGS
|
||||
DISPLAY_MSGS = not DISPLAY_MSGS
|
||||
logger.info(f"Messages are now {'enabled' if DISPLAY_MSGS else 'disabled'}")
|
||||
|
||||
|
||||
class FF1Context(CommonContext):
|
||||
command_processor = FF1CommandProcessor
|
||||
game = 'Final Fantasy'
|
||||
items_handling = 0b111 # full remote
|
||||
|
||||
def __init__(self, server_address, password):
|
||||
super().__init__(server_address, password)
|
||||
self.nes_streams: (StreamReader, StreamWriter) = None
|
||||
self.nes_sync_task = None
|
||||
self.messages = {}
|
||||
self.locations_array = None
|
||||
self.nes_status = CONNECTION_INITIAL_STATUS
|
||||
self.awaiting_rom = False
|
||||
self.display_msgs = True
|
||||
|
||||
async def server_auth(self, password_requested: bool = False):
|
||||
if password_requested and not self.password:
|
||||
await super(FF1Context, self).server_auth(password_requested)
|
||||
if not self.auth:
|
||||
self.awaiting_rom = True
|
||||
logger.info('Awaiting connection to NES to get Player information')
|
||||
return
|
||||
|
||||
await self.send_connect()
|
||||
|
||||
def _set_message(self, msg: str, msg_id: int):
|
||||
if DISPLAY_MSGS:
|
||||
self.messages[time.time(), msg_id] = msg
|
||||
|
||||
def on_package(self, cmd: str, args: dict):
|
||||
if cmd == 'Connected':
|
||||
async_start(parse_locations(self.locations_array, self, True))
|
||||
elif cmd == 'Print':
|
||||
msg = args['text']
|
||||
if ': !' not in msg:
|
||||
self._set_message(msg, SYSTEM_MESSAGE_ID)
|
||||
|
||||
def on_print_json(self, args: dict):
|
||||
if self.ui:
|
||||
self.ui.print_json(copy.deepcopy(args["data"]))
|
||||
else:
|
||||
text = self.jsontotextparser(copy.deepcopy(args["data"]))
|
||||
logger.info(text)
|
||||
relevant = args.get("type", None) in {"Hint", "ItemSend"}
|
||||
if relevant:
|
||||
item = args["item"]
|
||||
# goes to this world
|
||||
if self.slot_concerns_self(args["receiving"]):
|
||||
relevant = True
|
||||
# found in this world
|
||||
elif self.slot_concerns_self(item.player):
|
||||
relevant = True
|
||||
# not related
|
||||
else:
|
||||
relevant = False
|
||||
if relevant:
|
||||
item = args["item"]
|
||||
msg = self.raw_text_parser(copy.deepcopy(args["data"]))
|
||||
self._set_message(msg, item.item)
|
||||
|
||||
def run_gui(self):
|
||||
from kvui import GameManager
|
||||
|
||||
class FF1Manager(GameManager):
|
||||
logging_pairs = [
|
||||
("Client", "Archipelago")
|
||||
]
|
||||
base_title = "Archipelago Final Fantasy 1 Client"
|
||||
|
||||
self.ui = FF1Manager(self)
|
||||
self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI")
|
||||
|
||||
|
||||
def get_payload(ctx: FF1Context):
|
||||
current_time = time.time()
|
||||
return json.dumps(
|
||||
{
|
||||
"items": [item.item for item in ctx.items_received],
|
||||
"messages": {f'{key[0]}:{key[1]}': value for key, value in ctx.messages.items()
|
||||
if key[0] > current_time - 10}
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def parse_locations(locations_array: List[int], ctx: FF1Context, force: bool):
|
||||
if locations_array == ctx.locations_array and not force:
|
||||
return
|
||||
else:
|
||||
# print("New values")
|
||||
ctx.locations_array = locations_array
|
||||
locations_checked = []
|
||||
if len(locations_array) > 0xFE and locations_array[0xFE] & 0x02 != 0 and not ctx.finished_game:
|
||||
await ctx.send_msgs([
|
||||
{"cmd": "StatusUpdate",
|
||||
"status": 30}
|
||||
])
|
||||
ctx.finished_game = True
|
||||
for location in ctx.missing_locations:
|
||||
# index will be - 0x100 or 0x200
|
||||
index = location
|
||||
if location < 0x200:
|
||||
# Location is a chest
|
||||
index -= 0x100
|
||||
flag = 0x04
|
||||
else:
|
||||
# Location is an NPC
|
||||
index -= 0x200
|
||||
flag = 0x02
|
||||
|
||||
# print(f"Location: {ctx.location_names[location]}")
|
||||
# print(f"Index: {str(hex(index))}")
|
||||
# print(f"value: {locations_array[index] & flag != 0}")
|
||||
if locations_array[index] & flag != 0:
|
||||
locations_checked.append(location)
|
||||
if locations_checked:
|
||||
# print([ctx.location_names[location] for location in locations_checked])
|
||||
await ctx.send_msgs([
|
||||
{"cmd": "LocationChecks",
|
||||
"locations": locations_checked}
|
||||
])
|
||||
|
||||
|
||||
async def nes_sync_task(ctx: FF1Context):
|
||||
logger.info("Starting nes connector. Use /nes for status information")
|
||||
while not ctx.exit_event.is_set():
|
||||
error_status = None
|
||||
if ctx.nes_streams:
|
||||
(reader, writer) = ctx.nes_streams
|
||||
msg = get_payload(ctx).encode()
|
||||
writer.write(msg)
|
||||
writer.write(b'\n')
|
||||
try:
|
||||
await asyncio.wait_for(writer.drain(), timeout=1.5)
|
||||
try:
|
||||
# Data will return a dict with up to two fields:
|
||||
# 1. A keepalive response of the Players Name (always)
|
||||
# 2. An array representing the memory values of the locations area (if in game)
|
||||
data = await asyncio.wait_for(reader.readline(), timeout=5)
|
||||
data_decoded = json.loads(data.decode())
|
||||
# print(data_decoded)
|
||||
if ctx.game is not None and 'locations' in data_decoded:
|
||||
# Not just a keep alive ping, parse
|
||||
async_start(parse_locations(data_decoded['locations'], ctx, False))
|
||||
if not ctx.auth:
|
||||
ctx.auth = ''.join([chr(i) for i in data_decoded['playerName'] if i != 0])
|
||||
if ctx.auth == '':
|
||||
logger.info("Invalid ROM detected. No player name built into the ROM. Please regenerate"
|
||||
"the ROM using the same link but adding your slot name")
|
||||
if ctx.awaiting_rom:
|
||||
await ctx.server_auth(False)
|
||||
except asyncio.TimeoutError:
|
||||
logger.debug("Read Timed Out, Reconnecting")
|
||||
error_status = CONNECTION_TIMING_OUT_STATUS
|
||||
writer.close()
|
||||
ctx.nes_streams = None
|
||||
except ConnectionResetError as e:
|
||||
logger.debug("Read failed due to Connection Lost, Reconnecting")
|
||||
error_status = CONNECTION_RESET_STATUS
|
||||
writer.close()
|
||||
ctx.nes_streams = None
|
||||
except TimeoutError:
|
||||
logger.debug("Connection Timed Out, Reconnecting")
|
||||
error_status = CONNECTION_TIMING_OUT_STATUS
|
||||
writer.close()
|
||||
ctx.nes_streams = None
|
||||
except ConnectionResetError:
|
||||
logger.debug("Connection Lost, Reconnecting")
|
||||
error_status = CONNECTION_RESET_STATUS
|
||||
writer.close()
|
||||
ctx.nes_streams = None
|
||||
if ctx.nes_status == CONNECTION_TENTATIVE_STATUS:
|
||||
if not error_status:
|
||||
logger.info("Successfully Connected to NES")
|
||||
ctx.nes_status = CONNECTION_CONNECTED_STATUS
|
||||
else:
|
||||
ctx.nes_status = f"Was tentatively connected but error occured: {error_status}"
|
||||
elif error_status:
|
||||
ctx.nes_status = error_status
|
||||
logger.info("Lost connection to nes and attempting to reconnect. Use /nes for status updates")
|
||||
else:
|
||||
try:
|
||||
logger.debug("Attempting to connect to NES")
|
||||
ctx.nes_streams = await asyncio.wait_for(asyncio.open_connection("localhost", 52980), timeout=10)
|
||||
ctx.nes_status = CONNECTION_TENTATIVE_STATUS
|
||||
except TimeoutError:
|
||||
logger.debug("Connection Timed Out, Trying Again")
|
||||
ctx.nes_status = CONNECTION_TIMING_OUT_STATUS
|
||||
continue
|
||||
except ConnectionRefusedError:
|
||||
logger.debug("Connection Refused, Trying Again")
|
||||
ctx.nes_status = CONNECTION_REFUSED_STATUS
|
||||
continue
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
# Text Mode to use !hint and such with games that have no text entry
|
||||
Utils.init_logging("FF1Client")
|
||||
|
||||
options = Utils.get_options()
|
||||
DISPLAY_MSGS = options["ffr_options"]["display_msgs"]
|
||||
|
||||
async def main(args):
|
||||
ctx = FF1Context(args.connect, args.password)
|
||||
ctx.server_task = asyncio.create_task(server_loop(ctx), name="ServerLoop")
|
||||
if gui_enabled:
|
||||
ctx.run_gui()
|
||||
ctx.run_cli()
|
||||
ctx.nes_sync_task = asyncio.create_task(nes_sync_task(ctx), name="NES Sync")
|
||||
|
||||
await ctx.exit_event.wait()
|
||||
ctx.server_address = None
|
||||
|
||||
await ctx.shutdown()
|
||||
|
||||
if ctx.nes_sync_task:
|
||||
await ctx.nes_sync_task
|
||||
|
||||
|
||||
import colorama
|
||||
|
||||
parser = get_base_parser()
|
||||
args = parser.parse_args()
|
||||
colorama.init()
|
||||
|
||||
asyncio.run(main(args))
|
||||
colorama.deinit()
|
||||
12
FactorioClient.py
Normal file
@@ -0,0 +1,12 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import ModuleUpdate
|
||||
ModuleUpdate.update()
|
||||
|
||||
from worlds.factorio.Client import check_stdin, launch
|
||||
import Utils
|
||||
|
||||
if __name__ == "__main__":
|
||||
Utils.init_logging("FactorioClient", exception_logger="Client")
|
||||
check_stdin()
|
||||
launch()
|
||||
607
Generate.py
@@ -1,93 +1,82 @@
|
||||
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 itertools import chain
|
||||
from typing import Any
|
||||
from typing import Any, Dict, Tuple, Union
|
||||
|
||||
import ModuleUpdate
|
||||
|
||||
ModuleUpdate.update()
|
||||
|
||||
import copy
|
||||
import Utils
|
||||
import Options
|
||||
from BaseClasses import seeddigits, get_seed, PlandoOptions
|
||||
from Main import main as ERmain
|
||||
from settings import get_settings
|
||||
from Utils import parse_yamls, version_tuple, __version__, tuplize_version
|
||||
from worlds.alttp import Options as LttPOptions
|
||||
from worlds.alttp.EntranceRandomizer import parse_arguments
|
||||
from worlds.alttp.Text import TextTable
|
||||
from worlds.AutoWorld import AutoWorldRegister
|
||||
from worlds.generic import PlandoConnection
|
||||
|
||||
|
||||
def mystery_argparse(argv: list[str] | None = None) -> argparse.Namespace:
|
||||
from settings import get_settings
|
||||
settings = get_settings()
|
||||
defaults = settings.generator
|
||||
def mystery_argparse():
|
||||
options = get_settings()
|
||||
defaults = options.generator
|
||||
|
||||
parser = argparse.ArgumentParser(description="CMD Generation Interface, defaults come from host.yaml.")
|
||||
parser.add_argument('--weights_file_path', default=defaults.weights_file_path,
|
||||
help='Path to the weights file to use for rolling game options, urls are also valid')
|
||||
parser.add_argument('--sameoptions', help='Rolls options per weights file rather than per player',
|
||||
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,
|
||||
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('--outputpath', default=options.general_options.output_path,
|
||||
help="Path to output folder. Absolute or relative to cwd.") # absolute or relative to cwd
|
||||
parser.add_argument('--race', action='store_true', default=defaults.race)
|
||||
parser.add_argument('--meta_file_path', default=defaults.meta_file_path)
|
||||
parser.add_argument('--log_level', default=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('--log_level', default='info', help='Sets log level')
|
||||
parser.add_argument('--yaml_output', default=0, type=lambda value: max(int(value), 0),
|
||||
help='Output rolled mystery results to yaml up to specified number (made for async multiworld)')
|
||||
parser.add_argument('--plando', default=defaults.plando_options,
|
||||
help='List of options that can be set manually. Can be combined, for example "bosses, items"')
|
||||
parser.add_argument("--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("--spoiler_only", action="store_true",
|
||||
help="Skips generation assertion and multidata, outputting only a spoiler log. "
|
||||
"Intended for debugging and testing purposes.")
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
if args.skip_output and args.spoiler_only:
|
||||
parser.error("Cannot mix --skip_output and --spoiler_only")
|
||||
elif args.spoiler == 0 and args.spoiler_only:
|
||||
parser.error("Cannot use --spoiler_only when --spoiler=0. Use --skip_output or set --spoiler to a different value")
|
||||
|
||||
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.from_option_string(args.plando)
|
||||
|
||||
return args
|
||||
args.plando: PlandoOptions = PlandoOptions.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()
|
||||
else:
|
||||
options = get_settings()
|
||||
|
||||
seed = get_seed(args.seed)
|
||||
|
||||
Utils.init_logging(f"Generate_{seed}", loglevel=args.log_level, add_timestamp=args.log_time)
|
||||
Utils.init_logging(f"Generate_{seed}", loglevel=args.log_level)
|
||||
random.seed(seed)
|
||||
seed_name = get_seed_name(random)
|
||||
|
||||
@@ -95,7 +84,7 @@ def main(args=None) -> tuple[argparse.Namespace, int]:
|
||||
logging.info("Race mode enabled. Using non-deterministic random source.")
|
||||
random.seed() # reset to time-based random source
|
||||
|
||||
weights_cache: dict[str, tuple[Any, ...]] = {}
|
||||
weights_cache: Dict[str, Tuple[Any, ...]] = {}
|
||||
if args.weights_file_path and os.path.exists(args.weights_file_path):
|
||||
try:
|
||||
weights_cache[args.weights_file_path] = read_weights_yamls(args.weights_file_path)
|
||||
@@ -114,37 +103,24 @@ def main(args=None) -> tuple[argparse.Namespace, int]:
|
||||
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: int = 1
|
||||
player_files: dict[int, str] = {}
|
||||
player_errors: list[str] = []
|
||||
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 fname.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:
|
||||
logging.exception(f"Exception reading weights in file {fname}")
|
||||
player_errors.append(
|
||||
f"{len(player_errors) + 1}. "
|
||||
f"File {fname} is invalid. Please fix your yaml.\n{Utils.get_all_causes(e)}"
|
||||
)
|
||||
raise ValueError(f"File {fname} is invalid. Please fix your yaml.") from e
|
||||
|
||||
# sort dict for consistent results across platforms:
|
||||
weights_cache = {key: value for key, value in sorted(weights_cache.items(), 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:
|
||||
@@ -156,10 +132,6 @@ def main(args=None) -> tuple[argparse.Namespace, int]:
|
||||
args.multi = max(player_id - 1, args.multi)
|
||||
|
||||
if args.multi == 0:
|
||||
if player_errors:
|
||||
errors = "\n\n".join(player_errors)
|
||||
raise ValueError(f"Encountered {len(player_errors)} error(s) in player files. "
|
||||
f"See logs for full tracebacks.\n\n{errors}")
|
||||
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."
|
||||
@@ -169,19 +141,23 @@ def main(args=None) -> tuple[argparse.Namespace, int]:
|
||||
f"{seed_name} Seed {seed} with plando: {args.plando}")
|
||||
|
||||
if not weights_cache:
|
||||
if player_errors:
|
||||
errors = "\n\n".join(player_errors)
|
||||
raise ValueError(f"Encountered {len(player_errors)} error(s) in player files. "
|
||||
f"See logs for full tracebacks.\n\n{errors}")
|
||||
raise Exception(f"No weights found. "
|
||||
f"Provide a general weights file ({args.weights_file_path}) or individual player files. "
|
||||
f"A mix is also permitted.")
|
||||
erargs = parse_arguments(['--multi', str(args.multi)])
|
||||
erargs.seed = seed
|
||||
erargs.plando_options = args.plando
|
||||
erargs.glitch_triforce = options.generator.glitch_triforce_room
|
||||
erargs.spoiler = args.spoiler
|
||||
erargs.race = args.race
|
||||
erargs.outputname = seed_name
|
||||
erargs.outputpath = args.outputpath
|
||||
erargs.skip_prog_balancing = args.skip_prog_balancing
|
||||
erargs.skip_output = args.skip_output
|
||||
|
||||
from worlds.AutoWorld import AutoWorldRegister
|
||||
args.outputname = seed_name
|
||||
args.sprite = dict.fromkeys(range(1, args.multi+1), None)
|
||||
args.sprite_pool = dict.fromkeys(range(1, args.multi+1), None)
|
||||
args.name = {}
|
||||
settings_cache: Dict[str, Tuple[argparse.Namespace, ...]] = \
|
||||
{fname: (tuple(roll_settings(yaml, args.plando) for yaml in yamls) if args.samesettings else None)
|
||||
for fname, yamls in weights_cache.items()}
|
||||
|
||||
if meta_weights:
|
||||
for category_name, category_dict in meta_weights.items():
|
||||
@@ -197,98 +173,73 @@ def main(args=None) -> tuple[argparse.Namespace, int]:
|
||||
yaml[category][key] = option
|
||||
elif category_name not in yaml:
|
||||
logging.warning(f"Meta: Category {category_name} is not present in {path}.")
|
||||
elif key == "triggers":
|
||||
if "triggers" not in yaml[category_name]:
|
||||
yaml[category_name][key] = []
|
||||
for trigger in option:
|
||||
yaml[category_name][key].append(trigger)
|
||||
else:
|
||||
yaml[category_name][key] = option
|
||||
|
||||
settings_cache: dict[str, tuple[argparse.Namespace, ...] | None] = {fname: None for fname in weights_cache}
|
||||
if args.sameoptions:
|
||||
for fname, yamls in weights_cache.items():
|
||||
try:
|
||||
settings_cache[fname] = tuple(roll_settings(yaml, args.plando) for yaml in yamls)
|
||||
except Exception as e:
|
||||
logging.exception(f"Exception reading settings in file {fname}")
|
||||
player_errors.append(
|
||||
f"{len(player_errors) + 1}. "
|
||||
f"File {fname} is invalid. Please fix your yaml.\n{Utils.get_all_causes(e)}"
|
||||
)
|
||||
# Exit early here to avoid throwing the same errors again later
|
||||
if player_errors:
|
||||
errors = "\n\n".join(player_errors)
|
||||
raise ValueError(f"Encountered {len(player_errors)} error(s) in player files. "
|
||||
f"See logs for full tracebacks.\n\n{errors}")
|
||||
|
||||
player_path_cache: dict[int, str] = {}
|
||||
player_path_cache = {}
|
||||
for player in range(1, args.multi + 1):
|
||||
player_path_cache[player] = player_files.get(player, args.weights_file_path)
|
||||
name_counter: Counter[str] = Counter()
|
||||
args.player_options = {}
|
||||
name_counter = Counter()
|
||||
erargs.player_options = {}
|
||||
|
||||
player = 1
|
||||
while player <= args.multi:
|
||||
path = player_path_cache[player]
|
||||
if not path:
|
||||
player_errors.append(f'No weights specified for player {player}')
|
||||
player += 1
|
||||
continue
|
||||
|
||||
for doc_index, yaml in enumerate(weights_cache[path]):
|
||||
name = yaml.get("name")
|
||||
if path:
|
||||
try:
|
||||
# Use the cached settings object if it exists, otherwise roll settings within the try-catch
|
||||
# Invariant: settings_cache[path] and weights_cache[path] have the same length
|
||||
cached = settings_cache[path]
|
||||
settings_object: argparse.Namespace = (cached[doc_index] if cached else roll_settings(yaml, args.plando))
|
||||
settings: Tuple[argparse.Namespace, ...] = settings_cache[path] if settings_cache[path] else \
|
||||
tuple(roll_settings(yaml, args.plando) for yaml in weights_cache[path])
|
||||
for settingsObject in settings:
|
||||
for k, v in vars(settingsObject).items():
|
||||
if v is not None:
|
||||
try:
|
||||
getattr(erargs, k)[player] = v
|
||||
except AttributeError:
|
||||
setattr(erargs, k, {player: v})
|
||||
except Exception as e:
|
||||
raise Exception(f"Error setting {k} to {v} for player {player}") from e
|
||||
|
||||
for k, v in vars(settings_object).items():
|
||||
if v is not None:
|
||||
try:
|
||||
getattr(args, k)[player] = v
|
||||
except AttributeError:
|
||||
setattr(args, k, {player: v})
|
||||
except Exception as e:
|
||||
raise Exception(f"Error setting {k} to {v} for player {player}") from e
|
||||
|
||||
# name was not specified
|
||||
if player not in args.name:
|
||||
if path == args.weights_file_path:
|
||||
# weights file, so we need to make the name unique
|
||||
args.name[player] = f"Player{player}"
|
||||
else:
|
||||
# use the filename
|
||||
args.name[player] = os.path.splitext(os.path.split(path)[-1])[0]
|
||||
args.name[player] = handle_name(args.name[player], player, name_counter)
|
||||
if path == args.weights_file_path: # if name came from the weights file, just use base player name
|
||||
erargs.name[player] = f"Player{player}"
|
||||
elif not erargs.name[player]: # if name was not specified, generate it from filename
|
||||
erargs.name[player] = os.path.splitext(os.path.split(path)[-1])[0]
|
||||
erargs.name[player] = handle_name(erargs.name[player], player, name_counter)
|
||||
|
||||
player += 1
|
||||
except Exception as e:
|
||||
logging.exception(f"Exception reading settings in file {path} document #{doc_index + 1} "
|
||||
f"(name: {args.name.get(player, name)})")
|
||||
player_errors.append(
|
||||
f"{len(player_errors) + 1}. "
|
||||
f"File {path} document #{doc_index + 1} (name: {args.name.get(player, name)}) is invalid. "
|
||||
f"Please fix your yaml.\n{Utils.get_all_causes(e)}")
|
||||
raise ValueError(f"File {path} is invalid. Please fix your yaml.") from e
|
||||
else:
|
||||
raise RuntimeError(f'No weights specified for player {player}')
|
||||
|
||||
# increment for each yaml document in the file
|
||||
player += 1
|
||||
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(name.lower() for name in args.name.values())) != len(args.name):
|
||||
player_errors.append(
|
||||
f"{len(player_errors) + 1}. "
|
||||
f"Names have to be unique. Names: {Counter(name.lower() for name in args.name.values())}"
|
||||
)
|
||||
if args.yaml_output:
|
||||
import yaml
|
||||
important = {}
|
||||
for option, player_settings in vars(erargs).items():
|
||||
if type(player_settings) == dict:
|
||||
if all(type(value) != list for value in player_settings.values()):
|
||||
if len(player_settings.values()) > 1:
|
||||
important[option] = {player: value for player, value in player_settings.items() if
|
||||
player <= args.yaml_output}
|
||||
else:
|
||||
logging.debug(f"No player settings defined for option '{option}'")
|
||||
|
||||
if player_errors:
|
||||
errors = "\n\n".join(player_errors)
|
||||
raise ValueError(f"Encountered {len(player_errors)} error(s) in player files. "
|
||||
f"See logs for full tracebacks.\n\n{errors}")
|
||||
else:
|
||||
if player_settings != "": # is not empty name
|
||||
important[option] = player_settings
|
||||
else:
|
||||
logging.debug(f"No player settings defined for option '{option}'")
|
||||
if args.outputpath:
|
||||
os.makedirs(args.outputpath, exist_ok=True)
|
||||
with open(os.path.join(args.outputpath if args.outputpath else ".", f"generate_{seed_name}.yaml"), "wt") as f:
|
||||
yaml.dump(important, f)
|
||||
|
||||
return args, seed
|
||||
return callback(erargs, seed)
|
||||
|
||||
|
||||
def read_weights_yamls(path) -> tuple[Any, ...]:
|
||||
def read_weights_yamls(path) -> Tuple[Any, ...]:
|
||||
try:
|
||||
if urllib.parse.urlparse(path).scheme in ('https', 'file'):
|
||||
yaml = str(urllib.request.urlopen(path).read(), "utf-8-sig")
|
||||
@@ -298,20 +249,7 @@ def read_weights_yamls(path) -> tuple[Any, ...]:
|
||||
except Exception as e:
|
||||
raise Exception(f"Failed to read weights ({path})") from e
|
||||
|
||||
from yaml.error import MarkedYAMLError
|
||||
try:
|
||||
return tuple(parse_yamls(yaml))
|
||||
except MarkedYAMLError as ex:
|
||||
if ex.problem_mark:
|
||||
lines = yaml.splitlines()
|
||||
if ex.context_mark:
|
||||
relevant_lines = "\n".join(lines[ex.context_mark.line:ex.problem_mark.line+1])
|
||||
else:
|
||||
relevant_lines = lines[ex.problem_mark.line]
|
||||
error_line = " " * ex.problem_mark.column + "^"
|
||||
raise Exception(f"{ex.context} {ex.problem} on line {ex.problem_mark.line}:"
|
||||
f"\n{relevant_lines}\n{error_line}")
|
||||
raise ex
|
||||
return tuple(parse_yamls(yaml))
|
||||
|
||||
|
||||
def interpret_on_off(value) -> bool:
|
||||
@@ -351,89 +289,65 @@ def get_choice(option, root, value=None) -> Any:
|
||||
raise RuntimeError(f"All options specified in \"{option}\" are weighted as zero.")
|
||||
|
||||
|
||||
class SafeFormatter(string.Formatter):
|
||||
def get_value(self, key, args, kwargs):
|
||||
if isinstance(key, int):
|
||||
if key < len(args):
|
||||
return args[key]
|
||||
else:
|
||||
return "{" + str(key) + "}"
|
||||
else:
|
||||
return kwargs.get(key, "{" + key + "}")
|
||||
class SafeDict(dict):
|
||||
def __missing__(self, key):
|
||||
return '{' + key + '}'
|
||||
|
||||
|
||||
def handle_name(name: str, player: int, name_counter: Counter[str]):
|
||||
def handle_name(name: str, player: int, name_counter: Counter):
|
||||
name_counter[name.lower()] += 1
|
||||
number = name_counter[name.lower()]
|
||||
new_name = "%".join([x.replace("%number%", "{number}").replace("%player%", "{player}") for x in name.split("%%")])
|
||||
|
||||
new_name = SafeFormatter().vformat(new_name, (), {"number": number,
|
||||
"NUMBER": (number if number > 1 else ''),
|
||||
"player": player,
|
||||
"PLAYER": (player if player > 1 else '')})
|
||||
# Run .strip twice for edge case where after the initial .slice new_name has a leading whitespace.
|
||||
# Could cause issues for some clients that cannot handle the additional whitespace.
|
||||
new_name = new_name.strip()[:16].strip()
|
||||
|
||||
new_name = string.Formatter().vformat(new_name, (), SafeDict(number=number,
|
||||
NUMBER=(number if number > 1 else ''),
|
||||
player=player,
|
||||
PLAYER=(player if player > 1 else '')))
|
||||
new_name = new_name.strip()[:16]
|
||||
if new_name == "Archipelago":
|
||||
raise Exception(f"You cannot name yourself \"{new_name}\"")
|
||||
return new_name
|
||||
|
||||
|
||||
def update_weights(weights: dict, new_weights: dict, update_type: str, name: str) -> dict:
|
||||
def prefer_int(input_data: str) -> Union[str, int]:
|
||||
try:
|
||||
return int(input_data)
|
||||
except:
|
||||
return input_data
|
||||
|
||||
|
||||
goals = {
|
||||
'ganon': 'ganon',
|
||||
'crystals': 'crystals',
|
||||
'bosses': 'bosses',
|
||||
'pedestal': 'pedestal',
|
||||
'ganon_pedestal': 'ganonpedestal',
|
||||
'triforce_hunt': 'triforcehunt',
|
||||
'local_triforce_hunt': 'localtriforcehunt',
|
||||
'ganon_triforce_hunt': 'ganontriforcehunt',
|
||||
'local_ganon_triforce_hunt': 'localganontriforcehunt',
|
||||
'ice_rod_hunt': 'icerodhunt',
|
||||
}
|
||||
|
||||
|
||||
def roll_percentage(percentage: Union[int, float]) -> bool:
|
||||
"""Roll a percentage chance.
|
||||
percentage is expected to be in range [0, 100]"""
|
||||
return random.random() < (float(percentage) / 100)
|
||||
|
||||
|
||||
def update_weights(weights: dict, new_weights: dict, type: str, name: str) -> dict:
|
||||
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):
|
||||
counter_value = Counter(cleaned_value)
|
||||
counter_value.update(new_value)
|
||||
cleaned_value = dict(counter_value)
|
||||
else:
|
||||
raise Exception(f"Cannot apply merge to non-dict, set, or list type {option_name},"
|
||||
f" received {type(new_value).__name__}.")
|
||||
cleaned_weights[option_name] = cleaned_value
|
||||
elif option.startswith("-") and option_name in weights:
|
||||
cleaned_value = weights[option_name]
|
||||
new_value = new_weights[option]
|
||||
if isinstance(new_value, set):
|
||||
cleaned_value.difference_update(new_value)
|
||||
elif isinstance(new_value, list):
|
||||
for element in new_value:
|
||||
cleaned_value.remove(element)
|
||||
elif isinstance(new_value, dict):
|
||||
counter_value = Counter(cleaned_value)
|
||||
counter_value.subtract(new_value)
|
||||
cleaned_value = dict(counter_value)
|
||||
else:
|
||||
raise Exception(f"Cannot apply remove to non-dict, set, or list type {option_name},"
|
||||
f" received {type(new_value).__name__}.")
|
||||
cleaned_weights[option_name] = cleaned_value
|
||||
else:
|
||||
# Options starting with + and - may modify values in-place, and new_weights may be shared by multiple slots
|
||||
# using the same .yaml, so ensure that the new value is a copy.
|
||||
cleaned_value = copy.deepcopy(new_weights[option])
|
||||
cleaned_weights[option_name] = cleaned_value
|
||||
new_options = set(cleaned_weights) - set(weights)
|
||||
weights.update(cleaned_weights)
|
||||
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
|
||||
|
||||
def roll_meta_option(option_key, game: str, category_dict: Dict) -> Any:
|
||||
if not game:
|
||||
return get_choice(option_key, category_dict)
|
||||
if game in AutoWorldRegister.world_types:
|
||||
@@ -443,9 +357,16 @@ def roll_meta_option(option_key, game: str, category_dict: dict) -> Any:
|
||||
if options[option_key].supports_weighting:
|
||||
return get_choice(option_key, category_dict)
|
||||
return category_dict[option_key]
|
||||
if option_key == "triggers":
|
||||
return category_dict[option_key]
|
||||
raise Options.OptionError(f"Error generating meta option {option_key} for {game}.")
|
||||
if game == "A Link to the Past": # TODO wow i hate this
|
||||
if option_key in {"glitches_required", "dark_room_logic", "entrance_shuffle", "goals", "triforce_pieces_mode",
|
||||
"triforce_pieces_percentage", "triforce_pieces_available", "triforce_pieces_extra",
|
||||
"triforce_pieces_required", "shop_shuffle", "mode", "item_pool", "item_functionality",
|
||||
"boss_shuffle", "enemy_damage", "enemy_health", "timer", "countdown_start_time",
|
||||
"red_clock_time", "blue_clock_time", "green_clock_time", "dungeon_counters", "shuffle_prizes",
|
||||
"misery_mire_medallion", "turtle_rock_medallion", "sprite_pool", "sprite",
|
||||
"random_sprite_on_event"}:
|
||||
return get_choice(option_key, category_dict)
|
||||
raise Exception(f"Error generating meta option {option_key} for {game}.")
|
||||
|
||||
|
||||
def roll_linked_options(weights: dict) -> dict:
|
||||
@@ -454,7 +375,7 @@ def roll_linked_options(weights: dict) -> dict:
|
||||
if "name" not in option_set:
|
||||
raise ValueError("One of your linked options does not have a name.")
|
||||
try:
|
||||
if Options.roll_percentage(option_set["percentage"]):
|
||||
if roll_percentage(option_set["percentage"]):
|
||||
logging.debug(f"Linked option {option_set['name']} triggered.")
|
||||
new_options = option_set["options"]
|
||||
for category_name, category_options in new_options.items():
|
||||
@@ -470,7 +391,7 @@ def roll_linked_options(weights: dict) -> dict:
|
||||
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):
|
||||
@@ -487,53 +408,41 @@ def roll_triggers(weights: dict, triggers: list, valid_keys: set) -> dict:
|
||||
trigger_result = get_choice("option_result", option_set)
|
||||
result = get_choice(key, currently_targeted_weights)
|
||||
currently_targeted_weights[key] = result
|
||||
if result == trigger_result and Options.roll_percentage(get_choice("percentage", option_set, 100)):
|
||||
if result == trigger_result and roll_percentage(get_choice("percentage", option_set, 100)):
|
||||
for category_name, category_options in option_set["options"].items():
|
||||
currently_targeted_weights = weights
|
||||
if category_name:
|
||||
currently_targeted_weights = currently_targeted_weights[category_name]
|
||||
update_weights(currently_targeted_weights, category_options, "Triggered", option_set["option_name"])
|
||||
valid_keys.add(key)
|
||||
|
||||
except Exception as e:
|
||||
raise ValueError(f"Your trigger number {i + 1} is invalid. "
|
||||
f"Please fix your triggers.") from e
|
||||
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 handle_option(ret: argparse.Namespace, game_weights: dict, option_key: str, option: type(Options.Option), plando_options: PlandoOptions):
|
||||
if option_key in game_weights:
|
||||
try:
|
||||
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
|
||||
player_option.verify(AutoWorldRegister.world_types[ret.game], ret.name, plando_options)
|
||||
else:
|
||||
from worlds import AutoWorldRegister
|
||||
player_option.verify(AutoWorldRegister.world_types[ret.game], ret.name, plando_options)
|
||||
setattr(ret, option_key, option.from_any(option.default)) # call the from_any here to support default "random"
|
||||
|
||||
|
||||
def roll_settings(weights: dict, plando_options: PlandoOptions = PlandoOptions.bosses):
|
||||
"""
|
||||
Roll options from specified weights, usually originating from a .yaml options file.
|
||||
|
||||
Important note:
|
||||
The same weights dict is shared between all slots using the same yaml (e.g. generic weights file for filler slots).
|
||||
This means it should never be modified without making a deepcopy first.
|
||||
"""
|
||||
|
||||
from worlds import AutoWorldRegister
|
||||
|
||||
if "linked_options" in weights:
|
||||
weights = roll_linked_options(weights)
|
||||
|
||||
valid_keys = {"triggers"}
|
||||
if "triggers" in weights:
|
||||
weights = roll_triggers(weights, weights["triggers"], valid_keys)
|
||||
weights = roll_triggers(weights, weights["triggers"])
|
||||
|
||||
requirements = weights.get("requires", {})
|
||||
if requirements:
|
||||
@@ -546,39 +455,15 @@ def roll_settings(weights: dict, plando_options: PlandoOptions = PlandoOptions.b
|
||||
if required_plando_options:
|
||||
raise Exception(f"Settings reports required plando module {str(required_plando_options)}, "
|
||||
f"which is not enabled.")
|
||||
games = requirements.get("game", {})
|
||||
for game, version in games.items():
|
||||
if game not in AutoWorldRegister.world_types:
|
||||
continue
|
||||
if not version:
|
||||
raise Exception(f"Invalid version for game {game}: {version}.")
|
||||
if isinstance(version, str):
|
||||
version = {"min": version}
|
||||
if "min" in version and tuplize_version(version["min"]) > AutoWorldRegister.world_types[game].world_version:
|
||||
raise Exception(f"Settings reports required version of world \"{game}\" is at least {version['min']}, "
|
||||
f"however world is of version "
|
||||
f"{AutoWorldRegister.world_types[game].world_version.as_simple_string()}.")
|
||||
if "max" in version and tuplize_version(version["max"]) < AutoWorldRegister.world_types[game].world_version:
|
||||
raise Exception(f"Settings reports required version of world \"{game}\" is no later than {version['max']}, "
|
||||
f"however world is of version "
|
||||
f"{AutoWorldRegister.world_types[game].world_version.as_simple_string()}.")
|
||||
|
||||
ret = argparse.Namespace()
|
||||
for option_key in Options.PerGameCommonOptions.type_hints:
|
||||
if option_key in weights and option_key not in Options.CommonOptions.type_hints:
|
||||
raise Exception(f"Option {option_key} has to be in a game's section, not on its own.")
|
||||
|
||||
ret.game = get_choice("game", weights)
|
||||
if not isinstance(ret.game, str):
|
||||
if ret.game is None:
|
||||
raise Exception('"game" not specified')
|
||||
raise Exception(f"Invalid game: {ret.game}")
|
||||
if ret.game not in AutoWorldRegister.world_types:
|
||||
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.")
|
||||
picks = Utils.get_fuzzy_results(ret.game, AutoWorldRegister.world_types, limit=1)[0]
|
||||
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.")
|
||||
|
||||
@@ -588,14 +473,8 @@ def roll_settings(weights: dict, plando_options: PlandoOptions = PlandoOptions.b
|
||||
world_type = AutoWorldRegister.world_types[ret.game]
|
||||
game_weights = weights[ret.game]
|
||||
|
||||
for weight in chain(game_weights, weights):
|
||||
if weight.startswith("+"):
|
||||
raise Exception(f"Merge tag cannot be used outside of trigger contexts. Found {weight}")
|
||||
if weight.startswith("-"):
|
||||
raise Exception(f"Remove tag cannot be used outside of trigger contexts. Found {weight}")
|
||||
|
||||
if "triggers" in game_weights:
|
||||
weights = roll_triggers(weights, game_weights["triggers"], valid_keys)
|
||||
weights = roll_triggers(weights, game_weights["triggers"])
|
||||
game_weights = weights[ret.game]
|
||||
|
||||
ret.name = get_choice('name', weights)
|
||||
@@ -604,24 +483,146 @@ def roll_settings(weights: dict, plando_options: PlandoOptions = PlandoOptions.b
|
||||
|
||||
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)
|
||||
|
||||
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 PlandoOptions.items in plando_options:
|
||||
ret.plando_items = game_weights.get("plando_items", [])
|
||||
if ret.game == "Minecraft" or ret.game == "Ocarina of Time":
|
||||
# bad hardcoded behavior to make this work for now
|
||||
ret.plando_connections = []
|
||||
if PlandoOptions.connections in plando_options:
|
||||
options = game_weights.get("plando_connections", [])
|
||||
for placement in options:
|
||||
if roll_percentage(get_choice("percentage", placement, 100)):
|
||||
ret.plando_connections.append(PlandoConnection(
|
||||
get_choice("entrance", placement),
|
||||
get_choice("exit", placement),
|
||||
get_choice("direction", placement)
|
||||
))
|
||||
elif ret.game == "A Link to the Past":
|
||||
roll_alttp_settings(ret, game_weights, plando_options)
|
||||
|
||||
return ret
|
||||
|
||||
|
||||
def roll_alttp_settings(ret: argparse.Namespace, weights):
|
||||
def roll_alttp_settings(ret: argparse.Namespace, weights, plando_options):
|
||||
if "dungeon_items" in weights and get_choice_legacy('dungeon_items', weights, "none") != "none":
|
||||
raise Exception(f"dungeon_items key in A Link to the Past was removed, but is present in these weights as {get_choice_legacy('dungeon_items', weights, False)}.")
|
||||
glitches_required = get_choice_legacy('glitches_required', weights)
|
||||
if glitches_required not in [None, 'none', 'no_logic', 'overworld_glitches', 'hybrid_major_glitches', 'minor_glitches']:
|
||||
logging.warning("Only NMG, OWG, HMG and No Logic supported")
|
||||
glitches_required = 'none'
|
||||
ret.logic = {None: 'noglitches', 'none': 'noglitches', 'no_logic': 'nologic', 'overworld_glitches': 'owglitches',
|
||||
'minor_glitches': 'minorglitches', 'hybrid_major_glitches': 'hybridglitches'}[
|
||||
glitches_required]
|
||||
|
||||
ret.dark_room_logic = get_choice_legacy("dark_room_logic", weights, "lamp")
|
||||
if not ret.dark_room_logic: # None/False
|
||||
ret.dark_room_logic = "none"
|
||||
if ret.dark_room_logic == "sconces":
|
||||
ret.dark_room_logic = "torches"
|
||||
if ret.dark_room_logic not in {"lamp", "torches", "none"}:
|
||||
raise ValueError(f"Unknown Dark Room Logic: \"{ret.dark_room_logic}\"")
|
||||
|
||||
entrance_shuffle = get_choice_legacy('entrance_shuffle', weights, 'vanilla')
|
||||
if entrance_shuffle.startswith('none-'):
|
||||
ret.shuffle = 'vanilla'
|
||||
else:
|
||||
ret.shuffle = entrance_shuffle if entrance_shuffle != 'none' else 'vanilla'
|
||||
|
||||
goal = get_choice_legacy('goals', weights, 'ganon')
|
||||
|
||||
ret.goal = goals[goal]
|
||||
|
||||
|
||||
extra_pieces = get_choice_legacy('triforce_pieces_mode', weights, 'available')
|
||||
|
||||
ret.triforce_pieces_required = LttPOptions.TriforcePieces.from_any(get_choice_legacy('triforce_pieces_required', weights, 20))
|
||||
|
||||
# sum a percentage to required
|
||||
if extra_pieces == 'percentage':
|
||||
percentage = max(100, float(get_choice_legacy('triforce_pieces_percentage', weights, 150))) / 100
|
||||
ret.triforce_pieces_available = int(round(ret.triforce_pieces_required * percentage, 0))
|
||||
# vanilla mode (specify how many pieces are)
|
||||
elif extra_pieces == 'available':
|
||||
ret.triforce_pieces_available = LttPOptions.TriforcePieces.from_any(
|
||||
get_choice_legacy('triforce_pieces_available', weights, 30))
|
||||
# required pieces + fixed extra
|
||||
elif extra_pieces == 'extra':
|
||||
extra_pieces = max(0, int(get_choice_legacy('triforce_pieces_extra', weights, 10)))
|
||||
ret.triforce_pieces_available = ret.triforce_pieces_required + extra_pieces
|
||||
|
||||
# change minimum to required pieces to avoid problems
|
||||
ret.triforce_pieces_available = min(max(ret.triforce_pieces_required, int(ret.triforce_pieces_available)), 90)
|
||||
|
||||
ret.shop_shuffle = get_choice_legacy('shop_shuffle', weights, '')
|
||||
if not ret.shop_shuffle:
|
||||
ret.shop_shuffle = ''
|
||||
|
||||
ret.mode = get_choice_legacy("mode", weights)
|
||||
|
||||
ret.difficulty = get_choice_legacy('item_pool', weights)
|
||||
|
||||
ret.item_functionality = get_choice_legacy('item_functionality', weights)
|
||||
|
||||
|
||||
ret.enemy_damage = {None: 'default',
|
||||
'default': 'default',
|
||||
'shuffled': 'shuffled',
|
||||
'random': 'chaos', # to be removed
|
||||
'chaos': 'chaos',
|
||||
}[get_choice_legacy('enemy_damage', weights)]
|
||||
|
||||
ret.enemy_health = get_choice_legacy('enemy_health', weights)
|
||||
|
||||
ret.timer = {'none': False,
|
||||
None: False,
|
||||
False: False,
|
||||
'timed': 'timed',
|
||||
'timed_ohko': 'timed-ohko',
|
||||
'ohko': 'ohko',
|
||||
'timed_countdown': 'timed-countdown',
|
||||
'display': 'display'}[get_choice_legacy('timer', weights, False)]
|
||||
|
||||
ret.countdown_start_time = int(get_choice_legacy('countdown_start_time', weights, 10))
|
||||
ret.red_clock_time = int(get_choice_legacy('red_clock_time', weights, -2))
|
||||
ret.blue_clock_time = int(get_choice_legacy('blue_clock_time', weights, 2))
|
||||
ret.green_clock_time = int(get_choice_legacy('green_clock_time', weights, 4))
|
||||
|
||||
ret.dungeon_counters = get_choice_legacy('dungeon_counters', weights, 'default')
|
||||
|
||||
ret.shuffle_prizes = get_choice_legacy('shuffle_prizes', weights, "g")
|
||||
|
||||
ret.required_medallions = [get_choice_legacy("misery_mire_medallion", weights, "random"),
|
||||
get_choice_legacy("turtle_rock_medallion", weights, "random")]
|
||||
|
||||
for index, medallion in enumerate(ret.required_medallions):
|
||||
ret.required_medallions[index] = {"ether": "Ether", "quake": "Quake", "bombos": "Bombos", "random": "random"} \
|
||||
.get(medallion.lower(), None)
|
||||
if not ret.required_medallions[index]:
|
||||
raise Exception(f"unknown Medallion {medallion} for {'misery mire' if index == 0 else 'turtle rock'}")
|
||||
|
||||
ret.plando_texts = {}
|
||||
if PlandoOptions.texts in plando_options:
|
||||
tt = TextTable()
|
||||
tt.removeUnwantedText()
|
||||
options = weights.get("plando_texts", [])
|
||||
for placement in options:
|
||||
if roll_percentage(get_choice_legacy("percentage", placement, 100)):
|
||||
at = str(get_choice_legacy("at", placement))
|
||||
if at not in tt:
|
||||
raise Exception(f"No text target \"{at}\" found.")
|
||||
ret.plando_texts[at] = str(get_choice_legacy("text", placement))
|
||||
|
||||
ret.plando_connections = []
|
||||
if PlandoOptions.connections in plando_options:
|
||||
options = weights.get("plando_connections", [])
|
||||
for placement in options:
|
||||
if roll_percentage(get_choice_legacy("percentage", placement, 100)):
|
||||
ret.plando_connections.append(PlandoConnection(
|
||||
get_choice_legacy("entrance", placement),
|
||||
get_choice_legacy("exit", placement),
|
||||
get_choice_legacy("direction", placement, "both")
|
||||
))
|
||||
|
||||
ret.sprite_pool = weights.get('sprite_pool', [])
|
||||
ret.sprite = get_choice_legacy('sprite', weights, "Link")
|
||||
if 'random_sprite_on_event' in weights:
|
||||
@@ -649,9 +650,7 @@ 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)
|
||||
multiworld = main()
|
||||
if __debug__:
|
||||
import gc
|
||||
import sys
|
||||
|
||||
8
KH2Client.py
Normal file
@@ -0,0 +1,8 @@
|
||||
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
|
||||
|
||||
|
||||
424
Launcher.py
@@ -1,62 +1,51 @@
|
||||
"""
|
||||
Archipelago Launcher
|
||||
Archipelago launcher for bundled app.
|
||||
|
||||
* If run with a patch file as argument, launch corresponding client with the patch file as an argument.
|
||||
* If run with component name as argument, run it passing argv[2:] as arguments.
|
||||
* If run without arguments or unknown arguments, open launcher GUI.
|
||||
* if run with APBP as argument, launch corresponding client.
|
||||
* if run with executable as argument, run it passing argv[2:] as arguments
|
||||
* if run without arguments, open launcher GUI
|
||||
|
||||
Additional components can be added to worlds.LauncherComponents.components.
|
||||
Scroll down to components= to add components to the launcher as well as setup.py
|
||||
"""
|
||||
|
||||
|
||||
import argparse
|
||||
import itertools
|
||||
import logging
|
||||
import multiprocessing
|
||||
import os
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
import urllib.parse
|
||||
import webbrowser
|
||||
from collections.abc import Callable, Sequence
|
||||
from os.path import isfile
|
||||
from shutil import which
|
||||
from typing import Any
|
||||
from typing import Sequence, Union, Optional
|
||||
|
||||
import Utils
|
||||
import settings
|
||||
from worlds.LauncherComponents import Component, components, Type, SuffixIdentifier, icon_paths
|
||||
|
||||
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)
|
||||
|
||||
if __name__ == "__main__":
|
||||
init_logging('Launcher')
|
||||
|
||||
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()
|
||||
file = settings.get_settings().filename
|
||||
assert file, "host.yaml missing"
|
||||
if is_linux:
|
||||
exe = which('sensible-editor') or which('gedit') or \
|
||||
which('xdg-open') or which('gnome-open') or which('kde-open')
|
||||
subprocess.Popen([exe, file])
|
||||
elif is_macos:
|
||||
exe = which("open")
|
||||
subprocess.Popen([exe, file])
|
||||
else:
|
||||
webbrowser.open(file)
|
||||
return
|
||||
|
||||
env = os.environ
|
||||
if "LD_LIBRARY_PATH" in env:
|
||||
env = env.copy()
|
||||
del env["LD_LIBRARY_PATH"] # exe is a system binary, so reset LD_LIBRARY_PATH
|
||||
subprocess.Popen([exe, file], env=env)
|
||||
|
||||
def open_patch():
|
||||
suffixes = []
|
||||
@@ -79,17 +68,12 @@ def open_patch():
|
||||
launch([*exe, file], component.cli)
|
||||
|
||||
|
||||
def generate_yamls(*args):
|
||||
def generate_yamls():
|
||||
from Options import generate_yaml_templates
|
||||
|
||||
parser = argparse.ArgumentParser(description="Generate Template Options", usage="[-h] [--skip_open_folder]")
|
||||
parser.add_argument("--skip_open_folder", action="store_true")
|
||||
args = parser.parse_args(args)
|
||||
|
||||
target = Utils.user_path("Players", "Templates")
|
||||
generate_yaml_templates(target, False)
|
||||
if not args.skip_open_folder:
|
||||
open_folder(target)
|
||||
open_folder(target)
|
||||
|
||||
|
||||
def browse_files():
|
||||
@@ -99,20 +83,12 @@ def browse_files():
|
||||
def open_folder(folder_path):
|
||||
if is_linux:
|
||||
exe = which('xdg-open') or which('gnome-open') or which('kde-open')
|
||||
subprocess.Popen([exe, folder_path])
|
||||
elif is_macos:
|
||||
exe = which("open")
|
||||
subprocess.Popen([exe, folder_path])
|
||||
else:
|
||||
webbrowser.open(folder_path)
|
||||
return
|
||||
|
||||
if exe:
|
||||
env = os.environ
|
||||
if "LD_LIBRARY_PATH" in env:
|
||||
env = env.copy()
|
||||
del env["LD_LIBRARY_PATH"] # exe is a system binary, so reset LD_LIBRARY_PATH
|
||||
subprocess.Popen([exe, folder_path], env=env)
|
||||
else:
|
||||
logging.warning(f"No file browser available to open {folder_path}")
|
||||
|
||||
|
||||
def update_settings():
|
||||
@@ -122,51 +98,16 @@ def update_settings():
|
||||
|
||||
components.extend([
|
||||
# Functions
|
||||
Component("Open host.yaml", func=open_host_yaml,
|
||||
description="Open the host.yaml file to change settings for generation, games, and more."),
|
||||
Component("Open Patch", func=open_patch,
|
||||
description="Open a patch file, downloaded from the room page or provided by the host."),
|
||||
Component("Generate Template Options", func=generate_yamls,
|
||||
description="Generate template YAMLs for currently installed games."),
|
||||
Component("Archipelago Website", func=lambda: webbrowser.open("https://archipelago.gg/"),
|
||||
description="Open archipelago.gg in your browser."),
|
||||
Component("Discord Server", icon="discord", func=lambda: webbrowser.open("https://discord.gg/8Z65BR2"),
|
||||
description="Join the Discord server to play public multiworlds, report issues, or just chat!"),
|
||||
Component("Unrated/18+ Discord Server", icon="discord",
|
||||
func=lambda: webbrowser.open("https://discord.gg/fqvNCCRsu4"),
|
||||
description="Find unrated and 18+ games in the After Dark Discord server."),
|
||||
Component("Browse Files", func=browse_files,
|
||||
description="Open the Archipelago installation folder in your file browser."),
|
||||
Component("Open host.yaml", func=open_host_yaml),
|
||||
Component("Open Patch", func=open_patch),
|
||||
Component("Generate Template Settings", func=generate_yamls),
|
||||
Component("Discord Server", icon="discord", func=lambda: webbrowser.open("https://discord.gg/8Z65BR2")),
|
||||
Component("18+ Discord Server", icon="discord", func=lambda: webbrowser.open("https://discord.gg/fqvNCCRsu4")),
|
||||
Component("Browse Files", func=browse_files),
|
||||
])
|
||||
|
||||
|
||||
def handle_uri(path: str) -> tuple[list[Component], Component]:
|
||||
url = urllib.parse.urlparse(path)
|
||||
queries = urllib.parse.parse_qs(url.query)
|
||||
client_components = []
|
||||
text_client_component = None
|
||||
game = queries["game"][0]
|
||||
for component in components:
|
||||
if component.supports_uri and component.game_name == game:
|
||||
client_components.append(component)
|
||||
elif component.display_name == "Text Client":
|
||||
text_client_component = component
|
||||
return client_components, text_client_component
|
||||
|
||||
|
||||
def build_uri_popup(component_list: list[Component], launch_args: tuple[str, ...]) -> None:
|
||||
from kvui import ButtonsPrompt
|
||||
component_options = {
|
||||
component.display_name: component for component in component_list
|
||||
}
|
||||
popup = ButtonsPrompt("Connect to Multiworld",
|
||||
"Select client to open and connect with.",
|
||||
lambda component_name: run_component(component_options[component_name], *launch_args),
|
||||
*component_options.keys())
|
||||
popup.open()
|
||||
|
||||
|
||||
def identify(path: None | str) -> tuple[None | str, None | Component]:
|
||||
def identify(path: Union[None, str]):
|
||||
if path is None:
|
||||
return None, None
|
||||
for component in components:
|
||||
@@ -177,7 +118,7 @@ def identify(path: None | str) -> tuple[None | str, None | Component]:
|
||||
return None, None
|
||||
|
||||
|
||||
def get_exe(component: str | Component) -> Sequence[str] | None:
|
||||
def get_exe(component: Union[str, Component]) -> Optional[Sequence[str]]:
|
||||
if isinstance(component, str):
|
||||
name = component
|
||||
component = None
|
||||
@@ -205,8 +146,7 @@ def get_exe(component: str | Component) -> Sequence[str] | None:
|
||||
def launch(exe, in_terminal=False):
|
||||
if in_terminal:
|
||||
if is_windows:
|
||||
# intentionally using a window title with a space so it gets quoted and treated as a title
|
||||
subprocess.Popen(["start", "Running Archipelago", *exe], shell=True)
|
||||
subprocess.Popen(['start', *exe], shell=True)
|
||||
return
|
||||
elif is_linux:
|
||||
terminal = which('x-terminal-emulator') or which('gnome-terminal') or which('xterm')
|
||||
@@ -220,301 +160,139 @@ def launch(exe, in_terminal=False):
|
||||
subprocess.Popen(exe)
|
||||
|
||||
|
||||
def create_shortcut(button: Any, component: Component) -> None:
|
||||
from pyshortcuts import make_shortcut
|
||||
env = os.environ
|
||||
if "APPIMAGE" in env:
|
||||
script = env["ARGV0"]
|
||||
wkdir = None # defaults to ~ on Linux
|
||||
else:
|
||||
script = sys.argv[0]
|
||||
wkdir = Utils.local_path()
|
||||
def run_gui():
|
||||
from kvui import App, ContainerLayout, GridLayout, Button, Label
|
||||
from kivy.uix.image import AsyncImage
|
||||
from kivy.uix.relativelayout import RelativeLayout
|
||||
|
||||
script = f"{script} \"{component.display_name}\""
|
||||
make_shortcut(script, name=f"Archipelago {component.display_name}", icon=local_path("data", "icon.ico"),
|
||||
startmenu=False, terminal=False, working_dir=wkdir, noexe=Utils.is_frozen())
|
||||
button.menu.dismiss()
|
||||
|
||||
|
||||
refresh_components: Callable[[], None] | None = None
|
||||
|
||||
|
||||
def run_gui(launch_components: list[Component], args: Any) -> None:
|
||||
from kvui import (ThemedApp, MDFloatLayout, MDGridLayout, ScrollBox)
|
||||
from kivy.properties import ObjectProperty
|
||||
from kivy.core.window import Window
|
||||
from kivy.metrics import dp
|
||||
from kivymd.uix.button import MDIconButton, MDButton
|
||||
from kivymd.uix.card import MDCard
|
||||
from kivymd.uix.menu import MDDropdownMenu
|
||||
from kivymd.uix.snackbar import MDSnackbar, MDSnackbarText
|
||||
from kivymd.uix.textfield import MDTextField
|
||||
|
||||
from kivy.lang.builder import Builder
|
||||
|
||||
class LauncherCard(MDCard):
|
||||
component: Component | None
|
||||
image: str
|
||||
context_button: MDIconButton = ObjectProperty(None)
|
||||
|
||||
def __init__(self, *args, component: Component | None = None, image_path: str = "", **kwargs):
|
||||
self.component = component
|
||||
self.image = image_path
|
||||
super().__init__(args, kwargs)
|
||||
|
||||
class Launcher(ThemedApp):
|
||||
class Launcher(App):
|
||||
base_title: str = "Archipelago Launcher"
|
||||
top_screen: MDFloatLayout = ObjectProperty(None)
|
||||
navigation: MDGridLayout = ObjectProperty(None)
|
||||
grid: MDGridLayout = ObjectProperty(None)
|
||||
button_layout: ScrollBox = ObjectProperty(None)
|
||||
search_box: MDTextField = ObjectProperty(None)
|
||||
cards: list[LauncherCard]
|
||||
current_filter: Sequence[str | Type] | None
|
||||
container: ContainerLayout
|
||||
grid: GridLayout
|
||||
|
||||
def __init__(self, ctx=None, components=None, args=None):
|
||||
self.title = self.base_title + " " + Utils.__version__
|
||||
_tools = {c.display_name: c for c in components if c.type == Type.TOOL}
|
||||
_clients = {c.display_name: c for c in components if c.type == Type.CLIENT}
|
||||
_adjusters = {c.display_name: c for c in components if c.type == Type.ADJUSTER}
|
||||
_miscs = {c.display_name: c for c in components if c.type == Type.MISC}
|
||||
|
||||
def __init__(self, ctx=None):
|
||||
self.title = self.base_title
|
||||
self.ctx = ctx
|
||||
self.icon = r"data/icon.png"
|
||||
self.favorites = []
|
||||
self.launch_components = components
|
||||
self.launch_args = args
|
||||
self.cards = []
|
||||
self.current_filter = (Type.CLIENT, Type.TOOL, Type.ADJUSTER, Type.MISC)
|
||||
persistent = Utils.persistent_load()
|
||||
if "launcher" in persistent:
|
||||
if "favorites" in persistent["launcher"]:
|
||||
self.favorites.extend(persistent["launcher"]["favorites"])
|
||||
if "filter" in persistent["launcher"]:
|
||||
if persistent["launcher"]["filter"]:
|
||||
filters = []
|
||||
for filter in persistent["launcher"]["filter"].split(", "):
|
||||
if filter == "favorites":
|
||||
filters.append(filter)
|
||||
else:
|
||||
filters.append(Type[filter])
|
||||
self.current_filter = filters
|
||||
super().__init__()
|
||||
|
||||
def set_favorite(self, caller):
|
||||
if caller.component.display_name in self.favorites:
|
||||
self.favorites.remove(caller.component.display_name)
|
||||
caller.icon = "star-outline"
|
||||
else:
|
||||
self.favorites.append(caller.component.display_name)
|
||||
caller.icon = "star"
|
||||
|
||||
def build_card(self, component: Component) -> LauncherCard:
|
||||
"""
|
||||
Builds a card widget for a given component.
|
||||
|
||||
:param component: The component associated with the button.
|
||||
|
||||
:return: The created Card Widget.
|
||||
"""
|
||||
button_card = LauncherCard(component=component,
|
||||
image_path=icon_paths[component.icon])
|
||||
|
||||
def open_menu(caller):
|
||||
caller.menu.open()
|
||||
|
||||
menu_items = [
|
||||
{
|
||||
"text": "Add shortcut on desktop",
|
||||
"leading_icon": "laptop",
|
||||
"on_release": lambda: create_shortcut(button_card.context_button, component)
|
||||
}
|
||||
]
|
||||
button_card.context_button.menu = MDDropdownMenu(caller=button_card.context_button, items=menu_items)
|
||||
button_card.context_button.bind(on_release=open_menu)
|
||||
|
||||
return button_card
|
||||
|
||||
def _refresh_components(self, type_filter: Sequence[str | Type] | None = None) -> None:
|
||||
if not type_filter:
|
||||
type_filter = [Type.CLIENT, Type.ADJUSTER, Type.TOOL, Type.MISC]
|
||||
favorites = "favorites" in type_filter
|
||||
|
||||
# clear before repopulating
|
||||
assert self.button_layout, "must call `build` first"
|
||||
tool_children = reversed(self.button_layout.layout.children)
|
||||
for child in tool_children:
|
||||
self.button_layout.layout.remove_widget(child)
|
||||
|
||||
cards = [card for card in self.cards if card.component.type in type_filter
|
||||
or favorites and card.component.display_name in self.favorites]
|
||||
|
||||
self.current_filter = type_filter
|
||||
|
||||
for card in cards:
|
||||
self.button_layout.layout.add_widget(card)
|
||||
|
||||
top = self.button_layout.children[0].y + self.button_layout.children[0].height \
|
||||
- self.button_layout.height
|
||||
scroll_percent = self.button_layout.convert_distance_to_scroll(0, top)
|
||||
self.button_layout.scroll_y = max(0, min(1, scroll_percent[1]))
|
||||
|
||||
def filter_clients_by_type(self, caller: MDButton):
|
||||
self._refresh_components(caller.type)
|
||||
self.search_box.text = ""
|
||||
|
||||
def filter_clients_by_name(self, caller: MDTextField, name: str) -> None:
|
||||
if len(name) == 0:
|
||||
self._refresh_components(self.current_filter)
|
||||
return
|
||||
|
||||
sub_matches = [
|
||||
card for card in self.cards
|
||||
if name.lower() in card.component.display_name.lower() and card.component.type != Type.HIDDEN
|
||||
]
|
||||
self.button_layout.layout.clear_widgets()
|
||||
for card in sub_matches:
|
||||
self.button_layout.layout.add_widget(card)
|
||||
|
||||
def build(self):
|
||||
self.top_screen = Builder.load_file(Utils.local_path("data/launcher.kv"))
|
||||
self.grid = self.top_screen.ids.grid
|
||||
self.navigation = self.top_screen.ids.navigation
|
||||
self.button_layout = self.top_screen.ids.button_layout
|
||||
self.search_box = self.top_screen.ids.search_box
|
||||
self.set_colors()
|
||||
self.top_screen.md_bg_color = self.theme_cls.backgroundColor
|
||||
self.container = ContainerLayout()
|
||||
self.grid = GridLayout(cols=2)
|
||||
self.container.add_widget(self.grid)
|
||||
self.grid.add_widget(Label(text="General"))
|
||||
self.grid.add_widget(Label(text="Clients"))
|
||||
button_layout = self.grid # make buttons fill the window
|
||||
|
||||
global refresh_components
|
||||
refresh_components = self._refresh_components
|
||||
def build_button(component: Component):
|
||||
"""
|
||||
Builds a button widget for a given component.
|
||||
|
||||
Window.bind(on_drop_file=self._on_drop_file)
|
||||
Window.bind(on_keyboard=self._on_keyboard)
|
||||
Args:
|
||||
component (Component): The component associated with the button.
|
||||
|
||||
for component in components:
|
||||
self.cards.append(self.build_card(component))
|
||||
Returns:
|
||||
None. The button is added to the parent grid layout.
|
||||
|
||||
self._refresh_components(self.current_filter)
|
||||
"""
|
||||
button = Button(text=component.display_name)
|
||||
button.component = component
|
||||
button.bind(on_release=self.component_action)
|
||||
if component.icon != "icon":
|
||||
image = AsyncImage(source=icon_paths[component.icon],
|
||||
size=(38, 38), size_hint=(None, 1), pos=(5, 0))
|
||||
box_layout = RelativeLayout()
|
||||
box_layout.add_widget(button)
|
||||
box_layout.add_widget(image)
|
||||
button_layout.add_widget(box_layout)
|
||||
else:
|
||||
button_layout.add_widget(button)
|
||||
|
||||
# Uncomment to re-enable the Kivy console/live editor
|
||||
# Ctrl-E to enable it, make sure numlock/capslock is disabled
|
||||
# from kivy.modules.console import create_console
|
||||
# create_console(Window, self.top_screen)
|
||||
for (tool, client) in itertools.zip_longest(itertools.chain(
|
||||
self._tools.items(), self._miscs.items(), self._adjusters.items()), self._clients.items()):
|
||||
# column 1
|
||||
if tool:
|
||||
build_button(tool[1])
|
||||
else:
|
||||
button_layout.add_widget(Label())
|
||||
# column 2
|
||||
if client:
|
||||
build_button(client[1])
|
||||
else:
|
||||
button_layout.add_widget(Label())
|
||||
|
||||
return self.top_screen
|
||||
|
||||
def on_start(self):
|
||||
if self.launch_components:
|
||||
build_uri_popup(self.launch_components, self.launch_args)
|
||||
self.launch_components = None
|
||||
self.launch_args = None
|
||||
return self.container
|
||||
|
||||
@staticmethod
|
||||
def component_action(button):
|
||||
MDSnackbar(MDSnackbarText(text="Opening in a new window..."), y=dp(24), pos_hint={"center_x": 0.5},
|
||||
size_hint_x=0.5).open()
|
||||
if button.component.func:
|
||||
button.component.func()
|
||||
else:
|
||||
launch(get_exe(button.component), button.component.cli)
|
||||
|
||||
def _on_drop_file(self, window: Window, filename: bytes, x: int, y: int) -> None:
|
||||
""" When a patch file is dropped into the window, run the associated component. """
|
||||
file, component = identify(filename.decode())
|
||||
if file and component:
|
||||
run_component(component, file)
|
||||
else:
|
||||
logging.warning(f"unable to identify component for {filename}")
|
||||
|
||||
def _on_keyboard(self, window: Window, key: int, scancode: int, codepoint: str, modifier: list[str]):
|
||||
# Activate search as soon as we start typing, no matter if we are focused on the search box or not.
|
||||
# Focus first, then capture the first character we type, otherwise it gets swallowed and lost.
|
||||
# Limit text input to ASCII non-control characters (space bar to tilde).
|
||||
if not self.search_box.focus:
|
||||
self.search_box.focus = True
|
||||
if key in range(32, 126):
|
||||
self.search_box.text += codepoint
|
||||
|
||||
def _stop(self, *largs):
|
||||
# ran into what appears to be https://groups.google.com/g/kivy-users/c/saWDLoYCSZ4 with PyCharm.
|
||||
# Closing the window explicitly cleans it up.
|
||||
self.root_window.close()
|
||||
super()._stop(*largs)
|
||||
|
||||
def on_stop(self):
|
||||
Utils.persistent_store("launcher", "favorites", self.favorites)
|
||||
Utils.persistent_store("launcher", "filter", ", ".join(filter.name if isinstance(filter, Type) else filter
|
||||
for filter in self.current_filter))
|
||||
super().on_stop()
|
||||
|
||||
Launcher(components=launch_components, args=args).run()
|
||||
|
||||
# avoiding Launcher reference leak
|
||||
# and don't try to do something with widgets after window closed
|
||||
global refresh_components
|
||||
refresh_components = None
|
||||
Launcher().run()
|
||||
|
||||
|
||||
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: argparse.Namespace | dict | None = None):
|
||||
def main(args: Optional[Union[argparse.Namespace, dict]] = None):
|
||||
if isinstance(args, argparse.Namespace):
|
||||
args = {k: v for k, v in args._get_kwargs()}
|
||||
elif not args:
|
||||
args = {}
|
||||
|
||||
path = args.get("Patch|Game|Component|url", None)
|
||||
if path is not None:
|
||||
if path.startswith("archipelago://"):
|
||||
args["args"] = (path, *args.get("args", ()))
|
||||
# add the url arg to the passthrough args
|
||||
components, text_client_component = handle_uri(path)
|
||||
if not components:
|
||||
args["component"] = text_client_component
|
||||
else:
|
||||
args['launch_components'] = [text_client_component, *components]
|
||||
else:
|
||||
file, component = identify(path)
|
||||
if file:
|
||||
args['file'] = file
|
||||
if component:
|
||||
args['component'] = component
|
||||
if not component:
|
||||
logging.warning(f"Could not identify Component responsible for {path}")
|
||||
if "Patch|Game|Component" in args:
|
||||
file, component = identify(args["Patch|Game|Component"])
|
||||
if file:
|
||||
args['file'] = file
|
||||
if component:
|
||||
args['component'] = component
|
||||
if not component:
|
||||
logging.warning(f"Could not identify Component responsible for {args['Patch|Game|Component']}")
|
||||
|
||||
if args["update_settings"]:
|
||||
update_settings()
|
||||
if "file" in args:
|
||||
if 'file' in args:
|
||||
run_component(args["component"], args["file"], *args["args"])
|
||||
elif "component" in args:
|
||||
elif 'component' in args:
|
||||
run_component(args["component"], *args["args"])
|
||||
elif not args["update_settings"]:
|
||||
run_gui(args.get("launch_components", None), args.get("args", ()))
|
||||
run_gui()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
multiprocessing.freeze_support()
|
||||
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]"
|
||||
)
|
||||
parser = argparse.ArgumentParser(description='Archipelago Launcher')
|
||||
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("Patch|Game|Component", type=str, nargs="?",
|
||||
help="Pass either a patch file, a generated game or the name of a component to run.")
|
||||
run_group.add_argument("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
|
||||
|
||||
@@ -3,6 +3,9 @@ ModuleUpdate.update()
|
||||
|
||||
import Utils
|
||||
|
||||
if __name__ == "__main__":
|
||||
Utils.init_logging("LinksAwakeningContext", exception_logger="Client")
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import binascii
|
||||
@@ -23,14 +26,14 @@ import typing
|
||||
from CommonClient import (CommonContext, get_base_parser, gui_enabled, logger,
|
||||
server_loop)
|
||||
from NetUtils import ClientStatus
|
||||
from . import LinksAwakeningWorld
|
||||
from .Common import BASE_ID as LABaseID
|
||||
from .GpsTracker import GpsTracker
|
||||
from .TrackerConsts import storage_key
|
||||
from .ItemTracker import ItemTracker
|
||||
from .LADXR.checkMetadata import checkMetadataTable
|
||||
from .Locations import get_locations_to_id, meta_to_name
|
||||
from .Tracker import LocationTracker, MagpieBridge, Check
|
||||
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
|
||||
|
||||
@@ -47,8 +50,20 @@ class BadRetroArchResponse(GameboyException):
|
||||
pass
|
||||
|
||||
|
||||
class VersionError(Exception):
|
||||
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:
|
||||
@@ -85,23 +100,19 @@ class LAClientConstants:
|
||||
WRamCheckSize = 0x4
|
||||
WRamSafetyValue = bytearray([0]*WRamCheckSize)
|
||||
|
||||
wRamStart = 0xC000
|
||||
hRamStart = 0xFF80
|
||||
hRamSize = 0x80
|
||||
|
||||
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.cache_start = LAClientConstants.wRamStart
|
||||
self.cache_size = LAClientConstants.hRamStart + LAClientConstants.hRamSize - LAClientConstants.wRamStart
|
||||
|
||||
self.address = address
|
||||
self.port = port
|
||||
self.socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
@@ -120,14 +131,9 @@ class RAGameboy():
|
||||
async def get_retroarch_status(self):
|
||||
return await self.send_command("GET_STATUS")
|
||||
|
||||
def set_checks_range(self, checks_start, checks_size):
|
||||
self.checks_start = checks_start
|
||||
self.checks_size = checks_size
|
||||
|
||||
def set_location_range(self, location_start, location_size, critical_addresses):
|
||||
self.location_start = location_start
|
||||
self.location_size = location_size
|
||||
self.critical_location_addresses = critical_addresses
|
||||
def 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:
|
||||
@@ -140,7 +146,7 @@ class RAGameboy():
|
||||
return response
|
||||
|
||||
async def async_recv(self, timeout=1.0):
|
||||
response = await asyncio.wait_for(asyncio.get_running_loop().sock_recv(self.socket, 4096), timeout)
|
||||
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):
|
||||
@@ -182,57 +188,21 @@ class RAGameboy():
|
||||
if not await self.check_safe_gameplay():
|
||||
return
|
||||
|
||||
attempts = 0
|
||||
while True:
|
||||
# RA doesn't let us do an atomic read of a large enough block of RAM
|
||||
# Some bytes can't change in between reading location_block and hram_block
|
||||
location_block = await self.read_memory_block(self.location_start, self.location_size)
|
||||
hram_block = await self.read_memory_block(LAClientConstants.hRamStart, LAClientConstants.hRamSize)
|
||||
verification_block = await self.read_memory_block(self.location_start, self.location_size)
|
||||
|
||||
valid = True
|
||||
for address in self.critical_location_addresses:
|
||||
if location_block[address - self.location_start] != verification_block[address - self.location_start]:
|
||||
valid = False
|
||||
|
||||
if valid:
|
||||
break
|
||||
|
||||
attempts += 1
|
||||
|
||||
# Shouldn't really happen, but keep it from choking
|
||||
if attempts > 5:
|
||||
return
|
||||
|
||||
checks_block = await self.read_memory_block(self.checks_start, self.checks_size)
|
||||
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 = bytearray(self.cache_size)
|
||||
|
||||
start = self.checks_start - self.cache_start
|
||||
self.cache[start:start + len(checks_block)] = checks_block
|
||||
|
||||
start = self.location_start - self.cache_start
|
||||
self.cache[start:start + len(location_block)] = location_block
|
||||
|
||||
start = LAClientConstants.hRamStart - self.cache_start
|
||||
self.cache[start:start + len(hram_block)] = hram_block
|
||||
|
||||
self.cache = cache
|
||||
self.last_cache_read = time.time()
|
||||
|
||||
async def read_memory_block(self, address: int, size: int):
|
||||
block = bytearray()
|
||||
remaining_size = size
|
||||
while remaining_size:
|
||||
chunk = await self.async_read_memory(address + len(block), remaining_size)
|
||||
remaining_size -= len(chunk)
|
||||
block += chunk
|
||||
|
||||
return block
|
||||
|
||||
async def read_memory_cache(self, addresses):
|
||||
# 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:
|
||||
@@ -265,7 +235,7 @@ class RAGameboy():
|
||||
|
||||
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
|
||||
ok = re.match("\d+\.\d+\.\d+", response.decode('ascii')) is not None
|
||||
else:
|
||||
ok = response.startswith(command.encode())
|
||||
if not ok:
|
||||
@@ -378,8 +348,7 @@ class LinksAwakeningClient():
|
||||
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')}")
|
||||
logger.info(f"Connected to Retroarch {version.decode('ascii')} running {rom_name.decode('ascii')}")
|
||||
return
|
||||
except (BlockingIOError, TimeoutError, ConnectionResetError):
|
||||
await asyncio.sleep(1.0)
|
||||
@@ -389,12 +358,11 @@ class LinksAwakeningClient():
|
||||
auth = binascii.hexlify(await self.gameboy.async_read_memory(0x0134, 12)).decode()
|
||||
self.auth = auth
|
||||
|
||||
async def wait_and_init_tracker(self, magpie: MagpieBridge):
|
||||
async def wait_and_init_tracker(self):
|
||||
await self.wait_for_game_ready()
|
||||
self.tracker = LocationTracker(self.gameboy)
|
||||
self.item_tracker = ItemTracker(self.gameboy)
|
||||
self.gps_tracker = GpsTracker(self.gameboy)
|
||||
magpie.gps_tracker = self.gps_tracker
|
||||
|
||||
async def recved_item_from_ap(self, item_id, from_player, next_index):
|
||||
# Don't allow getting an item until you've got your first check
|
||||
@@ -411,10 +379,10 @@ class LinksAwakeningClient():
|
||||
status = (await self.gameboy.async_read_memory_safe(LAClientConstants.wLinkStatusBits))[0]
|
||||
|
||||
item_id -= LABaseID
|
||||
# The player name table only goes up to 101, so don't go past that
|
||||
# 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 > 101:
|
||||
from_player = 101
|
||||
if from_player > 100:
|
||||
from_player = 100
|
||||
|
||||
next_index += 1
|
||||
self.gameboy.write_memory(LAClientConstants.wLinkGiveItem, [
|
||||
@@ -436,11 +404,9 @@ class LinksAwakeningClient():
|
||||
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.gameboy.update_cache()
|
||||
await self.tracker.readChecks(item_get_cb)
|
||||
await self.item_tracker.readItems()
|
||||
await self.gps_tracker.read_location()
|
||||
await self.gps_tracker.read_entrances()
|
||||
|
||||
current_health = (await self.gameboy.read_memory_cache([LAClientConstants.wLinkHealth]))[LAClientConstants.wLinkHealth]
|
||||
if self.deathlink_debounce and current_health != 0:
|
||||
@@ -490,7 +456,7 @@ class LinksAwakeningContext(CommonContext):
|
||||
la_task = None
|
||||
client = None
|
||||
# TODO: does this need to re-read on reset?
|
||||
found_checks = set()
|
||||
found_checks = []
|
||||
last_resend = time.time()
|
||||
|
||||
magpie_enabled = False
|
||||
@@ -498,14 +464,8 @@ class LinksAwakeningContext(CommonContext):
|
||||
magpie_task = None
|
||||
won = False
|
||||
|
||||
@property
|
||||
def slot_storage_key(self):
|
||||
return f"{self.slot_info[self.slot].name}_{storage_key}"
|
||||
|
||||
def __init__(self, server_address: typing.Optional[str], password: typing.Optional[str], magpie: typing.Optional[bool]) -> None:
|
||||
self.client = LinksAwakeningClient()
|
||||
self.slot_data = {}
|
||||
|
||||
if magpie:
|
||||
self.magpie_enabled = True
|
||||
self.magpie = MagpieBridge()
|
||||
@@ -513,42 +473,38 @@ class LinksAwakeningContext(CommonContext):
|
||||
|
||||
def run_gui(self) -> None:
|
||||
import webbrowser
|
||||
from kvui import GameManager
|
||||
from kivy.metrics import dp
|
||||
from kivymd.uix.button import MDButton, MDButtonText
|
||||
import kvui
|
||||
from kvui import Button, GameManager
|
||||
from kivy.uix.image import Image
|
||||
|
||||
class LADXManager(GameManager):
|
||||
logging_pairs = [
|
||||
("Client", "Archipelago"),
|
||||
("Tracker", "Tracker"),
|
||||
]
|
||||
base_title = f"Links Awakening DX Client {LinksAwakeningWorld.world_version.as_simple_string()} | Archipelago"
|
||||
base_title = "Archipelago Links Awakening DX Client"
|
||||
|
||||
def build(self):
|
||||
b = super().build()
|
||||
|
||||
if self.ctx.magpie_enabled:
|
||||
button = MDButton(MDButtonText(text="Open Tracker"), style="filled", size=(dp(100), dp(70)), radius=5,
|
||||
size_hint_x=None, size_hint_y=None, pos_hint={"center_y": 0.55},
|
||||
on_press=lambda _: webbrowser.open('https://magpietracker.us/?enable_autotracker=1'))
|
||||
button.height = self.server_connect_bar.height
|
||||
self.connect_layout.add_widget(button)
|
||||
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_new_entrances(self, entrances: typing.Dict[str, str]):
|
||||
# Store the entrances we find on the server for future sessions
|
||||
message = [{
|
||||
"cmd": "Set",
|
||||
"key": self.slot_storage_key,
|
||||
"default": {},
|
||||
"want_reply": False,
|
||||
"operations": [{"operation": "update", "value": entrances}],
|
||||
}]
|
||||
|
||||
async def send_checks(self):
|
||||
message = [{"cmd": 'LocationChecks', "locations": self.found_checks}]
|
||||
await self.send_msgs(message)
|
||||
|
||||
had_invalid_slot_data = None
|
||||
@@ -578,19 +534,13 @@ class LinksAwakeningContext(CommonContext):
|
||||
await self.send_msgs(message)
|
||||
self.won = True
|
||||
|
||||
async def request_found_entrances(self):
|
||||
await self.send_msgs([{"cmd": "Get", "keys": [self.slot_storage_key]}])
|
||||
|
||||
# Ask for updates so that players can co-op entrances in a seed
|
||||
await self.send_msgs([{"cmd": "SetNotify", "keys": [self.slot_storage_key]}])
|
||||
|
||||
async def on_deathlink(self, data: typing.Dict[str, typing.Any]) -> None:
|
||||
if self.ENABLE_DEATHLINK:
|
||||
self.client.pending_deathlink = True
|
||||
|
||||
def new_checks(self, item_ids, ladxr_ids):
|
||||
self.found_checks.update(item_ids)
|
||||
create_task_log_exception(self.check_locations(self.found_checks))
|
||||
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))
|
||||
|
||||
@@ -607,60 +557,21 @@ class LinksAwakeningContext(CommonContext):
|
||||
|
||||
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", {})
|
||||
generated_version = Utils.tuplize_version(self.slot_data.get("world_version", "2.0.0"))
|
||||
client_version = LinksAwakeningWorld.world_version
|
||||
if generated_version.major != client_version.major:
|
||||
self.disconnected_intentionally = True
|
||||
raise VersionError(
|
||||
f"The installed world ({client_version.as_simple_string()}) is incompatible with "
|
||||
f"the world this game was generated on ({generated_version.as_simple_string()})"
|
||||
)
|
||||
# This is sent to magpie over local websocket to make its own connection
|
||||
self.slot_data.update({
|
||||
"server_address": self.server_address,
|
||||
"slot_name": self.player_names[self.slot],
|
||||
"password": self.password,
|
||||
"client_version": client_version.as_simple_string(),
|
||||
})
|
||||
|
||||
# We can process linked items on already-checked checks now that we have slot_data
|
||||
if self.client.tracker:
|
||||
checked_checks = set(self.client.tracker.all_checks) - set(self.client.tracker.remaining_checks)
|
||||
self.add_linked_items(checked_checks)
|
||||
|
||||
# TODO - use watcher_event
|
||||
if cmd == "ReceivedItems":
|
||||
for index, item in enumerate(args["items"], start=args["index"]):
|
||||
self.client.recvd_checks[index] = item
|
||||
|
||||
if cmd == "Retrieved" and self.magpie_enabled and self.slot_storage_key in args["keys"]:
|
||||
self.client.gps_tracker.receive_found_entrances(args["keys"][self.slot_storage_key])
|
||||
|
||||
if cmd == "SetReply" and self.magpie_enabled and args["key"] == self.slot_storage_key:
|
||||
self.client.gps_tracker.receive_found_entrances(args["value"])
|
||||
|
||||
async def sync(self):
|
||||
sync_msg = [{'cmd': 'Sync'}]
|
||||
await self.send_msgs(sync_msg)
|
||||
|
||||
def add_linked_items(self, checks: typing.List[Check]):
|
||||
for check in checks:
|
||||
if check.value and check.linkedItem:
|
||||
linkedItem = check.linkedItem
|
||||
if 'condition' not in linkedItem or (self.slot_data and linkedItem['condition'](self.slot_data)):
|
||||
self.client.item_tracker.setExtraItem(check.linkedItem['item'], check.linkedItem['qty'])
|
||||
|
||||
item_id_lookup = get_locations_to_id()
|
||||
|
||||
async def run_game_loop(self):
|
||||
@@ -669,8 +580,6 @@ class LinksAwakeningContext(CommonContext):
|
||||
checkMetadataTable[check.id])] for check in ladxr_checks]
|
||||
self.new_checks(checks, [check.id for check in ladxr_checks])
|
||||
|
||||
self.add_linked_items(ladxr_checks)
|
||||
|
||||
async def victory():
|
||||
await self.send_victory()
|
||||
|
||||
@@ -704,38 +613,20 @@ class LinksAwakeningContext(CommonContext):
|
||||
if not self.client.recvd_checks:
|
||||
await self.sync()
|
||||
|
||||
await self.client.wait_and_init_tracker(self.magpie)
|
||||
await self.client.wait_and_init_tracker()
|
||||
|
||||
min_tick_duration = 0.1
|
||||
last_tick = time.time()
|
||||
while True:
|
||||
await self.client.main_tick(on_item_get, victory, deathlink)
|
||||
|
||||
await asyncio.sleep(0.1)
|
||||
now = time.time()
|
||||
tick_duration = now - last_tick
|
||||
sleep_duration = max(min_tick_duration - tick_duration, 0)
|
||||
await asyncio.sleep(sleep_duration)
|
||||
|
||||
last_tick = now
|
||||
|
||||
if self.last_resend + 5.0 < now:
|
||||
self.last_resend = now
|
||||
await self.check_locations(self.found_checks)
|
||||
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)
|
||||
if self.slot_data and "slot_data" in self.magpie.features and not self.magpie.has_sent_slot_data:
|
||||
self.magpie.slot_data = self.slot_data
|
||||
await self.magpie.send_slot_data()
|
||||
|
||||
if self.client.gps_tracker.needs_found_entrances:
|
||||
await self.request_found_entrances()
|
||||
self.client.gps_tracker.needs_found_entrances = False
|
||||
|
||||
new_entrances = await self.magpie.send_gps(self.client.gps_tracker)
|
||||
if new_entrances:
|
||||
await self.send_new_entrances(new_entrances)
|
||||
await self.magpie.send_gps(self.client.gps_tracker)
|
||||
except Exception:
|
||||
# Don't let magpie errors take out the client
|
||||
pass
|
||||
@@ -746,8 +637,8 @@ class LinksAwakeningContext(CommonContext):
|
||||
await asyncio.sleep(1.0)
|
||||
|
||||
def run_game(romfile: str) -> None:
|
||||
auto_start = LinksAwakeningWorld.settings.rom_start
|
||||
|
||||
auto_start = typing.cast(typing.Union[bool, str],
|
||||
Utils.get_options()["ladx_options"].get("rom_start", True))
|
||||
if auto_start is True:
|
||||
import webbrowser
|
||||
webbrowser.open(romfile)
|
||||
@@ -768,44 +659,42 @@ def run_game(romfile: str) -> None:
|
||||
except FileNotFoundError:
|
||||
logger.error(f"Couldn't launch ROM, {args[0]} is missing")
|
||||
|
||||
def launch(*launch_args):
|
||||
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')
|
||||
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(launch_args)
|
||||
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}")
|
||||
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 = LinksAwakeningContext(args.connect, args.password, args.magpie)
|
||||
|
||||
ctx.server_task = asyncio.create_task(server_loop(ctx), name="server loop")
|
||||
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()
|
||||
# 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)
|
||||
# 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()
|
||||
await ctx.exit_event.wait()
|
||||
await ctx.shutdown()
|
||||
|
||||
Utils.init_logging("LinksAwakeningContext", exception_logger="Client")
|
||||
|
||||
colorama.just_fix_windows_console()
|
||||
if __name__ == '__main__':
|
||||
colorama.init()
|
||||
asyncio.run(main())
|
||||
colorama.deinit()
|
||||
@@ -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
|
||||
@@ -29,19 +29,13 @@ from Utils import output_path, local_path, user_path, open_file, get_cert_none_s
|
||||
|
||||
|
||||
GAME_ALTTP = "A Link to the Past"
|
||||
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.worlds = {1: self.AdjusterSubWorld(random)}
|
||||
self.per_slot_randoms = {1: random}
|
||||
|
||||
|
||||
class ArgumentDefaultsHelpFormatter(argparse.RawTextHelpFormatter):
|
||||
@@ -49,7 +43,6 @@ 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,
|
||||
@@ -249,17 +242,16 @@ def adjustGUI():
|
||||
from argparse import Namespace
|
||||
from Utils import __version__ as MWVersion
|
||||
adjustWindow = Tk()
|
||||
adjustWindow.minsize(WINDOW_MIN_WIDTH, WINDOW_MIN_HEIGHT)
|
||||
adjustWindow.wm_title("Archipelago %s LttP Adjuster" % MWVersion)
|
||||
set_icon(adjustWindow)
|
||||
|
||||
rom_options_frame, rom_vars, set_sprite = get_rom_options_frame(adjustWindow)
|
||||
|
||||
bottomFrame2 = Frame(adjustWindow, 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)
|
||||
@@ -269,9 +261,9 @@ def adjustGUI():
|
||||
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():
|
||||
@@ -339,11 +331,12 @@ def adjustGUI():
|
||||
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)
|
||||
@@ -365,10 +358,10 @@ def run_sprite_update():
|
||||
logging.info("Done updating sprites")
|
||||
|
||||
|
||||
def update_sprites(task, on_finish=None, repository_url: str = "https://alttpr.com/sprites"):
|
||||
def update_sprites(task, on_finish=None):
|
||||
resultmessage = ""
|
||||
successful = True
|
||||
sprite_dir = user_path("data", "sprites", "alttp", "remote")
|
||||
sprite_dir = user_path("data", "sprites", "alttpr")
|
||||
os.makedirs(sprite_dir, exist_ok=True)
|
||||
ctx = get_cert_none_ssl_context()
|
||||
|
||||
@@ -378,11 +371,11 @@ def update_sprites(task, on_finish=None, repository_url: str = "https://alttpr.c
|
||||
on_finish(successful, resultmessage)
|
||||
|
||||
try:
|
||||
task.update_status("Downloading remote sprites list")
|
||||
with urlopen(repository_url, context=ctx) as response:
|
||||
task.update_status("Downloading alttpr sprites list")
|
||||
with urlopen('https://alttpr.com/sprites', context=ctx) as response:
|
||||
sprites_arr = json.loads(response.read().decode("utf-8"))
|
||||
except Exception as e:
|
||||
resultmessage = "Error getting list of remote sprites. Sprites not updated.\n\n%s: %s" % (type(e).__name__, e)
|
||||
resultmessage = "Error getting list of alttpr sprites. Sprites not updated.\n\n%s: %s" % (type(e).__name__, e)
|
||||
successful = False
|
||||
task.queue_event(finished)
|
||||
return
|
||||
@@ -390,13 +383,13 @@ def update_sprites(task, on_finish=None, repository_url: str = "https://alttpr.c
|
||||
try:
|
||||
task.update_status("Determining needed sprites")
|
||||
current_sprites = [os.path.basename(file) for file in glob(sprite_dir + '/*')]
|
||||
remote_sprites = [(sprite['file'], os.path.basename(urlparse(sprite['file']).path))
|
||||
alttpr_sprites = [(sprite['file'], os.path.basename(urlparse(sprite['file']).path))
|
||||
for sprite in sprites_arr if sprite["author"] != "Nintendo"]
|
||||
needed_sprites = [(sprite_url, filename) for (sprite_url, filename) in remote_sprites if
|
||||
needed_sprites = [(sprite_url, filename) for (sprite_url, filename) in alttpr_sprites if
|
||||
filename not in current_sprites]
|
||||
|
||||
remote_filenames = [filename for (_, filename) in remote_sprites]
|
||||
obsolete_sprites = [sprite for sprite in current_sprites if sprite not in remote_filenames]
|
||||
alttpr_filenames = [filename for (_, filename) in alttpr_sprites]
|
||||
obsolete_sprites = [sprite for sprite in current_sprites if sprite not in alttpr_filenames]
|
||||
except Exception as e:
|
||||
resultmessage = "Error Determining which sprites to update. Sprites not updated.\n\n%s: %s" % (
|
||||
type(e).__name__, e)
|
||||
@@ -448,7 +441,7 @@ def update_sprites(task, on_finish=None, repository_url: str = "https://alttpr.c
|
||||
successful = False
|
||||
|
||||
if successful:
|
||||
resultmessage = "Remote sprites updated successfully"
|
||||
resultmessage = "alttpr sprites updated successfully"
|
||||
|
||||
task.queue_event(finished)
|
||||
|
||||
@@ -583,7 +576,7 @@ class AttachTooltip(object):
|
||||
def get_rom_frame(parent=None):
|
||||
adjuster_settings = get_adjuster_settings(GAME_ALTTP)
|
||||
|
||||
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)
|
||||
@@ -603,19 +596,20 @@ 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)
|
||||
|
||||
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()
|
||||
@@ -666,7 +660,7 @@ 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)
|
||||
@@ -869,7 +863,7 @@ class SpriteSelector():
|
||||
def open_custom_sprite_dir(_evt):
|
||||
open_file(self.custom_sprite_dir)
|
||||
|
||||
remote_frametitle = Label(self.window, text='Remote Sprites')
|
||||
alttpr_frametitle = Label(self.window, text='ALTTPR Sprites')
|
||||
|
||||
custom_frametitle = Frame(self.window)
|
||||
title_text = Label(custom_frametitle, text="Custom Sprites")
|
||||
@@ -878,8 +872,8 @@ class SpriteSelector():
|
||||
title_link.pack(side=LEFT)
|
||||
title_link.bind("<Button-1>", open_custom_sprite_dir)
|
||||
|
||||
self.icon_section(remote_frametitle, self.remote_sprite_dir,
|
||||
'Remote sprites not found. Click "Update remote sprites" to download them.')
|
||||
self.icon_section(alttpr_frametitle, self.alttpr_sprite_dir,
|
||||
'ALTTPR sprites not found. Click "Update alttpr sprites" to download them.')
|
||||
self.icon_section(custom_frametitle, self.custom_sprite_dir,
|
||||
'Put sprites in the custom sprites folder (see open link above) to have them appear here.')
|
||||
if not randomOnEvent:
|
||||
@@ -892,18 +886,11 @@ class SpriteSelector():
|
||||
button = Button(frame, text="Browse for file...", command=self.browse_for_sprite)
|
||||
button.pack(side=RIGHT, padx=(5, 0))
|
||||
|
||||
button = Button(frame, text="Update remote sprites", command=self.update_remote_sprites)
|
||||
button = Button(frame, text="Update alttpr sprites", command=self.update_alttpr_sprites)
|
||||
button.pack(side=RIGHT, padx=(5, 0))
|
||||
|
||||
repository_label = Label(frame, text='Sprite Repository:')
|
||||
self.repository_url = StringVar(frame, "https://alttpr.com/sprites")
|
||||
repository_entry = Entry(frame, textvariable=self.repository_url)
|
||||
|
||||
repository_entry.pack(side=RIGHT, expand=True, fill=BOTH, pady=1)
|
||||
repository_label.pack(side=RIGHT, expand=False, padx=(0, 5))
|
||||
|
||||
button = Button(frame, text="Do not adjust sprite",command=self.use_default_sprite)
|
||||
button.pack(side=LEFT, padx=(0, 5))
|
||||
button.pack(side=LEFT,padx=(0,5))
|
||||
|
||||
button = Button(frame, text="Default Link sprite", command=self.use_default_link_sprite)
|
||||
button.pack(side=LEFT, padx=(0, 5))
|
||||
@@ -1063,7 +1050,7 @@ class SpriteSelector():
|
||||
for i, button in enumerate(frame.buttons):
|
||||
button.grid(row=i // self.spritesPerRow, column=i % self.spritesPerRow)
|
||||
|
||||
def update_remote_sprites(self):
|
||||
def update_alttpr_sprites(self):
|
||||
# need to wrap in try catch. We don't want errors getting the json or downloading the files to break us.
|
||||
self.window.destroy()
|
||||
self.parent.update()
|
||||
@@ -1076,8 +1063,7 @@ class SpriteSelector():
|
||||
messagebox.showerror("Sprite Updater", resultmessage)
|
||||
SpriteSelector(self.parent, self.callback, self.adjuster)
|
||||
|
||||
BackgroundTaskProgress(self.parent, update_sprites, "Updating Sprites",
|
||||
on_finish, self.repository_url.get())
|
||||
BackgroundTaskProgress(self.parent, update_sprites, "Updating Sprites", on_finish)
|
||||
|
||||
def browse_for_sprite(self):
|
||||
sprite = filedialog.askopenfilename(
|
||||
@@ -1167,13 +1153,12 @@ class SpriteSelector():
|
||||
os.makedirs(self.custom_sprite_dir)
|
||||
|
||||
@property
|
||||
def remote_sprite_dir(self):
|
||||
return user_path("data", "sprites", "alttp", "remote")
|
||||
def alttpr_sprite_dir(self):
|
||||
return user_path("data", "sprites", "alttpr")
|
||||
|
||||
@property
|
||||
def custom_sprite_dir(self):
|
||||
return user_path("data", "sprites", "alttp", "custom")
|
||||
|
||||
return user_path("data", "sprites", "custom")
|
||||
|
||||
def get_image_for_sprite(sprite, gif_only: bool = False):
|
||||
if not sprite.valid:
|
||||
|
||||
@@ -286,14 +286,16 @@ async def gba_sync_task(ctx: MMBN3Context):
|
||||
except ConnectionRefusedError:
|
||||
logger.debug("Connection Refused, Trying Again")
|
||||
ctx.gba_status = CONNECTION_REFUSED_STATUS
|
||||
await asyncio.sleep(1)
|
||||
continue
|
||||
|
||||
|
||||
async def run_game(romfile):
|
||||
from worlds.mmbn3 import MMBN3World
|
||||
auto_start = MMBN3World.settings.rom_start
|
||||
if auto_start is True:
|
||||
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):
|
||||
@@ -368,7 +370,7 @@ if __name__ == "__main__":
|
||||
|
||||
import colorama
|
||||
|
||||
colorama.just_fix_windows_console()
|
||||
colorama.init()
|
||||
|
||||
asyncio.run(main())
|
||||
colorama.deinit()
|
||||
|
||||
491
Main.py
@@ -1,21 +1,19 @@
|
||||
import collections
|
||||
from collections.abc import Mapping
|
||||
import concurrent.futures
|
||||
import logging
|
||||
import os
|
||||
import pickle
|
||||
import tempfile
|
||||
import time
|
||||
from typing import Any
|
||||
import zipfile
|
||||
import zlib
|
||||
from typing import Dict, List, Optional, Set, Tuple, Union
|
||||
|
||||
import worlds
|
||||
from BaseClasses import CollectionState, Item, Location, LocationProgressType, MultiWorld
|
||||
from Fill import FillError, balance_multiworld_progression, distribute_items_restrictive, flood_items, \
|
||||
parse_planned_blocks, distribute_planned_blocks, resolve_early_locations_for_planned
|
||||
from NetUtils import convert_to_base_types
|
||||
from BaseClasses import CollectionState, Item, Location, LocationProgressType, MultiWorld, Region
|
||||
from Fill import balance_multiworld_progression, distribute_items_restrictive, distribute_planned, flood_items
|
||||
from Options import StartInventoryPool
|
||||
from Utils import __version__, output_path, restricted_dumps, version_tuple
|
||||
from Utils import __version__, output_path, version_tuple
|
||||
from settings import get_settings
|
||||
from worlds import AutoWorld
|
||||
from worlds.generic.Rules import exclusion_rules, locality_rules
|
||||
@@ -23,7 +21,7 @@ from worlds.generic.Rules import exclusion_rules, locality_rules
|
||||
__all__ = ["main"]
|
||||
|
||||
|
||||
def main(args, seed=None, baked_server_options: dict[str, object] | None = None):
|
||||
def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = None):
|
||||
if not baked_server_options:
|
||||
baked_server_options = get_settings().server_options.as_dict()
|
||||
assert isinstance(baked_server_options, dict)
|
||||
@@ -32,337 +30,396 @@ def main(args, seed=None, baked_server_options: dict[str, object] | None = None)
|
||||
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
|
||||
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 None)
|
||||
world.plando_options = args.plando_options
|
||||
|
||||
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.countdown_start_time = args.countdown_start_time.copy()
|
||||
world.red_clock_time = args.red_clock_time.copy()
|
||||
world.blue_clock_time = args.blue_clock_time.copy()
|
||||
world.green_clock_time = args.green_clock_time.copy()
|
||||
world.dungeon_counters = args.dungeon_counters.copy()
|
||||
world.triforce_pieces_available = args.triforce_pieces_available.copy()
|
||||
world.triforce_pieces_required = args.triforce_pieces_required.copy()
|
||||
world.shop_shuffle = args.shop_shuffle.copy()
|
||||
world.shuffle_prizes = args.shuffle_prizes.copy()
|
||||
world.sprite_pool = args.sprite_pool.copy()
|
||||
world.dark_room_logic = args.dark_room_logic.copy()
|
||||
world.plando_items = args.plando_items.copy()
|
||||
world.plando_texts = args.plando_texts.copy()
|
||||
world.plando_connections = args.plando_connections.copy()
|
||||
world.required_medallions = args.required_medallions.copy()
|
||||
world.game = args.game.copy()
|
||||
world.player_name = args.name.copy()
|
||||
world.sprite = args.sprite.copy()
|
||||
world.glitch_triforce = args.glitch_triforce # This is enabled/disabled globally, no per player option.
|
||||
|
||||
world.set_options(args)
|
||||
world.set_item_links()
|
||||
world.state = CollectionState(world)
|
||||
logger.info('Archipelago Version %s - Seed: %s\n', __version__, world.seed)
|
||||
|
||||
logger.info(f"Found {len(AutoWorld.AutoWorldRegister.world_types)} World Types:")
|
||||
longest_name = max(len(text) for text in AutoWorld.AutoWorldRegister.world_types)
|
||||
|
||||
world_classes = AutoWorld.AutoWorldRegister.world_types.values()
|
||||
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))
|
||||
|
||||
version_count = max(len(cls.world_version.as_simple_string()) for cls in world_classes)
|
||||
item_count = len(str(max(len(cls.item_names) for cls in world_classes)))
|
||||
location_count = len(str(max(len(cls.location_names) for cls in world_classes)))
|
||||
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
|
||||
|
||||
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}}: "
|
||||
f"v{cls.world_version.as_simple_string():{version_count}} | "
|
||||
f"Items: {len(cls.item_names):{item_count}} | "
|
||||
f"Locations: {len(cls.location_names):{location_count}}")
|
||||
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}})")
|
||||
|
||||
del item_count, location_count
|
||||
del item_digits, location_digits, item_count, location_count
|
||||
|
||||
# This assertion method should not be necessary to run if we are not outputting any multidata.
|
||||
if not args.skip_output and not args.spoiler_only:
|
||||
AutoWorld.call_stage(multiworld, "assert_generate")
|
||||
if not args.skip_output:
|
||||
AutoWorld.call_stage(world, "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.worlds[player].options.start_inventory.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 item_name, count in world.start_inventory_from_pool.setdefault(player, StartInventoryPool({})).value.items():
|
||||
for _ in range(count):
|
||||
multiworld.push_precollected(multiworld.create_item(item_name, player))
|
||||
world.push_precollected(world.create_item(item_name, player))
|
||||
# remove from_pool items also from early items handling, as starting is plenty early.
|
||||
early = multiworld.early_items[player].get(item_name, 0)
|
||||
early = world.early_items[player].get(item_name, 0)
|
||||
if early:
|
||||
multiworld.early_items[player][item_name] = max(0, early-count)
|
||||
world.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)
|
||||
local_early = world.early_local_items[player].get(item_name, 0)
|
||||
if local_early:
|
||||
multiworld.early_items[player][item_name] = max(0, local_early - remaining_count)
|
||||
world.early_items[player][item_name] = max(0, local_early - remaining_count)
|
||||
del local_early
|
||||
del early
|
||||
|
||||
# items can't be both local and non-local, prefer local
|
||||
multiworld.worlds[player].options.non_local_items.value -= multiworld.worlds[player].options.local_items.value
|
||||
multiworld.worlds[player].options.non_local_items.value -= set(multiworld.local_early_items[player])
|
||||
|
||||
# Clear non-applicable local and non-local items.
|
||||
if multiworld.players == 1:
|
||||
multiworld.worlds[1].options.non_local_items.value = set()
|
||||
multiworld.worlds[1].options.local_items.value = set()
|
||||
|
||||
logger.info('Creating MultiWorld.')
|
||||
AutoWorld.call_all(multiworld, "create_regions")
|
||||
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.')
|
||||
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:
|
||||
for player in world.player_ids:
|
||||
# items can't be both local and non-local, prefer local
|
||||
world.worlds[player].options.non_local_items.value -= world.worlds[player].options.local_items.value
|
||||
world.worlds[player].options.non_local_items.value -= set(world.local_early_items[player])
|
||||
|
||||
AutoWorld.call_all(world, "set_rules")
|
||||
|
||||
for player in world.player_ids:
|
||||
exclusion_rules(world, player, world.worlds[player].options.exclude_locations.value)
|
||||
world.worlds[player].options.priority_locations.value -= world.worlds[player].options.exclude_locations.value
|
||||
for location_name in world.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
|
||||
location = world.get_location(location_name, player)
|
||||
except KeyError as e: # failed to find the given location. Check if it's a legitimate location
|
||||
if location_name not in world.worlds[player].location_name_to_id:
|
||||
raise Exception(f"Unable to prioritize location {location_name} in player {player}'s world.") from e
|
||||
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
|
||||
location.progress_type = LocationProgressType.PRIORITY
|
||||
|
||||
# Set local and non-local item rules.
|
||||
# This function is called so late because worlds might otherwise overwrite item_rules which are how locality works
|
||||
if multiworld.players > 1:
|
||||
locality_rules(multiworld)
|
||||
|
||||
multiworld.plando_item_blocks = parse_planned_blocks(multiworld)
|
||||
|
||||
AutoWorld.call_all(multiworld, "connect_entrances")
|
||||
AutoWorld.call_all(multiworld, "generate_basic")
|
||||
if world.players > 1:
|
||||
locality_rules(world)
|
||||
else:
|
||||
world.worlds[1].options.non_local_items.value = set()
|
||||
world.worlds[1].options.local_items.value = set()
|
||||
|
||||
AutoWorld.call_all(world, "generate_basic")
|
||||
|
||||
# remove starting inventory from pool items.
|
||||
# Because some worlds don't actually create items during create_items this has to be as late as possible.
|
||||
fallback_inventory = StartInventoryPool({})
|
||||
depletion_pool: dict[int, dict[str, int]] = {
|
||||
player: getattr(multiworld.worlds[player].options, "start_inventory_from_pool", fallback_inventory).value.copy()
|
||||
for player in multiworld.player_ids
|
||||
}
|
||||
target_per_player = {
|
||||
player: sum(target_items.values()) for player, target_items in depletion_pool.items() if target_items
|
||||
}
|
||||
|
||||
if target_per_player:
|
||||
new_itempool: list[Item] = []
|
||||
|
||||
# Make new itempool with start_inventory_from_pool items removed
|
||||
for item in multiworld.itempool:
|
||||
if any(getattr(world.worlds[player].options, "start_inventory_from_pool", StartInventoryPool({})).value for player in world.player_ids):
|
||||
new_items: List[Item] = []
|
||||
depletion_pool: Dict[int, Dict[str, int]] = {
|
||||
player: getattr(world.worlds[player].options, "start_inventory_from_pool", StartInventoryPool({})).value.copy()
|
||||
for player in world.player_ids}
|
||||
for player, items in depletion_pool.items():
|
||||
player_world: AutoWorld.World = world.worlds[player]
|
||||
for count in items.values():
|
||||
for _ in range(count):
|
||||
new_items.append(player_world.create_filler())
|
||||
target: int = sum(sum(items.values()) for items in depletion_pool.values())
|
||||
for i, item in enumerate(world.itempool):
|
||||
if depletion_pool[item.player].get(item.name, 0):
|
||||
target -= 1
|
||||
depletion_pool[item.player][item.name] -= 1
|
||||
# quick abort if we have found all items
|
||||
if not target:
|
||||
new_items.extend(world.itempool[i+1:])
|
||||
break
|
||||
else:
|
||||
new_items.append(item)
|
||||
|
||||
# leftovers?
|
||||
if target:
|
||||
for player, remaining_items in depletion_pool.items():
|
||||
remaining_items = {name: count for name, count in remaining_items.items() if count}
|
||||
if remaining_items:
|
||||
raise Exception(f"{world.get_player_name(player)}"
|
||||
f" is trying to remove items from their pool that don't exist: {remaining_items}")
|
||||
assert len(world.itempool) == len(new_items), "Item Pool amounts should not change."
|
||||
world.itempool[:] = new_items
|
||||
|
||||
# temporary home for item links, should be moved out of Main
|
||||
for group_id, group in world.groups.items():
|
||||
def find_common_pool(players: Set[int], shared_pool: Set[str]) -> Tuple[
|
||||
Optional[Dict[int, Dict[str, int]]], Optional[Dict[str, int]]
|
||||
]:
|
||||
classifications: Dict[str, int] = collections.defaultdict(int)
|
||||
counters = {player: {name: 0 for name in shared_pool} for player in players}
|
||||
for item in world.itempool:
|
||||
if item.player in counters and item.name in shared_pool:
|
||||
counters[item.player][item.name] += 1
|
||||
classifications[item.name] |= item.classification
|
||||
|
||||
for player in players.copy():
|
||||
if all([counters[player][item] == 0 for item in shared_pool]):
|
||||
players.remove(player)
|
||||
del (counters[player])
|
||||
|
||||
if not players:
|
||||
return None, None
|
||||
|
||||
for item in shared_pool:
|
||||
count = min(counters[player][item] for player in players)
|
||||
if count:
|
||||
for player in players:
|
||||
counters[player][item] = count
|
||||
else:
|
||||
for player in players:
|
||||
del (counters[player][item])
|
||||
return counters, classifications
|
||||
|
||||
common_item_count, classifications = find_common_pool(group["players"], group["item_pool"])
|
||||
if not common_item_count:
|
||||
continue
|
||||
|
||||
new_itempool: List[Item] = []
|
||||
for item_name, item_count in next(iter(common_item_count.values())).items():
|
||||
for _ in range(item_count):
|
||||
new_item = group["world"].create_item(item_name)
|
||||
# mangle together all original classification bits
|
||||
new_item.classification |= classifications[item_name]
|
||||
new_itempool.append(new_item)
|
||||
|
||||
region = Region("Menu", group_id, world, "ItemLink")
|
||||
world.regions.append(region)
|
||||
locations = region.locations
|
||||
for item in world.itempool:
|
||||
count = common_item_count.get(item.player, {}).get(item.name, 0)
|
||||
if count:
|
||||
loc = Location(group_id, f"Item Link: {item.name} -> {world.player_name[item.player]} {count}",
|
||||
None, region)
|
||||
loc.access_rule = lambda state, item_name = item.name, group_id_ = group_id, count_ = count: \
|
||||
state.has(item_name, group_id_, count_)
|
||||
|
||||
locations.append(loc)
|
||||
loc.place_locked_item(item)
|
||||
common_item_count[item.player][item.name] -= 1
|
||||
else:
|
||||
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["link_replacement"]:
|
||||
item_player = group_id
|
||||
else:
|
||||
item_player = player
|
||||
if group["replacement_items"][player]:
|
||||
items_to_add.append(AutoWorld.call_single(world, "create_item", item_player,
|
||||
group["replacement_items"][player]))
|
||||
else:
|
||||
items_to_add.append(AutoWorld.call_single(world, "create_filler", item_player))
|
||||
world.random.shuffle(items_to_add)
|
||||
world.itempool.extend(items_to_add[:itemcount - len(world.itempool)])
|
||||
|
||||
needed_items = target_per_player[player] - sum(unfound_items.values())
|
||||
new_itempool += [multiworld.worlds[player].create_filler() for _ in range(needed_items)]
|
||||
|
||||
assert len(multiworld.itempool) == len(new_itempool), "Item Pool amounts should not change."
|
||||
multiworld.itempool[:] = new_itempool
|
||||
|
||||
multiworld.link_items()
|
||||
|
||||
if any(world.options.item_links for world in multiworld.worlds.values()):
|
||||
multiworld._all_state = None
|
||||
if any(world.item_links.values()):
|
||||
world._all_state = None
|
||||
|
||||
logger.info("Running Item Plando.")
|
||||
resolve_early_locations_for_planned(multiworld)
|
||||
distribute_planned_blocks(multiworld, [x for player in multiworld.plando_item_blocks
|
||||
for x in multiworld.plando_item_blocks[player]])
|
||||
|
||||
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)
|
||||
if world.players > 1 and not args.skip_prog_balancing:
|
||||
balance_multiworld_progression(world)
|
||||
else:
|
||||
logger.info("Progression balancing skipped.")
|
||||
|
||||
AutoWorld.call_all(multiworld, "finalize_multiworld")
|
||||
AutoWorld.call_all(multiworld, "pre_output")
|
||||
|
||||
# we're about to output using multithreading, so we're removing the global random state to prevent accidental use
|
||||
multiworld.random.passthrough = False
|
||||
world.random.passthrough = False
|
||||
|
||||
if args.skip_output:
|
||||
logger.info('Done. Skipped output/spoiler generation. Total Time: %s', time.perf_counter() - start)
|
||||
return multiworld
|
||||
return world
|
||||
|
||||
logger.info(f'Beginning output...')
|
||||
outfilebase = 'AP_' + multiworld.seed_name
|
||||
|
||||
if args.spoiler_only:
|
||||
if args.spoiler > 1:
|
||||
logger.info('Calculating playthrough.')
|
||||
multiworld.spoiler.create_playthrough(create_paths=args.spoiler > 2)
|
||||
|
||||
multiworld.spoiler.to_file(output_path('%s_Spoiler.txt' % outfilebase))
|
||||
logger.info('Done. Skipped multidata modification. Total time: %s', time.perf_counter() - start)
|
||||
return multiworld
|
||||
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__]
|
||||
output_players = [player for player in world.player_ids if AutoWorld.World.generate_output.__code__
|
||||
is not world.worlds[player].generate_output.__code__]
|
||||
with concurrent.futures.ThreadPoolExecutor(len(output_players) + 2) as pool:
|
||||
check_accessibility_task = pool.submit(multiworld.fulfills_accessibility)
|
||||
check_accessibility_task = pool.submit(world.fulfills_accessibility)
|
||||
|
||||
output_file_futures = [pool.submit(AutoWorld.call_stage, multiworld, "generate_output", temp_dir)]
|
||||
output_file_futures = [pool.submit(AutoWorld.call_stage, world, "generate_output", temp_dir)]
|
||||
for player in output_players:
|
||||
# skip starting a thread for methods that say "pass".
|
||||
output_file_futures.append(
|
||||
pool.submit(AutoWorld.call_single, multiworld, "generate_output", player, temp_dir))
|
||||
pool.submit(AutoWorld.call_single, world, "generate_output", player, temp_dir))
|
||||
|
||||
# collect ER hint info
|
||||
er_hint_data: dict[int, dict[int, str]] = {}
|
||||
AutoWorld.call_all(multiworld, 'extend_hint_information', er_hint_data)
|
||||
er_hint_data: Dict[int, Dict[int, str]] = {}
|
||||
AutoWorld.call_all(world, 'extend_hint_information', er_hint_data)
|
||||
|
||||
def write_multidata():
|
||||
import NetUtils
|
||||
from NetUtils import HintStatus
|
||||
slot_data: dict[int, Mapping[str, Any]] = {}
|
||||
client_versions: dict[int, tuple[int, int, int]] = {}
|
||||
games: dict[int, str] = {}
|
||||
minimum_versions: NetUtils.MinimumVersions = {
|
||||
"server": AutoWorld.World.required_server_version, "clients": client_versions
|
||||
}
|
||||
slot_info: dict[int, NetUtils.NetworkSlot] = {}
|
||||
names = [[name for player, name in sorted(multiworld.player_name.items())]]
|
||||
for slot in multiworld.player_ids:
|
||||
player_world: AutoWorld.World = multiworld.worlds[slot]
|
||||
slot_data = {}
|
||||
client_versions = {}
|
||||
games = {}
|
||||
minimum_versions = {"server": AutoWorld.World.required_server_version, "clients": client_versions}
|
||||
slot_info = {}
|
||||
names = [[name for player, name in sorted(world.player_name.items())]]
|
||||
for slot in world.player_ids:
|
||||
player_world: AutoWorld.World = world.worlds[slot]
|
||||
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: dict[int, set[NetUtils.Hint]] = {
|
||||
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()
|
||||
for slot in world.player_ids:
|
||||
slot_data[slot] = world.worlds[slot].fill_slot_data()
|
||||
|
||||
def precollect_hint(location: Location, auto_status: HintStatus):
|
||||
def precollect_hint(location):
|
||||
entrance = er_hint_data.get(location.player, {}).get(location.address, "")
|
||||
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}"
|
||||
f" {location}"
|
||||
assert location.address not in locations_data[location.player], (
|
||||
f"Locations with duplicate address. {location} and "
|
||||
f"{locations_data[location.player][location.address]}")
|
||||
locations_data[location.player][location.address] = \
|
||||
location.item.code, location.item.player, location.item.flags
|
||||
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)
|
||||
if location.name in world.worlds[location.player].options.start_location_hints:
|
||||
precollect_hint(location)
|
||||
elif location.item.name in world.worlds[location.item.player].options.start_hints:
|
||||
precollect_hint(location)
|
||||
elif any([location.item.name in world.worlds[player].options.start_hints
|
||||
for player in world.groups.get(location.item.player, {}).get("players", [])]):
|
||||
precollect_hint(location)
|
||||
|
||||
# embedded data package
|
||||
data_package = {
|
||||
game_world.game: worlds.network_data_package["games"][game_world.game]
|
||||
for game_world in multiworld.worlds.values()
|
||||
for game_world in world.worlds.values()
|
||||
}
|
||||
data_package["Archipelago"] = worlds.network_data_package["games"]["Archipelago"]
|
||||
|
||||
checks_in_area: dict[int, dict[str, int | list[int]]] = {}
|
||||
checks_in_area: Dict[int, Dict[str, Union[int, List[int]]]] = {}
|
||||
|
||||
# get spheres -> filter address==None -> skip empty
|
||||
spheres: list[dict[int, set[int]]] = []
|
||||
for sphere in multiworld.get_sendable_spheres():
|
||||
current_sphere: dict[int, set[int]] = collections.defaultdict(set)
|
||||
for sphere_location in sphere:
|
||||
current_sphere[sphere_location.player].add(sphere_location.address)
|
||||
|
||||
if current_sphere:
|
||||
spheres.append(dict(current_sphere))
|
||||
|
||||
multidata: NetUtils.MultiData = {
|
||||
multidata = {
|
||||
"slot_data": slot_data,
|
||||
"slot_info": slot_info,
|
||||
"connect_names": {name: (0, player) for player, name in multiworld.player_name.items()},
|
||||
"connect_names": {name: (0, player) for player, name in world.player_name.items()},
|
||||
"locations": locations_data,
|
||||
"checks_in_area": checks_in_area,
|
||||
"server_options": baked_server_options,
|
||||
"er_hint_data": er_hint_data,
|
||||
"precollected_items": precollected_items,
|
||||
"precollected_hints": precollected_hints,
|
||||
"version": (version_tuple.major, version_tuple.minor, version_tuple.build),
|
||||
"version": tuple(version_tuple),
|
||||
"tags": ["AP"],
|
||||
"minimum_versions": minimum_versions,
|
||||
"seed_name": multiworld.seed_name,
|
||||
"spheres": spheres,
|
||||
"seed_name": world.seed_name,
|
||||
"datapackage": data_package,
|
||||
"race_mode": int(multiworld.is_race),
|
||||
}
|
||||
# TODO: change to `"version": version_tuple` after getting better serialization
|
||||
AutoWorld.call_all(multiworld, "modify_multidata", multidata)
|
||||
AutoWorld.call_all(world, "modify_multidata", multidata)
|
||||
|
||||
for key in ("slot_data", "er_hint_data"):
|
||||
multidata[key] = convert_to_base_types(multidata[key])
|
||||
|
||||
serialized_multidata = zlib.compress(restricted_dumps(multidata), 9)
|
||||
multidata = zlib.compress(pickle.dumps(multidata), 9)
|
||||
|
||||
with open(os.path.join(temp_dir, f'{outfilebase}.archipelago'), 'wb') as f:
|
||||
f.write(bytes([3])) # version of format
|
||||
f.write(serialized_multidata)
|
||||
f.write(multidata)
|
||||
|
||||
output_file_futures.append(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.")
|
||||
|
||||
@@ -374,12 +431,12 @@ def main(args, seed=None, baked_server_options: dict[str, object] | None = None)
|
||||
|
||||
if args.spoiler > 1:
|
||||
logger.info('Calculating playthrough.')
|
||||
multiworld.spoiler.create_playthrough(create_paths=args.spoiler > 2)
|
||||
world.spoiler.create_playthrough(create_paths=args.spoiler > 2)
|
||||
|
||||
if args.spoiler:
|
||||
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:
|
||||
@@ -387,4 +444,4 @@ def main(args, seed=None, baked_server_options: dict[str, object] | None = None)
|
||||
zf.write(file.path, arcname=file.name)
|
||||
|
||||
logger.info('Done. Enjoy. Total Time: %s', time.perf_counter() - start)
|
||||
return multiworld
|
||||
return world
|
||||
|
||||
344
MinecraftClient.py
Normal file
@@ -0,0 +1,344 @@
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import re
|
||||
import atexit
|
||||
import shutil
|
||||
from subprocess import Popen
|
||||
from shutil import copyfile
|
||||
from time import strftime
|
||||
import logging
|
||||
|
||||
import requests
|
||||
|
||||
import Utils
|
||||
from Utils import is_windows
|
||||
|
||||
atexit.register(input, "Press enter to exit.")
|
||||
|
||||
# 1 or more digits followed by m or g, then optional b
|
||||
max_heap_re = re.compile(r"^\d+[mMgG][bB]?$")
|
||||
|
||||
|
||||
def prompt_yes_no(prompt):
|
||||
yes_inputs = {'yes', 'ye', 'y'}
|
||||
no_inputs = {'no', 'n'}
|
||||
while True:
|
||||
choice = input(prompt + " [y/n] ").lower()
|
||||
if choice in yes_inputs:
|
||||
return True
|
||||
elif choice in no_inputs:
|
||||
return False
|
||||
else:
|
||||
print('Please respond with "y" or "n".')
|
||||
|
||||
|
||||
def find_ap_randomizer_jar(forge_dir):
|
||||
"""Create mods folder if needed; find AP randomizer jar; return None if not found."""
|
||||
mods_dir = os.path.join(forge_dir, 'mods')
|
||||
if os.path.isdir(mods_dir):
|
||||
for entry in os.scandir(mods_dir):
|
||||
if entry.name.startswith("aprandomizer") and entry.name.endswith(".jar"):
|
||||
logging.info(f"Found AP randomizer mod: {entry.name}")
|
||||
return entry.name
|
||||
return None
|
||||
else:
|
||||
os.mkdir(mods_dir)
|
||||
logging.info(f"Created mods folder in {forge_dir}")
|
||||
return None
|
||||
|
||||
|
||||
def replace_apmc_files(forge_dir, apmc_file):
|
||||
"""Create APData folder if needed; clean .apmc files from APData; copy given .apmc into directory."""
|
||||
if apmc_file is None:
|
||||
return
|
||||
apdata_dir = os.path.join(forge_dir, 'APData')
|
||||
copy_apmc = True
|
||||
if not os.path.isdir(apdata_dir):
|
||||
os.mkdir(apdata_dir)
|
||||
logging.info(f"Created APData folder in {forge_dir}")
|
||||
for entry in os.scandir(apdata_dir):
|
||||
if entry.name.endswith(".apmc") and entry.is_file():
|
||||
if not os.path.samefile(apmc_file, entry.path):
|
||||
os.remove(entry.path)
|
||||
logging.info(f"Removed {entry.name} in {apdata_dir}")
|
||||
else: # apmc already in apdata
|
||||
copy_apmc = False
|
||||
if copy_apmc:
|
||||
copyfile(apmc_file, os.path.join(apdata_dir, os.path.basename(apmc_file)))
|
||||
logging.info(f"Copied {os.path.basename(apmc_file)} to {apdata_dir}")
|
||||
|
||||
|
||||
def read_apmc_file(apmc_file):
|
||||
from base64 import b64decode
|
||||
|
||||
with open(apmc_file, 'r') as f:
|
||||
return json.loads(b64decode(f.read()))
|
||||
|
||||
|
||||
def update_mod(forge_dir, url: str):
|
||||
"""Check mod version, download new mod from GitHub releases page if needed. """
|
||||
ap_randomizer = find_ap_randomizer_jar(forge_dir)
|
||||
os.path.basename(url)
|
||||
if ap_randomizer is not None:
|
||||
logging.info(f"Your current mod is {ap_randomizer}.")
|
||||
else:
|
||||
logging.info(f"You do not have the AP randomizer mod installed.")
|
||||
|
||||
if ap_randomizer != os.path.basename(url):
|
||||
logging.info(f"A new release of the Minecraft AP randomizer mod was found: "
|
||||
f"{os.path.basename(url)}")
|
||||
if prompt_yes_no("Would you like to update?"):
|
||||
old_ap_mod = os.path.join(forge_dir, 'mods', ap_randomizer) if ap_randomizer is not None else None
|
||||
new_ap_mod = os.path.join(forge_dir, 'mods', os.path.basename(url))
|
||||
logging.info("Downloading AP randomizer mod. This may take a moment...")
|
||||
apmod_resp = requests.get(url)
|
||||
if apmod_resp.status_code == 200:
|
||||
with open(new_ap_mod, 'wb') as f:
|
||||
f.write(apmod_resp.content)
|
||||
logging.info(f"Wrote new mod file to {new_ap_mod}")
|
||||
if old_ap_mod is not None:
|
||||
os.remove(old_ap_mod)
|
||||
logging.info(f"Removed old mod file from {old_ap_mod}")
|
||||
else:
|
||||
logging.error(f"Error retrieving the randomizer mod (status code {apmod_resp.status_code}).")
|
||||
logging.error(f"Please report this issue on the Archipelago Discord server.")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def check_eula(forge_dir):
|
||||
"""Check if the EULA is agreed to, and prompt the user to read and agree if necessary."""
|
||||
eula_path = os.path.join(forge_dir, "eula.txt")
|
||||
if not os.path.isfile(eula_path):
|
||||
# Create eula.txt
|
||||
with open(eula_path, 'w') as f:
|
||||
f.write("#By changing the setting below to TRUE you are indicating your agreement to our EULA (https://account.mojang.com/documents/minecraft_eula).\n")
|
||||
f.write(f"#{strftime('%a %b %d %X %Z %Y')}\n")
|
||||
f.write("eula=false\n")
|
||||
with open(eula_path, 'r+') as f:
|
||||
text = f.read()
|
||||
if 'false' in text:
|
||||
# Prompt user to agree to the EULA
|
||||
logging.info("You need to agree to the Minecraft EULA in order to run the server.")
|
||||
logging.info("The EULA can be found at https://account.mojang.com/documents/minecraft_eula")
|
||||
if prompt_yes_no("Do you agree to the EULA?"):
|
||||
f.seek(0)
|
||||
f.write(text.replace('false', 'true'))
|
||||
f.truncate()
|
||||
logging.info(f"Set {eula_path} to true")
|
||||
else:
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
def find_jdk_dir(version: str) -> str:
|
||||
"""get the specified versions jdk directory"""
|
||||
for entry in os.listdir():
|
||||
if os.path.isdir(entry) and entry.startswith(f"jdk{version}"):
|
||||
return os.path.abspath(entry)
|
||||
|
||||
|
||||
def find_jdk(version: str) -> str:
|
||||
"""get the java exe location"""
|
||||
|
||||
if is_windows:
|
||||
jdk = find_jdk_dir(version)
|
||||
jdk_exe = os.path.join(jdk, "bin", "java.exe")
|
||||
if os.path.isfile(jdk_exe):
|
||||
return jdk_exe
|
||||
else:
|
||||
jdk_exe = shutil.which(options["minecraft_options"].get("java", "java"))
|
||||
if not jdk_exe:
|
||||
raise Exception("Could not find Java. Is Java installed on the system?")
|
||||
return jdk_exe
|
||||
|
||||
|
||||
def download_java(java: str):
|
||||
"""Download Corretto (Amazon JDK)"""
|
||||
|
||||
jdk = find_jdk_dir(java)
|
||||
if jdk is not None:
|
||||
print(f"Removing old JDK...")
|
||||
from shutil import rmtree
|
||||
rmtree(jdk)
|
||||
|
||||
print(f"Downloading Java...")
|
||||
jdk_url = f"https://corretto.aws/downloads/latest/amazon-corretto-{java}-x64-windows-jdk.zip"
|
||||
resp = requests.get(jdk_url)
|
||||
if resp.status_code == 200: # OK
|
||||
print(f"Extracting...")
|
||||
import zipfile
|
||||
from io import BytesIO
|
||||
with zipfile.ZipFile(BytesIO(resp.content)) as zf:
|
||||
zf.extractall()
|
||||
else:
|
||||
print(f"Error downloading Java (status code {resp.status_code}).")
|
||||
print(f"If this was not expected, please report this issue on the Archipelago Discord server.")
|
||||
if not prompt_yes_no("Continue anyways?"):
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
def install_forge(directory: str, forge_version: str, java_version: str):
|
||||
"""download and install forge"""
|
||||
|
||||
java_exe = find_jdk(java_version)
|
||||
if java_exe is not None:
|
||||
print(f"Downloading Forge {forge_version}...")
|
||||
forge_url = f"https://maven.minecraftforge.net/net/minecraftforge/forge/{forge_version}/forge-{forge_version}-installer.jar"
|
||||
resp = requests.get(forge_url)
|
||||
if resp.status_code == 200: # OK
|
||||
forge_install_jar = os.path.join(directory, "forge_install.jar")
|
||||
if not os.path.exists(directory):
|
||||
os.mkdir(directory)
|
||||
with open(forge_install_jar, 'wb') as f:
|
||||
f.write(resp.content)
|
||||
print(f"Installing Forge...")
|
||||
install_process = Popen([java_exe, "-jar", forge_install_jar, "--installServer", directory])
|
||||
install_process.wait()
|
||||
os.remove(forge_install_jar)
|
||||
|
||||
|
||||
def run_forge_server(forge_dir: str, java_version: str, heap_arg: str) -> Popen:
|
||||
"""Run the Forge server."""
|
||||
|
||||
java_exe = find_jdk(java_version)
|
||||
if not os.path.isfile(java_exe):
|
||||
java_exe = "java" # try to fall back on java in the PATH
|
||||
|
||||
heap_arg = max_heap_re.match(heap_arg).group()
|
||||
if heap_arg[-1] in ['b', 'B']:
|
||||
heap_arg = heap_arg[:-1]
|
||||
heap_arg = "-Xmx" + heap_arg
|
||||
|
||||
os_args = "win_args.txt" if is_windows else "unix_args.txt"
|
||||
args_file = os.path.join(forge_dir, "libraries", "net", "minecraftforge", "forge", forge_version, os_args)
|
||||
forge_args = []
|
||||
with open(args_file) as argfile:
|
||||
for line in argfile:
|
||||
forge_args.extend(line.strip().split(" "))
|
||||
|
||||
args = [java_exe, heap_arg, *forge_args, "-nogui"]
|
||||
logging.info(f"Running Forge server: {args}")
|
||||
os.chdir(forge_dir)
|
||||
return Popen(args)
|
||||
|
||||
|
||||
def get_minecraft_versions(version, release_channel="release"):
|
||||
version_file_endpoint = "https://raw.githubusercontent.com/KonoTyran/Minecraft_AP_Randomizer/master/versions/minecraft_versions.json"
|
||||
resp = requests.get(version_file_endpoint)
|
||||
local = False
|
||||
if resp.status_code == 200: # OK
|
||||
try:
|
||||
data = resp.json()
|
||||
except requests.exceptions.JSONDecodeError:
|
||||
logging.warning(f"Unable to fetch version update file, using local version. (status code {resp.status_code}).")
|
||||
local = True
|
||||
else:
|
||||
logging.warning(f"Unable to fetch version update file, using local version. (status code {resp.status_code}).")
|
||||
local = True
|
||||
|
||||
if local:
|
||||
with open(Utils.user_path("minecraft_versions.json"), 'r') as f:
|
||||
data = json.load(f)
|
||||
else:
|
||||
with open(Utils.user_path("minecraft_versions.json"), 'w') as f:
|
||||
json.dump(data, f)
|
||||
|
||||
try:
|
||||
if version:
|
||||
return next(filter(lambda entry: entry["version"] == version, data[release_channel]))
|
||||
else:
|
||||
return resp.json()[release_channel][0]
|
||||
except (StopIteration, KeyError):
|
||||
logging.error(f"No compatible mod version found for client version {version} on \"{release_channel}\" channel.")
|
||||
if release_channel != "release":
|
||||
logging.error("Consider switching \"release_channel\" to \"release\" in your Host.yaml file")
|
||||
else:
|
||||
logging.error("No suitable mod found on the \"release\" channel. Please Contact us on discord to report this error.")
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
def is_correct_forge(forge_dir) -> bool:
|
||||
if os.path.isdir(os.path.join(forge_dir, "libraries", "net", "minecraftforge", "forge", forge_version)):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
Utils.init_logging("MinecraftClient")
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("apmc_file", default=None, nargs='?', help="Path to an Archipelago Minecraft data file (.apmc)")
|
||||
parser.add_argument('--install', '-i', dest='install', default=False, action='store_true',
|
||||
help="Download and install Java and the Forge server. Does not launch the client afterwards.")
|
||||
parser.add_argument('--release_channel', '-r', dest="channel", type=str, action='store',
|
||||
help="Specify release channel to use.")
|
||||
parser.add_argument('--java', '-j', metavar='17', dest='java', type=str, default=False, action='store',
|
||||
help="specify java version.")
|
||||
parser.add_argument('--forge', '-f', metavar='1.18.2-40.1.0', dest='forge', type=str, default=False, action='store',
|
||||
help="specify forge version. (Minecraft Version-Forge Version)")
|
||||
parser.add_argument('--version', '-v', metavar='9', dest='data_version', type=int, action='store',
|
||||
help="specify Mod data version to download.")
|
||||
|
||||
args = parser.parse_args()
|
||||
apmc_file = os.path.abspath(args.apmc_file) if args.apmc_file else None
|
||||
|
||||
# Change to executable's working directory
|
||||
os.chdir(os.path.abspath(os.path.dirname(sys.argv[0])))
|
||||
|
||||
options = Utils.get_options()
|
||||
channel = args.channel or options["minecraft_options"]["release_channel"]
|
||||
apmc_data = None
|
||||
data_version = args.data_version or None
|
||||
|
||||
if apmc_file is None and not args.install:
|
||||
apmc_file = Utils.open_filename('Select APMC file', (('APMC File', ('.apmc',)),))
|
||||
|
||||
if apmc_file is not None and data_version is None:
|
||||
apmc_data = read_apmc_file(apmc_file)
|
||||
data_version = apmc_data.get('client_version', '')
|
||||
|
||||
versions = get_minecraft_versions(data_version, channel)
|
||||
|
||||
forge_dir = options["minecraft_options"]["forge_directory"]
|
||||
max_heap = options["minecraft_options"]["max_heap_size"]
|
||||
forge_version = args.forge or versions["forge"]
|
||||
java_version = args.java or versions["java"]
|
||||
mod_url = versions["url"]
|
||||
java_dir = find_jdk_dir(java_version)
|
||||
|
||||
if args.install:
|
||||
if is_windows:
|
||||
print("Installing Java")
|
||||
download_java(java_version)
|
||||
if not is_correct_forge(forge_dir):
|
||||
print("Installing Minecraft Forge")
|
||||
install_forge(forge_dir, forge_version, java_version)
|
||||
else:
|
||||
print("Correct Forge version already found, skipping install.")
|
||||
sys.exit(0)
|
||||
|
||||
if apmc_data is None:
|
||||
raise FileNotFoundError(f"APMC file does not exist or is inaccessible at the given location ({apmc_file})")
|
||||
|
||||
if is_windows:
|
||||
if java_dir is None or not os.path.isdir(java_dir):
|
||||
if prompt_yes_no("Did not find java directory. Download and install java now?"):
|
||||
download_java(java_version)
|
||||
java_dir = find_jdk_dir(java_version)
|
||||
if java_dir is None or not os.path.isdir(java_dir):
|
||||
raise NotADirectoryError(f"Path {java_dir} does not exist or could not be accessed.")
|
||||
|
||||
if not is_correct_forge(forge_dir):
|
||||
if prompt_yes_no(f"Did not find forge version {forge_version} download and install it now?"):
|
||||
install_forge(forge_dir, forge_version, java_version)
|
||||
if not os.path.isdir(forge_dir):
|
||||
raise NotADirectoryError(f"Path {forge_dir} does not exist or could not be accessed.")
|
||||
|
||||
if not max_heap_re.match(max_heap):
|
||||
raise Exception(f"Max heap size {max_heap} in incorrect format. Use a number followed by M or G, e.g. 512M or 2G.")
|
||||
|
||||
update_mod(forge_dir, mod_url)
|
||||
replace_apmc_files(forge_dir, apmc_file)
|
||||
check_eula(forge_dir)
|
||||
server_process = run_forge_server(forge_dir, java_version, max_heap)
|
||||
server_process.wait()
|
||||
@@ -4,41 +4,14 @@ import subprocess
|
||||
import multiprocessing
|
||||
import warnings
|
||||
|
||||
local_dir = os.path.dirname(__file__)
|
||||
requirements_files = {os.path.join(local_dir, 'requirements.txt')}
|
||||
|
||||
if sys.platform in ("win32", "darwin") and not (3, 11, 9) <= sys.version_info < (3, 14, 0):
|
||||
# Official micro version updates. This should match the number in docs/running from source.md.
|
||||
raise RuntimeError(f"Incompatible Python Version found: {sys.version_info}. "
|
||||
"Official 3.11.9 through 3.13.x is supported.")
|
||||
elif sys.platform in ("win32", "darwin") and sys.version_info < (3, 11, 13):
|
||||
# There are known security issues, but no easy way to install fixed versions on Windows for testing.
|
||||
warnings.warn(f"Python Version {sys.version_info} has security issues. Don't use in production.")
|
||||
elif not (3, 11, 0) <= sys.version_info < (3, 14, 0):
|
||||
# Other platforms may get security backports instead of micro updates, so the number is unreliable.
|
||||
raise RuntimeError(f"Incompatible Python Version found: {sys.version_info}. 3.11.0 through 3.13.x is supported.")
|
||||
if sys.version_info < (3, 8, 6):
|
||||
raise RuntimeError("Incompatible Python Version. 3.8.7+ 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() or
|
||||
os.environ.get("SKIP_REQUIREMENTS_UPDATE", "").lower() in ("1", "true", "yes")
|
||||
)
|
||||
update_ran = _skip_update
|
||||
|
||||
|
||||
class RequirementsSet(set):
|
||||
def add(self, e):
|
||||
global update_ran
|
||||
update_ran &= _skip_update
|
||||
super().add(e)
|
||||
|
||||
def update(self, *s):
|
||||
global update_ran
|
||||
update_ran &= _skip_update
|
||||
super().update(*s)
|
||||
|
||||
|
||||
local_dir = os.path.dirname(__file__)
|
||||
requirements_files = RequirementsSet((os.path.join(local_dir, 'requirements.txt'),))
|
||||
update_ran = getattr(sys, "frozen", False) or multiprocessing.parent_process()
|
||||
|
||||
if not update_ran:
|
||||
for entry in os.scandir(os.path.join(local_dir, "worlds")):
|
||||
@@ -75,25 +48,25 @@ def update_command():
|
||||
def install_pkg_resources(yes=False):
|
||||
try:
|
||||
import pkg_resources # noqa: F401
|
||||
except (AttributeError, ImportError):
|
||||
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>=75,<81"])
|
||||
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
|
||||
|
||||
install_pkg_resources(yes=yes)
|
||||
import pkg_resources
|
||||
|
||||
prev = "" # if a line ends in \ we store here and merge later
|
||||
for req_file in requirements_files:
|
||||
path = os.path.join(os.path.dirname(sys.argv[0]), req_file)
|
||||
|
||||
952
MultiServer.py
150
NetUtils.py
@@ -1,25 +1,15 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping, Sequence
|
||||
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
|
||||
|
||||
|
||||
class JSONMessagePart(typing.TypedDict, total=False):
|
||||
text: str
|
||||
# optional
|
||||
@@ -29,8 +19,6 @@ 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):
|
||||
@@ -84,14 +72,13 @@ class NetworkSlot(typing.NamedTuple):
|
||||
name: str
|
||||
game: str
|
||||
type: SlotType
|
||||
group_members: Sequence[int] = () # only populated if type == group
|
||||
group_members: typing.Union[typing.List[int], typing.Tuple] = () # only populated if type == group
|
||||
|
||||
|
||||
class NetworkItem(typing.NamedTuple):
|
||||
item: int
|
||||
location: int
|
||||
player: int
|
||||
""" Sending player, except in LocationInfo (from LocationScouts), where it is the receiving player. """
|
||||
flags: int = 0
|
||||
|
||||
|
||||
@@ -107,27 +94,6 @@ def _scan_for_TypedTuples(obj: typing.Any) -> typing.Any:
|
||||
return obj
|
||||
|
||||
|
||||
_base_types = str | int | bool | float | None | tuple["_base_types", ...] | dict["_base_types", "base_types"]
|
||||
|
||||
|
||||
def convert_to_base_types(obj: typing.Any) -> _base_types:
|
||||
if isinstance(obj, (tuple, list, set, frozenset)):
|
||||
return tuple(convert_to_base_types(o) for o in obj)
|
||||
elif isinstance(obj, dict):
|
||||
return {convert_to_base_types(key): convert_to_base_types(value) for key, value in obj.items()}
|
||||
elif obj is None or type(obj) in (str, int, float, bool):
|
||||
return obj
|
||||
# unwrap simple types to their base, such as StrEnum
|
||||
elif isinstance(obj, str):
|
||||
return str(obj)
|
||||
elif isinstance(obj, int):
|
||||
return int(obj)
|
||||
elif isinstance(obj, float):
|
||||
return float(obj)
|
||||
else:
|
||||
raise Exception(f"Cannot handle {type(obj)}")
|
||||
|
||||
|
||||
_encode = JSONEncoder(
|
||||
ensure_ascii=False,
|
||||
check_circular=False,
|
||||
@@ -174,9 +140,7 @@ decode = JSONDecoder(object_hook=_object_hook).decode
|
||||
|
||||
|
||||
class Endpoint:
|
||||
__slots__ = ("socket",)
|
||||
|
||||
socket: "ServerConnection"
|
||||
socket: websockets.WebSocketServerProtocol
|
||||
|
||||
def __init__(self, socket):
|
||||
self.socket = socket
|
||||
@@ -219,7 +183,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):
|
||||
@@ -235,8 +198,7 @@ class JSONtoTextParser(metaclass=HandlerMeta):
|
||||
"slateblue": "6D8BE8",
|
||||
"plum": "AF99EF",
|
||||
"salmon": "FA8072",
|
||||
"white": "FFFFFF",
|
||||
"orange": "FF7700",
|
||||
"white": "FFFFFF"
|
||||
}
|
||||
|
||||
def __init__(self, ctx):
|
||||
@@ -260,7 +222,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)
|
||||
|
||||
@@ -285,7 +247,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):
|
||||
@@ -293,18 +255,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):
|
||||
@@ -313,8 +271,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):
|
||||
@@ -333,29 +290,8 @@ def add_json_item(parts: list, item_id: int, player: int = 0, item_flags: int =
|
||||
parts.append({"text": str(item_id), "player": player, "flags": item_flags, "type": JSONTypes.item_id, **kwargs})
|
||||
|
||||
|
||||
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):
|
||||
@@ -366,21 +302,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):
|
||||
@@ -402,7 +331,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,
|
||||
@@ -448,8 +380,6 @@ class _LocationStore(dict, typing.MutableMapping[int, typing.Dict[int, typing.Tu
|
||||
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
|
||||
@@ -466,48 +396,12 @@ class _LocationStore(dict, typing.MutableMapping[int, typing.Dict[int, typing.Tu
|
||||
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]]:
|
||||
) -> typing.List[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])
|
||||
|
||||
|
||||
class MinimumVersions(typing.TypedDict):
|
||||
server: tuple[int, int, int]
|
||||
clients: dict[int, tuple[int, int, int]]
|
||||
|
||||
|
||||
class GamesPackage(typing.TypedDict, total=False):
|
||||
item_name_groups: dict[str, list[str]]
|
||||
item_name_to_id: dict[str, int]
|
||||
location_name_groups: dict[str, list[str]]
|
||||
location_name_to_id: dict[str, int]
|
||||
checksum: str
|
||||
|
||||
|
||||
class DataPackage(typing.TypedDict):
|
||||
games: dict[str, GamesPackage]
|
||||
|
||||
|
||||
class MultiData(typing.TypedDict):
|
||||
slot_data: dict[int, Mapping[str, typing.Any]]
|
||||
slot_info: dict[int, NetworkSlot]
|
||||
connect_names: dict[str, tuple[int, int]]
|
||||
locations: dict[int, dict[int, tuple[int, int, int]]]
|
||||
checks_in_area: dict[int, dict[str, int | list[int]]]
|
||||
server_options: dict[str, object]
|
||||
er_hint_data: dict[int, dict[int, str]]
|
||||
precollected_items: dict[int, list[int]]
|
||||
precollected_hints: dict[int, set[Hint]]
|
||||
version: tuple[int, int, int]
|
||||
tags: list[str]
|
||||
minimum_versions: MinimumVersions
|
||||
seed_name: str
|
||||
spheres: list[dict[int, set[int]]]
|
||||
datapackage: dict[str, GamesPackage]
|
||||
race_mode: int
|
||||
return sorted([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
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import tkinter as tk
|
||||
import argparse
|
||||
import logging
|
||||
import random
|
||||
import os
|
||||
import zipfile
|
||||
from itertools import chain
|
||||
@@ -194,9 +195,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.per_slot_randoms = {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)
|
||||
|
||||
@@ -12,7 +12,6 @@ from CommonClient import CommonContext, server_loop, gui_enabled, \
|
||||
import Utils
|
||||
from Utils import async_start
|
||||
from worlds import network_data_package
|
||||
from worlds.oot import OOTWorld
|
||||
from worlds.oot.Rom import Rom, compress_rom_file
|
||||
from worlds.oot.N64Patch import apply_patch_file
|
||||
from worlds.oot.Utils import data_path
|
||||
@@ -277,12 +276,11 @@ async def n64_sync_task(ctx: OoTContext):
|
||||
except ConnectionRefusedError:
|
||||
logger.debug("Connection Refused, Trying Again")
|
||||
ctx.n64_status = CONNECTION_REFUSED_STATUS
|
||||
await asyncio.sleep(1)
|
||||
continue
|
||||
|
||||
|
||||
async def run_game(romfile):
|
||||
auto_start = OOTWorld.settings.rom_start
|
||||
auto_start = Utils.get_options()["oot_options"].get("rom_start", True)
|
||||
if auto_start is True:
|
||||
import webbrowser
|
||||
webbrowser.open(romfile)
|
||||
@@ -297,7 +295,7 @@ async def patch_and_run_game(apz5_file):
|
||||
decomp_path = base_name + '-decomp.z64'
|
||||
comp_path = base_name + '.z64'
|
||||
# Load vanilla ROM, patch file, compress ROM
|
||||
rom_file_name = OOTWorld.settings.rom_file
|
||||
rom_file_name = Utils.get_options()["oot_options"]["rom_file"]
|
||||
rom = Rom(rom_file_name)
|
||||
|
||||
sub_file = None
|
||||
@@ -348,7 +346,7 @@ if __name__ == '__main__':
|
||||
|
||||
import colorama
|
||||
|
||||
colorama.just_fix_windows_console()
|
||||
colorama.init()
|
||||
|
||||
asyncio.run(main())
|
||||
colorama.deinit()
|
||||
|
||||
1124
Options.py
@@ -1,708 +0,0 @@
|
||||
if __name__ == "__main__":
|
||||
import ModuleUpdate
|
||||
|
||||
ModuleUpdate.update()
|
||||
|
||||
|
||||
from kvui import (ThemedApp, ScrollBox, MainLayout, ContainerLayout, dp, Widget, MDBoxLayout, TooltipLabel, MDLabel,
|
||||
ToggleButton, MarkupDropdown, ResizableTextField)
|
||||
from kivy.clock import Clock
|
||||
from kivy.uix.behaviors.button import ButtonBehavior
|
||||
from kivymd.uix.behaviors import RotateBehavior
|
||||
from kivymd.uix.anchorlayout import MDAnchorLayout
|
||||
from kivymd.uix.expansionpanel import MDExpansionPanel, MDExpansionPanelContent, MDExpansionPanelHeader
|
||||
from kivymd.uix.list import MDListItem, MDListItemTrailingIcon, MDListItemSupportingText
|
||||
from kivymd.uix.slider import MDSlider
|
||||
from kivymd.uix.snackbar import MDSnackbar, MDSnackbarText
|
||||
from kivymd.uix.menu import MDDropdownMenu
|
||||
from kivymd.uix.button import MDButton, MDButtonText, MDIconButton
|
||||
from kivymd.uix.dialog import MDDialog
|
||||
from kivy.core.text.markup import MarkupLabel
|
||||
from kivy.utils import escape_markup
|
||||
from kivy.lang.builder import Builder
|
||||
from kivy.properties import ObjectProperty
|
||||
from textwrap import dedent
|
||||
from copy import deepcopy
|
||||
import Utils
|
||||
import typing
|
||||
import webbrowser
|
||||
import re
|
||||
from urllib.parse import urlparse
|
||||
from worlds.AutoWorld import AutoWorldRegister, World
|
||||
from Options import (Option, Toggle, TextChoice, Choice, FreeText, NamedRange, Range, OptionSet, OptionList,
|
||||
OptionCounter, Visibility)
|
||||
|
||||
|
||||
def validate_url(x):
|
||||
try:
|
||||
result = urlparse(x)
|
||||
return all([result.scheme, result.netloc])
|
||||
except AttributeError:
|
||||
return False
|
||||
|
||||
|
||||
def filter_tooltip(tooltip):
|
||||
if tooltip is None:
|
||||
tooltip = "No tooltip available."
|
||||
tooltip = dedent(tooltip).strip().replace("\n", "<br>").replace("&", "&") \
|
||||
.replace("[", "&bl;").replace("]", "&br;")
|
||||
tooltip = re.sub(r"\*\*(.+?)\*\*", r"[b]\g<1>[/b]", tooltip)
|
||||
tooltip = re.sub(r"\*(.+?)\*", r"[i]\g<1>[/i]", tooltip)
|
||||
return escape_markup(tooltip)
|
||||
|
||||
|
||||
def option_can_be_randomized(option: typing.Type[Option]):
|
||||
# most options can be randomized, so we should just check for those that cannot
|
||||
if not option.supports_weighting:
|
||||
return False
|
||||
elif issubclass(option, FreeText) and not issubclass(option, TextChoice):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def check_random(value: typing.Any):
|
||||
if not isinstance(value, str):
|
||||
return value # cannot be random if evaluated
|
||||
if value.startswith("random-"):
|
||||
return "random"
|
||||
return value
|
||||
|
||||
|
||||
class TrailingPressedIconButton(ButtonBehavior, RotateBehavior, MDListItemTrailingIcon):
|
||||
pass
|
||||
|
||||
|
||||
class WorldButton(ToggleButton):
|
||||
world_cls: typing.Type[World]
|
||||
|
||||
|
||||
class VisualRange(MDBoxLayout):
|
||||
option: typing.Type[Range]
|
||||
name: str
|
||||
tag: MDLabel = ObjectProperty(None)
|
||||
slider: MDSlider = ObjectProperty(None)
|
||||
|
||||
def __init__(self, *args, option: typing.Type[Range], name: str, **kwargs):
|
||||
self.option = option
|
||||
self.name = name
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def update_points(*update_args):
|
||||
pass
|
||||
|
||||
self.slider._update_points = update_points
|
||||
|
||||
|
||||
class VisualChoice(MDButton):
|
||||
option: typing.Type[Choice]
|
||||
name: str
|
||||
text: MDButtonText = ObjectProperty(None)
|
||||
|
||||
def __init__(self, *args, option: typing.Type[Choice], name: str, **kwargs):
|
||||
self.option = option
|
||||
self.name = name
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
|
||||
class VisualNamedRange(MDBoxLayout):
|
||||
option: typing.Type[NamedRange]
|
||||
name: str
|
||||
range: VisualRange = ObjectProperty(None)
|
||||
choice: MDButton = ObjectProperty(None)
|
||||
|
||||
def __init__(self, *args, option: typing.Type[NamedRange], name: str, range_widget: VisualRange, **kwargs):
|
||||
self.option = option
|
||||
self.name = name
|
||||
super().__init__(*args, **kwargs)
|
||||
self.range = range_widget
|
||||
self.add_widget(self.range)
|
||||
|
||||
|
||||
class VisualFreeText(ResizableTextField):
|
||||
option: typing.Type[FreeText] | typing.Type[TextChoice]
|
||||
name: str
|
||||
|
||||
def __init__(self, *args, option: typing.Type[FreeText] | typing.Type[TextChoice], name: str, **kwargs):
|
||||
self.option = option
|
||||
self.name = name
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
|
||||
class VisualTextChoice(MDBoxLayout):
|
||||
option: typing.Type[TextChoice]
|
||||
name: str
|
||||
choice: VisualChoice = ObjectProperty(None)
|
||||
text: VisualFreeText = ObjectProperty(None)
|
||||
|
||||
def __init__(self, *args, option: typing.Type[TextChoice], name: str, choice: VisualChoice,
|
||||
text: VisualFreeText, **kwargs):
|
||||
self.option = option
|
||||
self.name = name
|
||||
super(MDBoxLayout, self).__init__(*args, **kwargs)
|
||||
self.choice = choice
|
||||
self.text = text
|
||||
self.add_widget(self.choice)
|
||||
self.add_widget(self.text)
|
||||
|
||||
|
||||
class VisualToggle(MDBoxLayout):
|
||||
button: MDIconButton = ObjectProperty(None)
|
||||
option: typing.Type[Toggle]
|
||||
name: str
|
||||
|
||||
def __init__(self, *args, option: typing.Type[Toggle], name: str, **kwargs):
|
||||
self.option = option
|
||||
self.name = name
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
|
||||
class CounterItemValue(ResizableTextField):
|
||||
pat = re.compile('[^0-9]')
|
||||
|
||||
def insert_text(self, substring, from_undo=False):
|
||||
return super().insert_text(re.sub(self.pat, "", substring), from_undo=from_undo)
|
||||
|
||||
|
||||
class VisualListSetCounter(MDDialog):
|
||||
button: MDIconButton = ObjectProperty(None)
|
||||
option: typing.Type[OptionSet] | typing.Type[OptionList] | typing.Type[OptionCounter]
|
||||
scrollbox: ScrollBox = ObjectProperty(None)
|
||||
add: MDIconButton = ObjectProperty(None)
|
||||
save: MDButton = ObjectProperty(None)
|
||||
input: ResizableTextField = ObjectProperty(None)
|
||||
dropdown: MDDropdownMenu
|
||||
valid_keys: typing.Iterable[str]
|
||||
|
||||
def __init__(self, *args, option: typing.Type[OptionSet] | typing.Type[OptionList],
|
||||
name: str, valid_keys: typing.Iterable[str], **kwargs):
|
||||
self.option = option
|
||||
self.name = name
|
||||
self.valid_keys = valid_keys
|
||||
super().__init__(*args, **kwargs)
|
||||
self.dropdown = MarkupDropdown(caller=self.input, border_margin=dp(2),
|
||||
width=self.input.width, position="bottom")
|
||||
self.input.bind(text=self.on_text)
|
||||
self.input.bind(on_text_validate=self.validate_add)
|
||||
|
||||
def validate_add(self, instance):
|
||||
if self.valid_keys:
|
||||
if self.input.text not in self.valid_keys:
|
||||
MDSnackbar(MDSnackbarText(text="Item must be a valid key for this option."), y=dp(24),
|
||||
pos_hint={"center_x": 0.5}, size_hint_x=0.5).open()
|
||||
return
|
||||
|
||||
if not issubclass(self.option, OptionList):
|
||||
if any(self.input.text == child.text.text for child in self.scrollbox.layout.children):
|
||||
MDSnackbar(MDSnackbarText(text="This value is already in the set."), y=dp(24),
|
||||
pos_hint={"center_x": 0.5}, size_hint_x=0.5).open()
|
||||
return
|
||||
|
||||
self.add_set_item(self.input.text)
|
||||
self.input.set_text(self.input, "")
|
||||
|
||||
def remove_item(self, button: MDIconButton):
|
||||
list_item = button.parent
|
||||
self.scrollbox.layout.remove_widget(list_item)
|
||||
|
||||
def add_set_item(self, key: str, value: int | None = None):
|
||||
text = MDListItemSupportingText(text=key, id="value")
|
||||
if issubclass(self.option, OptionCounter):
|
||||
value_txt = CounterItemValue(text=str(value) if value else "1")
|
||||
item = MDListItem(text,
|
||||
value_txt,
|
||||
MDIconButton(icon="minus", on_release=self.remove_item), focus_behavior=False)
|
||||
item.value = value_txt
|
||||
else:
|
||||
item = MDListItem(text, MDIconButton(icon="minus", on_release=self.remove_item), focus_behavior=False)
|
||||
item.text = text
|
||||
self.scrollbox.layout.add_widget(item)
|
||||
|
||||
def on_text(self, instance, value):
|
||||
if not self.valid_keys:
|
||||
return
|
||||
if len(value) >= 3:
|
||||
self.dropdown.items.clear()
|
||||
|
||||
def on_press(txt):
|
||||
split_text = MarkupLabel(text=txt, markup=True).markup
|
||||
self.input.set_text(self.input, "".join(text_frag for text_frag in split_text
|
||||
if not text_frag.startswith("[")))
|
||||
self.input.focus = True
|
||||
self.dropdown.dismiss()
|
||||
|
||||
lowered = value.lower()
|
||||
for item_name in self.valid_keys:
|
||||
try:
|
||||
index = item_name.lower().index(lowered)
|
||||
except ValueError:
|
||||
pass # substring not found
|
||||
else:
|
||||
text = escape_markup(item_name)
|
||||
text = text[:index] + "[b]" + text[index:index + len(value)] + "[/b]" + text[index + len(value):]
|
||||
self.dropdown.items.append({
|
||||
"text": text,
|
||||
"on_release": lambda txt=text: on_press(txt),
|
||||
"markup": True
|
||||
})
|
||||
if not self.dropdown.parent:
|
||||
self.dropdown.open()
|
||||
else:
|
||||
self.dropdown.dismiss()
|
||||
|
||||
|
||||
class OptionsCreator(ThemedApp):
|
||||
base_title: str = "Archipelago Options Creator"
|
||||
container: ContainerLayout
|
||||
main_layout: MainLayout
|
||||
scrollbox: ScrollBox
|
||||
main_panel: MainLayout
|
||||
player_options: MainLayout
|
||||
option_layout: MainLayout
|
||||
name_input: ResizableTextField
|
||||
game_label: MDLabel
|
||||
current_game: str
|
||||
options: typing.Dict[str, typing.Any]
|
||||
|
||||
def __init__(self):
|
||||
self.title = self.base_title + " " + Utils.__version__
|
||||
self.icon = r"data/icon.png"
|
||||
self.current_game = ""
|
||||
self.options = {}
|
||||
super().__init__()
|
||||
|
||||
@staticmethod
|
||||
def show_result_snack(text: str) -> None:
|
||||
MDSnackbar(MDSnackbarText(text=text), y=dp(24), pos_hint={"center_x": 0.5}, size_hint_x=0.5).open()
|
||||
|
||||
def on_export_result(self, text: str | None) -> None:
|
||||
self.container.disabled = False
|
||||
if text is not None:
|
||||
Clock.schedule_once(lambda _: self.show_result_snack(text), 0)
|
||||
|
||||
def export_options_background(self, options: dict[str, typing.Any]) -> None:
|
||||
try:
|
||||
file_name = Utils.save_filename("Export Options File As...", [("YAML", [".yaml"])],
|
||||
Utils.get_file_safe_name(f"{self.name_input.text}.yaml"))
|
||||
except Exception:
|
||||
self.on_export_result("Could not open dialog. Already open?")
|
||||
raise
|
||||
|
||||
if not file_name:
|
||||
self.on_export_result(None) # No file selected. No need to show a message for this.
|
||||
return
|
||||
|
||||
try:
|
||||
with open(file_name, 'w') as f:
|
||||
f.write(Utils.dump(options, sort_keys=False))
|
||||
f.close()
|
||||
self.on_export_result("File saved successfully.")
|
||||
except Exception:
|
||||
self.on_export_result("Could not save file.")
|
||||
raise
|
||||
|
||||
def export_options(self, button: Widget) -> None:
|
||||
if 0 < len(self.name_input.text) < 17 and self.current_game:
|
||||
import threading
|
||||
options = {
|
||||
"name": self.name_input.text,
|
||||
"description": f"YAML generated by Archipelago {Utils.__version__}.",
|
||||
"game": self.current_game,
|
||||
self.current_game: {k: check_random(v) for k, v in self.options.items()}
|
||||
}
|
||||
threading.Thread(target=self.export_options_background, args=(options,), daemon=True).start()
|
||||
self.container.disabled = True
|
||||
elif not self.name_input.text:
|
||||
self.show_result_snack("Name must not be empty.")
|
||||
elif not self.current_game:
|
||||
self.show_result_snack("You must select a game to play.")
|
||||
else:
|
||||
self.show_result_snack("Name cannot be longer than 16 characters.")
|
||||
|
||||
def create_range(self, option: typing.Type[Range], name: str, bind=True):
|
||||
def update_text(range_box: VisualRange):
|
||||
self.options[name] = int(range_box.slider.value)
|
||||
range_box.tag.text = str(int(range_box.slider.value))
|
||||
return
|
||||
|
||||
box = VisualRange(option=option, name=name)
|
||||
if bind:
|
||||
box.slider.bind(value=lambda _, _1: update_text(box))
|
||||
self.options[name] = option.default
|
||||
return box
|
||||
|
||||
def create_named_range(self, option: typing.Type[NamedRange], name: str):
|
||||
def set_to_custom(range_box: VisualNamedRange):
|
||||
range_box.range.tag.text = str(int(range_box.range.slider.value))
|
||||
if range_box.range.slider.value in option.special_range_names.values():
|
||||
value = next(key for key, val in option.special_range_names.items()
|
||||
if val == range_box.range.slider.value)
|
||||
self.options[name] = value
|
||||
set_button_text(box.choice, value.title())
|
||||
else:
|
||||
self.options[name] = int(range_box.range.slider.value)
|
||||
set_button_text(range_box.choice, "Custom")
|
||||
|
||||
def set_button_text(button: MDButton, text: str):
|
||||
button.text.text = text
|
||||
|
||||
def set_value(text: str, range_box: VisualNamedRange):
|
||||
range_box.range.slider.value = min(max(option.special_range_names[text.lower()], option.range_start),
|
||||
option.range_end)
|
||||
range_box.range.tag.text = str(option.special_range_names[text.lower()])
|
||||
set_button_text(range_box.choice, text)
|
||||
self.options[name] = text.lower()
|
||||
range_box.range.slider.dropdown.dismiss()
|
||||
|
||||
def open_dropdown(button):
|
||||
# for some reason this fixes an issue causing some to not open
|
||||
box.range.slider.dropdown.open()
|
||||
|
||||
box = VisualNamedRange(option=option, name=name, range_widget=self.create_range(option, name, bind=False))
|
||||
default: int | str = option.default
|
||||
if default in option.special_range_names:
|
||||
# value can get mismatched in this case
|
||||
box.range.slider.value = min(max(option.special_range_names[default], option.range_start),
|
||||
option.range_end)
|
||||
box.range.tag.text = str(int(box.range.slider.value))
|
||||
elif default in option.special_range_names.values():
|
||||
# better visual
|
||||
default = next(key for key, val in option.special_range_names.items() if val == option.default)
|
||||
set_button_text(box.choice, default.title())
|
||||
box.range.slider.bind(value=lambda _, _2: set_to_custom(box))
|
||||
items = [
|
||||
{
|
||||
"text": choice.title(),
|
||||
"on_release": lambda text=choice.title(): set_value(text, box)
|
||||
}
|
||||
for choice in option.special_range_names
|
||||
]
|
||||
box.range.slider.dropdown = MDDropdownMenu(caller=box.choice, items=items)
|
||||
box.choice.bind(on_release=open_dropdown)
|
||||
self.options[name] = default
|
||||
return box
|
||||
|
||||
def create_free_text(self, option: typing.Type[FreeText] | typing.Type[TextChoice], name: str):
|
||||
text = VisualFreeText(option=option, name=name)
|
||||
|
||||
def set_value(instance):
|
||||
self.options[name] = instance.text
|
||||
|
||||
text.bind(on_text_validate=set_value)
|
||||
return text
|
||||
|
||||
def create_choice(self, option: typing.Type[Choice], name: str):
|
||||
def set_button_text(button: VisualChoice, text: str):
|
||||
button.text.text = text
|
||||
|
||||
def set_value(text, value):
|
||||
set_button_text(main_button, text)
|
||||
self.options[name] = value
|
||||
dropdown.dismiss()
|
||||
|
||||
def open_dropdown(button):
|
||||
# for some reason this fixes an issue causing some to not open
|
||||
dropdown.open()
|
||||
|
||||
default_string = isinstance(option.default, str)
|
||||
main_button = VisualChoice(option=option, name=name)
|
||||
main_button.bind(on_release=open_dropdown)
|
||||
|
||||
items = [
|
||||
{
|
||||
"text": option.get_option_name(choice),
|
||||
"on_release": lambda val=choice: set_value(option.get_option_name(val), option.name_lookup[val])
|
||||
}
|
||||
for choice in option.name_lookup
|
||||
]
|
||||
dropdown = MDDropdownMenu(caller=main_button, items=items)
|
||||
self.options[name] = option.name_lookup[option.default] if not default_string else option.default
|
||||
return main_button
|
||||
|
||||
def create_text_choice(self, option: typing.Type[TextChoice], name: str):
|
||||
def set_button_text(button: MDButton, text: str):
|
||||
for child in button.children:
|
||||
if isinstance(child, MDButtonText):
|
||||
child.text = text
|
||||
|
||||
box = VisualTextChoice(option=option, name=name, choice=self.create_choice(option, name),
|
||||
text=self.create_free_text(option, name))
|
||||
|
||||
def set_value(instance):
|
||||
set_button_text(box.choice, "Custom")
|
||||
self.options[name] = instance.text
|
||||
|
||||
box.text.bind(on_text_validate=set_value)
|
||||
return box
|
||||
|
||||
def create_toggle(self, option: typing.Type[Toggle], name: str) -> Widget:
|
||||
def set_value(instance: MDIconButton):
|
||||
if instance.icon == "checkbox-outline":
|
||||
instance.icon = "checkbox-blank-outline"
|
||||
else:
|
||||
instance.icon = "checkbox-outline"
|
||||
self.options[name] = bool(not self.options[name])
|
||||
|
||||
self.options[name] = bool(option.default)
|
||||
checkbox = VisualToggle(option=option, name=name)
|
||||
checkbox.button.bind(on_release=set_value)
|
||||
|
||||
return checkbox
|
||||
|
||||
def create_popup(self, option: typing.Type[OptionList] | typing.Type[OptionSet] | typing.Type[OptionCounter],
|
||||
name: str, world: typing.Type[World]):
|
||||
|
||||
valid_keys = sorted(option.valid_keys)
|
||||
if option.verify_item_name:
|
||||
valid_keys += list(world.item_name_to_id.keys())
|
||||
if option.convert_name_groups:
|
||||
valid_keys += list(world.item_name_groups.keys())
|
||||
if option.verify_location_name:
|
||||
valid_keys += list(world.location_name_to_id.keys())
|
||||
if option.convert_name_groups:
|
||||
valid_keys += list(world.location_name_groups.keys())
|
||||
|
||||
if not issubclass(option, OptionCounter):
|
||||
def apply_changes(button):
|
||||
self.options[name].clear()
|
||||
for list_item in dialog.scrollbox.layout.children:
|
||||
self.options[name].append(getattr(list_item.text, "text"))
|
||||
dialog.dismiss()
|
||||
else:
|
||||
def apply_changes(button):
|
||||
self.options[name].clear()
|
||||
for list_item in dialog.scrollbox.layout.children:
|
||||
self.options[name][getattr(list_item.text, "text")] = int(getattr(list_item.value, "text"))
|
||||
dialog.dismiss()
|
||||
|
||||
dialog = VisualListSetCounter(option=option, name=name, valid_keys=valid_keys)
|
||||
dialog.ids.container.spacing = dp(30)
|
||||
dialog.scrollbox.layout.theme_bg_color = "Custom"
|
||||
dialog.scrollbox.layout.md_bg_color = self.theme_cls.surfaceContainerLowColor
|
||||
dialog.scrollbox.layout.spacing = dp(5)
|
||||
dialog.scrollbox.layout.padding = [0, dp(5), 0, 0]
|
||||
|
||||
if issubclass(option, OptionCounter):
|
||||
for value in sorted(self.options[name]):
|
||||
dialog.add_set_item(value, self.options[name].get(value, None))
|
||||
else:
|
||||
for value in sorted(self.options[name]):
|
||||
dialog.add_set_item(value)
|
||||
|
||||
dialog.save.bind(on_release=apply_changes)
|
||||
dialog.open()
|
||||
|
||||
def create_option_set_list_counter(self, option: typing.Type[OptionList] | typing.Type[OptionSet] |
|
||||
typing.Type[OptionCounter], name: str, world: typing.Type[World]):
|
||||
main_button = MDButton(MDButtonText(text="Edit"), on_release=lambda x: self.create_popup(option, name, world))
|
||||
|
||||
if name not in self.options:
|
||||
# convert from non-mutable to mutable
|
||||
# We use list syntax even for sets, set behavior is enforced through GUI
|
||||
if issubclass(option, OptionCounter):
|
||||
self.options[name] = deepcopy(option.default)
|
||||
else:
|
||||
self.options[name] = sorted(option.default)
|
||||
|
||||
return main_button
|
||||
|
||||
def create_option(self, option: typing.Type[Option], name: str, world: typing.Type[World]) -> Widget:
|
||||
option_base = MDBoxLayout(orientation="vertical", size_hint_y=None, padding=[0, 0, dp(5), dp(5)])
|
||||
|
||||
tooltip = filter_tooltip(option.__doc__)
|
||||
option_label = TooltipLabel(text=f"[ref=0|{tooltip}]{getattr(option, 'display_name', name)}")
|
||||
label_box = MDBoxLayout(orientation="horizontal")
|
||||
label_anchor = MDAnchorLayout(anchor_x="right", anchor_y="center")
|
||||
label_anchor.add_widget(option_label)
|
||||
label_box.add_widget(label_anchor)
|
||||
|
||||
option_base.add_widget(label_box)
|
||||
if issubclass(option, NamedRange):
|
||||
option_base.add_widget(self.create_named_range(option, name))
|
||||
elif issubclass(option, Range):
|
||||
option_base.add_widget(self.create_range(option, name))
|
||||
elif issubclass(option, Toggle):
|
||||
option_base.add_widget(self.create_toggle(option, name))
|
||||
elif issubclass(option, TextChoice):
|
||||
option_base.add_widget(self.create_text_choice(option, name))
|
||||
elif issubclass(option, Choice):
|
||||
option_base.add_widget(self.create_choice(option, name))
|
||||
elif issubclass(option, FreeText):
|
||||
option_base.add_widget(self.create_free_text(option, name))
|
||||
elif any(issubclass(option, cls) for cls in (OptionSet, OptionList, OptionCounter)):
|
||||
option_base.add_widget(self.create_option_set_list_counter(option, name, world))
|
||||
else:
|
||||
option_base.add_widget(MDLabel(text="This option isn't supported by the option creator.\n"
|
||||
"Please edit your yaml manually to set this option."))
|
||||
|
||||
if option_can_be_randomized(option):
|
||||
def randomize_option(instance: Widget, value: str):
|
||||
value = value == "down"
|
||||
if value:
|
||||
self.options[name] = "random-" + str(self.options[name])
|
||||
else:
|
||||
self.options[name] = self.options[name].replace("random-", "")
|
||||
if self.options[name].isnumeric():
|
||||
self.options[name] = int(self.options[name])
|
||||
elif self.options[name] in ("True", "False"):
|
||||
self.options[name] = self.options[name] == "True"
|
||||
|
||||
base_object = instance.parent.parent
|
||||
label_object = instance.parent
|
||||
for child in base_object.children:
|
||||
if child is not label_object:
|
||||
child.disabled = value
|
||||
|
||||
default_random = option.default == "random"
|
||||
random_toggle = ToggleButton(MDButtonText(text="Random?"), size_hint_x=None, width=dp(100),
|
||||
state="down" if default_random else "normal")
|
||||
random_toggle.bind(state=randomize_option)
|
||||
label_box.add_widget(random_toggle)
|
||||
if default_random:
|
||||
randomize_option(random_toggle, "down")
|
||||
|
||||
return option_base
|
||||
|
||||
def create_options_panel(self, world_button: WorldButton):
|
||||
self.option_layout.clear_widgets()
|
||||
self.options.clear()
|
||||
cls: typing.Type[World] = world_button.world_cls
|
||||
|
||||
self.current_game = cls.game
|
||||
if not cls.web.options_page:
|
||||
self.current_game = "None"
|
||||
return
|
||||
elif isinstance(cls.web.options_page, str):
|
||||
self.current_game = "None"
|
||||
if validate_url(cls.web.options_page):
|
||||
webbrowser.open(cls.web.options_page)
|
||||
MDSnackbar(MDSnackbarText(text="Launching in default browser..."), y=dp(24), pos_hint={"center_x": 0.5},
|
||||
size_hint_x=0.5).open()
|
||||
world_button.state = "normal"
|
||||
else:
|
||||
# attach onto archipelago.gg and see if we pass
|
||||
new_url = "https://archipelago.gg/" + cls.web.options_page
|
||||
if validate_url(new_url):
|
||||
webbrowser.open(new_url)
|
||||
MDSnackbar(MDSnackbarText(text="Launching in default browser..."), y=dp(24),
|
||||
pos_hint={"center_x": 0.5},
|
||||
size_hint_x=0.5).open()
|
||||
else:
|
||||
MDSnackbar(MDSnackbarText(text="Invalid options page, please report to world developer."), y=dp(24),
|
||||
pos_hint={"center_x": 0.5},
|
||||
size_hint_x=0.5).open()
|
||||
world_button.state = "normal"
|
||||
# else just fall through
|
||||
else:
|
||||
expansion_box = ScrollBox()
|
||||
expansion_box.layout.orientation = "vertical"
|
||||
expansion_box.layout.spacing = dp(3)
|
||||
expansion_box.scroll_type = ["bars"]
|
||||
expansion_box.do_scroll_x = False
|
||||
group_names = ["Game Options", *(group.name for group in cls.web.option_groups)]
|
||||
groups = {name: [] for name in group_names}
|
||||
for name, option in cls.options_dataclass.type_hints.items():
|
||||
group = next((group.name for group in cls.web.option_groups if option in group.options), "Game Options")
|
||||
groups[group].append((name, option))
|
||||
|
||||
for group, options in groups.items():
|
||||
options = [(name, option) for name, option in options
|
||||
if name and option.visibility & Visibility.simple_ui]
|
||||
if not options:
|
||||
continue # Game Options can be empty if every other option is in another group
|
||||
# Can also have an option group of options that should not render on simple ui
|
||||
group_item = MDExpansionPanel(size_hint_y=None)
|
||||
group_header = MDExpansionPanelHeader(MDListItem(MDListItemSupportingText(text=group),
|
||||
TrailingPressedIconButton(icon="chevron-right",
|
||||
on_release=lambda x,
|
||||
item=group_item:
|
||||
self.tap_expansion_chevron(
|
||||
item, x)),
|
||||
md_bg_color=self.theme_cls.surfaceContainerLowestColor,
|
||||
theme_bg_color="Custom",
|
||||
on_release=lambda x, item=group_item:
|
||||
self.tap_expansion_chevron(item, x)))
|
||||
group_content = MDExpansionPanelContent(orientation="vertical", theme_bg_color="Custom",
|
||||
md_bg_color=self.theme_cls.surfaceContainerLowestColor,
|
||||
padding=[dp(12), dp(100), dp(12), 0],
|
||||
spacing=dp(3))
|
||||
group_item.add_widget(group_header)
|
||||
group_item.add_widget(group_content)
|
||||
group_box = ScrollBox()
|
||||
group_box.layout.orientation = "vertical"
|
||||
group_box.layout.spacing = dp(3)
|
||||
for name, option in options:
|
||||
group_content.add_widget(self.create_option(option, name, cls))
|
||||
expansion_box.layout.add_widget(group_item)
|
||||
self.option_layout.add_widget(expansion_box)
|
||||
self.game_label.text = f"Game: {self.current_game}"
|
||||
|
||||
@staticmethod
|
||||
def tap_expansion_chevron(panel: MDExpansionPanel, chevron: TrailingPressedIconButton | MDListItem):
|
||||
if isinstance(chevron, MDListItem):
|
||||
chevron = next((child for child in chevron.ids.trailing_container.children
|
||||
if isinstance(child, TrailingPressedIconButton)), None)
|
||||
panel.open() if not panel.is_open else panel.close()
|
||||
if chevron:
|
||||
panel.set_chevron_down(
|
||||
chevron
|
||||
) if not panel.is_open else panel.set_chevron_up(chevron)
|
||||
|
||||
def build(self):
|
||||
self.set_colors()
|
||||
self.options = {}
|
||||
self.container = Builder.load_file(Utils.local_path("data/optionscreator.kv"))
|
||||
self.root = self.container
|
||||
self.main_layout = self.container.ids.main
|
||||
self.scrollbox = self.container.ids.scrollbox
|
||||
|
||||
def world_button_action(world_btn: WorldButton):
|
||||
if self.current_game != world_btn.world_cls.game:
|
||||
old_button = next((button for button in self.scrollbox.layout.children
|
||||
if button.world_cls.game == self.current_game), None)
|
||||
if old_button:
|
||||
old_button.state = "normal"
|
||||
else:
|
||||
world_btn.state = "down"
|
||||
self.create_options_panel(world_btn)
|
||||
|
||||
for world, cls in sorted(AutoWorldRegister.world_types.items(), key=lambda x: x[0]):
|
||||
if cls.hidden:
|
||||
continue
|
||||
world_text = MDButtonText(text=world, size_hint_y=None, width=dp(150),
|
||||
pos_hint={"x": 0.03, "center_y": 0.5})
|
||||
world_text.text_size = (world_text.width, None)
|
||||
world_text.bind(width=lambda *x, text=world_text: text.setter('text_size')(text, (text.width, None)),
|
||||
texture_size=lambda *x, text=world_text: text.setter("height")(text,
|
||||
world_text.texture_size[1]))
|
||||
world_button = WorldButton(world_text, size_hint_x=None, width=dp(150), theme_width="Custom",
|
||||
radius=(dp(5), dp(5), dp(5), dp(5)))
|
||||
world_button.bind(on_release=world_button_action)
|
||||
world_button.world_cls = cls
|
||||
self.scrollbox.layout.add_widget(world_button)
|
||||
self.main_panel = self.container.ids.player_layout
|
||||
self.player_options = self.container.ids.player_options
|
||||
self.game_label = self.container.ids.game
|
||||
self.name_input = self.container.ids.player_name
|
||||
self.option_layout = self.container.ids.options
|
||||
|
||||
def set_height(instance, value):
|
||||
instance.height = value[1]
|
||||
|
||||
self.game_label.bind(texture_size=set_height)
|
||||
|
||||
# Uncomment to re-enable the Kivy console/live editor
|
||||
# Ctrl-E to enable it, make sure numlock/capslock is disabled
|
||||
# from kivy.modules.console import create_console
|
||||
# from kivy.core.window import Window
|
||||
# create_console(Window, self.container)
|
||||
|
||||
return self.container
|
||||
|
||||
|
||||
def launch():
|
||||
OptionsCreator().run()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
Utils.init_logging("OptionsCreator")
|
||||
launch()
|
||||
4
Patch.py
@@ -8,7 +8,7 @@ if __name__ == "__main__":
|
||||
import ModuleUpdate
|
||||
ModuleUpdate.update()
|
||||
|
||||
from worlds.Files import AutoPatchRegister, APAutoPatchInterface
|
||||
from worlds.Files import AutoPatchRegister, APDeltaPatch
|
||||
|
||||
|
||||
class RomMeta(TypedDict):
|
||||
@@ -20,7 +20,7 @@ class RomMeta(TypedDict):
|
||||
def create_rom_file(patch_file: str) -> Tuple[RomMeta, str]:
|
||||
auto_handler = AutoPatchRegister.get_handler(patch_file)
|
||||
if auto_handler:
|
||||
handler: 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,
|
||||
|
||||
82
README.md
@@ -1,29 +1,31 @@
|
||||
# [Archipelago](https://archipelago.gg)  | [Install](https://github.com/ArchipelagoMW/Archipelago/releases)
|
||||
|
||||
Archipelago provides a generic framework for developing multiworld capability for game randomizers. In all cases,
|
||||
presently, Archipelago is also the randomizer itself.
|
||||
Archipelago provides a generic framework for developing multiworld capability for game randomizers. In all cases, presently, Archipelago is also the randomizer itself.
|
||||
|
||||
Currently, the following games are supported:
|
||||
|
||||
* The Legend of Zelda: A Link to the Past
|
||||
* Factorio
|
||||
* Minecraft
|
||||
* Subnautica
|
||||
* Slay the Spire
|
||||
* Risk of Rain 2
|
||||
* The Legend of Zelda: Ocarina of Time
|
||||
* Timespinner
|
||||
* Super Metroid
|
||||
* Secret of Evermore
|
||||
* Final Fantasy
|
||||
* Rogue Legacy
|
||||
* VVVVVV
|
||||
* Raft
|
||||
* Super Mario 64
|
||||
* Meritous
|
||||
* Super Metroid/Link to the Past combo randomizer (SMZ3)
|
||||
* ChecksFinder
|
||||
* ArchipIDLE
|
||||
* Hollow Knight
|
||||
* The Witness
|
||||
* Sonic Adventure 2: Battle
|
||||
* Starcraft 2
|
||||
* Starcraft 2: Wings of Liberty
|
||||
* Donkey Kong Country 3
|
||||
* Dark Souls 3
|
||||
* Super Mario World
|
||||
@@ -39,6 +41,7 @@ Currently, the following games are supported:
|
||||
* The Messenger
|
||||
* Kingdom Hearts 2
|
||||
* The Legend of Zelda: Link's Awakening DX
|
||||
* Clique
|
||||
* Adventure
|
||||
* DLC Quest
|
||||
* Noita
|
||||
@@ -55,36 +58,6 @@ Currently, the following games are supported:
|
||||
* Heretic
|
||||
* Landstalker: The Treasures of King Nole
|
||||
* Final Fantasy Mystic Quest
|
||||
* TUNIC
|
||||
* Kirby's Dream Land 3
|
||||
* Celeste 64
|
||||
* Castlevania 64
|
||||
* A Short Hike
|
||||
* Yoshi's Island
|
||||
* Mario & Luigi: Superstar Saga
|
||||
* Bomb Rush Cyberfunk
|
||||
* Aquaria
|
||||
* Yu-Gi-Oh! Ultimate Masters: World Championship Tournament 2006
|
||||
* A Hat in Time
|
||||
* Old School Runescape
|
||||
* Kingdom Hearts 1
|
||||
* Mega Man 2
|
||||
* Yacht Dice
|
||||
* Faxanadu
|
||||
* Saving Princess
|
||||
* Castlevania: Circle of the Moon
|
||||
* Inscryption
|
||||
* Civilization VI
|
||||
* The Legend of Zelda: The Wind Waker
|
||||
* Jak and Daxter: The Precursor Legacy
|
||||
* Super Mario Land 2: 6 Golden Coins
|
||||
* shapez
|
||||
* Paint
|
||||
* Celeste (Open World)
|
||||
* Choo-Choo Charles
|
||||
* APQuest
|
||||
* Satisfactory
|
||||
* EarthBound
|
||||
|
||||
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
|
||||
@@ -92,57 +65,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)
|
||||
|
||||
44
SNIClient.py
@@ -18,7 +18,6 @@ from json import loads, dumps
|
||||
from CommonClient import CommonContext, server_loop, ClientCommandProcessor, gui_enabled, get_base_parser
|
||||
|
||||
import Utils
|
||||
import settings
|
||||
from Utils import async_start
|
||||
from MultiServer import mark_raw
|
||||
if typing.TYPE_CHECKING:
|
||||
@@ -86,7 +85,6 @@ class SNIClientCommandProcessor(ClientCommandProcessor):
|
||||
"""Close connection to a currently connected snes"""
|
||||
self.ctx.snes_reconnect_address = None
|
||||
self.ctx.cancel_snes_autoreconnect()
|
||||
self.ctx.snes_state = SNESState.SNES_DISCONNECTED
|
||||
if self.ctx.snes_socket and not self.ctx.snes_socket.closed:
|
||||
async_start(self.ctx.snes_socket.close())
|
||||
return True
|
||||
@@ -244,9 +242,6 @@ class SNIContext(CommonContext):
|
||||
# Once the games handled by SNIClient gets made to be remote items,
|
||||
# this will no longer be needed.
|
||||
async_start(self.send_msgs([{"cmd": "LocationScouts", "locations": list(new_locations)}]))
|
||||
|
||||
if self.client_handler is not None:
|
||||
self.client_handler.on_package(self, cmd, args)
|
||||
|
||||
def run_gui(self) -> None:
|
||||
from kvui import GameManager
|
||||
@@ -286,7 +281,7 @@ class SNESState(enum.IntEnum):
|
||||
|
||||
|
||||
def launch_sni() -> None:
|
||||
sni_path = settings.get_settings().sni_options.sni_path
|
||||
sni_path = Utils.get_options()["sni_options"]["sni_path"]
|
||||
|
||||
if not os.path.isdir(sni_path):
|
||||
sni_path = Utils.local_path(sni_path)
|
||||
@@ -569,12 +564,16 @@ async def snes_write(ctx: SNIContext, write_list: typing.List[typing.Tuple[int,
|
||||
PutAddress_Request: SNESRequest = {"Opcode": "PutAddress", "Operands": [], 'Space': 'SNES'}
|
||||
try:
|
||||
for address, data in write_list:
|
||||
PutAddress_Request['Operands'] = [hex(address)[2:], hex(len(data))[2:]]
|
||||
if ctx.snes_socket is not None:
|
||||
await ctx.snes_socket.send(dumps(PutAddress_Request))
|
||||
await ctx.snes_socket.send(data)
|
||||
else:
|
||||
snes_logger.warning(f"Could not send data to SNES: {data}")
|
||||
while data:
|
||||
# Divide the write into packets of 256 bytes.
|
||||
PutAddress_Request['Operands'] = [hex(address)[2:], hex(min(len(data), 256))[2:]]
|
||||
if ctx.snes_socket is not None:
|
||||
await ctx.snes_socket.send(dumps(PutAddress_Request))
|
||||
await ctx.snes_socket.send(data[:256])
|
||||
address += 256
|
||||
data = data[256:]
|
||||
else:
|
||||
snes_logger.warning(f"Could not send data to SNES: {data}")
|
||||
except ConnectionClosed:
|
||||
return False
|
||||
|
||||
@@ -637,13 +636,7 @@ async def game_watcher(ctx: SNIContext) -> None:
|
||||
if not ctx.client_handler:
|
||||
continue
|
||||
|
||||
try:
|
||||
rom_validated = await ctx.client_handler.validate_rom(ctx)
|
||||
except Exception as e:
|
||||
snes_logger.error(f"An error occurred, see logs for details: {e}")
|
||||
text_file_logger = logging.getLogger()
|
||||
text_file_logger.exception(e)
|
||||
rom_validated = False
|
||||
rom_validated = await ctx.client_handler.validate_rom(ctx)
|
||||
|
||||
if not rom_validated or (ctx.auth and ctx.auth != ctx.rom):
|
||||
snes_logger.warning("ROM change detected, please reconnect to the multiworld server")
|
||||
@@ -659,17 +652,12 @@ async def game_watcher(ctx: SNIContext) -> None:
|
||||
|
||||
perf_counter = time.perf_counter()
|
||||
|
||||
try:
|
||||
await ctx.client_handler.game_watcher(ctx)
|
||||
except Exception as e:
|
||||
snes_logger.error(f"An error occurred, see logs for details: {e}")
|
||||
text_file_logger = logging.getLogger()
|
||||
text_file_logger.exception(e)
|
||||
await snes_disconnect(ctx)
|
||||
await ctx.client_handler.game_watcher(ctx)
|
||||
|
||||
|
||||
async def run_game(romfile: str) -> None:
|
||||
auto_start = settings.get_settings().sni_options.snes_rom_start
|
||||
auto_start = typing.cast(typing.Union[bool, str],
|
||||
Utils.get_options()["sni_options"].get("snes_rom_start", True))
|
||||
if auto_start is True:
|
||||
import webbrowser
|
||||
webbrowser.open(romfile)
|
||||
@@ -735,6 +723,6 @@ async def main() -> None:
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
colorama.just_fix_windows_console()
|
||||
colorama.init()
|
||||
asyncio.run(main())
|
||||
colorama.deinit()
|
||||
|
||||
11
Starcraft2Client.py
Normal file
@@ -0,0 +1,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import ModuleUpdate
|
||||
ModuleUpdate.update()
|
||||
|
||||
from worlds.sc2wol.Client import launch
|
||||
import Utils
|
||||
|
||||
if __name__ == "__main__":
|
||||
Utils.init_logging("Starcraft2Client", exception_logger="Client")
|
||||
launch()
|
||||
@@ -1,7 +1,6 @@
|
||||
from __future__ import annotations
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import asyncio
|
||||
import typing
|
||||
import bsdiff4
|
||||
@@ -16,9 +15,6 @@ from CommonClient import CommonContext, server_loop, \
|
||||
gui_enabled, ClientCommandProcessor, logger, get_base_parser
|
||||
from Utils import async_start
|
||||
|
||||
# Heartbeat for position sharing via bounces, in seconds
|
||||
UNDERTALE_STATUS_INTERVAL = 30.0
|
||||
UNDERTALE_ONLINE_TIMEOUT = 60.0
|
||||
|
||||
class UndertaleCommandProcessor(ClientCommandProcessor):
|
||||
def __init__(self, ctx):
|
||||
@@ -33,7 +29,7 @@ class UndertaleCommandProcessor(ClientCommandProcessor):
|
||||
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)
|
||||
os.makedirs(name=os.path.join(os.getcwd(), "Undertale"), exist_ok=True)
|
||||
self.ctx.patch_game()
|
||||
self.output("Patched.")
|
||||
|
||||
@@ -47,7 +43,7 @@ class UndertaleCommandProcessor(ClientCommandProcessor):
|
||||
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)
|
||||
os.makedirs(name=os.path.join(os.getcwd(), "Undertale"), exist_ok=True)
|
||||
tempInstall = steaminstall
|
||||
if not os.path.isfile(os.path.join(tempInstall, "data.win")):
|
||||
tempInstall = None
|
||||
@@ -66,7 +62,7 @@ class UndertaleCommandProcessor(ClientCommandProcessor):
|
||||
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))
|
||||
os.path.join(os.getcwd(), "Undertale", file_name))
|
||||
self.ctx.patch_game()
|
||||
self.output("Patching successful!")
|
||||
|
||||
@@ -113,19 +109,14 @@ class UndertaleContext(CommonContext):
|
||||
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")
|
||||
self.last_sent_position: typing.Optional[tuple] = None
|
||||
self.last_room: typing.Optional[str] = None
|
||||
self.last_status_write: float = 0.0
|
||||
self.other_undertale_status: dict[int, dict] = {}
|
||||
|
||||
|
||||
def patch_game(self):
|
||||
with open(Utils.user_path("Undertale", "data.win"), "rb") as f:
|
||||
with open(os.path.join(os.getcwd(), "Undertale", "data.win"), "rb") as f:
|
||||
patchedFile = bsdiff4.patch(f.read(), undertale.data_path("patch.bsdiff"))
|
||||
with open(Utils.user_path("Undertale", "data.win"), "wb") as f:
|
||||
with open(os.path.join(os.getcwd(), "Undertale", "data.win"), "wb") as f:
|
||||
f.write(patchedFile)
|
||||
os.makedirs(name=Utils.user_path("Undertale", "Custom Sprites"), exist_ok=True)
|
||||
with open(os.path.expandvars(Utils.user_path("Undertale", "Custom Sprites",
|
||||
os.makedirs(name=os.path.join(os.getcwd(), "Undertale", "Custom Sprites"), exist_ok=True)
|
||||
with open(os.path.expandvars(os.path.join(os.getcwd(), "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"])
|
||||
@@ -228,9 +219,6 @@ async def process_undertale_cmd(ctx: UndertaleContext, cmd: str, args: dict):
|
||||
await ctx.send_msgs([{"cmd": "SetNotify", "keys": [str(ctx.slot)+" RoutesDone neutral",
|
||||
str(ctx.slot)+" RoutesDone pacifist",
|
||||
str(ctx.slot)+" RoutesDone genocide"]}])
|
||||
if any(info.game == "Undertale" and slot != ctx.slot
|
||||
for slot, info in ctx.slot_info.items()):
|
||||
ctx.set_notify("undertale_room_status")
|
||||
if args["slot_data"]["only_flakes"]:
|
||||
with open(os.path.join(ctx.save_game_folder, "GenoNoChest.flag"), "w") as f:
|
||||
f.close()
|
||||
@@ -259,8 +247,8 @@ async def process_undertale_cmd(ctx: UndertaleContext, cmd: str, args: dict):
|
||||
with open(os.path.join(ctx.save_game_folder, filename), "w") as f:
|
||||
toDraw = ""
|
||||
for i in range(20):
|
||||
if i < len(str(ctx.item_names.lookup_in_game(l.item))):
|
||||
toDraw += str(ctx.item_names.lookup_in_game(l.item))[i]
|
||||
if i < len(str(ctx.item_names[l.item])):
|
||||
toDraw += str(ctx.item_names[l.item])[i]
|
||||
else:
|
||||
break
|
||||
f.write(toDraw)
|
||||
@@ -275,12 +263,6 @@ async def process_undertale_cmd(ctx: UndertaleContext, cmd: str, args: dict):
|
||||
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"]
|
||||
if "undertale_room_status" in args["keys"] and args["keys"]["undertale_room_status"]:
|
||||
status = args["keys"]["undertale_room_status"]
|
||||
ctx.other_undertale_status = {
|
||||
int(key): val for key, val in status.items()
|
||||
if int(key) != ctx.slot
|
||||
}
|
||||
elif cmd == "SetReply":
|
||||
if args["value"] is not None:
|
||||
if str(ctx.slot)+" RoutesDone pacifist" == args["key"]:
|
||||
@@ -289,19 +271,17 @@ async def process_undertale_cmd(ctx: UndertaleContext, cmd: str, args: dict):
|
||||
ctx.completed_routes["genocide"] = args["value"]
|
||||
elif str(ctx.slot)+" RoutesDone neutral" == args["key"]:
|
||||
ctx.completed_routes["neutral"] = args["value"]
|
||||
if args.get("key") == "undertale_room_status" and args.get("value"):
|
||||
ctx.other_undertale_status = {
|
||||
int(key): val for key, val in args["value"].items()
|
||||
if int(key) != ctx.slot
|
||||
}
|
||||
elif cmd == "ReceivedItems":
|
||||
start_index = args["index"]
|
||||
|
||||
if start_index == 0:
|
||||
ctx.items_received = []
|
||||
elif start_index != len(ctx.items_received):
|
||||
await ctx.check_locations(ctx.locations_checked)
|
||||
await ctx.send_msgs([{"cmd": "Sync"}])
|
||||
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
|
||||
@@ -388,8 +368,9 @@ async def process_undertale_cmd(ctx: UndertaleContext, cmd: str, args: dict):
|
||||
f.close()
|
||||
|
||||
elif cmd == "Bounced":
|
||||
data = args.get("data", {})
|
||||
if "x" in data and "room" in data:
|
||||
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:
|
||||
@@ -400,63 +381,21 @@ async def process_undertale_cmd(ctx: UndertaleContext, cmd: str, args: dict):
|
||||
|
||||
async def multi_watcher(ctx: UndertaleContext):
|
||||
while not ctx.exit_event.is_set():
|
||||
if "Online" in ctx.tags and any(
|
||||
info.game == "Undertale" and slot != ctx.slot
|
||||
for slot, info in ctx.slot_info.items()):
|
||||
now = time.time()
|
||||
path = ctx.save_game_folder
|
||||
for root, dirs, files in os.walk(path):
|
||||
for file in files:
|
||||
if "spots.mine" in file:
|
||||
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()
|
||||
|
||||
if this_room != ctx.last_room or \
|
||||
now - ctx.last_status_write >= UNDERTALE_STATUS_INTERVAL:
|
||||
ctx.last_room = this_room
|
||||
ctx.last_status_write = now
|
||||
await ctx.send_msgs([{
|
||||
"cmd": "Set",
|
||||
"key": "undertale_room_status",
|
||||
"default": {},
|
||||
"want_reply": False,
|
||||
"operations": [{"operation": "update",
|
||||
"value": {str(ctx.slot): {"room": this_room,
|
||||
"time": now}}}]
|
||||
}])
|
||||
|
||||
# If player was visible but timed out (heartbeat) or left the room, remove them.
|
||||
for slot, entry in ctx.other_undertale_status.items():
|
||||
if entry.get("room") != this_room or \
|
||||
now - entry.get("time", now) > UNDERTALE_ONLINE_TIMEOUT:
|
||||
playerspot = os.path.join(ctx.save_game_folder,
|
||||
f"FRISK{slot}.playerspot")
|
||||
if os.path.exists(playerspot):
|
||||
os.remove(playerspot)
|
||||
|
||||
current_position = (this_x, this_y, this_room, this_sprite, this_frame)
|
||||
if current_position == ctx.last_sent_position:
|
||||
continue
|
||||
|
||||
# Empty status dict = no data yet → send to bootstrap.
|
||||
online_in_room = any(
|
||||
entry.get("room") == this_room and
|
||||
now - entry.get("time", now) <= UNDERTALE_ONLINE_TIMEOUT
|
||||
for entry in ctx.other_undertale_status.values()
|
||||
)
|
||||
if ctx.other_undertale_status and not online_in_room:
|
||||
continue
|
||||
|
||||
message = [{"cmd": "Bounce", "games": ["Undertale"],
|
||||
"data": {"player": ctx.slot, "x": this_x, "y": this_y,
|
||||
"room": this_room, "spr": this_sprite,
|
||||
"frm": this_frame}}]
|
||||
await ctx.send_msgs(message)
|
||||
ctx.last_sent_position = current_position
|
||||
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)
|
||||
|
||||
@@ -470,9 +409,10 @@ async def game_watcher(ctx: UndertaleContext):
|
||||
for file in files:
|
||||
if ".item" in file:
|
||||
os.remove(os.path.join(root, file))
|
||||
await ctx.check_locations(ctx.locations_checked)
|
||||
await ctx.send_msgs([{"cmd": "Sync"}])
|
||||
|
||||
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
|
||||
@@ -507,7 +447,7 @@ async def game_watcher(ctx: UndertaleContext):
|
||||
for l in lines:
|
||||
sending = sending+[(int(l.rstrip('\n')))+12000]
|
||||
finally:
|
||||
await ctx.check_locations(sending)
|
||||
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:
|
||||
@@ -560,7 +500,7 @@ def main():
|
||||
|
||||
import colorama
|
||||
|
||||
colorama.just_fix_windows_console()
|
||||
colorama.init()
|
||||
|
||||
asyncio.run(_main())
|
||||
colorama.deinit()
|
||||
|
||||
@@ -2,15 +2,14 @@ from __future__ import annotations
|
||||
|
||||
import atexit
|
||||
import os
|
||||
import pkgutil
|
||||
import sys
|
||||
import asyncio
|
||||
import random
|
||||
import typing
|
||||
import shutil
|
||||
from typing import Tuple, List, Iterable, Dict
|
||||
|
||||
from . import WargrooveWorld
|
||||
from .Items import item_table, faction_table, CommanderData, ItemData
|
||||
from worlds.wargroove import WargrooveWorld
|
||||
from worlds.wargroove.Items import item_table, faction_table, CommanderData, ItemData
|
||||
|
||||
import ModuleUpdate
|
||||
ModuleUpdate.update()
|
||||
@@ -22,7 +21,7 @@ import logging
|
||||
if __name__ == "__main__":
|
||||
Utils.init_logging("WargrooveClient", exception_logger="Client")
|
||||
|
||||
from NetUtils import ClientStatus
|
||||
from NetUtils import NetworkItem, ClientStatus
|
||||
from CommonClient import gui_enabled, logger, get_base_parser, ClientCommandProcessor, \
|
||||
CommonContext, server_loop
|
||||
|
||||
@@ -30,34 +29,6 @@ wg_logger = logging.getLogger("WG")
|
||||
|
||||
|
||||
class WargrooveClientCommandProcessor(ClientCommandProcessor):
|
||||
def _cmd_sacrifice_summon(self):
|
||||
"""Toggles sacrifices and summons On/Off"""
|
||||
if isinstance(self.ctx, WargrooveContext):
|
||||
self.ctx.has_sacrifice_summon = not self.ctx.has_sacrifice_summon
|
||||
if self.ctx.has_sacrifice_summon:
|
||||
self.output(f"Sacrifices and summons are enabled.")
|
||||
else:
|
||||
unit_summon_response_file = os.path.join(self.ctx.game_communication_path, "unitSummonResponse")
|
||||
if os.path.exists(unit_summon_response_file):
|
||||
os.remove(unit_summon_response_file)
|
||||
self.output(f"Sacrifices and summons are disabled.")
|
||||
|
||||
def _cmd_deathlink(self):
|
||||
"""Toggles deathlink On/Off"""
|
||||
if isinstance(self.ctx, WargrooveContext):
|
||||
self.ctx.has_death_link = not self.ctx.has_death_link
|
||||
Utils.async_start(self.ctx.update_death_link(self.ctx.has_death_link), name="Update Deathlink")
|
||||
if self.ctx.has_death_link:
|
||||
death_link_send_file = os.path.join(self.ctx.game_communication_path, "deathLinkSend")
|
||||
if os.path.exists(death_link_send_file):
|
||||
os.remove(death_link_send_file)
|
||||
self.output(f"Deathlink enabled.")
|
||||
else:
|
||||
death_link_receive_file = os.path.join(self.ctx.game_communication_path, "deathLinkReceive")
|
||||
if os.path.exists(death_link_receive_file):
|
||||
os.remove(death_link_receive_file)
|
||||
self.output(f"Deathlink disabled.")
|
||||
|
||||
def _cmd_resync(self):
|
||||
"""Manually trigger a resync."""
|
||||
self.output(f"Syncing items.")
|
||||
@@ -87,11 +58,6 @@ class WargrooveContext(CommonContext):
|
||||
commander_defense_boost_multiplier: int = 0
|
||||
income_boost_multiplier: int = 0
|
||||
starting_groove_multiplier: float
|
||||
has_death_link: bool = False
|
||||
has_sacrifice_summon: bool = True
|
||||
player_stored_units_key: str = ""
|
||||
ai_stored_units_key: str = ""
|
||||
max_stored_units: int = 1000
|
||||
faction_item_ids = {
|
||||
'Starter': 0,
|
||||
'Cherrystone': 52025,
|
||||
@@ -105,31 +71,6 @@ class WargrooveContext(CommonContext):
|
||||
'Income Boost': 52023,
|
||||
'Commander Defense Boost': 52024,
|
||||
}
|
||||
unit_classes = {
|
||||
"archer",
|
||||
"ballista",
|
||||
"balloon",
|
||||
"dog",
|
||||
"dragon",
|
||||
"giant",
|
||||
"harpoonship",
|
||||
"harpy",
|
||||
"knight",
|
||||
"mage",
|
||||
"merman",
|
||||
"rifleman",
|
||||
"soldier",
|
||||
"spearman",
|
||||
"thief",
|
||||
"thief_with_gold",
|
||||
"travelboat",
|
||||
"trebuchet",
|
||||
"turtle",
|
||||
"villager",
|
||||
"wagon",
|
||||
"warship",
|
||||
"witch",
|
||||
}
|
||||
|
||||
def __init__(self, server_address, password):
|
||||
super(WargrooveContext, self).__init__(server_address, password)
|
||||
@@ -137,80 +78,31 @@ class WargrooveContext(CommonContext):
|
||||
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
|
||||
game_options = WargrooveWorld.settings
|
||||
|
||||
# Validate the AppData directory with Wargroove save data.
|
||||
# By default, Windows sets an environment variable we can leverage.
|
||||
# However, other OSes don't usually have this value set, so we need to rely on a settings value instead.
|
||||
appdata_wargroove = None
|
||||
if "appdata" in os.environ:
|
||||
appdata_wargroove = os.environ['appdata']
|
||||
else:
|
||||
try:
|
||||
appdata_wargroove = game_options.save_directory
|
||||
except FileNotFoundError:
|
||||
print_error_and_close("WargrooveClient couldn't detect a path to the AppData folder.\n"
|
||||
"Unable to infer required game_communication_path.\n"
|
||||
"Try setting the \"save_directory\" value in your local options file "
|
||||
"to the AppData folder containing your Wargroove saves.")
|
||||
appdata_wargroove = os.path.expandvars(os.path.join(appdata_wargroove, "Chucklefish", "Wargroove"))
|
||||
if not os.path.isdir(appdata_wargroove):
|
||||
print_error_and_close(f"WargrooveClient couldn't find Wargroove data in your AppData folder.\n"
|
||||
f"Looked in \"{appdata_wargroove}\".\n"
|
||||
f"If you haven't yet booted the game at least once, boot Wargroove "
|
||||
f"and then close it to attempt to fix this error.\n"
|
||||
f"If the AppData folder above seems wrong, try setting the "
|
||||
f"\"save_directory\" value in your local options file "
|
||||
f"to the AppData folder containing your Wargroove saves.")
|
||||
|
||||
# Check for the Wargroove game executable path.
|
||||
# This should always be set regardless of the OS.
|
||||
root_directory = game_options["root_directory"]
|
||||
if not os.path.isfile(os.path.join(root_directory, "win64_bin", "wargroove64.exe")):
|
||||
print_error_and_close(f"WargrooveClient couldn't find wargroove64.exe in "
|
||||
f"\"{root_directory}/win64_bin/\".\n"
|
||||
f"Unable to infer required game_communication_path.\n"
|
||||
f"Please verify the \"root_directory\" value in your local "
|
||||
f"options file is set correctly.")
|
||||
self.game_communication_path = os.path.join(root_directory, "AP")
|
||||
if not os.path.exists(self.game_communication_path):
|
||||
os.makedirs(self.game_communication_path)
|
||||
self.remove_communication_files()
|
||||
atexit.register(self.remove_communication_files)
|
||||
if not os.path.isdir(appdata_wargroove):
|
||||
print_error_and_close("WargrooveClient couldn't find Wargoove in appdata! "
|
||||
"Boot Wargroove and then close it to attempt to fix this error")
|
||||
mods_directory = os.path.join(appdata_wargroove, "mods", "ArchipelagoMod")
|
||||
save_directory = os.path.join(appdata_wargroove, "save")
|
||||
|
||||
# Wargroove doesn't always create the mods directory, so we have to do it
|
||||
if not os.path.isdir(mods_directory):
|
||||
os.makedirs(mods_directory)
|
||||
resources = ["data/mods/ArchipelagoMod/maps.dat",
|
||||
"data/mods/ArchipelagoMod/mod.dat",
|
||||
"data/mods/ArchipelagoMod/modAssets.dat",
|
||||
"data/save/campaign-c40a6e5b0cdf86ddac03b276691c483d.cmp",
|
||||
"data/save/campaign-c40a6e5b0cdf86ddac03b276691c483d.cmp.bak"]
|
||||
file_paths = [os.path.join(mods_directory, "maps.dat"),
|
||||
os.path.join(mods_directory, "mod.dat"),
|
||||
os.path.join(mods_directory, "modAssets.dat"),
|
||||
os.path.join(save_directory, "campaign-c40a6e5b0cdf86ddac03b276691c483d.cmp"),
|
||||
os.path.join(save_directory, "campaign-c40a6e5b0cdf86ddac03b276691c483d.cmp.bak")]
|
||||
for resource, destination in zip(resources, file_paths):
|
||||
file_data = pkgutil.get_data("worlds.wargroove", resource)
|
||||
if file_data is None:
|
||||
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!")
|
||||
with open(destination, 'wb') as f:
|
||||
f.write(file_data)
|
||||
|
||||
def on_deathlink(self, data: typing.Dict[str, typing.Any]) -> None:
|
||||
with open(os.path.join(self.game_communication_path, "deathLinkReceive"), 'w+') as f:
|
||||
text = data.get("cause", "")
|
||||
if text:
|
||||
f.write(f"DeathLink: {text}")
|
||||
else:
|
||||
f.write(f"DeathLink: Received from {data['source']}")
|
||||
super(WargrooveContext, self).on_deathlink(data)
|
||||
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:
|
||||
@@ -246,25 +138,20 @@ class WargrooveContext(CommonContext):
|
||||
|
||||
def on_package(self, cmd: str, args: dict):
|
||||
if cmd in {"Connected"}:
|
||||
slot_data = args["slot_data"]
|
||||
self.has_death_link = slot_data.get("death_link", False)
|
||||
filename = f"AP_settings.json"
|
||||
with open(os.path.join(self.game_communication_path, filename), 'w') as f:
|
||||
json.dump(slot_data, f)
|
||||
slot_data = args["slot_data"]
|
||||
json.dump(args["slot_data"], f)
|
||||
self.can_choose_commander = slot_data["can_choose_commander"]
|
||||
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:
|
||||
pass
|
||||
|
||||
self.player_stored_units_key = f"wargroove_player_units_{self.team}"
|
||||
self.ai_stored_units_key = f"wargroove_ai_units_{self.team}"
|
||||
self.set_notify(self.player_stored_units_key, self.ai_stored_units_key)
|
||||
|
||||
f.close()
|
||||
self.update_commander_data()
|
||||
self.ui.update_tracker()
|
||||
|
||||
@@ -274,6 +161,7 @@ class WargrooveContext(CommonContext):
|
||||
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"]
|
||||
@@ -288,7 +176,7 @@ class WargrooveContext(CommonContext):
|
||||
if not os.path.isfile(path):
|
||||
open(path, 'w').close()
|
||||
# Announcing commander unlocks
|
||||
item_name = self.item_names.lookup_in_game(network_item.item)
|
||||
item_name = self.item_names[network_item.item]
|
||||
if item_name in faction_table.keys():
|
||||
for commander in faction_table[item_name]:
|
||||
logger.info(f"{commander.name} has been unlocked!")
|
||||
@@ -301,6 +189,7 @@ class WargrooveContext(CommonContext):
|
||||
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)
|
||||
@@ -308,9 +197,10 @@ class WargrooveContext(CommonContext):
|
||||
open(print_path, 'w').close()
|
||||
with open(print_path, 'w') as f:
|
||||
f.write("Received " +
|
||||
self.item_names.lookup_in_game(network_item.item) +
|
||||
self.item_names[network_item.item] +
|
||||
" from " +
|
||||
self.player_names[network_item.player])
|
||||
f.close()
|
||||
self.update_commander_data()
|
||||
self.ui.update_tracker()
|
||||
|
||||
@@ -319,16 +209,22 @@ class WargrooveContext(CommonContext):
|
||||
for ss in self.checked_locations:
|
||||
filename = f"send{ss}"
|
||||
with open(os.path.join(self.game_communication_path, filename), 'w') as f:
|
||||
pass
|
||||
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 kivymd.uix.tab import MDTabsItem, MDTabsItemText
|
||||
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):
|
||||
@@ -371,7 +267,9 @@ class WargrooveContext(CommonContext):
|
||||
|
||||
def build(self):
|
||||
container = super().build()
|
||||
self.add_client_tab("Wargroove", self.build_tracker())
|
||||
panel = TabbedPanelItem(text="Wargroove")
|
||||
panel.content = self.build_tracker()
|
||||
self.tabs.add_widget(panel)
|
||||
return container
|
||||
|
||||
def build_tracker(self) -> TrackerLayout:
|
||||
@@ -444,7 +342,7 @@ class WargrooveContext(CommonContext):
|
||||
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:
|
||||
if self.item_names[network_item.item] in faction_item_names:
|
||||
faction_items += 1
|
||||
starting_groove = (faction_items - 1) * self.starting_groove_multiplier
|
||||
# Must be an integer larger than 0
|
||||
@@ -495,75 +393,32 @@ class WargrooveContext(CommonContext):
|
||||
|
||||
|
||||
async def game_watcher(ctx: WargrooveContext):
|
||||
from worlds.wargroove.Locations import location_table
|
||||
while not ctx.exit_event.is_set():
|
||||
try:
|
||||
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 == "deathLinkSend" and ctx.has_death_link:
|
||||
with open(os.path.join(ctx.game_communication_path, file), 'r') as f:
|
||||
failed_mission = f.read()
|
||||
if ctx.slot is not None:
|
||||
await ctx.send_death(f"{ctx.player_names[ctx.slot]} failed {failed_mission}")
|
||||
os.remove(os.path.join(ctx.game_communication_path, file))
|
||||
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))
|
||||
if file == "unitSacrifice" or file == "unitSacrificeAI":
|
||||
if ctx.has_sacrifice_summon:
|
||||
stored_units_key = ctx.player_stored_units_key
|
||||
if file == "unitSacrificeAI":
|
||||
stored_units_key = ctx.ai_stored_units_key
|
||||
with open(os.path.join(ctx.game_communication_path, file), 'r') as f:
|
||||
unit_class = f.read()
|
||||
message = [{"cmd": 'Set', "key": stored_units_key,
|
||||
"default": [],
|
||||
"want_reply": True,
|
||||
"operations": [{"operation": "add", "value": [unit_class[:64]]}]}]
|
||||
await ctx.send_msgs(message)
|
||||
os.remove(os.path.join(ctx.game_communication_path, file))
|
||||
if file == "unitSummonRequestAI" or file == "unitSummonRequest":
|
||||
if ctx.has_sacrifice_summon:
|
||||
stored_units_key = ctx.player_stored_units_key
|
||||
if file == "unitSummonRequestAI":
|
||||
stored_units_key = ctx.ai_stored_units_key
|
||||
with open(os.path.join(ctx.game_communication_path, "unitSummonResponse"), 'w') as f:
|
||||
if stored_units_key in ctx.stored_data:
|
||||
stored_units = ctx.stored_data[stored_units_key]
|
||||
if stored_units is None:
|
||||
stored_units = []
|
||||
wg1_stored_units = [unit for unit in stored_units if unit in ctx.unit_classes]
|
||||
if len(wg1_stored_units) != 0:
|
||||
summoned_unit = random.choice(wg1_stored_units)
|
||||
message = [{"cmd": 'Set', "key": stored_units_key,
|
||||
"default": [],
|
||||
"want_reply": True,
|
||||
"operations": [{"operation": "remove", "value": summoned_unit[:64]}]}]
|
||||
await ctx.send_msgs(message)
|
||||
f.write(summoned_unit)
|
||||
os.remove(os.path.join(ctx.game_communication_path, file))
|
||||
|
||||
ctx.locations_checked = sending
|
||||
message = [{"cmd": 'LocationChecks', "locations": sending}]
|
||||
await ctx.send_msgs(message)
|
||||
if not ctx.finished_game and victory:
|
||||
await ctx.send_msgs([{"cmd": "StatusUpdate", "status": ClientStatus.CLIENT_GOAL}])
|
||||
ctx.finished_game = True
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
except Exception as err:
|
||||
logger.warn("Exception in communication thread, a check may not have been sent: " + str(err))
|
||||
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):
|
||||
@@ -571,9 +426,8 @@ def print_error_and_close(msg):
|
||||
Utils.messagebox("Error", msg, error=True)
|
||||
sys.exit(1)
|
||||
|
||||
def launch(*launch_args: str):
|
||||
async def main():
|
||||
args = parser.parse_args(launch_args)
|
||||
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:
|
||||
@@ -593,6 +447,7 @@ def launch(*launch_args: str):
|
||||
|
||||
parser = get_base_parser(description="Wargroove Client, for text interfacing.")
|
||||
|
||||
colorama.just_fix_windows_console()
|
||||
asyncio.run(main())
|
||||
args, rest = parser.parse_known_args()
|
||||
colorama.init()
|
||||
asyncio.run(main(args))
|
||||
colorama.deinit()
|
||||
91
WebHost.py
@@ -1,4 +1,3 @@
|
||||
import argparse
|
||||
import os
|
||||
import multiprocessing
|
||||
import logging
|
||||
@@ -12,67 +11,53 @@ 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__)
|
||||
Utils.local_path.cached_path = os.path.dirname(__file__) or "." # py3.8 is not abs. remove "." when dropping 3.8
|
||||
settings.no_gui = True
|
||||
configpath = os.path.abspath("config.yaml")
|
||||
if not os.path.exists(configpath):
|
||||
# fall back to config.yaml in user_path if config does not exist in cwd to match settings.py
|
||||
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":
|
||||
def get_app():
|
||||
from WebHostLib import register, cache, app as raw_app
|
||||
from WebHostLib.models import db
|
||||
|
||||
register()
|
||||
app = raw_app
|
||||
if os.path.exists(configpath) and not app.config["TESTING"]:
|
||||
import yaml
|
||||
app.config.from_file(configpath, yaml.safe_load)
|
||||
logging.info(f"Updated config from {configpath}")
|
||||
# inside get_app() so it's usable in systems like gunicorn, which do not run WebHost.py, but import it.
|
||||
parser = argparse.ArgumentParser(allow_abbrev=False)
|
||||
parser.add_argument('--config_override', default=None,
|
||||
help="Path to yaml config file that overrules config.yaml.")
|
||||
args = parser.parse_known_args()[0]
|
||||
if args.config_override:
|
||||
import yaml
|
||||
app.config.from_file(os.path.abspath(args.config_override), yaml.safe_load)
|
||||
logging.info(f"Updated config from {args.config_override}")
|
||||
if not app.config["HOST_ADDRESS"]:
|
||||
logging.info("Getting public IP, as HOST_ADDRESS is empty.")
|
||||
app.config["HOST_ADDRESS"] = Utils.get_public_ipv4()
|
||||
logging.info(f"HOST_ADDRESS was set to {app.config['HOST_ADDRESS']}")
|
||||
|
||||
register()
|
||||
cache.init_app(app)
|
||||
db.bind(**app.config["PONY"])
|
||||
db.generate_mapping(create_tables=True)
|
||||
return app
|
||||
|
||||
|
||||
def copy_tutorials_files_to_static() -> None:
|
||||
def create_ordered_tutorials_file() -> typing.List[typing.Dict[str, typing.Any]]:
|
||||
import json
|
||||
import shutil
|
||||
import zipfile
|
||||
from werkzeug.utils import secure_filename
|
||||
|
||||
zfile: zipfile.ZipInfo
|
||||
|
||||
from worlds.AutoWorld import AutoWorldRegister
|
||||
worlds = {}
|
||||
data = []
|
||||
for game, world in AutoWorldRegister.world_types.items():
|
||||
if hasattr(world.web, 'tutorials') and (not world.hidden or game == 'Archipelago'):
|
||||
worlds[game] = world
|
||||
|
||||
base_target_path = Utils.local_path("WebHostLib", "static", "generated", "docs")
|
||||
shutil.rmtree(base_target_path, ignore_errors=True)
|
||||
for game, world in worlds.items():
|
||||
# copy files from world's docs folder to the generated folder
|
||||
target_path = os.path.join(base_target_path, secure_filename(game))
|
||||
target_path = os.path.join(base_target_path, game)
|
||||
os.makedirs(target_path, exist_ok=True)
|
||||
|
||||
if world.zip_path:
|
||||
@@ -85,14 +70,45 @@ def copy_tutorials_files_to_static() -> None:
|
||||
for zfile in zf.infolist():
|
||||
if not zfile.is_dir() and "/docs/" in zfile.filename:
|
||||
zfile.filename = os.path.basename(zfile.filename)
|
||||
with open(os.path.join(target_path, secure_filename(zfile.filename)), "wb") as f:
|
||||
f.write(zf.read(zfile))
|
||||
zf.extract(zfile, target_path)
|
||||
else:
|
||||
source_path = Utils.local_path(os.path.dirname(world.__file__), "docs")
|
||||
files = os.listdir(source_path)
|
||||
for file in files:
|
||||
shutil.copyfile(Utils.local_path(source_path, file),
|
||||
Utils.local_path(target_path, secure_filename(file)))
|
||||
shutil.copyfile(Utils.local_path(source_path, file), Utils.local_path(target_path, file))
|
||||
|
||||
# build a json tutorial dict per game
|
||||
game_data = {'gameTitle': game, 'tutorials': []}
|
||||
for tutorial in world.web.tutorials:
|
||||
# build dict for the json file
|
||||
current_tutorial = {
|
||||
'name': tutorial.tutorial_name,
|
||||
'description': tutorial.description,
|
||||
'files': [{
|
||||
'language': tutorial.language,
|
||||
'filename': game + '/' + tutorial.file_name,
|
||||
'link': f'{game}/{tutorial.link}',
|
||||
'authors': tutorial.authors
|
||||
}]
|
||||
}
|
||||
|
||||
# check if the name of the current guide exists already
|
||||
for guide in game_data['tutorials']:
|
||||
if guide and tutorial.tutorial_name == guide['name']:
|
||||
guide['files'].append(current_tutorial['files'][0])
|
||||
break
|
||||
else:
|
||||
game_data['tutorials'].append(current_tutorial)
|
||||
|
||||
data.append(game_data)
|
||||
with open(Utils.local_path("WebHostLib", "static", "generated", "tutorials.json"), 'w', encoding='utf-8-sig') as json_target:
|
||||
generic_data = {}
|
||||
for games in data:
|
||||
if 'Archipelago' in games['gameTitle']:
|
||||
generic_data = data.pop(data.index(games))
|
||||
sorted_data = [generic_data] + Utils.title_sorted(data, key=lambda entry: entry["gameTitle"])
|
||||
json.dump(sorted_data, json_target, indent=2, ensure_ascii=False)
|
||||
return sorted_data
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
@@ -100,25 +116,18 @@ if __name__ == "__main__":
|
||||
multiprocessing.set_start_method('spawn')
|
||||
logging.basicConfig(format='[%(asctime)s] %(message)s', level=logging.INFO)
|
||||
|
||||
from WebHostLib.autolauncher import autohost, autogen, stop
|
||||
from WebHostLib.lttpsprites import update_sprites_lttp
|
||||
from WebHostLib.autolauncher import autohost, autogen
|
||||
from WebHostLib.options import create as create_options_files
|
||||
|
||||
try:
|
||||
from WebHostLib.lttpsprites import update_sprites_lttp
|
||||
update_sprites_lttp()
|
||||
except Exception as e:
|
||||
logging.exception(e)
|
||||
logging.warning("Could not update LttP sprites.")
|
||||
app = get_app()
|
||||
from worlds import AutoWorldRegister
|
||||
# Update to only valid WebHost worlds
|
||||
invalid_worlds = {name for name, world in AutoWorldRegister.world_types.items()
|
||||
if not hasattr(world.web, "tutorials")}
|
||||
if invalid_worlds:
|
||||
logging.error(f"Following worlds not loaded as they are invalid for WebHost: {invalid_worlds}")
|
||||
AutoWorldRegister.world_types = {k: v for k, v in AutoWorldRegister.world_types.items() if k not in invalid_worlds}
|
||||
create_options_files()
|
||||
copy_tutorials_files_to_static()
|
||||
create_ordered_tutorials_file()
|
||||
if app.config["SELFLAUNCH"]:
|
||||
autohost(app.config)
|
||||
if app.config["SELFGEN"]:
|
||||
@@ -129,11 +138,3 @@ if __name__ == "__main__":
|
||||
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,20 +1,46 @@
|
||||
# WebHost
|
||||
|
||||
## Asset License
|
||||
|
||||
The image files used in the page design were specifically designed for archipelago.gg and are **not** covered by the top
|
||||
level LICENSE.
|
||||
See individual LICENSE files in `./static/static/**`.
|
||||
|
||||
You are only allowed to use them for personal use, testing and development.
|
||||
If the site is reachable over the internet, have a robots.txt in place (see `ASSET_RIGHTS` in `config.yaml`)
|
||||
and do not promote it publicly. Alternatively replace or remove the assets.
|
||||
|
||||
## Contribution Guidelines
|
||||
**Thank you for your interest in contributing to the Archipelago website!**
|
||||
Much of the content on the website is generated automatically, but there are some things
|
||||
that need a personal touch. For those things, we rely on contributions from both the core
|
||||
team and the community. The current primary maintainer of the website is Farrak Kilhn.
|
||||
He may be found on Discord as `Farrak Kilhn#0418`, or on GitHub as `LegendaryLinux`.
|
||||
|
||||
Pages should preferably be rendered on the server side with Jinja. Features should work with noscript if feasible.
|
||||
Design changes have to fit the overall design.
|
||||
### Small Changes
|
||||
Little changes like adding a button or a couple new select elements are perfectly fine.
|
||||
Tweaks to style specific to a PR's content are also probably not a problem. For example, if
|
||||
you build a new page which needs two side by side tables, and you need to write a CSS file
|
||||
specific to your page, that is perfectly reasonable.
|
||||
|
||||
Introduction of JS dependencies should first be discussed on Discord or in a draft PR.
|
||||
### Content Additions
|
||||
Once you develop a new feature or add new content the website, make a pull request. It will
|
||||
be reviewed by the community and there will probably be some discussion around it. Depending
|
||||
on the size of the feature, and if new styles are required, there may be an additional step
|
||||
before the PR is accepted wherein Farrak works with the designer to implement styles.
|
||||
|
||||
See also [docs/style.md](/docs/style.md) for the style guide.
|
||||
### Restrictions on Style Changes
|
||||
A professional designer is paid to develop the styles and assets for the Archipelago website.
|
||||
In an effort to maintain a consistent look and feel, pull requests which *exclusively*
|
||||
change site styles are rejected. Please note this applies to code which changes the overall
|
||||
look and feel of the site, not to small tweaks to CSS for your custom page. The intention
|
||||
behind these restrictions is to maintain a curated feel for the design of the site. If
|
||||
any PR affects the overall feel of the site but includes additive changes, there will
|
||||
likely be a conversation about how to implement those changes without compromising the
|
||||
curated site style. It is therefore worth noting there are a couple files which, if
|
||||
changed in your pull request, will cause it to draw additional scrutiny.
|
||||
|
||||
These closely guarded files are:
|
||||
- `globalStyles.css`
|
||||
- `islandFooter.css`
|
||||
- `landing.css`
|
||||
- `markdown.css`
|
||||
- `tooltip.css`
|
||||
|
||||
### Site Themes
|
||||
There are several themes available for game pages. It is possible to request a new theme in
|
||||
the `#art-and-design` channel on Discord. Because themes are created by the designer, they
|
||||
are not free, and take some time to create. Farrak works closely with the designer to implement
|
||||
these themes, and pays for the assets out of pocket. Therefore, only a couple themes per year
|
||||
are added. If a proposed theme seems like a cool idea and the community likes it, there is a
|
||||
good chance it will become a reality.
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import base64
|
||||
import os
|
||||
import socket
|
||||
import typing
|
||||
import uuid
|
||||
|
||||
from flask import Flask
|
||||
@@ -10,7 +9,7 @@ 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
|
||||
|
||||
UPLOAD_FOLDER = os.path.relpath('uploads')
|
||||
LOGS_FOLDER = os.path.relpath('logs')
|
||||
@@ -21,32 +20,24 @@ 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
|
||||
|
||||
# overwrites of flask default config
|
||||
app.config["DEBUG"] = False
|
||||
app.config["PORT"] = 80
|
||||
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")
|
||||
app.config["SESSION_PERMANENT"] = True
|
||||
app.config["MAX_FORM_MEMORY_SIZE"] = 2 * 1024 * 1024 # 2 MB, needed for large option pages such as SC2
|
||||
|
||||
# custom config
|
||||
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
|
||||
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
|
||||
app.config['SESSION_PERMANENT'] = True
|
||||
|
||||
# waitress uses one thread for I/O, these are for processing of views that then get sent
|
||||
# archipelago.gg uses gunicorn + nginx; ignoring this option
|
||||
@@ -60,50 +51,37 @@ app.config["PONY"] = {
|
||||
app.config["MAX_ROLL"] = 20
|
||||
app.config["CACHE_TYPE"] = "SimpleCache"
|
||||
app.config["HOST_ADDRESS"] = ""
|
||||
app.config["ASSET_RIGHTS"] = False
|
||||
|
||||
cache = Cache()
|
||||
Compress(app)
|
||||
|
||||
|
||||
def to_python(value: str) -> uuid.UUID:
|
||||
return uuid.UUID(bytes=base64.urlsafe_b64decode(value + '=='))
|
||||
|
||||
|
||||
def to_url(value: uuid.UUID) -> str:
|
||||
return base64.urlsafe_b64encode(value.bytes).rstrip(b'=').decode('ascii')
|
||||
|
||||
|
||||
class B64UUIDConverter(BaseConverter):
|
||||
|
||||
def to_python(self, value: str) -> uuid.UUID:
|
||||
return to_python(value)
|
||||
def to_python(self, value):
|
||||
return uuid.UUID(bytes=base64.urlsafe_b64decode(value + '=='))
|
||||
|
||||
def to_url(self, value: typing.Any) -> str:
|
||||
assert isinstance(value, uuid.UUID)
|
||||
return to_url(value)
|
||||
def to_url(self, value):
|
||||
return base64.urlsafe_b64encode(value.bytes).rstrip(b'=').decode('ascii')
|
||||
|
||||
|
||||
# short UUID
|
||||
app.url_map.converters["suuid"] = B64UUIDConverter
|
||||
app.jinja_env.filters["suuid"] = to_url
|
||||
app.jinja_env.filters['suuid'] = lambda value: base64.urlsafe_b64encode(value.bytes).rstrip(b'=').decode('ascii')
|
||||
app.jinja_env.filters["title_sorted"] = title_sorted
|
||||
|
||||
|
||||
def register() -> None:
|
||||
def register():
|
||||
"""Import submodules, triggering their registering on flask routing.
|
||||
Note: initializes worlds subsystem."""
|
||||
import importlib
|
||||
|
||||
from werkzeug.utils import find_modules
|
||||
# has automatic patch integration
|
||||
import worlds.AutoWorld
|
||||
import worlds.Files
|
||||
app.jinja_env.filters['is_applayercontainer'] = worlds.Files.is_ap_player_container
|
||||
app.jinja_env.filters['supports_apdeltapatch'] = lambda game_name: \
|
||||
game_name in worlds.Files.AutoPatchRegister.patch_types
|
||||
|
||||
from WebHostLib.customserver import run_server_process
|
||||
# to trigger app routing picking up on it
|
||||
from . import tracker, upload, landing, check, generate, downloads, api, stats, misc
|
||||
|
||||
for module in find_modules("WebHostLib", include_packages=True):
|
||||
importlib.import_module(module)
|
||||
|
||||
from . import api
|
||||
app.register_blueprint(api.api_endpoints)
|
||||
|
||||
@@ -1,25 +1,59 @@
|
||||
"""API endpoints package."""
|
||||
from typing import List, Tuple
|
||||
from uuid import UUID
|
||||
|
||||
from flask import Blueprint
|
||||
from flask_cors import CORS
|
||||
from flask import Blueprint, abort
|
||||
|
||||
from ..models import Seed, Slot
|
||||
from .. import cache
|
||||
from ..models import Room, Seed
|
||||
|
||||
api_endpoints = Blueprint('api', __name__, url_prefix="/api")
|
||||
cors = CORS(api_endpoints, resources={
|
||||
r"/api/datapackage/*": {"origins": "*"},
|
||||
r"/api/datapackage": {"origins": "*"},
|
||||
r"/api/datapackage_checksum/*": {"origins": "*"},
|
||||
r"/api/room_status/*": {"origins": "*"},
|
||||
r"/api/tracker/*": {"origins": "*"},
|
||||
r"/api/static_tracker/*": {"origins": "*"},
|
||||
r"/api/slot_data_tracker/*": {"origins": "*"}
|
||||
})
|
||||
|
||||
# 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]
|
||||
|
||||
# trigger endpoint registration
|
||||
from . import datapackage, generate, room, tracker, user
|
||||
|
||||
@api_endpoints.route('/room_status/<suuid:room>')
|
||||
def room_info(room: UUID):
|
||||
room = Room.get(id=room)
|
||||
if room is None:
|
||||
return abort(404)
|
||||
return {
|
||||
"tracker": room.tracker,
|
||||
"players": get_players(room.seed),
|
||||
"last_port": room.last_port,
|
||||
"last_activity": room.last_activity,
|
||||
"timeout": room.timeout
|
||||
}
|
||||
|
||||
|
||||
@api_endpoints.route('/datapackage')
|
||||
@cache.cached()
|
||||
def get_datapackage():
|
||||
from worlds import network_data_package
|
||||
return network_data_package
|
||||
|
||||
|
||||
@api_endpoints.route('/datapackage_version')
|
||||
@cache.cached()
|
||||
def get_datapackage_versions():
|
||||
from worlds import AutoWorldRegister
|
||||
|
||||
version_package = {game: world.data_version for game, world in AutoWorldRegister.world_types.items()}
|
||||
return version_package
|
||||
|
||||
|
||||
@api_endpoints.route('/datapackage_checksum')
|
||||
@cache.cached()
|
||||
def get_datapackage_checksums():
|
||||
from worlds import network_data_package
|
||||
version_package = {
|
||||
game: game_data["checksum"] for game, game_data in network_data_package["games"].items()
|
||||
}
|
||||
return version_package
|
||||
|
||||
|
||||
from . import generate, user # trigger registration
|
||||
|
||||
@@ -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,11 +1,11 @@
|
||||
import json
|
||||
import pickle
|
||||
from uuid import UUID
|
||||
|
||||
from flask import request, session, url_for
|
||||
from markupsafe import Markup
|
||||
from pony.orm import commit
|
||||
|
||||
from Utils import restricted_dumps
|
||||
from WebHostLib import app
|
||||
from WebHostLib.check import get_yaml_data, roll_options
|
||||
from WebHostLib.generate import get_meta
|
||||
@@ -20,8 +20,8 @@ def generate_api():
|
||||
race = False
|
||||
meta_options_source = {}
|
||||
if 'file' in request.files:
|
||||
files = request.files.getlist('file')
|
||||
options = get_yaml_data(files)
|
||||
file = request.files['file']
|
||||
options = get_yaml_data(file)
|
||||
if isinstance(options, Markup):
|
||||
return {"text": options.striptags()}, 400
|
||||
if isinstance(options, str):
|
||||
@@ -56,7 +56,7 @@ def generate_api():
|
||||
"detail": results}, 400
|
||||
else:
|
||||
gen = Generation(
|
||||
options=restricted_dumps({name: vars(options) for name, options in gen_options.items()}),
|
||||
options=pickle.dumps({name: vars(options) for name, options in gen_options.items()}),
|
||||
# convert to json compatible
|
||||
meta=json.dumps(meta), state=STATE_QUEUED,
|
||||
owner=session["_id"])
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
from typing import Any, Dict
|
||||
from uuid import UUID
|
||||
|
||||
from flask import abort, url_for
|
||||
|
||||
from WebHostLib import to_url
|
||||
import worlds.Files
|
||||
from . import api_endpoints, get_players
|
||||
from ..models import Room
|
||||
|
||||
|
||||
@api_endpoints.route('/room_status/<suuid:room_id>')
|
||||
def room_info(room_id: UUID) -> Dict[str, Any]:
|
||||
room = Room.get(id=room_id)
|
||||
if room is None:
|
||||
return abort(404)
|
||||
|
||||
def supports_apdeltapatch(game: str) -> bool:
|
||||
return game in worlds.Files.AutoPatchRegister.patch_types
|
||||
|
||||
downloads = []
|
||||
for slot in sorted(room.seed.slots):
|
||||
if slot.data and not supports_apdeltapatch(slot.game):
|
||||
slot_download = {
|
||||
"slot": slot.player_id,
|
||||
"download": url_for("download_slot_file", room_id=room.id, player_id=slot.player_id)
|
||||
}
|
||||
downloads.append(slot_download)
|
||||
elif slot.data:
|
||||
slot_download = {
|
||||
"slot": slot.player_id,
|
||||
"download": url_for("download_patch", patch_id=slot.id, room_id=room.id)
|
||||
}
|
||||
downloads.append(slot_download)
|
||||
|
||||
return {
|
||||
"tracker": to_url(room.tracker),
|
||||
"players": get_players(room.seed),
|
||||
"last_port": room.last_port,
|
||||
"last_activity": room.last_activity,
|
||||
"timeout": room.timeout,
|
||||
"downloads": downloads,
|
||||
}
|
||||
@@ -1,258 +0,0 @@
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, TypedDict
|
||||
from uuid import UUID
|
||||
|
||||
from flask import abort
|
||||
|
||||
from NetUtils import ClientStatus, Hint, NetworkItem, SlotType
|
||||
from WebHostLib import cache
|
||||
from WebHostLib.api import api_endpoints
|
||||
from WebHostLib.models import Room
|
||||
from WebHostLib.tracker import TrackerData
|
||||
|
||||
|
||||
class PlayerAlias(TypedDict):
|
||||
team: int
|
||||
player: int
|
||||
alias: str | None
|
||||
|
||||
|
||||
class PlayerItemsReceived(TypedDict):
|
||||
team: int
|
||||
player: int
|
||||
items: list[NetworkItem]
|
||||
|
||||
|
||||
class PlayerChecksDone(TypedDict):
|
||||
team: int
|
||||
player: int
|
||||
locations: list[int]
|
||||
|
||||
|
||||
class TeamTotalChecks(TypedDict):
|
||||
team: int
|
||||
checks_done: int
|
||||
|
||||
|
||||
class PlayerHints(TypedDict):
|
||||
team: int
|
||||
player: int
|
||||
hints: list[Hint]
|
||||
|
||||
|
||||
class PlayerTimer(TypedDict):
|
||||
team: int
|
||||
player: int
|
||||
time: datetime | None
|
||||
|
||||
|
||||
class PlayerStatus(TypedDict):
|
||||
team: int
|
||||
player: int
|
||||
status: ClientStatus
|
||||
|
||||
|
||||
class PlayerLocationsTotal(TypedDict):
|
||||
team: int
|
||||
player: int
|
||||
total_locations: int
|
||||
|
||||
|
||||
class PlayerGame(TypedDict):
|
||||
team: int
|
||||
player: int
|
||||
game: str
|
||||
|
||||
|
||||
@api_endpoints.route("/tracker/<suuid:tracker>")
|
||||
@cache.memoize(timeout=60)
|
||||
def tracker_data(tracker: UUID) -> dict[str, Any]:
|
||||
"""
|
||||
Outputs json data to <root_path>/api/tracker/<id of current session tracker>.
|
||||
|
||||
:param tracker: UUID of current session tracker.
|
||||
|
||||
:return: Tracking data for all players in the room. Typing and docstrings describe the format of each value.
|
||||
"""
|
||||
room: Room | None = Room.get(tracker=tracker)
|
||||
if not room:
|
||||
abort(404)
|
||||
|
||||
tracker_data = TrackerData(room)
|
||||
|
||||
all_players: dict[int, list[int]] = tracker_data.get_all_players()
|
||||
|
||||
player_aliases: list[PlayerAlias] = []
|
||||
"""Slot aliases of all players."""
|
||||
for team, players in all_players.items():
|
||||
for player in players:
|
||||
player_aliases.append(
|
||||
{"team": team, "player": player, "alias": tracker_data.get_player_alias(team, player)})
|
||||
|
||||
player_items_received: list[PlayerItemsReceived] = []
|
||||
"""Items received by each player."""
|
||||
for team, players in all_players.items():
|
||||
for player in players:
|
||||
player_items_received.append(
|
||||
{"team": team, "player": player, "items": tracker_data.get_player_received_items(team, player)})
|
||||
|
||||
player_checks_done: list[PlayerChecksDone] = []
|
||||
"""ID of all locations checked by each player."""
|
||||
for team, players in all_players.items():
|
||||
for player in players:
|
||||
player_checks_done.append(
|
||||
{"team": team, "player": player,
|
||||
"locations": sorted(tracker_data.get_player_checked_locations(team, player))})
|
||||
|
||||
total_checks_done: list[TeamTotalChecks] = [
|
||||
{"team": team, "checks_done": checks_done}
|
||||
for team, checks_done in tracker_data.get_team_locations_checked_count().items()
|
||||
]
|
||||
"""Total number of locations checked for the entire multiworld per team."""
|
||||
|
||||
hints: list[PlayerHints] = []
|
||||
"""Hints that all players have used or received."""
|
||||
for team, players in tracker_data.get_all_slots().items():
|
||||
for player in players:
|
||||
player_hints = sorted(tracker_data.get_player_hints(team, player))
|
||||
hints.append({"team": team, "player": player, "hints": player_hints})
|
||||
slot_info = tracker_data.get_slot_info(player)
|
||||
# this assumes groups are always after players
|
||||
if slot_info.type != SlotType.group:
|
||||
continue
|
||||
for member in slot_info.group_members:
|
||||
hints[member - 1]["hints"] += player_hints
|
||||
|
||||
activity_timers: list[PlayerTimer] = []
|
||||
"""Time of last activity per player. Returned as RFC 1123 format and null if no connection has been made."""
|
||||
for team, players in all_players.items():
|
||||
for player in players:
|
||||
activity_timers.append({"team": team, "player": player, "time": None})
|
||||
|
||||
for (team, player), timestamp in tracker_data._multisave.get("client_activity_timers", []):
|
||||
for entry in activity_timers:
|
||||
if entry["team"] == team and entry["player"] == player:
|
||||
entry["time"] = datetime.fromtimestamp(timestamp, timezone.utc)
|
||||
break
|
||||
|
||||
connection_timers: list[PlayerTimer] = []
|
||||
"""Time of last connection per player. Returned as RFC 1123 format and null if no connection has been made."""
|
||||
for team, players in all_players.items():
|
||||
for player in players:
|
||||
connection_timers.append({"team": team, "player": player, "time": None})
|
||||
|
||||
for (team, player), timestamp in tracker_data._multisave.get("client_connection_timers", []):
|
||||
# find the matching entry
|
||||
for entry in connection_timers:
|
||||
if entry["team"] == team and entry["player"] == player:
|
||||
entry["time"] = datetime.fromtimestamp(timestamp, timezone.utc)
|
||||
break
|
||||
|
||||
player_status: list[PlayerStatus] = []
|
||||
"""The current client status for each player."""
|
||||
for team, players in all_players.items():
|
||||
for player in players:
|
||||
player_status.append(
|
||||
{"team": team, "player": player, "status": tracker_data.get_player_client_status(team, player)})
|
||||
|
||||
return {
|
||||
"aliases": player_aliases,
|
||||
"player_items_received": player_items_received,
|
||||
"player_checks_done": player_checks_done,
|
||||
"total_checks_done": total_checks_done,
|
||||
"hints": hints,
|
||||
"activity_timers": activity_timers,
|
||||
"connection_timers": connection_timers,
|
||||
"player_status": player_status,
|
||||
}
|
||||
|
||||
|
||||
class PlayerGroups(TypedDict):
|
||||
slot: int
|
||||
name: str
|
||||
members: list[int]
|
||||
|
||||
|
||||
class PlayerSlotData(TypedDict):
|
||||
player: int
|
||||
slot_data: dict[str, Any]
|
||||
|
||||
|
||||
@api_endpoints.route("/static_tracker/<suuid:tracker>")
|
||||
@cache.memoize(timeout=300)
|
||||
def static_tracker_data(tracker: UUID) -> dict[str, Any]:
|
||||
"""
|
||||
Outputs json data to <root_path>/api/static_tracker/<id of current session tracker>.
|
||||
|
||||
:param tracker: UUID of current session tracker.
|
||||
|
||||
:return: Static tracking data for all players in the room. Typing and docstrings describe the format of each value.
|
||||
"""
|
||||
room: Room | None = Room.get(tracker=tracker)
|
||||
if not room:
|
||||
abort(404)
|
||||
tracker_data = TrackerData(room)
|
||||
|
||||
all_players: dict[int, list[int]] = tracker_data.get_all_players()
|
||||
|
||||
groups: list[PlayerGroups] = []
|
||||
"""The Slot ID of groups and the IDs of the group's members."""
|
||||
for team, players in tracker_data.get_all_slots().items():
|
||||
for player in players:
|
||||
slot_info = tracker_data.get_slot_info(player)
|
||||
if slot_info.type != SlotType.group or not slot_info.group_members:
|
||||
continue
|
||||
groups.append(
|
||||
{
|
||||
"slot": player,
|
||||
"name": slot_info.name,
|
||||
"members": list(slot_info.group_members),
|
||||
})
|
||||
break
|
||||
|
||||
player_locations_total: list[PlayerLocationsTotal] = []
|
||||
for team, players in all_players.items():
|
||||
for player in players:
|
||||
player_locations_total.append(
|
||||
{"team": team, "player": player, "total_locations": len(tracker_data.get_player_locations(player))})
|
||||
|
||||
player_game: list[PlayerGame] = []
|
||||
"""The played game per player slot."""
|
||||
for team, players in all_players.items():
|
||||
for player in players:
|
||||
player_game.append({"team": team, "player": player, "game": tracker_data.get_player_game(player)})
|
||||
|
||||
return {
|
||||
"groups": groups,
|
||||
"datapackage": tracker_data._multidata["datapackage"],
|
||||
"player_locations_total": player_locations_total,
|
||||
"player_game": player_game,
|
||||
}
|
||||
|
||||
|
||||
# It should be exceedingly rare that slot data is needed, so it's separated out.
|
||||
@api_endpoints.route("/slot_data_tracker/<suuid:tracker>")
|
||||
@cache.memoize(timeout=300)
|
||||
def tracker_slot_data(tracker: UUID) -> list[PlayerSlotData]:
|
||||
"""
|
||||
Outputs json data to <root_path>/api/slot_data_tracker/<id of current session tracker>.
|
||||
|
||||
:param tracker: UUID of current session tracker.
|
||||
|
||||
:return: Slot data for all players in the room. Typing completely arbitrary per game.
|
||||
"""
|
||||
room: Room | None = Room.get(tracker=tracker)
|
||||
if not room:
|
||||
abort(404)
|
||||
tracker_data = TrackerData(room)
|
||||
|
||||
all_players: dict[int, list[int]] = tracker_data.get_all_players()
|
||||
|
||||
slot_data: list[PlayerSlotData] = []
|
||||
"""Slot data for each player."""
|
||||
for team, players in all_players.items():
|
||||
for player in players:
|
||||
slot_data.append({"player": player, "slot_data": tracker_data.get_slot_data(player)})
|
||||
break
|
||||
|
||||
return slot_data
|
||||
@@ -1,7 +1,6 @@
|
||||
from flask import session, jsonify
|
||||
from pony.orm import select
|
||||
|
||||
from WebHostLib import to_url
|
||||
from WebHostLib.models import Room, Seed
|
||||
from . import api_endpoints, get_players
|
||||
|
||||
@@ -11,13 +10,13 @@ def get_rooms():
|
||||
response = []
|
||||
for room in select(room for room in Room if room.owner == session["_id"]):
|
||||
response.append({
|
||||
"room_id": to_url(room.id),
|
||||
"seed_id": to_url(room.seed.id),
|
||||
"room_id": room.id,
|
||||
"seed_id": room.seed.id,
|
||||
"creation_time": room.creation_time,
|
||||
"last_activity": room.last_activity,
|
||||
"last_port": room.last_port,
|
||||
"timeout": room.timeout,
|
||||
"tracker": to_url(room.tracker),
|
||||
"tracker": room.tracker,
|
||||
})
|
||||
return jsonify(response)
|
||||
|
||||
@@ -27,8 +26,8 @@ def get_seeds():
|
||||
response = []
|
||||
for seed in select(seed for seed in Seed if seed.owner == session["_id"]):
|
||||
response.append({
|
||||
"seed_id": to_url(seed.id),
|
||||
"seed_id": seed.id,
|
||||
"creation_time": seed.creation_time,
|
||||
"players": get_players(seed),
|
||||
"players": get_players(seed.slots),
|
||||
})
|
||||
return jsonify(response)
|
||||
return jsonify(response)
|
||||
@@ -3,26 +3,25 @@ from __future__ import annotations
|
||||
import json
|
||||
import logging
|
||||
import multiprocessing
|
||||
import threading
|
||||
import time
|
||||
import typing
|
||||
from datetime import timedelta, datetime
|
||||
from threading import Event, Thread
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from pony.orm import db_session, select, commit, PrimaryKey
|
||||
from pony.orm import db_session, select, commit
|
||||
|
||||
from Utils import restricted_loads
|
||||
from .locker import Locker, AlreadyRunningException
|
||||
|
||||
_stop_event = Event()
|
||||
|
||||
def launch_room(room: Room, config: dict):
|
||||
# requires db_session!
|
||||
if room.last_activity >= datetime.utcnow() - timedelta(seconds=room.timeout):
|
||||
multiworld = multiworlds.get(room.id, None)
|
||||
if not multiworld:
|
||||
multiworld = MultiworldInstance(room, config)
|
||||
|
||||
def stop() -> None:
|
||||
"""Stops previously launched threads"""
|
||||
global _stop_event
|
||||
stop_event = _stop_event
|
||||
_stop_event = Event() # new event for new threads
|
||||
stop_event.set()
|
||||
multiworld.start()
|
||||
|
||||
|
||||
def handle_generation_success(seed_id):
|
||||
@@ -36,39 +35,16 @@ def handle_generation_failure(result: BaseException):
|
||||
logging.exception(e)
|
||||
|
||||
|
||||
def _mp_gen_game(
|
||||
gen_options: dict,
|
||||
meta: dict[str, Any] | None = None,
|
||||
owner=None,
|
||||
sid=None,
|
||||
timeout: int|None = None,
|
||||
) -> PrimaryKey | None:
|
||||
from setproctitle import setproctitle
|
||||
|
||||
setproctitle(f"Generator ({sid})")
|
||||
try:
|
||||
return gen_game(gen_options, meta=meta, owner=owner, sid=sid, timeout=timeout)
|
||||
finally:
|
||||
setproctitle(f"Generator (idle)")
|
||||
|
||||
|
||||
def launch_generator(pool: multiprocessing.pool.Pool, generation: Generation, timeout: int|None) -> None:
|
||||
def launch_generator(pool: multiprocessing.pool.Pool, generation: Generation):
|
||||
try:
|
||||
meta = json.loads(generation.meta)
|
||||
options = restricted_loads(generation.options)
|
||||
logging.info(f"Generating {generation.id} for {len(options)} players")
|
||||
pool.apply_async(
|
||||
_mp_gen_game,
|
||||
(options,),
|
||||
{
|
||||
"meta": meta,
|
||||
"sid": generation.id,
|
||||
"owner": generation.owner,
|
||||
"timeout": timeout,
|
||||
},
|
||||
handle_generation_success,
|
||||
handle_generation_failure,
|
||||
)
|
||||
pool.apply_async(gen_game, (options,),
|
||||
{"meta": meta,
|
||||
"sid": generation.id,
|
||||
"owner": generation.owner},
|
||||
handle_generation_success, handle_generation_failure)
|
||||
except Exception as e:
|
||||
generation.state = STATE_ERROR
|
||||
commit()
|
||||
@@ -77,79 +53,39 @@ def launch_generator(pool: multiprocessing.pool.Pool, generation: Generation, ti
|
||||
generation.state = STATE_STARTED
|
||||
|
||||
|
||||
def init_generator(config: dict[str, Any]) -> None:
|
||||
from setproctitle import setproctitle
|
||||
|
||||
setproctitle("Generator (idle)")
|
||||
|
||||
try:
|
||||
import resource
|
||||
except ModuleNotFoundError:
|
||||
pass # unix only module
|
||||
else:
|
||||
# set soft limit for memory to from config (default 4GiB)
|
||||
soft_limit = config["GENERATOR_MEMORY_LIMIT"]
|
||||
old_limit, hard_limit = resource.getrlimit(resource.RLIMIT_AS)
|
||||
if soft_limit != old_limit:
|
||||
resource.setrlimit(resource.RLIMIT_AS, (soft_limit, hard_limit))
|
||||
logging.debug(f"Changed AS mem limit {old_limit} -> {soft_limit}")
|
||||
del resource, soft_limit, hard_limit
|
||||
|
||||
pony_config = config["PONY"]
|
||||
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:
|
||||
job_time = config["JOB_TIME"]
|
||||
with multiprocessing.Pool(config["GENERATORS"], initializer=init_db,
|
||||
initargs=(config["PONY"],), maxtasksperchild=10) as generator_pool:
|
||||
with db_session:
|
||||
to_start = select(generation for generation in Generation if generation.state == STATE_STARTED)
|
||||
|
||||
@@ -160,58 +96,54 @@ def autogen(config: dict):
|
||||
if sid:
|
||||
generation.delete()
|
||||
else:
|
||||
launch_generator(generator_pool, generation, timeout=job_time)
|
||||
launch_generator(generator_pool, generation)
|
||||
|
||||
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(
|
||||
generation for generation in Generation
|
||||
if generation.state == STATE_QUEUED).for_update()
|
||||
for generation in to_start:
|
||||
launch_generator(generator_pool, generation, timeout=job_time)
|
||||
launch_generator(generator_pool, generation)
|
||||
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(),
|
||||
self.cert, self.key, self.host),
|
||||
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()
|
||||
@@ -225,6 +157,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,7 +1,7 @@
|
||||
import os
|
||||
import zipfile
|
||||
import base64
|
||||
from collections.abc import Set
|
||||
from typing import Union, Dict, Set, Tuple
|
||||
|
||||
from flask import request, flash, redirect, url_for, render_template
|
||||
from markupsafe import Markup
|
||||
@@ -28,7 +28,7 @@ def check():
|
||||
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')}"
|
||||
combined_yaml = "---\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:
|
||||
@@ -43,7 +43,7 @@ def mysterycheck():
|
||||
return redirect(url_for("check"), 301)
|
||||
|
||||
|
||||
def get_yaml_data(files) -> dict[str, str] | str | Markup:
|
||||
def get_yaml_data(files) -> Union[Dict[str, str], str, Markup]:
|
||||
options = {}
|
||||
for uploaded_file in files:
|
||||
if banned_file(uploaded_file.filename):
|
||||
@@ -84,12 +84,12 @@ def get_yaml_data(files) -> dict[str, str] | str | Markup:
|
||||
return options
|
||||
|
||||
|
||||
def roll_options(options: dict[str, dict | str],
|
||||
def roll_options(options: Dict[str, Union[dict, str]],
|
||||
plando_options: Set[str] = frozenset({"bosses", "items", "connections", "texts"})) -> \
|
||||
tuple[dict[str, str | bool], dict[str, dict]]:
|
||||
Tuple[Dict[str, Union[str, bool]], Dict[str, dict]]:
|
||||
plando_options = PlandoOptions.from_set(set(plando_options))
|
||||
results: dict[str, str | bool] = {}
|
||||
rolled_results: dict[str, dict] = {}
|
||||
results = {}
|
||||
rolled_results = {}
|
||||
for filename, text in options.items():
|
||||
try:
|
||||
if type(text) is dict:
|
||||
@@ -105,14 +105,10 @@ def roll_options(options: dict[str, 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 options in {filename}: {e}"
|
||||
else:
|
||||
results[filename] = True
|
||||
return results, rolled_results
|
||||
|
||||
@@ -5,7 +5,6 @@ import collections
|
||||
import datetime
|
||||
import functools
|
||||
import logging
|
||||
import multiprocessing
|
||||
import pickle
|
||||
import random
|
||||
import socket
|
||||
@@ -19,10 +18,7 @@ from pony.orm import commit, db_session, select
|
||||
|
||||
import Utils
|
||||
|
||||
from MultiServer import (
|
||||
Context, server, auto_shutdown, ServerCommandProcessor, ClientMessageProcessor, load_server_cert,
|
||||
server_per_message_deflate_factory,
|
||||
)
|
||||
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
|
||||
@@ -57,56 +53,39 @@ 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)
|
||||
|
||||
async def listen_to_db_commands(self):
|
||||
def listen_to_db_commands(self):
|
||||
cmdprocessor = DBCommandProcessor(self)
|
||||
|
||||
while not self.exit_event.is_set():
|
||||
await self.main_loop.run_in_executor(None, self._process_db_commands, cmdprocessor)
|
||||
try:
|
||||
await asyncio.wait_for(self.exit_event.wait(), 5)
|
||||
except asyncio.TimeoutError:
|
||||
pass
|
||||
|
||||
def _process_db_commands(self, cmdprocessor):
|
||||
with db_session:
|
||||
commands = select(command for command in Command if command.room.id == self.room_id)
|
||||
if commands:
|
||||
for command in commands:
|
||||
self.main_loop.call_soon_threadsafe(cmdprocessor, command.commandtext)
|
||||
command.delete()
|
||||
commit()
|
||||
with db_session:
|
||||
commands = select(command for command in Command if command.room.id == self.room_id)
|
||||
if commands:
|
||||
for command in commands:
|
||||
self.main_loop.call_soon_threadsafe(cmdprocessor, command.commandtext)
|
||||
command.delete()
|
||||
commit()
|
||||
time.sleep(5)
|
||||
|
||||
@db_session
|
||||
def load(self, room_id: int):
|
||||
@@ -119,56 +98,33 @@ class WebHostContext(Context):
|
||||
|
||||
multidata = self.decompress(room.seed.multidata)
|
||||
game_data_packages = {}
|
||||
|
||||
static_gamespackage = self.gamespackage # this is shared across all rooms
|
||||
static_item_name_groups = self.item_name_groups
|
||||
static_location_name_groups = self.location_name_groups
|
||||
self.gamespackage = {"Archipelago": static_gamespackage.get("Archipelago", {})} # this may be modified by _load
|
||||
self.item_name_groups = {"Archipelago": static_item_name_groups.get("Archipelago", {})}
|
||||
self.location_name_groups = {"Archipelago": static_location_name_groups.get("Archipelago", {})}
|
||||
missing_checksum = False
|
||||
|
||||
for game in list(multidata.get("datapackage", {})):
|
||||
game_data = multidata["datapackage"][game]
|
||||
if "checksum" in game_data:
|
||||
if static_gamespackage.get(game, {}).get("checksum") == game_data["checksum"]:
|
||||
# non-custom. remove from multidata and use static data
|
||||
if self.gamespackage.get(game, {}).get("checksum") == game_data["checksum"]:
|
||||
# non-custom. remove from multidata
|
||||
# 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] = restricted_loads(row.data)
|
||||
continue
|
||||
else:
|
||||
self.logger.warning(f"Did not find game_data_package for {game}: {game_data['checksum']}")
|
||||
else:
|
||||
missing_checksum = True # Game rolled on old AP and will load data package from multidata
|
||||
self.gamespackage[game] = static_gamespackage.get(game, {})
|
||||
self.item_name_groups[game] = static_item_name_groups.get(game, {})
|
||||
self.location_name_groups[game] = static_location_name_groups.get(game, {})
|
||||
game_data_packages[game] = Utils.restricted_loads(row.data)
|
||||
|
||||
if not game_data_packages and not missing_checksum:
|
||||
# all static -> use the static dicts directly
|
||||
self.gamespackage = static_gamespackage
|
||||
self.item_name_groups = static_item_name_groups
|
||||
self.location_name_groups = static_location_name_groups
|
||||
return self._load(multidata, game_data_packages, True)
|
||||
|
||||
@db_session
|
||||
def init_save(self, enabled: bool = True):
|
||||
self.saving = enabled
|
||||
if self.saving:
|
||||
with db_session:
|
||||
savegame_data = Room.get(id=self.room_id).multisave
|
||||
if savegame_data:
|
||||
self.set_save(restricted_loads(savegame_data))
|
||||
self._start_async_saving(atexit_save=False)
|
||||
asyncio.create_task(self.listen_to_db_commands())
|
||||
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()
|
||||
threading.Thread(target=self.listen_to_db_commands, daemon=True).start()
|
||||
|
||||
@db_session
|
||||
def _save(self, exit_save: bool = False) -> bool:
|
||||
room = Room.get(id=self.room_id)
|
||||
# Does not use Utils.restricted_dumps because we'd rather make a save than not make one
|
||||
room.multisave = pickle.dumps(self.get_save())
|
||||
# saving only occurs on activity, so we can "abuse" this information to mark this as last_activity
|
||||
if not exit_save: # we don't want to count a shutdown as activity, which would restart the server again
|
||||
@@ -189,222 +145,86 @@ 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()
|
||||
},
|
||||
"non_hintable_names": {},
|
||||
"gamespackage": worlds.network_data_package["games"],
|
||||
"item_name_groups": {world_name: world.item_name_groups for world_name, world in
|
||||
worlds.AutoWorldRegister.world_types.items()},
|
||||
"location_name_groups": {world_name: world.location_name_groups for world_name, world in
|
||||
worlds.AutoWorldRegister.world_types.items()},
|
||||
}
|
||||
|
||||
for world_name, world in worlds.AutoWorldRegister.world_types.items():
|
||||
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 tear_down_logging(room_id):
|
||||
"""Close logging handling for a room."""
|
||||
logger_name = f"RoomLogger {room_id}"
|
||||
if logger_name in logging.Logger.manager.loggerDict:
|
||||
logger = logging.getLogger(logger_name)
|
||||
for handler in logger.handlers[:]:
|
||||
logger.removeHandler(handler)
|
||||
handler.close()
|
||||
del logging.Logger.manager.loggerDict[logger_name]
|
||||
|
||||
|
||||
def run_server_process(name: str, ponyconfig: dict, static_server_data: dict,
|
||||
def run_server_process(room_id, 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):
|
||||
from setproctitle import setproctitle
|
||||
|
||||
setproctitle(name)
|
||||
Utils.init_logging(name)
|
||||
try:
|
||||
import resource
|
||||
except ModuleNotFoundError:
|
||||
pass # unix only module
|
||||
else:
|
||||
# Each Server is another file handle, so request as many as we can from the system
|
||||
file_limit = resource.getrlimit(resource.RLIMIT_NOFILE)[1]
|
||||
# set soft limit to hard limit
|
||||
resource.setrlimit(resource.RLIMIT_NOFILE, (file_limit, file_limit))
|
||||
del resource, file_limit
|
||||
|
||||
host: str):
|
||||
# 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():
|
||||
if "worlds" in sys.modules:
|
||||
raise Exception("Worlds system should not be loaded in the custom server.")
|
||||
|
||||
import gc
|
||||
import gc
|
||||
Utils.init_logging(str(room_id), write_mode="a")
|
||||
ctx = WebHostContext(static_server_data)
|
||||
ctx.load(room_id)
|
||||
ctx.init_save()
|
||||
ssl_context = load_server_cert(cert_file, cert_key_file) if cert_file else None
|
||||
gc.collect() # free intermediate objects used during setup
|
||||
try:
|
||||
ctx.server = websockets.serve(functools.partial(server, ctx=ctx), ctx.host, ctx.port, ssl=ssl_context)
|
||||
|
||||
if not cert_file:
|
||||
def get_ssl_context():
|
||||
return None
|
||||
else:
|
||||
load_date = None
|
||||
ssl_context = load_server_cert(cert_file, cert_key_file)
|
||||
await ctx.server
|
||||
except OSError: # likely port in use
|
||||
ctx.server = websockets.serve(functools.partial(server, ctx=ctx), ctx.host, 0, ssl=ssl_context)
|
||||
|
||||
def get_ssl_context():
|
||||
nonlocal load_date, ssl_context
|
||||
today = datetime.date.today()
|
||||
if load_date != today:
|
||||
ssl_context = load_server_cert(cert_file, cert_key_file)
|
||||
load_date = today
|
||||
return ssl_context
|
||||
await ctx.server
|
||||
port = 0
|
||||
for wssocket in ctx.server.ws_server.sockets:
|
||||
socketname = wssocket.getsockname()
|
||||
if wssocket.family == socket.AF_INET6:
|
||||
# Prefer IPv4, as most users seem to not have working ipv6 support
|
||||
if not port:
|
||||
port = socketname[1]
|
||||
elif wssocket.family == socket.AF_INET:
|
||||
port = socketname[1]
|
||||
if port:
|
||||
logging.info(f'Hosting game at {host}:{port}')
|
||||
with db_session:
|
||||
room = Room.get(id=ctx.room_id)
|
||||
room.last_port = port
|
||||
else:
|
||||
logging.exception("Could not determine port. Likely hosting failure.")
|
||||
with db_session:
|
||||
ctx.auto_shutdown = Room.get(id=room_id).timeout
|
||||
ctx.shutdown_task = asyncio.create_task(auto_shutdown(ctx, []))
|
||||
await ctx.shutdown_task
|
||||
|
||||
del ponyconfig
|
||||
gc.collect() # free intermediate objects used during setup
|
||||
# ensure auto launch is on the same page in regard to room activity.
|
||||
with db_session:
|
||||
room: Room = Room.get(id=ctx.room_id)
|
||||
room.last_activity = datetime.datetime.utcnow() - datetime.timedelta(seconds=room.timeout + 60)
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
logging.info("Shutting down")
|
||||
|
||||
async def start_room(room_id):
|
||||
with Locker(f"RoomLocker {room_id}"):
|
||||
try:
|
||||
logger = set_up_logging(room_id)
|
||||
ctx = WebHostContext(static_server_data, logger)
|
||||
ctx.load(room_id)
|
||||
ctx.init_save()
|
||||
assert ctx.server is None
|
||||
try:
|
||||
ctx.server = websockets.serve(
|
||||
functools.partial(server, ctx=ctx),
|
||||
ctx.host,
|
||||
ctx.port,
|
||||
ssl=get_ssl_context(),
|
||||
extensions=[server_per_message_deflate_factory],
|
||||
)
|
||||
await ctx.server
|
||||
except OSError: # likely port in use
|
||||
ctx.server = websockets.serve(
|
||||
functools.partial(server, ctx=ctx), ctx.host, 0, ssl=get_ssl_context())
|
||||
|
||||
await ctx.server
|
||||
port = 0
|
||||
for wssocket in ctx.server.ws_server.sockets:
|
||||
socketname = wssocket.getsockname()
|
||||
if wssocket.family == socket.AF_INET6:
|
||||
# Prefer IPv4, as most users seem to not have working ipv6 support
|
||||
if not port:
|
||||
port = socketname[1]
|
||||
elif wssocket.family == socket.AF_INET:
|
||||
port = socketname[1]
|
||||
if port:
|
||||
ctx.logger.info(f'Hosting game at {host}:{port}')
|
||||
with db_session:
|
||||
room = Room.get(id=ctx.room_id)
|
||||
room.last_port = port
|
||||
del room
|
||||
else:
|
||||
ctx.logger.exception("Could not determine port. Likely hosting failure.")
|
||||
with db_session:
|
||||
ctx.auto_shutdown = Room.get(id=room_id).timeout
|
||||
if ctx.saving:
|
||||
setattr(asyncio.current_task(), "save", lambda: ctx._save(True))
|
||||
assert ctx.shutdown_task is None
|
||||
ctx.shutdown_task = asyncio.create_task(auto_shutdown(ctx, []))
|
||||
await ctx.shutdown_task
|
||||
|
||||
except (KeyboardInterrupt, SystemExit):
|
||||
if ctx.saving:
|
||||
ctx._save(True)
|
||||
setattr(asyncio.current_task(), "save", None)
|
||||
except Exception as e:
|
||||
with db_session:
|
||||
room = Room.get(id=room_id)
|
||||
room.last_port = -1
|
||||
del room
|
||||
logger.exception(e)
|
||||
raise
|
||||
else:
|
||||
if ctx.saving:
|
||||
ctx._save(True)
|
||||
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
|
||||
|
||||
if ctx.server and hasattr(ctx.server, "ws_server"):
|
||||
ctx.server.ws_server.close()
|
||||
await ctx.server.ws_server.wait_closed()
|
||||
|
||||
with db_session:
|
||||
# ensure the Room does not spin up again on its own, minute of safety buffer
|
||||
room = Room.get(id=room_id)
|
||||
room.last_activity = datetime.datetime.utcnow() - \
|
||||
datetime.timedelta(minutes=1, seconds=room.timeout)
|
||||
del room
|
||||
tear_down_logging(room_id)
|
||||
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()
|
||||
with Locker(room_id):
|
||||
try:
|
||||
asyncio.run(main())
|
||||
except (KeyboardInterrupt, SystemExit):
|
||||
with db_session:
|
||||
room = Room.get(id=room_id)
|
||||
# ensure the Room does not spin up again on its own, minute of safety buffer
|
||||
room.last_activity = datetime.datetime.utcnow() - datetime.timedelta(minutes=1, seconds=room.timeout)
|
||||
except Exception:
|
||||
with db_session:
|
||||
room = Room.get(id=room_id)
|
||||
room.last_port = -1
|
||||
# ensure the Room does not spin up again on its own, minute of safety buffer
|
||||
room.last_activity = datetime.datetime.utcnow() - datetime.timedelta(minutes=1, seconds=room.timeout)
|
||||
raise
|
||||
|
||||
@@ -61,7 +61,12 @@ def download_slot_file(room_id, player_id: int):
|
||||
else:
|
||||
import io
|
||||
|
||||
if slot_data.game == "Factorio":
|
||||
if slot_data.game == "Minecraft":
|
||||
from worlds.minecraft import mc_update_output
|
||||
fname = f"AP_{app.jinja_env.filters['suuid'](room_id)}_P{slot_data.player_id}_{slot_data.player_name}.apmc"
|
||||
data = mc_update_output(slot_data.data, server=app.config['HOST_ADDRESS'], port=room.last_port)
|
||||
return send_file(io.BytesIO(data), as_attachment=True, download_name=fname)
|
||||
elif slot_data.game == "Factorio":
|
||||
with zipfile.ZipFile(io.BytesIO(slot_data.data)) as zf:
|
||||
for name in zf.namelist():
|
||||
if name.endswith("info.json"):
|
||||
|
||||
@@ -1,45 +1,47 @@
|
||||
import concurrent.futures
|
||||
import json
|
||||
import os
|
||||
import pickle
|
||||
import random
|
||||
import tempfile
|
||||
import zipfile
|
||||
from collections import Counter
|
||||
from pickle import PicklingError
|
||||
from typing import Any
|
||||
from typing import Any, Dict, List, Optional, Union
|
||||
|
||||
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, mystery_argparse
|
||||
from Generate import PlandoOptions, handle_name
|
||||
from Main import main as ERmain
|
||||
from Utils import __version__, restricted_dumps, DaemonThreadPoolExecutor
|
||||
from Utils import __version__
|
||||
from WebHostLib import app
|
||||
from settings import ServerOptions, GeneratorOptions
|
||||
from worlds.alttp.EntranceRandomizer import parse_arguments
|
||||
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, 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, race: bool = False) -> Dict[str, Union[List[str], Dict[str, Any]]]:
|
||||
plando_options = {
|
||||
options_source.get("plando_bosses", ""),
|
||||
options_source.get("plando_items", ""),
|
||||
options_source.get("plando_connections", ""),
|
||||
options_source.get("plando_texts", "")
|
||||
}
|
||||
plando_options -= {""}
|
||||
|
||||
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)),
|
||||
"countdown_mode": str(options_source.get("countdown_mode", ServerOptions.countdown_mode)),
|
||||
"item_cheat": bool(int(options_source.get("item_cheat", not ServerOptions.disable_item_cheat))),
|
||||
"server_password": str(options_source.get("server_password", None)),
|
||||
"hint_cost": int(options_source.get("hint_cost", 10)),
|
||||
"release_mode": options_source.get("release_mode", "goal"),
|
||||
"remaining_mode": options_source.get("remaining_mode", "disabled"),
|
||||
"collect_mode": options_source.get("collect_mode", "disabled"),
|
||||
"item_cheat": bool(int(options_source.get("item_cheat", 1))),
|
||||
"server_password": options_source.get("server_password", None),
|
||||
}
|
||||
generator_options = {
|
||||
"spoiler": int(options_source.get("spoiler", GeneratorOptions.spoiler)),
|
||||
"race": race,
|
||||
"spoiler": int(options_source.get("spoiler", 0)),
|
||||
"race": race
|
||||
}
|
||||
|
||||
if race:
|
||||
@@ -68,59 +70,40 @@ def generate(race=False):
|
||||
flash(options)
|
||||
else:
|
||||
meta = get_meta(request.form, race)
|
||||
return start_generation(options, meta)
|
||||
results, gen_options = roll_options(options, set(meta["plando_options"]))
|
||||
|
||||
if any(type(result) == str for result in results.values()):
|
||||
return render_template("checkResult.html", results=results)
|
||||
elif len(gen_options) > app.config["MAX_ROLL"]:
|
||||
flash(f"Sorry, generating of multiworlds is limited to {app.config['MAX_ROLL']} players. "
|
||||
f"If you have a larger group, please generate it yourself and upload it.")
|
||||
elif len(gen_options) >= app.config["JOB_THRESHOLD"]:
|
||||
gen = Generation(
|
||||
options=pickle.dumps({name: vars(options) for name, options in gen_options.items()}),
|
||||
# convert to json compatible
|
||||
meta=json.dumps(meta),
|
||||
state=STATE_QUEUED,
|
||||
owner=session["_id"])
|
||||
commit()
|
||||
|
||||
return redirect(url_for("wait_seed", seed=gen.id))
|
||||
else:
|
||||
try:
|
||||
seed_id = gen_game({name: vars(options) for name, options in gen_options.items()},
|
||||
meta=meta, owner=session["_id"].int)
|
||||
except BaseException as e:
|
||||
from .autolauncher import handle_generation_failure
|
||||
handle_generation_failure(e)
|
||||
return render_template("seedError.html", seed_error=(e.__class__.__name__ + ": " + str(e)))
|
||||
|
||||
return redirect(url_for("view_seed", seed=seed_id))
|
||||
|
||||
return render_template("generate.html", race=race, version=__version__)
|
||||
|
||||
|
||||
def format_exception(e: BaseException) -> str:
|
||||
return f"{e.__class__.__name__}: {e}"
|
||||
|
||||
|
||||
def start_generation(options: dict[str, dict | str], meta: dict[str, Any]):
|
||||
results, gen_options = roll_options(options, set(meta["plando_options"]))
|
||||
|
||||
if any(type(result) == str for result in results.values()):
|
||||
return render_template("checkResult.html", results=results)
|
||||
elif len(gen_options) > app.config["MAX_ROLL"]:
|
||||
flash(f"Sorry, generating of multiworlds is limited to {app.config['MAX_ROLL']} players. "
|
||||
f"If you have a larger group, please generate it yourself and upload it.")
|
||||
return redirect(url_for(request.endpoint, **(request.view_args or {})))
|
||||
elif len(gen_options) >= app.config["JOB_THRESHOLD"]:
|
||||
try:
|
||||
gen = Generation(
|
||||
options=restricted_dumps({name: vars(options) for name, options in gen_options.items()}),
|
||||
# convert to json compatible
|
||||
meta=json.dumps(meta),
|
||||
state=STATE_QUEUED,
|
||||
owner=session["_id"])
|
||||
except PicklingError as e:
|
||||
from .autolauncher import handle_generation_failure
|
||||
handle_generation_failure(e)
|
||||
meta["error"] = format_exception(e)
|
||||
details = json.dumps(meta, indent=4).strip()
|
||||
return render_template("seedError.html", seed_error=meta["error"], details=details)
|
||||
|
||||
commit()
|
||||
|
||||
return redirect(url_for("wait_seed", seed=gen.id))
|
||||
else:
|
||||
try:
|
||||
seed_id = gen_game({name: vars(options) for name, options in gen_options.items()},
|
||||
meta=meta, owner=session["_id"].int, timeout=app.config["JOB_TIME"])
|
||||
except BaseException as e:
|
||||
from .autolauncher import handle_generation_failure
|
||||
handle_generation_failure(e)
|
||||
meta["error"] = format_exception(e)
|
||||
details = json.dumps(meta, indent=4).strip()
|
||||
return render_template("seedError.html", seed_error=meta["error"], details=details)
|
||||
|
||||
return redirect(url_for("view_seed", seed=seed_id))
|
||||
|
||||
|
||||
def gen_game(gen_options: dict, meta: dict[str, Any] | None = None, owner=None, sid=None, timeout: int|None = None):
|
||||
if meta is None:
|
||||
meta = {}
|
||||
def gen_game(gen_options: dict, 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)
|
||||
@@ -137,47 +120,41 @@ def gen_game(gen_options: dict, meta: dict[str, Any] | None = None, owner=None,
|
||||
|
||||
seedname = "W" + (f"{random.randint(0, pow(10, seeddigits) - 1)}".zfill(seeddigits))
|
||||
|
||||
args = mystery_argparse([]) # Just to set up the Namespace with defaults
|
||||
args.multi = playercount
|
||||
args.seed = seed
|
||||
args.name = {x: "" for x in range(1, playercount + 1)} # only so it can be overwritten in mystery
|
||||
args.spoiler = meta["generator_options"].get("spoiler", 0)
|
||||
args.race = race
|
||||
args.outputname = seedname
|
||||
args.outputpath = target.name
|
||||
args.teams = 1
|
||||
args.plando_options = PlandoOptions.from_set(meta.setdefault("plando_options",
|
||||
{"bosses", "items", "connections", "texts"}))
|
||||
args.skip_prog_balancing = False
|
||||
args.skip_output = False
|
||||
args.spoiler_only = False
|
||||
args.csv_output = False
|
||||
args.sprite = dict.fromkeys(range(1, args.multi+1), None)
|
||||
args.sprite_pool = dict.fromkeys(range(1, args.multi+1), None)
|
||||
erargs = parse_arguments(['--multi', str(playercount)])
|
||||
erargs.seed = seed
|
||||
erargs.name = {x: "" for x in range(1, playercount + 1)} # only so it can be overwritten in mystery
|
||||
erargs.spoiler = meta["generator_options"].get("spoiler", 0)
|
||||
erargs.race = race
|
||||
erargs.outputname = seedname
|
||||
erargs.outputpath = target.name
|
||||
erargs.teams = 1
|
||||
erargs.plando_options = PlandoOptions.from_set(meta.setdefault("plando_options",
|
||||
{"bosses", "items", "connections", "texts"}))
|
||||
erargs.skip_prog_balancing = False
|
||||
erargs.skip_output = False
|
||||
|
||||
name_counter = Counter()
|
||||
for player, (playerfile, settings) in enumerate(gen_options.items(), 1):
|
||||
for k, v in settings.items():
|
||||
if v is not None:
|
||||
if hasattr(args, k):
|
||||
getattr(args, k)[player] = v
|
||||
if hasattr(erargs, k):
|
||||
getattr(erargs, k)[player] = v
|
||||
else:
|
||||
setattr(args, k, {player: v})
|
||||
setattr(erargs, k, {player: v})
|
||||
|
||||
if not args.name[player]:
|
||||
args.name[player] = os.path.splitext(os.path.split(playerfile)[-1])[0]
|
||||
args.name[player] = handle_name(args.name[player], player, name_counter)
|
||||
if len(set(args.name.values())) != len(args.name):
|
||||
raise Exception(f"Names have to be unique. Names: {Counter(args.name.values())}")
|
||||
ERmain(args, seed, baked_server_options=meta["server_options"])
|
||||
if not erargs.name[player]:
|
||||
erargs.name[player] = os.path.splitext(os.path.split(playerfile)[-1])[0]
|
||||
erargs.name[player] = handle_name(erargs.name[player], player, name_counter)
|
||||
if len(set(erargs.name.values())) != len(erargs.name):
|
||||
raise Exception(f"Names have to be unique. Names: {Counter(erargs.name.values())}")
|
||||
ERmain(erargs, seed, baked_server_options=meta["server_options"])
|
||||
|
||||
return upload_to_db(target.name, sid, owner, race)
|
||||
|
||||
thread_pool = DaemonThreadPoolExecutor(max_workers=1)
|
||||
thread_pool = concurrent.futures.ThreadPoolExecutor(max_workers=1)
|
||||
thread = thread_pool.submit(task)
|
||||
|
||||
try:
|
||||
return thread.result(timeout)
|
||||
return thread.result(app.config["JOB_TIME"])
|
||||
except concurrent.futures.TimeoutError as e:
|
||||
if sid:
|
||||
with db_session:
|
||||
@@ -185,14 +162,11 @@ def gen_game(gen_options: dict, meta: dict[str, Any] | None = None, owner=None,
|
||||
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. " +
|
||||
format_exception(e))
|
||||
meta["error"] = (
|
||||
"Allowed time for Generation exceeded, please consider generating locally instead. " +
|
||||
e.__class__.__name__ + ": " + str(e))
|
||||
gen.meta = json.dumps(meta)
|
||||
commit()
|
||||
except (KeyboardInterrupt, SystemExit):
|
||||
# don't update db, retry next time
|
||||
raise
|
||||
except BaseException as e:
|
||||
if sid:
|
||||
with db_session:
|
||||
@@ -200,15 +174,10 @@ def gen_game(gen_options: dict, meta: dict[str, Any] | None = None, owner=None,
|
||||
if gen is not None:
|
||||
gen.state = STATE_ERROR
|
||||
meta = json.loads(gen.meta)
|
||||
meta["error"] = format_exception(e)
|
||||
meta["error"] = (e.__class__.__name__ + ": " + str(e))
|
||||
gen.meta = json.dumps(meta)
|
||||
commit()
|
||||
raise
|
||||
finally:
|
||||
# free resources claimed by thread pool, if possible
|
||||
# NOTE: Timeout depends on the process being killed at some point
|
||||
# since we can't actually cancel a running gen at the moment.
|
||||
thread_pool.shutdown(wait=False, cancel_futures=True)
|
||||
|
||||
|
||||
@app.route('/wait/<suuid:seed>')
|
||||
@@ -222,9 +191,7 @@ def wait_seed(seed: UUID):
|
||||
if not generation:
|
||||
return "Generation not found."
|
||||
elif generation.state == STATE_ERROR:
|
||||
meta = json.loads(generation.meta)
|
||||
details = json.dumps(meta, indent=4).strip()
|
||||
return render_template("seedError.html", seed_error=meta["error"], details=details)
|
||||
return render_template("seedError.html", seed_error=generation.meta)
|
||||
return render_template("waitSeed.html", seed_id=seed_id)
|
||||
|
||||
|
||||
|
||||
@@ -3,10 +3,10 @@ import threading
|
||||
import json
|
||||
|
||||
from Utils import local_path, user_path
|
||||
from worlds.alttp.Rom import Sprite
|
||||
|
||||
|
||||
def update_sprites_lttp():
|
||||
from worlds.alttp.Rom import Sprite
|
||||
from tkinter import Tk
|
||||
from LttPAdjuster import get_image_for_sprite
|
||||
from LttPAdjuster import BackgroundTaskProgress
|
||||
@@ -14,7 +14,7 @@ def update_sprites_lttp():
|
||||
from LttPAdjuster import update_sprites
|
||||
|
||||
# Target directories
|
||||
input_dir = user_path("data", "sprites", "alttp", "remote")
|
||||
input_dir = user_path("data", "sprites", "alttpr")
|
||||
output_dir = local_path("WebHostLib", "static", "generated") # TODO: move to user_path
|
||||
|
||||
os.makedirs(os.path.join(output_dir, "sprites"), exist_ok=True)
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
import re
|
||||
from collections import Counter
|
||||
|
||||
import mistune
|
||||
from werkzeug.utils import secure_filename
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ImgUrlRewriteInlineParser",
|
||||
'render_markdown',
|
||||
]
|
||||
|
||||
|
||||
class ImgUrlRewriteInlineParser(mistune.InlineParser):
|
||||
relative_url_base: str
|
||||
|
||||
def __init__(self, relative_url_base: str, hard_wrap: bool = False) -> None:
|
||||
super().__init__(hard_wrap)
|
||||
self.relative_url_base = relative_url_base
|
||||
|
||||
@staticmethod
|
||||
def _find_game_name_by_folder_name(name: str) -> str | None:
|
||||
from worlds.AutoWorld import AutoWorldRegister
|
||||
|
||||
for world_name, world_type in AutoWorldRegister.world_types.items():
|
||||
if world_type.__module__ == f"worlds.{name}":
|
||||
return world_name
|
||||
return None
|
||||
|
||||
def parse_link(self, m: re.Match[str], state: mistune.InlineState) -> int | None:
|
||||
res = super().parse_link(m, state)
|
||||
if res is not None and state.tokens and state.tokens[-1]["type"] == "image":
|
||||
image_token = state.tokens[-1]
|
||||
url: str = image_token["attrs"]["url"]
|
||||
if not url.startswith("/") and not "://" in url:
|
||||
# replace relative URL to another world's doc folder with the webhost folder layout
|
||||
if url.startswith("../../") and "/docs/" in self.relative_url_base:
|
||||
parts = url.split("/", 4)
|
||||
if parts[2] != ".." and parts[3] == "docs":
|
||||
game_name = self._find_game_name_by_folder_name(parts[2])
|
||||
if game_name is not None:
|
||||
url = "/".join(parts[1:2] + [secure_filename(game_name)] + parts[4:])
|
||||
# change relative URL to point to deployment folder
|
||||
url = f"{self.relative_url_base}/{url}"
|
||||
image_token['attrs']['url'] = url
|
||||
return res
|
||||
|
||||
|
||||
def render_markdown(path: str, img_url_base: str | None = None) -> str:
|
||||
markdown = mistune.create_markdown(
|
||||
escape=False,
|
||||
plugins=[
|
||||
"strikethrough",
|
||||
"footnotes",
|
||||
"table",
|
||||
"speedup",
|
||||
],
|
||||
)
|
||||
|
||||
heading_id_count: Counter[str] = Counter()
|
||||
|
||||
def heading_id(text: str) -> str:
|
||||
nonlocal heading_id_count
|
||||
|
||||
# there is no good way to do this without regex
|
||||
s = re.sub(r"[^\w\- ]", "", text.lower()).replace(" ", "-").strip("-")
|
||||
n = heading_id_count[s]
|
||||
heading_id_count[s] += 1
|
||||
if n > 0:
|
||||
s += f"-{n}"
|
||||
return s
|
||||
|
||||
def id_hook(_: mistune.Markdown, state: mistune.BlockState) -> None:
|
||||
for tok in state.tokens:
|
||||
if tok["type"] == "heading" and tok["attrs"]["level"] < 4:
|
||||
text = tok["text"]
|
||||
assert isinstance(text, str)
|
||||
unique_id = heading_id(text)
|
||||
tok["attrs"]["id"] = unique_id
|
||||
tok["text"] = f"<a href=\"#{unique_id}\">{text}</a>" # make header link to itself
|
||||
|
||||
markdown.before_render_hooks.append(id_hook)
|
||||
if img_url_base:
|
||||
markdown.inline = ImgUrlRewriteInlineParser(img_url_base)
|
||||
|
||||
with open(path, encoding="utf-8-sig") as f:
|
||||
document = f.read()
|
||||
html = markdown(document)
|
||||
assert isinstance(html, str), "Unexpected mistune renderer in render_markdown"
|
||||
return html
|
||||
@@ -1,47 +1,27 @@
|
||||
import datetime
|
||||
import os
|
||||
import warnings
|
||||
from enum import StrEnum
|
||||
from typing import Any, IO, Dict, Iterator, List, Tuple, Union
|
||||
from typing import List, Dict, Union
|
||||
|
||||
import jinja2.exceptions
|
||||
from flask import request, redirect, url_for, render_template, Response, session, abort, send_from_directory
|
||||
from pony.orm import count, commit, db_session
|
||||
from werkzeug.utils import secure_filename
|
||||
|
||||
from worlds.AutoWorld import AutoWorldRegister, World
|
||||
from worlds.AutoWorld import AutoWorldRegister
|
||||
from . import app, cache
|
||||
from .markdown import render_markdown
|
||||
from .models import Seed, Room, Command, UUID, uuid4
|
||||
from Utils import title_sorted
|
||||
|
||||
class WebWorldTheme(StrEnum):
|
||||
DIRT = "dirt"
|
||||
GRASS = "grass"
|
||||
GRASS_FLOWERS = "grassFlowers"
|
||||
ICE = "ice"
|
||||
JUNGLE = "jungle"
|
||||
OCEAN = "ocean"
|
||||
PARTY_TIME = "partyTime"
|
||||
STONE = "stone"
|
||||
|
||||
def get_world_theme(game_name: str) -> str:
|
||||
if game_name not in AutoWorldRegister.world_types:
|
||||
return "grass"
|
||||
chosen_theme = AutoWorldRegister.world_types[game_name].web.theme
|
||||
available_themes = [theme.value for theme in WebWorldTheme]
|
||||
if chosen_theme not in available_themes:
|
||||
warnings.warn(f"Theme '{chosen_theme}' for {game_name} not valid, switching to default 'grass' theme.")
|
||||
return "grass"
|
||||
return chosen_theme
|
||||
|
||||
|
||||
def get_visible_worlds() -> dict[str, type(World)]:
|
||||
worlds = {}
|
||||
for game, world in AutoWorldRegister.world_types.items():
|
||||
if not world.hidden:
|
||||
worlds[game] = world
|
||||
return worlds
|
||||
def get_world_theme(game_name: str):
|
||||
if game_name in AutoWorldRegister.world_types:
|
||||
return AutoWorldRegister.world_types[game_name].web.theme
|
||||
return 'grass'
|
||||
|
||||
|
||||
@app.before_request
|
||||
def register_session():
|
||||
session.permanent = True # technically 31 days after the last visit
|
||||
if not session.get("_id", None):
|
||||
session["_id"] = uuid4() # uniquely identify each session without needing a login
|
||||
|
||||
|
||||
@app.errorhandler(404)
|
||||
@@ -57,107 +37,71 @@ def start_playing():
|
||||
return render_template(f"startPlaying.html")
|
||||
|
||||
|
||||
# TODO for back compat. remove around 0.4.5
|
||||
@app.route("/weighted-settings")
|
||||
def weighted_settings():
|
||||
return redirect("weighted-options", 301)
|
||||
|
||||
|
||||
@app.route("/weighted-options")
|
||||
@cache.cached()
|
||||
def weighted_options():
|
||||
return render_template("weighted-options.html")
|
||||
|
||||
|
||||
# TODO for back compat. remove around 0.4.5
|
||||
@app.route("/games/<string:game>/player-settings")
|
||||
def player_settings(game: str):
|
||||
return redirect(url_for("player_options", game=game), 301)
|
||||
|
||||
|
||||
# Player options pages
|
||||
@app.route("/games/<string:game>/player-options")
|
||||
@cache.cached()
|
||||
def player_options(game: str):
|
||||
return render_template("player-options.html", game=game, theme=get_world_theme(game))
|
||||
|
||||
|
||||
# Game Info Pages
|
||||
@app.route('/games/<string:game>/info/<string:lang>')
|
||||
@cache.cached()
|
||||
def game_info(game, lang):
|
||||
"""Game Info Pages"""
|
||||
try:
|
||||
theme = get_world_theme(game)
|
||||
secure_game_name = secure_filename(game)
|
||||
lang = secure_filename(lang)
|
||||
file_dir = os.path.join(app.static_folder, "generated", "docs", secure_game_name)
|
||||
file_dir_url = url_for("static", filename=f"generated/docs/{secure_game_name}")
|
||||
document = render_markdown(os.path.join(file_dir, f"{lang}_{secure_game_name}.md"), file_dir_url)
|
||||
return render_template(
|
||||
"markdown_document.html",
|
||||
title=f"{game} Guide",
|
||||
html_from_markdown=document,
|
||||
theme=theme,
|
||||
)
|
||||
except FileNotFoundError:
|
||||
return abort(404)
|
||||
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():
|
||||
"""List of supported games"""
|
||||
return render_template("supportedGames.html", worlds=get_visible_worlds())
|
||||
|
||||
|
||||
@app.route('/tutorial/<string:game>/<string:file>')
|
||||
@cache.cached()
|
||||
def tutorial(game: str, file: str):
|
||||
try:
|
||||
theme = get_world_theme(game)
|
||||
secure_game_name = secure_filename(game)
|
||||
file = secure_filename(file)
|
||||
file_dir = os.path.join(app.static_folder, "generated", "docs", secure_game_name)
|
||||
file_dir_url = url_for("static", filename=f"generated/docs/{secure_game_name}")
|
||||
document = render_markdown(os.path.join(file_dir, f"{file}.md"), file_dir_url)
|
||||
return render_template(
|
||||
"markdown_document.html",
|
||||
title=f"{game} Guide",
|
||||
html_from_markdown=document,
|
||||
theme=theme,
|
||||
)
|
||||
except FileNotFoundError:
|
||||
return abort(404)
|
||||
worlds = {}
|
||||
for game, world in AutoWorldRegister.world_types.items():
|
||||
if not world.hidden:
|
||||
worlds[game] = world
|
||||
return render_template("supportedGames.html", worlds=worlds)
|
||||
|
||||
|
||||
@app.route('/tutorial/<string:game>/<string:file>/<string:lang>')
|
||||
def tutorial_redirect(game: str, file: str, lang: str):
|
||||
"""
|
||||
Permanent redirect old tutorial URLs to new ones to keep search engines happy.
|
||||
e.g. /tutorial/Archipelago/setup/en -> /tutorial/Archipelago/setup_en
|
||||
"""
|
||||
return redirect(url_for("tutorial", game=game, file=f"{file}_{lang}"), code=301)
|
||||
@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():
|
||||
tutorials = {}
|
||||
worlds = AutoWorldRegister.world_types
|
||||
for world_name, world_type in worlds.items():
|
||||
current_world = tutorials[world_name] = {}
|
||||
for tutorial in world_type.web.tutorials:
|
||||
current_tutorial = current_world.setdefault(tutorial.tutorial_name, {
|
||||
"description": tutorial.description, "files": {}})
|
||||
current_tutorial["files"][secure_filename(tutorial.file_name).rsplit(".", 1)[0]] = {
|
||||
"authors": tutorial.authors,
|
||||
"language": tutorial.language
|
||||
}
|
||||
|
||||
worlds = dict(
|
||||
title_sorted(
|
||||
worlds.items(), key=lambda element: "\x00" if element[0] == "Archipelago" else worlds[element[0]].game
|
||||
)
|
||||
)
|
||||
|
||||
return render_template("tutorialLanding.html", worlds=worlds, tutorials=tutorials)
|
||||
return render_template("tutorialLanding.html")
|
||||
|
||||
|
||||
@app.route('/faq/<string:lang>/')
|
||||
@cache.cached()
|
||||
def faq(lang: str):
|
||||
document = render_markdown(os.path.join(app.static_folder, "assets", "faq", secure_filename(lang)+".md"))
|
||||
return render_template(
|
||||
"markdown_document.html",
|
||||
title="Frequently Asked Questions",
|
||||
html_from_markdown=document,
|
||||
)
|
||||
def faq(lang):
|
||||
return render_template("faq.html", lang=lang)
|
||||
|
||||
|
||||
@app.route('/glossary/<string:lang>/')
|
||||
@cache.cached()
|
||||
def glossary(lang: str):
|
||||
document = render_markdown(os.path.join(app.static_folder, "assets", "glossary", secure_filename(lang)+".md"))
|
||||
return render_template(
|
||||
"markdown_document.html",
|
||||
title="Glossary",
|
||||
html_from_markdown=document,
|
||||
)
|
||||
def terms(lang):
|
||||
return render_template("glossary.html", lang=lang)
|
||||
|
||||
|
||||
@app.route('/seed/<suuid:seed>')
|
||||
@@ -178,94 +122,48 @@ 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")
|
||||
if os.path.exists(file_path):
|
||||
return Response(_read_log(file_path), mimetype="text/plain;charset=UTF-8")
|
||||
return "Log File does not exist."
|
||||
|
||||
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))
|
||||
|
||||
if now - room.last_activity > datetime.timedelta(minutes=1):
|
||||
# we only set last_activity if needed, otherwise parallel access on /room will cause an internal server error
|
||||
# due to "pony.orm.core.OptimisticCheckError: Object Room was updated outside of current transaction"
|
||||
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) -> Tuple[str, int]:
|
||||
if max_size == 0:
|
||||
return "…", 0
|
||||
try:
|
||||
with open(os.path.join("logs", str(room.id) + ".txt"), "rb") as log:
|
||||
raw_size = 0
|
||||
fragments: List[str] = []
|
||||
for block in _read_log(log):
|
||||
if raw_size + len(block) > max_size:
|
||||
fragments.append("…")
|
||||
break
|
||||
raw_size += len(block)
|
||||
fragments.append(block.decode("utf-8"))
|
||||
return "".join(fragments), raw_size
|
||||
except FileNotFoundError:
|
||||
return "", 0
|
||||
|
||||
return render_template("hostRoom.html", room=room, should_refresh=should_refresh, get_log=get_log)
|
||||
return render_template("hostRoom.html", room=room, should_refresh=should_refresh)
|
||||
|
||||
|
||||
@app.route('/favicon.ico')
|
||||
|
||||
@@ -1,287 +1,188 @@
|
||||
import collections.abc
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from textwrap import dedent
|
||||
from typing import Dict, Union
|
||||
from docutils.core import publish_parts
|
||||
|
||||
import yaml
|
||||
from flask import redirect, render_template, request, Response, abort
|
||||
import typing
|
||||
|
||||
import Options
|
||||
from Utils import local_path
|
||||
from worlds.AutoWorld import AutoWorldRegister
|
||||
from . import app, cache
|
||||
from .generate import get_meta
|
||||
from .misc import get_world_theme
|
||||
|
||||
handled_in_js = {"start_inventory", "local_items", "non_local_items", "start_hints", "start_location_hints",
|
||||
"exclude_locations", "priority_locations"}
|
||||
|
||||
|
||||
def create() -> None:
|
||||
def create():
|
||||
target_folder = local_path("WebHostLib", "static", "generated")
|
||||
yaml_folder = os.path.join(target_folder, "configs")
|
||||
|
||||
Options.generate_yaml_templates(yaml_folder)
|
||||
|
||||
def get_html_doc(option_type: type(Options.Option)) -> str:
|
||||
if not option_type.__doc__:
|
||||
return "Please document me!"
|
||||
return "\n".join(line.strip() for line in option_type.__doc__.split("\n")).strip()
|
||||
|
||||
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
|
||||
weighted_options = {
|
||||
"baseOptions": {
|
||||
"description": "Generated by https://archipelago.gg/",
|
||||
"name": "",
|
||||
"game": {},
|
||||
},
|
||||
"games": {},
|
||||
}
|
||||
|
||||
start_collapsed = {"Game Options": False}
|
||||
for group in world.web.option_groups:
|
||||
start_collapsed[group.name] = group.start_collapsed
|
||||
for game_name, world in AutoWorldRegister.world_types.items():
|
||||
|
||||
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),
|
||||
)
|
||||
all_options: typing.Dict[str, Options.AssembleOptions] = world.options_dataclass.type_hints
|
||||
|
||||
|
||||
def generate_game(options: Dict[str, Union[dict, str]]) -> Union[Response, str]:
|
||||
from .generate import start_generation
|
||||
return start_generation(options, get_meta({}))
|
||||
|
||||
|
||||
def send_yaml(player_name: str, formatted_options: dict) -> Response:
|
||||
response = Response(yaml.dump(formatted_options, sort_keys=False))
|
||||
response.headers["Content-Type"] = "text/yaml"
|
||||
response.headers["Content-Disposition"] = f"attachment; filename={player_name}.yaml"
|
||||
return response
|
||||
|
||||
|
||||
@app.template_filter("dedent")
|
||||
def filter_dedent(text: str) -> str:
|
||||
return dedent(text).strip("\n ")
|
||||
|
||||
|
||||
@app.template_filter("rst_to_html")
|
||||
def filter_rst_to_html(text: str) -> str:
|
||||
"""Converts reStructuredText (such as a Python docstring) to HTML."""
|
||||
if text.startswith(" ") or text.startswith("\t"):
|
||||
text = dedent(text)
|
||||
elif "\n" in text:
|
||||
lines = text.splitlines()
|
||||
text = lines[0] + "\n" + dedent("\n".join(lines[1:]))
|
||||
|
||||
return publish_parts(text, writer='html', settings=None, settings_overrides={
|
||||
'raw_enable': False,
|
||||
'file_insertion_enabled': False,
|
||||
'output_encoding': 'unicode'
|
||||
})['body']
|
||||
|
||||
|
||||
@app.template_test("ordered")
|
||||
def test_ordered(obj):
|
||||
return isinstance(obj, collections.abc.Sequence)
|
||||
|
||||
|
||||
@app.route("/games/<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.OptionCounter)):
|
||||
presets[preset_name][preset_option_name] = option.value
|
||||
elif isinstance(preset_option, str):
|
||||
# Ensure the option value is valid for Choice and Toggle options
|
||||
assert option.name_lookup[option.value] == preset_option, \
|
||||
f"Invalid option value '{preset_option}' for '{preset_option_name}' in preset '{preset_name}'. " \
|
||||
f"Values must not be resolved to a different option via option.from_text (or an alias)."
|
||||
# Use the name of the option
|
||||
presets[preset_name][preset_option_name] = option.current_key
|
||||
else:
|
||||
# Use the name of the option
|
||||
presets[preset_name][preset_option_name] = option.current_key
|
||||
|
||||
class SetEncoder(json.JSONEncoder):
|
||||
def default(self, obj):
|
||||
from collections.abc import Set
|
||||
if isinstance(obj, Set):
|
||||
return list(obj)
|
||||
return json.JSONEncoder.default(self, obj)
|
||||
|
||||
json_data = json.dumps(presets, cls=SetEncoder)
|
||||
response = Response(json_data)
|
||||
response.headers["Content-Type"] = "application/json"
|
||||
return response
|
||||
|
||||
|
||||
@app.route("/weighted-options")
|
||||
def weighted_options_old():
|
||||
return redirect("games", 301)
|
||||
|
||||
|
||||
@app.route("/games/<string:game>/weighted-options")
|
||||
@cache.cached()
|
||||
def weighted_options(game: str):
|
||||
try:
|
||||
return render_options_page("weightedOptions/weightedOptions.html", game, is_complex=True)
|
||||
except KeyError:
|
||||
return abort(404)
|
||||
|
||||
|
||||
@app.route("/games/<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 val == "_ensure-empty-list":
|
||||
options[key] = {}
|
||||
elif "||" not in key:
|
||||
if len(str(val)) == 0:
|
||||
continue
|
||||
|
||||
options[key] = val
|
||||
else:
|
||||
if int(val) == 0:
|
||||
continue
|
||||
|
||||
[option, setting] = key.split("||")
|
||||
options.setdefault(option, {})[setting] = int(val)
|
||||
|
||||
# Error checking
|
||||
if "name" not in options:
|
||||
return "Player name is required."
|
||||
|
||||
# Remove POST data irrelevant to YAML
|
||||
if "intent-generate" in options:
|
||||
intent_generate = True
|
||||
del options["intent-generate"]
|
||||
if "intent-export" in options:
|
||||
del options["intent-export"]
|
||||
|
||||
# Properly format YAML output
|
||||
player_name = options["name"]
|
||||
del options["name"]
|
||||
|
||||
formatted_options = {
|
||||
"name": player_name,
|
||||
"game": game,
|
||||
"description": f"Generated by https://archipelago.gg/ for {game}",
|
||||
game: options,
|
||||
# Generate JSON files for player-options pages
|
||||
player_options = {
|
||||
"baseOptions": {
|
||||
"description": f"Generated by https://archipelago.gg/ for {game_name}",
|
||||
"game": game_name,
|
||||
"name": "",
|
||||
},
|
||||
}
|
||||
|
||||
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 issubclass(option, Options.Choice) or issubclass(option, Options.Toggle):
|
||||
game_options[option_name] = this_option = {
|
||||
"type": "select",
|
||||
"displayName": option.display_name if hasattr(option, "display_name") else option_name,
|
||||
"description": get_html_doc(option),
|
||||
"defaultValue": None,
|
||||
"options": []
|
||||
}
|
||||
|
||||
for sub_option_id, sub_option_name in option.name_lookup.items():
|
||||
if sub_option_name != "random":
|
||||
this_option["options"].append({
|
||||
"name": option.get_option_name(sub_option_id),
|
||||
"value": sub_option_name,
|
||||
})
|
||||
if sub_option_id == option.default:
|
||||
this_option["defaultValue"] = sub_option_name
|
||||
|
||||
# Player options pages
|
||||
@app.route("/games/<string:game>/player-options")
|
||||
@cache.cached()
|
||||
def player_options(game: str):
|
||||
try:
|
||||
return render_options_page("playerOptions/playerOptions.html", game, is_complex=False)
|
||||
except KeyError:
|
||||
return abort(404)
|
||||
if not this_option["defaultValue"]:
|
||||
this_option["defaultValue"] = "random"
|
||||
|
||||
elif issubclass(option, Options.Range):
|
||||
game_options[option_name] = {
|
||||
"type": "range",
|
||||
"displayName": option.display_name if hasattr(option, "display_name") else option_name,
|
||||
"description": get_html_doc(option),
|
||||
"defaultValue": option.default if hasattr(
|
||||
option, "default") and option.default != "random" else option.range_start,
|
||||
"min": option.range_start,
|
||||
"max": option.range_end,
|
||||
}
|
||||
|
||||
# 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
|
||||
if issubclass(option, Options.NamedRange):
|
||||
game_options[option_name]["type"] = 'named_range'
|
||||
game_options[option_name]["value_names"] = {}
|
||||
for key, val in option.special_range_names.items():
|
||||
game_options[option_name]["value_names"][key] = val
|
||||
|
||||
elif issubclass(option, Options.ItemSet):
|
||||
game_options[option_name] = {
|
||||
"type": "items-list",
|
||||
"displayName": option.display_name if hasattr(option, "display_name") else option_name,
|
||||
"description": get_html_doc(option),
|
||||
"defaultValue": list(option.default)
|
||||
}
|
||||
|
||||
elif issubclass(option, Options.LocationSet):
|
||||
game_options[option_name] = {
|
||||
"type": "locations-list",
|
||||
"displayName": option.display_name if hasattr(option, "display_name") else option_name,
|
||||
"description": get_html_doc(option),
|
||||
"defaultValue": list(option.default)
|
||||
}
|
||||
|
||||
elif issubclass(option, Options.VerifyKeys) and not issubclass(option, Options.OptionDict):
|
||||
if option.valid_keys:
|
||||
game_options[option_name] = {
|
||||
"type": "custom-list",
|
||||
"displayName": option.display_name if hasattr(option, "display_name") else option_name,
|
||||
"description": get_html_doc(option),
|
||||
"options": list(option.valid_keys),
|
||||
"defaultValue": list(option.default) if hasattr(option, "default") else []
|
||||
}
|
||||
|
||||
for key, val in request.form.items(multi=True):
|
||||
if val == "_ensure-empty-list":
|
||||
options[key] = []
|
||||
elif options.get(key):
|
||||
if not isinstance(options[key], list):
|
||||
options[key] = [options[key]]
|
||||
options[key].append(val)
|
||||
else:
|
||||
options[key] = val
|
||||
logging.debug(f"{option} not exported to Web Options.")
|
||||
|
||||
for key, val in options.copy().items():
|
||||
key_parts = key.rsplit("||", 2)
|
||||
# Detect and build OptionCounter options from their name pattern
|
||||
if key_parts[-1] == "qty":
|
||||
if key_parts[0] not in options:
|
||||
options[key_parts[0]] = {}
|
||||
if val and val != "0":
|
||||
options[key_parts[0]][key_parts[1]] = int(val)
|
||||
del options[key]
|
||||
player_options["gameOptions"] = game_options
|
||||
|
||||
# Detect keys which end with -custom, indicating a TextChoice with a possible custom value
|
||||
elif key_parts[-1].endswith("-custom"):
|
||||
if val:
|
||||
options[key_parts[-1][:-7]] = val
|
||||
player_options["presetOptions"] = {}
|
||||
for preset_name, preset in world.web.options_presets.items():
|
||||
player_options["presetOptions"][preset_name] = {}
|
||||
for option_name, option_value in preset.items():
|
||||
# Random range type settings are not valid.
|
||||
assert (not str(option_value).startswith("random-")), \
|
||||
f"Invalid preset value '{option_value}' for '{option_name}' in '{preset_name}'. Special random " \
|
||||
f"values are not supported for presets."
|
||||
|
||||
del options[key]
|
||||
# Normal random is supported, but needs to be handled explicitly.
|
||||
if option_value == "random":
|
||||
player_options["presetOptions"][preset_name][option_name] = option_value
|
||||
continue
|
||||
|
||||
# Detect keys which end with -range, indicating a NamedRange with a possible custom value
|
||||
elif key_parts[-1].endswith("-range"):
|
||||
if options[key_parts[-1][:-6]] == "custom":
|
||||
options[key_parts[-1][:-6]] = val
|
||||
option = world.options_dataclass.type_hints[option_name].from_any(option_value)
|
||||
if isinstance(option, Options.NamedRange) and isinstance(option_value, str):
|
||||
assert option_value in option.special_range_names, \
|
||||
f"Invalid preset value '{option_value}' for '{option_name}' in '{preset_name}'. " \
|
||||
f"Expected {option.special_range_names.keys()} or {option.range_start}-{option.range_end}."
|
||||
|
||||
del options[key]
|
||||
# Still use the true value for the option, not the name.
|
||||
player_options["presetOptions"][preset_name][option_name] = option.value
|
||||
elif isinstance(option, Options.Range):
|
||||
player_options["presetOptions"][preset_name][option_name] = option.value
|
||||
elif isinstance(option_value, str):
|
||||
# For Choice and Toggle options, the value should be the name of the option. This is to prevent
|
||||
# setting a preset for an option with an overridden from_text method that would normally be okay,
|
||||
# but would not be okay for the webhost's current implementation of player options UI.
|
||||
assert option.name_lookup[option.value] == option_value, \
|
||||
f"Invalid option value '{option_value}' for '{option_name}' in preset '{preset_name}'. " \
|
||||
f"Values must not be resolved to a different option via option.from_text (or an alias)."
|
||||
player_options["presetOptions"][preset_name][option_name] = option.current_key
|
||||
else:
|
||||
# int and bool values are fine, just resolve them to the current key for webhost.
|
||||
player_options["presetOptions"][preset_name][option_name] = option.current_key
|
||||
|
||||
# Detect random-* keys and set their options accordingly
|
||||
for key, val in options.copy().items():
|
||||
if key.startswith("random-"):
|
||||
options[key.removeprefix("random-")] = "random"
|
||||
del options[key]
|
||||
os.makedirs(os.path.join(target_folder, 'player-options'), exist_ok=True)
|
||||
|
||||
# Error checking
|
||||
if not options["name"]:
|
||||
return "Player name is required."
|
||||
with open(os.path.join(target_folder, 'player-options', game_name + ".json"), "w") as f:
|
||||
json.dump(player_options, f, indent=2, separators=(',', ': '))
|
||||
|
||||
# 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"]
|
||||
if not world.hidden and world.web.options_page is True:
|
||||
# Add the random option to Choice, TextChoice, and Toggle options
|
||||
for option in game_options.values():
|
||||
if option["type"] == "select":
|
||||
option["options"].append({"name": "Random", "value": "random"})
|
||||
|
||||
# Properly format YAML output
|
||||
player_name = options["name"]
|
||||
del options["name"]
|
||||
if not option["defaultValue"]:
|
||||
option["defaultValue"] = "random"
|
||||
|
||||
description = f"Generated by https://archipelago.gg/ for {game}"
|
||||
if preset_name != 'default' and preset_name != 'custom':
|
||||
description += f" using {preset_name} preset"
|
||||
weighted_options["baseOptions"]["game"][game_name] = 0
|
||||
weighted_options["games"][game_name] = {
|
||||
"gameSettings": game_options,
|
||||
"gameItems": tuple(world.item_names),
|
||||
"gameItemGroups": [
|
||||
group for group in world.item_name_groups.keys() if group != "Everything"
|
||||
],
|
||||
"gameItemDescriptions": world.item_descriptions,
|
||||
"gameLocations": tuple(world.location_names),
|
||||
"gameLocationGroups": [
|
||||
group for group in world.location_name_groups.keys() if group != "Everywhere"
|
||||
],
|
||||
"gameLocationDescriptions": world.location_descriptions,
|
||||
}
|
||||
|
||||
formatted_options = {
|
||||
"name": player_name,
|
||||
"game": game,
|
||||
"description": description,
|
||||
game: options,
|
||||
}
|
||||
with open(os.path.join(target_folder, 'weighted-options.json'), "w") as f:
|
||||
json.dump(weighted_options, f, indent=2, separators=(',', ': '))
|
||||
|
||||
if intent_generate:
|
||||
return generate_game({player_name: formatted_options})
|
||||
|
||||
else:
|
||||
return send_yaml(player_name, formatted_options)
|
||||
|
||||
@@ -1,14 +1,9 @@
|
||||
flask>=3.1.1
|
||||
werkzeug>=3.1.3
|
||||
pony>=0.7.19; python_version <= '3.12'
|
||||
pony @ git+https://github.com/black-sliver/pony@7feb1221953b7fa4a6735466bf21a8b4d35e33ba#0.7.19; python_version >= '3.13'
|
||||
waitress>=3.0.2
|
||||
Flask-Caching>=2.3.0
|
||||
Flask-Compress==1.18 # pkg_resources can't resolve the "backports.zstd" dependency of >1.18, breaking ModuleUpdate.py
|
||||
Flask-Limiter>=3.12
|
||||
Flask-Cors>=6.0.2
|
||||
bokeh>=3.6.3
|
||||
markupsafe>=3.0.2
|
||||
setproctitle>=1.3.5
|
||||
mistune>=3.1.3
|
||||
docutils>=0.22.2
|
||||
flask>=3.0.0
|
||||
pony>=0.7.17
|
||||
waitress>=2.1.2
|
||||
Flask-Caching>=2.1.0
|
||||
Flask-Compress>=1.14
|
||||
Flask-Limiter>=3.5.0
|
||||
bokeh>=3.1.1; python_version <= '3.8'
|
||||
bokeh>=3.3.2; python_version >= '3.9'
|
||||
markupsafe>=2.1.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,
|
||||
)
|
||||
51
WebHostLib/static/assets/faq.js
Normal file
@@ -0,0 +1,51 @@
|
||||
window.addEventListener('load', () => {
|
||||
const tutorialWrapper = document.getElementById('faq-wrapper');
|
||||
new Promise((resolve, reject) => {
|
||||
const ajax = new XMLHttpRequest();
|
||||
ajax.onreadystatechange = () => {
|
||||
if (ajax.readyState !== 4) { return; }
|
||||
if (ajax.status === 404) {
|
||||
reject("Sorry, the tutorial is not available in that language yet.");
|
||||
return;
|
||||
}
|
||||
if (ajax.status !== 200) {
|
||||
reject("Something went wrong while loading the tutorial.");
|
||||
return;
|
||||
}
|
||||
resolve(ajax.responseText);
|
||||
};
|
||||
ajax.open('GET', `${window.location.origin}/static/assets/faq/` +
|
||||
`faq_${tutorialWrapper.getAttribute('data-lang')}.md`, true);
|
||||
ajax.send();
|
||||
}).then((results) => {
|
||||
// Populate page with HTML generated from markdown
|
||||
showdown.setOption('tables', true);
|
||||
showdown.setOption('strikethrough', true);
|
||||
showdown.setOption('literalMidWordUnderscores', true);
|
||||
tutorialWrapper.innerHTML += (new showdown.Converter()).makeHtml(results);
|
||||
adjustHeaderWidth();
|
||||
|
||||
// Reset the id of all header divs to something nicer
|
||||
for (const header of document.querySelectorAll('h1, h2, h3, h4, h5, h6')) {
|
||||
const headerId = header.innerText.replace(/\s+/g, '-').toLowerCase();
|
||||
header.setAttribute('id', headerId);
|
||||
header.addEventListener('click', () => {
|
||||
window.location.hash = `#${headerId}`;
|
||||
header.scrollIntoView();
|
||||
});
|
||||
}
|
||||
|
||||
// Manually scroll the user to the appropriate header if anchor navigation is used
|
||||
document.fonts.ready.finally(() => {
|
||||
if (window.location.hash) {
|
||||
const scrollTarget = document.getElementById(window.location.hash.substring(1));
|
||||
scrollTarget?.scrollIntoView();
|
||||
}
|
||||
});
|
||||
}).catch((error) => {
|
||||
console.error(error);
|
||||
tutorialWrapper.innerHTML =
|
||||
`<h2>This page is out of logic!</h2>
|
||||
<h3>Click <a href="${window.location.origin}">here</a> to return to safety.</h3>`;
|
||||
});
|
||||
});
|
||||
@@ -22,8 +22,8 @@ players to rely upon each other to complete their game.
|
||||
|
||||
While a multiworld game traditionally requires all players to be playing the same game, a multi-game multiworld allows
|
||||
players to randomize any of the supported games, and send items between them. This allows players of different
|
||||
games to interact with one another in a single multiplayer environment. Archipelago supports multi-game multiworlds.
|
||||
Here is a list of our [Supported Games](/games).
|
||||
games to interact with one another in a single multiplayer environment. Archipelago supports multi-game multiworld.
|
||||
Here is a list of our [Supported Games](https://archipelago.gg/games).
|
||||
|
||||
## Can I generate a single-player game with Archipelago?
|
||||
|
||||
@@ -33,7 +33,7 @@ play, open the Settings Page, pick your settings, and click Generate Game.
|
||||
|
||||
## How do I get started?
|
||||
|
||||
We have a [Getting Started](/tutorial/Archipelago/setup/en) guide that will help you get the
|
||||
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.
|
||||
|
||||
@@ -57,7 +57,7 @@ their multiworld.
|
||||
|
||||
If a player must leave early, they can use Archipelago's release system. When a player releases their game, all items
|
||||
in that game belonging to other players are sent out automatically. This allows other players to continue to play
|
||||
uninterrupted. Here is a list of all of our [Server Commands](/tutorial/Archipelago/commands/en).
|
||||
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?
|
||||
|
||||
@@ -66,7 +66,7 @@ is to ensure items necessary to complete the game will be accessible to the play
|
||||
rules allowing certain items to be placed in normally unreachable locations, provided the player has indicated they are
|
||||
comfortable exploiting certain glitches in the game.
|
||||
|
||||
## I want to develop a game implementation for Archipelago. How do I do that?
|
||||
## 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).
|
||||
@@ -77,5 +77,4 @@ There, you will find examples of games in the `worlds` folder:
|
||||
You may also find developer documentation in the `docs` folder:
|
||||
[/docs Folder in Archipelago Code](https://github.com/ArchipelagoMW/Archipelago/tree/main/docs).
|
||||
|
||||
If you have more questions regarding development of a game implementation, feel free to ask in the **#ap-world-dev**
|
||||
channel on our Discord.
|
||||
If you have more questions, feel free to ask in the **#archipelago-dev** channel on our Discord.
|
||||
51
WebHostLib/static/assets/gameInfo.js
Normal file
@@ -0,0 +1,51 @@
|
||||
window.addEventListener('load', () => {
|
||||
const gameInfo = document.getElementById('game-info');
|
||||
new Promise((resolve, reject) => {
|
||||
const ajax = new XMLHttpRequest();
|
||||
ajax.onreadystatechange = () => {
|
||||
if (ajax.readyState !== 4) { return; }
|
||||
if (ajax.status === 404) {
|
||||
reject("Sorry, this game's info page is not available in that language yet.");
|
||||
return;
|
||||
}
|
||||
if (ajax.status !== 200) {
|
||||
reject("Something went wrong while loading the info page.");
|
||||
return;
|
||||
}
|
||||
resolve(ajax.responseText);
|
||||
};
|
||||
ajax.open('GET', `${window.location.origin}/static/generated/docs/${gameInfo.getAttribute('data-game')}/` +
|
||||
`${gameInfo.getAttribute('data-lang')}_${gameInfo.getAttribute('data-game')}.md`, true);
|
||||
ajax.send();
|
||||
}).then((results) => {
|
||||
// Populate page with HTML generated from markdown
|
||||
showdown.setOption('tables', true);
|
||||
showdown.setOption('strikethrough', true);
|
||||
showdown.setOption('literalMidWordUnderscores', true);
|
||||
gameInfo.innerHTML += (new showdown.Converter()).makeHtml(results);
|
||||
adjustHeaderWidth();
|
||||
|
||||
// Reset the id of all header divs to something nicer
|
||||
for (const header of document.querySelectorAll('h1, h2, h3, h4, h5, h6')) {
|
||||
const headerId = header.innerText.replace(/\s+/g, '-').toLowerCase();
|
||||
header.setAttribute('id', headerId);
|
||||
header.addEventListener('click', () => {
|
||||
window.location.hash = `#${headerId}`;
|
||||
header.scrollIntoView();
|
||||
});
|
||||
}
|
||||
|
||||
// Manually scroll the user to the appropriate header if anchor navigation is used
|
||||
document.fonts.ready.finally(() => {
|
||||
if (window.location.hash) {
|
||||
const scrollTarget = document.getElementById(window.location.hash.substring(1));
|
||||
scrollTarget?.scrollIntoView();
|
||||
}
|
||||
});
|
||||
}).catch((error) => {
|
||||
console.error(error);
|
||||
gameInfo.innerHTML =
|
||||
`<h2>This page is out of logic!</h2>
|
||||
<h3>Click <a href="${window.location.origin}">here</a> to return to safety.</h3>`;
|
||||
});
|
||||
});
|
||||
51
WebHostLib/static/assets/glossary.js
Normal file
@@ -0,0 +1,51 @@
|
||||
window.addEventListener('load', () => {
|
||||
const tutorialWrapper = document.getElementById('glossary-wrapper');
|
||||
new Promise((resolve, reject) => {
|
||||
const ajax = new XMLHttpRequest();
|
||||
ajax.onreadystatechange = () => {
|
||||
if (ajax.readyState !== 4) { return; }
|
||||
if (ajax.status === 404) {
|
||||
reject("Sorry, the glossary page is not available in that language yet.");
|
||||
return;
|
||||
}
|
||||
if (ajax.status !== 200) {
|
||||
reject("Something went wrong while loading the glossary.");
|
||||
return;
|
||||
}
|
||||
resolve(ajax.responseText);
|
||||
};
|
||||
ajax.open('GET', `${window.location.origin}/static/assets/faq/` +
|
||||
`glossary_${tutorialWrapper.getAttribute('data-lang')}.md`, true);
|
||||
ajax.send();
|
||||
}).then((results) => {
|
||||
// Populate page with HTML generated from markdown
|
||||
showdown.setOption('tables', true);
|
||||
showdown.setOption('strikethrough', true);
|
||||
showdown.setOption('literalMidWordUnderscores', true);
|
||||
tutorialWrapper.innerHTML += (new showdown.Converter()).makeHtml(results);
|
||||
adjustHeaderWidth();
|
||||
|
||||
// Reset the id of all header divs to something nicer
|
||||
for (const header of document.querySelectorAll('h1, h2, h3, h4, h5, h6')) {
|
||||
const headerId = header.innerText.replace(/\s+/g, '-').toLowerCase();
|
||||
header.setAttribute('id', headerId);
|
||||
header.addEventListener('click', () => {
|
||||
window.location.hash = `#${headerId}`;
|
||||
header.scrollIntoView();
|
||||
});
|
||||
}
|
||||
|
||||
// Manually scroll the user to the appropriate header if anchor navigation is used
|
||||
document.fonts.ready.finally(() => {
|
||||
if (window.location.hash) {
|
||||
const scrollTarget = document.getElementById(window.location.hash.substring(1));
|
||||
scrollTarget?.scrollIntoView();
|
||||
}
|
||||
});
|
||||
}).catch((error) => {
|
||||
console.error(error);
|
||||
tutorialWrapper.innerHTML =
|
||||
`<h2>This page is out of logic!</h2>
|
||||
<h3>Click <a href="${window.location.origin}">here</a> to return to safety.</h3>`;
|
||||
});
|
||||
});
|
||||
@@ -6,4 +6,6 @@ window.addEventListener('load', () => {
|
||||
document.getElementById('file-input').addEventListener('change', () => {
|
||||
document.getElementById('host-game-form').submit();
|
||||
});
|
||||
|
||||
adjustFooterHeight();
|
||||
});
|
||||
|
||||
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)
|
||||
});
|
||||
49
WebHostLib/static/assets/minecraftTracker.js
Normal file
@@ -0,0 +1,49 @@
|
||||
window.addEventListener('load', () => {
|
||||
// Reload tracker every 15 seconds
|
||||
const url = window.location;
|
||||
setInterval(() => {
|
||||
const ajax = new XMLHttpRequest();
|
||||
ajax.onreadystatechange = () => {
|
||||
if (ajax.readyState !== 4) { return; }
|
||||
|
||||
// Create a fake DOM using the returned HTML
|
||||
const domParser = new DOMParser();
|
||||
const fakeDOM = domParser.parseFromString(ajax.responseText, 'text/html');
|
||||
|
||||
// Update item tracker
|
||||
document.getElementById('inventory-table').innerHTML = fakeDOM.getElementById('inventory-table').innerHTML;
|
||||
// Update only counters in the location-table
|
||||
let counters = document.getElementsByClassName('counter');
|
||||
const fakeCounters = fakeDOM.getElementsByClassName('counter');
|
||||
for (let i = 0; i < counters.length; i++) {
|
||||
counters[i].innerHTML = fakeCounters[i].innerHTML;
|
||||
}
|
||||
};
|
||||
ajax.open('GET', url);
|
||||
ajax.send();
|
||||
}, 15000)
|
||||
|
||||
// Collapsible advancement sections
|
||||
const categories = document.getElementsByClassName("location-category");
|
||||
for (let i = 0; i < categories.length; i++) {
|
||||
let hide_id = categories[i].id.split('-')[0];
|
||||
if (hide_id == 'Total') {
|
||||
continue;
|
||||
}
|
||||
categories[i].addEventListener('click', function() {
|
||||
// Toggle the advancement list
|
||||
document.getElementById(hide_id).classList.toggle("hide");
|
||||
// Change text of the header
|
||||
const tab_header = document.getElementById(hide_id+'-header').children[0];
|
||||
const orig_text = tab_header.innerHTML;
|
||||
let new_text;
|
||||
if (orig_text.includes("▼")) {
|
||||
new_text = orig_text.replace("▼", "▲");
|
||||
}
|
||||
else {
|
||||
new_text = orig_text.replace("▲", "▼");
|
||||
}
|
||||
tab_header.innerHTML = new_text;
|
||||
});
|
||||
}
|
||||
});
|
||||
523
WebHostLib/static/assets/player-options.js
Normal file
@@ -0,0 +1,523 @@
|
||||
let gameName = null;
|
||||
|
||||
window.addEventListener('load', () => {
|
||||
gameName = document.getElementById('player-options').getAttribute('data-game');
|
||||
|
||||
// Update game name on page
|
||||
document.getElementById('game-name').innerText = gameName;
|
||||
|
||||
fetchOptionData().then((results) => {
|
||||
let optionHash = localStorage.getItem(`${gameName}-hash`);
|
||||
if (!optionHash) {
|
||||
// If no hash data has been set before, set it now
|
||||
optionHash = md5(JSON.stringify(results));
|
||||
localStorage.setItem(`${gameName}-hash`, optionHash);
|
||||
localStorage.removeItem(gameName);
|
||||
}
|
||||
|
||||
if (optionHash !== md5(JSON.stringify(results))) {
|
||||
showUserMessage(
|
||||
'Your options are out of date! Click here to update them! Be aware this will reset them all to default.'
|
||||
);
|
||||
document.getElementById('user-message').addEventListener('click', resetOptions);
|
||||
}
|
||||
|
||||
// Page setup
|
||||
createDefaultOptions(results);
|
||||
buildUI(results);
|
||||
adjustHeaderWidth();
|
||||
|
||||
// Event listeners
|
||||
document.getElementById('export-options').addEventListener('click', () => exportOptions());
|
||||
document.getElementById('generate-race').addEventListener('click', () => generateGame(true));
|
||||
document.getElementById('generate-game').addEventListener('click', () => generateGame());
|
||||
|
||||
// Name input field
|
||||
const playerOptions = JSON.parse(localStorage.getItem(gameName));
|
||||
const nameInput = document.getElementById('player-name');
|
||||
nameInput.addEventListener('keyup', (event) => updateBaseOption(event));
|
||||
nameInput.value = playerOptions.name;
|
||||
|
||||
// Presets
|
||||
const presetSelect = document.getElementById('game-options-preset');
|
||||
presetSelect.addEventListener('change', (event) => setPresets(results, event.target.value));
|
||||
for (const preset in results['presetOptions']) {
|
||||
const presetOption = document.createElement('option');
|
||||
presetOption.innerText = preset;
|
||||
presetSelect.appendChild(presetOption);
|
||||
}
|
||||
presetSelect.value = localStorage.getItem(`${gameName}-preset`);
|
||||
results['presetOptions']['__default'] = {};
|
||||
}).catch((e) => {
|
||||
console.error(e);
|
||||
const url = new URL(window.location.href);
|
||||
window.location.replace(`${url.protocol}//${url.hostname}/page-not-found`);
|
||||
})
|
||||
});
|
||||
|
||||
const resetOptions = () => {
|
||||
localStorage.removeItem(gameName);
|
||||
localStorage.removeItem(`${gameName}-hash`);
|
||||
localStorage.removeItem(`${gameName}-preset`);
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
const fetchOptionData = () => new Promise((resolve, reject) => {
|
||||
const ajax = new XMLHttpRequest();
|
||||
ajax.onreadystatechange = () => {
|
||||
if (ajax.readyState !== 4) { return; }
|
||||
if (ajax.status !== 200) {
|
||||
reject(ajax.responseText);
|
||||
return;
|
||||
}
|
||||
try{ resolve(JSON.parse(ajax.responseText)); }
|
||||
catch(error){ reject(error); }
|
||||
};
|
||||
ajax.open('GET', `${window.location.origin}/static/generated/player-options/${gameName}.json`, true);
|
||||
ajax.send();
|
||||
});
|
||||
|
||||
const createDefaultOptions = (optionData) => {
|
||||
if (!localStorage.getItem(gameName)) {
|
||||
const newOptions = {
|
||||
[gameName]: {},
|
||||
};
|
||||
for (let baseOption of Object.keys(optionData.baseOptions)){
|
||||
newOptions[baseOption] = optionData.baseOptions[baseOption];
|
||||
}
|
||||
for (let gameOption of Object.keys(optionData.gameOptions)){
|
||||
newOptions[gameName][gameOption] = optionData.gameOptions[gameOption].defaultValue;
|
||||
}
|
||||
localStorage.setItem(gameName, JSON.stringify(newOptions));
|
||||
}
|
||||
|
||||
if (!localStorage.getItem(`${gameName}-preset`)) {
|
||||
localStorage.setItem(`${gameName}-preset`, '__default');
|
||||
}
|
||||
};
|
||||
|
||||
const buildUI = (optionData) => {
|
||||
// Game Options
|
||||
const leftGameOpts = {};
|
||||
const rightGameOpts = {};
|
||||
Object.keys(optionData.gameOptions).forEach((key, index) => {
|
||||
if (index < Object.keys(optionData.gameOptions).length / 2) {
|
||||
leftGameOpts[key] = optionData.gameOptions[key];
|
||||
} else {
|
||||
rightGameOpts[key] = optionData.gameOptions[key];
|
||||
}
|
||||
});
|
||||
document.getElementById('game-options-left').appendChild(buildOptionsTable(leftGameOpts));
|
||||
document.getElementById('game-options-right').appendChild(buildOptionsTable(rightGameOpts));
|
||||
};
|
||||
|
||||
const buildOptionsTable = (options, romOpts = false) => {
|
||||
const currentOptions = JSON.parse(localStorage.getItem(gameName));
|
||||
const table = document.createElement('table');
|
||||
const tbody = document.createElement('tbody');
|
||||
|
||||
Object.keys(options).forEach((option) => {
|
||||
const tr = document.createElement('tr');
|
||||
|
||||
// td Left
|
||||
const tdl = document.createElement('td');
|
||||
const label = document.createElement('label');
|
||||
label.textContent = `${options[option].displayName}: `;
|
||||
label.setAttribute('for', option);
|
||||
|
||||
const questionSpan = document.createElement('span');
|
||||
questionSpan.classList.add('interactive');
|
||||
questionSpan.setAttribute('data-tooltip', options[option].description);
|
||||
questionSpan.innerText = '(?)';
|
||||
|
||||
label.appendChild(questionSpan);
|
||||
tdl.appendChild(label);
|
||||
tr.appendChild(tdl);
|
||||
|
||||
// td Right
|
||||
const tdr = document.createElement('td');
|
||||
let element = null;
|
||||
|
||||
const randomButton = document.createElement('button');
|
||||
|
||||
switch(options[option].type) {
|
||||
case 'select':
|
||||
element = document.createElement('div');
|
||||
element.classList.add('select-container');
|
||||
let select = document.createElement('select');
|
||||
select.setAttribute('id', option);
|
||||
select.setAttribute('data-key', option);
|
||||
if (romOpts) { select.setAttribute('data-romOpt', '1'); }
|
||||
options[option].options.forEach((opt) => {
|
||||
const optionElement = document.createElement('option');
|
||||
optionElement.setAttribute('value', opt.value);
|
||||
optionElement.innerText = opt.name;
|
||||
|
||||
if ((isNaN(currentOptions[gameName][option]) &&
|
||||
(parseInt(opt.value, 10) === parseInt(currentOptions[gameName][option]))) ||
|
||||
(opt.value === currentOptions[gameName][option]))
|
||||
{
|
||||
optionElement.selected = true;
|
||||
}
|
||||
select.appendChild(optionElement);
|
||||
});
|
||||
select.addEventListener('change', (event) => updateGameOption(event.target));
|
||||
element.appendChild(select);
|
||||
|
||||
// Randomize button
|
||||
randomButton.innerText = '🎲';
|
||||
randomButton.classList.add('randomize-button');
|
||||
randomButton.setAttribute('data-key', option);
|
||||
randomButton.setAttribute('data-tooltip', 'Toggle randomization for this option!');
|
||||
randomButton.addEventListener('click', (event) => toggleRandomize(event, select));
|
||||
if (currentOptions[gameName][option] === 'random') {
|
||||
randomButton.classList.add('active');
|
||||
select.disabled = true;
|
||||
}
|
||||
|
||||
element.appendChild(randomButton);
|
||||
break;
|
||||
|
||||
case 'range':
|
||||
element = document.createElement('div');
|
||||
element.classList.add('range-container');
|
||||
|
||||
let range = document.createElement('input');
|
||||
range.setAttribute('id', option);
|
||||
range.setAttribute('type', 'range');
|
||||
range.setAttribute('data-key', option);
|
||||
range.setAttribute('min', options[option].min);
|
||||
range.setAttribute('max', options[option].max);
|
||||
range.value = currentOptions[gameName][option];
|
||||
range.addEventListener('change', (event) => {
|
||||
document.getElementById(`${option}-value`).innerText = event.target.value;
|
||||
updateGameOption(event.target);
|
||||
});
|
||||
element.appendChild(range);
|
||||
|
||||
let rangeVal = document.createElement('span');
|
||||
rangeVal.classList.add('range-value');
|
||||
rangeVal.setAttribute('id', `${option}-value`);
|
||||
rangeVal.innerText = currentOptions[gameName][option] !== 'random' ?
|
||||
currentOptions[gameName][option] : options[option].defaultValue;
|
||||
element.appendChild(rangeVal);
|
||||
|
||||
// Randomize button
|
||||
randomButton.innerText = '🎲';
|
||||
randomButton.classList.add('randomize-button');
|
||||
randomButton.setAttribute('data-key', option);
|
||||
randomButton.setAttribute('data-tooltip', 'Toggle randomization for this option!');
|
||||
randomButton.addEventListener('click', (event) => toggleRandomize(event, range));
|
||||
if (currentOptions[gameName][option] === 'random') {
|
||||
randomButton.classList.add('active');
|
||||
range.disabled = true;
|
||||
}
|
||||
|
||||
element.appendChild(randomButton);
|
||||
break;
|
||||
|
||||
case 'named_range':
|
||||
element = document.createElement('div');
|
||||
element.classList.add('named-range-container');
|
||||
|
||||
// Build the select element
|
||||
let namedRangeSelect = document.createElement('select');
|
||||
namedRangeSelect.setAttribute('data-key', option);
|
||||
Object.keys(options[option].value_names).forEach((presetName) => {
|
||||
let presetOption = document.createElement('option');
|
||||
presetOption.innerText = presetName;
|
||||
presetOption.value = options[option].value_names[presetName];
|
||||
const words = presetOption.innerText.split('_');
|
||||
for (let i = 0; i < words.length; i++) {
|
||||
words[i] = words[i][0].toUpperCase() + words[i].substring(1);
|
||||
}
|
||||
presetOption.innerText = words.join(' ');
|
||||
namedRangeSelect.appendChild(presetOption);
|
||||
});
|
||||
let customOption = document.createElement('option');
|
||||
customOption.innerText = 'Custom';
|
||||
customOption.value = 'custom';
|
||||
customOption.selected = true;
|
||||
namedRangeSelect.appendChild(customOption);
|
||||
if (Object.values(options[option].value_names).includes(Number(currentOptions[gameName][option]))) {
|
||||
namedRangeSelect.value = Number(currentOptions[gameName][option]);
|
||||
}
|
||||
|
||||
// Build range element
|
||||
let namedRangeWrapper = document.createElement('div');
|
||||
namedRangeWrapper.classList.add('named-range-wrapper');
|
||||
let namedRange = document.createElement('input');
|
||||
namedRange.setAttribute('type', 'range');
|
||||
namedRange.setAttribute('data-key', option);
|
||||
namedRange.setAttribute('min', options[option].min);
|
||||
namedRange.setAttribute('max', options[option].max);
|
||||
namedRange.value = currentOptions[gameName][option];
|
||||
|
||||
// Build rage value element
|
||||
let namedRangeVal = document.createElement('span');
|
||||
namedRangeVal.classList.add('range-value');
|
||||
namedRangeVal.setAttribute('id', `${option}-value`);
|
||||
namedRangeVal.innerText = currentOptions[gameName][option] !== 'random' ?
|
||||
currentOptions[gameName][option] : options[option].defaultValue;
|
||||
|
||||
// Configure select event listener
|
||||
namedRangeSelect.addEventListener('change', (event) => {
|
||||
if (event.target.value === 'custom') { return; }
|
||||
|
||||
// Update range slider
|
||||
namedRange.value = event.target.value;
|
||||
document.getElementById(`${option}-value`).innerText = event.target.value;
|
||||
updateGameOption(event.target);
|
||||
});
|
||||
|
||||
// Configure range event handler
|
||||
namedRange.addEventListener('change', (event) => {
|
||||
// Update select element
|
||||
namedRangeSelect.value =
|
||||
(Object.values(options[option].value_names).includes(parseInt(event.target.value))) ?
|
||||
parseInt(event.target.value) : 'custom';
|
||||
document.getElementById(`${option}-value`).innerText = event.target.value;
|
||||
updateGameOption(event.target);
|
||||
});
|
||||
|
||||
element.appendChild(namedRangeSelect);
|
||||
namedRangeWrapper.appendChild(namedRange);
|
||||
namedRangeWrapper.appendChild(namedRangeVal);
|
||||
element.appendChild(namedRangeWrapper);
|
||||
|
||||
// Randomize button
|
||||
randomButton.innerText = '🎲';
|
||||
randomButton.classList.add('randomize-button');
|
||||
randomButton.setAttribute('data-key', option);
|
||||
randomButton.setAttribute('data-tooltip', 'Toggle randomization for this option!');
|
||||
randomButton.addEventListener('click', (event) => toggleRandomize(
|
||||
event, namedRange, namedRangeSelect)
|
||||
);
|
||||
if (currentOptions[gameName][option] === 'random') {
|
||||
randomButton.classList.add('active');
|
||||
namedRange.disabled = true;
|
||||
namedRangeSelect.disabled = true;
|
||||
}
|
||||
|
||||
namedRangeWrapper.appendChild(randomButton);
|
||||
break;
|
||||
|
||||
default:
|
||||
console.error(`Ignoring unknown option type: ${options[option].type} with name ${option}`);
|
||||
return;
|
||||
}
|
||||
|
||||
tdr.appendChild(element);
|
||||
tr.appendChild(tdr);
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
|
||||
table.appendChild(tbody);
|
||||
return table;
|
||||
};
|
||||
|
||||
const setPresets = (optionsData, presetName) => {
|
||||
const defaults = optionsData['gameOptions'];
|
||||
const preset = optionsData['presetOptions'][presetName];
|
||||
|
||||
localStorage.setItem(`${gameName}-preset`, presetName);
|
||||
|
||||
if (!preset) {
|
||||
console.error(`No presets defined for preset name: '${presetName}'`);
|
||||
return;
|
||||
}
|
||||
|
||||
const updateOptionElement = (option, presetValue) => {
|
||||
const optionElement = document.querySelector(`#${option}[data-key='${option}']`);
|
||||
const randomElement = document.querySelector(`.randomize-button[data-key='${option}']`);
|
||||
|
||||
if (presetValue === 'random') {
|
||||
randomElement.classList.add('active');
|
||||
optionElement.disabled = true;
|
||||
updateGameOption(randomElement, false);
|
||||
} else {
|
||||
optionElement.value = presetValue;
|
||||
randomElement.classList.remove('active');
|
||||
optionElement.disabled = undefined;
|
||||
updateGameOption(optionElement, false);
|
||||
}
|
||||
};
|
||||
|
||||
for (const option in defaults) {
|
||||
let presetValue = preset[option];
|
||||
if (presetValue === undefined) {
|
||||
// Using the default value if not set in presets.
|
||||
presetValue = defaults[option]['defaultValue'];
|
||||
}
|
||||
|
||||
switch (defaults[option].type) {
|
||||
case 'range':
|
||||
const numberElement = document.querySelector(`#${option}-value`);
|
||||
if (presetValue === 'random') {
|
||||
numberElement.innerText = defaults[option]['defaultValue'] === 'random'
|
||||
? defaults[option]['min'] // A fallback so we don't print 'random' in the UI.
|
||||
: defaults[option]['defaultValue'];
|
||||
} else {
|
||||
numberElement.innerText = presetValue;
|
||||
}
|
||||
|
||||
updateOptionElement(option, presetValue);
|
||||
break;
|
||||
|
||||
case 'select': {
|
||||
updateOptionElement(option, presetValue);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'named_range': {
|
||||
const selectElement = document.querySelector(`select[data-key='${option}']`);
|
||||
const rangeElement = document.querySelector(`input[data-key='${option}']`);
|
||||
const randomElement = document.querySelector(`.randomize-button[data-key='${option}']`);
|
||||
|
||||
if (presetValue === 'random') {
|
||||
randomElement.classList.add('active');
|
||||
selectElement.disabled = true;
|
||||
rangeElement.disabled = true;
|
||||
updateGameOption(randomElement, false);
|
||||
} else {
|
||||
rangeElement.value = presetValue;
|
||||
selectElement.value = Object.values(defaults[option]['value_names']).includes(parseInt(presetValue)) ?
|
||||
parseInt(presetValue) : 'custom';
|
||||
document.getElementById(`${option}-value`).innerText = presetValue;
|
||||
|
||||
randomElement.classList.remove('active');
|
||||
selectElement.disabled = undefined;
|
||||
rangeElement.disabled = undefined;
|
||||
updateGameOption(rangeElement, false);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
console.warn(`Ignoring preset value for unknown option type: ${defaults[option].type} with name ${option}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const toggleRandomize = (event, inputElement, optionalSelectElement = null) => {
|
||||
const active = event.target.classList.contains('active');
|
||||
const randomButton = event.target;
|
||||
|
||||
if (active) {
|
||||
randomButton.classList.remove('active');
|
||||
inputElement.disabled = undefined;
|
||||
if (optionalSelectElement) {
|
||||
optionalSelectElement.disabled = undefined;
|
||||
}
|
||||
} else {
|
||||
randomButton.classList.add('active');
|
||||
inputElement.disabled = true;
|
||||
if (optionalSelectElement) {
|
||||
optionalSelectElement.disabled = true;
|
||||
}
|
||||
}
|
||||
updateGameOption(active ? inputElement : randomButton);
|
||||
};
|
||||
|
||||
const updateBaseOption = (event) => {
|
||||
const options = JSON.parse(localStorage.getItem(gameName));
|
||||
options[event.target.getAttribute('data-key')] = isNaN(event.target.value) ?
|
||||
event.target.value : parseInt(event.target.value);
|
||||
localStorage.setItem(gameName, JSON.stringify(options));
|
||||
};
|
||||
|
||||
const updateGameOption = (optionElement, toggleCustomPreset = true) => {
|
||||
const options = JSON.parse(localStorage.getItem(gameName));
|
||||
|
||||
if (toggleCustomPreset) {
|
||||
localStorage.setItem(`${gameName}-preset`, '__custom');
|
||||
const presetElement = document.getElementById('game-options-preset');
|
||||
presetElement.value = '__custom';
|
||||
}
|
||||
|
||||
if (optionElement.classList.contains('randomize-button')) {
|
||||
// If the event passed in is the randomize button, then we know what we must do.
|
||||
options[gameName][optionElement.getAttribute('data-key')] = 'random';
|
||||
} else {
|
||||
options[gameName][optionElement.getAttribute('data-key')] = isNaN(optionElement.value) ?
|
||||
optionElement.value : parseInt(optionElement.value, 10);
|
||||
}
|
||||
|
||||
localStorage.setItem(gameName, JSON.stringify(options));
|
||||
};
|
||||
|
||||
const exportOptions = () => {
|
||||
const options = JSON.parse(localStorage.getItem(gameName));
|
||||
const preset = localStorage.getItem(`${gameName}-preset`);
|
||||
switch (preset) {
|
||||
case '__default':
|
||||
options['description'] = `Generated by https://archipelago.gg with the default preset.`;
|
||||
break;
|
||||
|
||||
case '__custom':
|
||||
options['description'] = `Generated by https://archipelago.gg.`;
|
||||
break;
|
||||
|
||||
default:
|
||||
options['description'] = `Generated by https://archipelago.gg with the ${preset} preset.`;
|
||||
}
|
||||
|
||||
if (!options.name || options.name.toString().trim().length === 0) {
|
||||
return showUserMessage('You must enter a player name!');
|
||||
}
|
||||
const yamlText = jsyaml.safeDump(options, { noCompatMode: true }).replaceAll(/'(\d+)':/g, (x, y) => `${y}:`);
|
||||
download(`${document.getElementById('player-name').value}.yaml`, yamlText);
|
||||
};
|
||||
|
||||
/** Create an anchor and trigger a download of a text file. */
|
||||
const download = (filename, text) => {
|
||||
const downloadLink = document.createElement('a');
|
||||
downloadLink.setAttribute('href','data:text/yaml;charset=utf-8,'+ encodeURIComponent(text))
|
||||
downloadLink.setAttribute('download', filename);
|
||||
downloadLink.style.display = 'none';
|
||||
document.body.appendChild(downloadLink);
|
||||
downloadLink.click();
|
||||
document.body.removeChild(downloadLink);
|
||||
};
|
||||
|
||||
const generateGame = (raceMode = false) => {
|
||||
const options = JSON.parse(localStorage.getItem(gameName));
|
||||
if (!options.name || options.name.toLowerCase() === 'player' || options.name.trim().length === 0) {
|
||||
return showUserMessage('You must enter a player name!');
|
||||
}
|
||||
|
||||
axios.post('/api/generate', {
|
||||
weights: { player: options },
|
||||
presetData: { player: options },
|
||||
playerCount: 1,
|
||||
spoiler: 3,
|
||||
race: raceMode ? '1' : '0',
|
||||
}).then((response) => {
|
||||
window.location.href = response.data.url;
|
||||
}).catch((error) => {
|
||||
let userMessage = 'Something went wrong and your game could not be generated.';
|
||||
if (error.response.data.text) {
|
||||
userMessage += ' ' + error.response.data.text;
|
||||
}
|
||||
showUserMessage(userMessage);
|
||||
console.error(error);
|
||||
});
|
||||
};
|
||||
|
||||
const showUserMessage = (message) => {
|
||||
const userMessage = document.getElementById('user-message');
|
||||
userMessage.innerText = message;
|
||||
userMessage.classList.add('visible');
|
||||
window.scrollTo(0, 0);
|
||||
userMessage.addEventListener('click', () => {
|
||||
userMessage.classList.remove('visible');
|
||||
userMessage.addEventListener('click', hideUserMessage);
|
||||
});
|
||||
};
|
||||
|
||||
const hideUserMessage = () => {
|
||||
const userMessage = document.getElementById('user-message');
|
||||
userMessage.classList.remove('visible');
|
||||
userMessage.removeEventListener('click', hideUserMessage);
|
||||
};
|
||||
@@ -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,43 +0,0 @@
|
||||
let updateSection = (sectionName, fakeDOM) => {
|
||||
document.getElementById(sectionName).innerHTML = fakeDOM.getElementById(sectionName).innerHTML;
|
||||
}
|
||||
|
||||
window.addEventListener('load', () => {
|
||||
// Reload tracker every 60 seconds (sync'd)
|
||||
const url = window.location;
|
||||
// Note: This synchronization code is adapted from code in trackerCommon.js
|
||||
const targetSecond = parseInt(document.getElementById('player-tracker').getAttribute('data-second')) + 3;
|
||||
console.log("Target second of refresh: " + targetSecond);
|
||||
|
||||
let getSleepTimeSeconds = () => {
|
||||
// -40 % 60 is -40, which is absolutely wrong and should burn
|
||||
var sleepSeconds = (((targetSecond - new Date().getSeconds()) % 60) + 60) % 60;
|
||||
return sleepSeconds || 60;
|
||||
};
|
||||
|
||||
let updateTracker = () => {
|
||||
const ajax = new XMLHttpRequest();
|
||||
ajax.onreadystatechange = () => {
|
||||
if (ajax.readyState !== 4) { return; }
|
||||
|
||||
// Create a fake DOM using the returned HTML
|
||||
const domParser = new DOMParser();
|
||||
const fakeDOM = domParser.parseFromString(ajax.responseText, 'text/html');
|
||||
|
||||
// Update dynamic sections
|
||||
updateSection('player-info', fakeDOM);
|
||||
updateSection('section-filler', fakeDOM);
|
||||
updateSection('section-terran', fakeDOM);
|
||||
updateSection('section-zerg', fakeDOM);
|
||||
updateSection('section-protoss', fakeDOM);
|
||||
updateSection('section-nova', fakeDOM);
|
||||
updateSection('section-kerrigan', fakeDOM);
|
||||
updateSection('section-keys', fakeDOM);
|
||||
updateSection('section-locations', fakeDOM);
|
||||
};
|
||||
ajax.open('GET', url);
|
||||
ajax.send();
|
||||
updater = setTimeout(updateTracker, getSleepTimeSeconds() * 1000);
|
||||
};
|
||||
window.updater = setTimeout(updateTracker, getSleepTimeSeconds() * 1000);
|
||||
});
|
||||
49
WebHostLib/static/assets/sc2wolTracker.js
Normal file
@@ -0,0 +1,49 @@
|
||||
window.addEventListener('load', () => {
|
||||
// Reload tracker every 15 seconds
|
||||
const url = window.location;
|
||||
setInterval(() => {
|
||||
const ajax = new XMLHttpRequest();
|
||||
ajax.onreadystatechange = () => {
|
||||
if (ajax.readyState !== 4) { return; }
|
||||
|
||||
// Create a fake DOM using the returned HTML
|
||||
const domParser = new DOMParser();
|
||||
const fakeDOM = domParser.parseFromString(ajax.responseText, 'text/html');
|
||||
|
||||
// Update item tracker
|
||||
document.getElementById('inventory-table').innerHTML = fakeDOM.getElementById('inventory-table').innerHTML;
|
||||
// Update only counters in the location-table
|
||||
let counters = document.getElementsByClassName('counter');
|
||||
const fakeCounters = fakeDOM.getElementsByClassName('counter');
|
||||
for (let i = 0; i < counters.length; i++) {
|
||||
counters[i].innerHTML = fakeCounters[i].innerHTML;
|
||||
}
|
||||
};
|
||||
ajax.open('GET', url);
|
||||
ajax.send();
|
||||
}, 15000)
|
||||
|
||||
// Collapsible advancement sections
|
||||
const categories = document.getElementsByClassName("location-category");
|
||||
for (let i = 0; i < categories.length; i++) {
|
||||
let hide_id = categories[i].id.split('-')[0];
|
||||
if (hide_id == 'Total') {
|
||||
continue;
|
||||
}
|
||||
categories[i].addEventListener('click', function() {
|
||||
// Toggle the advancement list
|
||||
document.getElementById(hide_id).classList.toggle("hide");
|
||||
// Change text of the header
|
||||
const tab_header = document.getElementById(hide_id+'-header').children[0];
|
||||
const orig_text = tab_header.innerHTML;
|
||||
let new_text;
|
||||
if (orig_text.includes("▼")) {
|
||||
new_text = orig_text.replace("▼", "▲");
|
||||
}
|
||||
else {
|
||||
new_text = orig_text.replace("▲", "▼");
|
||||
}
|
||||
tab_header.innerHTML = new_text;
|
||||
});
|
||||
}
|
||||
});
|
||||
47
WebHostLib/static/assets/styleController.js
Normal file
@@ -0,0 +1,47 @@
|
||||
const adjustFooterHeight = () => {
|
||||
// If there is no footer on this page, do nothing
|
||||
const footer = document.getElementById('island-footer');
|
||||
if (!footer) { return; }
|
||||
|
||||
// If the body is taller than the window, also do nothing
|
||||
if (document.body.offsetHeight > window.innerHeight) {
|
||||
footer.style.marginTop = '0';
|
||||
return;
|
||||
}
|
||||
|
||||
// Add a margin-top to the footer to position it at the bottom of the screen
|
||||
const sibling = footer.previousElementSibling;
|
||||
const margin = (window.innerHeight - sibling.offsetTop - sibling.offsetHeight - footer.offsetHeight);
|
||||
if (margin < 1) {
|
||||
footer.style.marginTop = '0';
|
||||
return;
|
||||
}
|
||||
footer.style.marginTop = `${margin}px`;
|
||||
};
|
||||
|
||||
const adjustHeaderWidth = () => {
|
||||
// If there is no header, do nothing
|
||||
const header = document.getElementById('base-header');
|
||||
if (!header) { return; }
|
||||
|
||||
const tempDiv = document.createElement('div');
|
||||
tempDiv.style.width = '100px';
|
||||
tempDiv.style.height = '100px';
|
||||
tempDiv.style.overflow = 'scroll';
|
||||
tempDiv.style.position = 'absolute';
|
||||
tempDiv.style.top = '-500px';
|
||||
document.body.appendChild(tempDiv);
|
||||
const scrollbarWidth = tempDiv.offsetWidth - tempDiv.clientWidth;
|
||||
document.body.removeChild(tempDiv);
|
||||
|
||||
const documentRoot = document.compatMode === 'BackCompat' ? document.body : document.documentElement;
|
||||
const margin = (documentRoot.scrollHeight > documentRoot.clientHeight) ? 0-scrollbarWidth : 0;
|
||||
document.getElementById('base-header-right').style.marginRight = `${margin}px`;
|
||||
};
|
||||
|
||||
window.addEventListener('load', () => {
|
||||
window.addEventListener('resize', adjustFooterHeight);
|
||||
window.addEventListener('resize', adjustHeaderWidth);
|
||||
adjustFooterHeight();
|
||||
adjustHeaderWidth();
|
||||
});
|
||||
@@ -1,16 +1,18 @@
|
||||
window.addEventListener('load', () => {
|
||||
// Add toggle listener to all elements with .collapse-toggle
|
||||
const toggleButtons = document.querySelectorAll('details');
|
||||
const toggleButtons = document.querySelectorAll('.collapse-toggle');
|
||||
toggleButtons.forEach((e) => e.addEventListener('click', toggleCollapse));
|
||||
|
||||
// Handle game filter input
|
||||
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
|
||||
// If input is empty, display all collapsed games
|
||||
return toggleButtons.forEach((header) => {
|
||||
header.style.display = null;
|
||||
header.removeAttribute('open');
|
||||
header.firstElementChild.innerText = '▶';
|
||||
header.nextElementSibling.classList.add('collapsed');
|
||||
});
|
||||
}
|
||||
|
||||
@@ -19,10 +21,12 @@ window.addEventListener('load', () => {
|
||||
// If the game name includes the search string, display the game. If not, hide it
|
||||
if (header.getAttribute('data-game').toLowerCase().includes(evt.target.value.toLowerCase())) {
|
||||
header.style.display = null;
|
||||
header.setAttribute('open', '1');
|
||||
header.firstElementChild.innerText = '▼';
|
||||
header.nextElementSibling.classList.remove('collapsed');
|
||||
} else {
|
||||
header.style.display = 'none';
|
||||
header.removeAttribute('open');
|
||||
header.firstElementChild.innerText = '▶';
|
||||
header.nextElementSibling.classList.add('collapsed');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -31,14 +35,30 @@ window.addEventListener('load', () => {
|
||||
document.getElementById('collapse-all').addEventListener('click', collapseAll);
|
||||
});
|
||||
|
||||
const toggleCollapse = (evt) => {
|
||||
const gameArrow = evt.target.firstElementChild;
|
||||
const gameInfo = evt.target.nextElementSibling;
|
||||
if (gameInfo.classList.contains('collapsed')) {
|
||||
gameArrow.innerText = '▼';
|
||||
gameInfo.classList.remove('collapsed');
|
||||
} else {
|
||||
gameArrow.innerText = '▶';
|
||||
gameInfo.classList.add('collapsed');
|
||||
}
|
||||
};
|
||||
|
||||
const expandAll = () => {
|
||||
document.querySelectorAll('details').forEach((detail) => {
|
||||
detail.setAttribute('open', '1');
|
||||
document.querySelectorAll('.collapse-toggle').forEach((header) => {
|
||||
if (header.style.display === 'none') { return; }
|
||||
header.firstElementChild.innerText = '▼';
|
||||
header.nextElementSibling.classList.remove('collapsed');
|
||||
});
|
||||
};
|
||||
|
||||
const collapseAll = () => {
|
||||
document.querySelectorAll('details').forEach((detail) => {
|
||||
detail.removeAttribute('open');
|
||||
document.querySelectorAll('.collapse-toggle').forEach((header) => {
|
||||
if (header.style.display === 'none') { return; }
|
||||
header.firstElementChild.innerText = '▶';
|
||||
header.nextElementSibling.classList.add('collapsed');
|
||||
});
|
||||
};
|
||||
|
||||
@@ -27,7 +27,7 @@ const adjustTableHeight = () => {
|
||||
* @returns {string}
|
||||
*/
|
||||
const secondsToHours = (seconds) => {
|
||||
let hours = Math.floor(seconds / 3600);
|
||||
let hours = Math.floor(seconds / 3600);
|
||||
let minutes = Math.floor((seconds - (hours * 3600)) / 60).toString().padStart(2, '0');
|
||||
return `${hours}:${minutes}`;
|
||||
};
|
||||
@@ -38,18 +38,18 @@ window.addEventListener('load', () => {
|
||||
info: false,
|
||||
dom: "t",
|
||||
stateSave: true,
|
||||
stateSaveCallback: function (settings, data) {
|
||||
stateSaveCallback: function(settings, data) {
|
||||
delete data.search;
|
||||
localStorage.setItem(`DataTables_${settings.sInstance}_/tracker`, JSON.stringify(data));
|
||||
},
|
||||
stateLoadCallback: function (settings) {
|
||||
stateLoadCallback: function(settings) {
|
||||
return JSON.parse(localStorage.getItem(`DataTables_${settings.sInstance}_/tracker`));
|
||||
},
|
||||
footerCallback: function (tfoot, data, start, end, display) {
|
||||
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';
|
||||
(activityData.length) ? secondsToHours(Math.min(...activityData)) : 'None';
|
||||
}
|
||||
},
|
||||
columnDefs: [
|
||||
@@ -123,64 +123,49 @@ window.addEventListener('load', () => {
|
||||
event.preventDefault();
|
||||
}
|
||||
});
|
||||
const target_second = parseInt(document.getElementById('tracker-wrapper').getAttribute('data-second')) + 3;
|
||||
console.log("Target second of refresh: " + target_second);
|
||||
const tracker = document.getElementById('tracker-wrapper').getAttribute('data-tracker');
|
||||
const target_second = document.getElementById('tracker-wrapper').getAttribute('data-second') + 3;
|
||||
|
||||
function getSleepTimeSeconds() {
|
||||
function getSleepTimeSeconds(){
|
||||
// -40 % 60 is -40, which is absolutely wrong and should burn
|
||||
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);
|
||||
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);
|
||||
}
|
||||
})
|
||||
setTimeout(update, getSleepTimeSeconds()*1000);
|
||||
}
|
||||
let updater = setTimeout(update, getSleepTimeSeconds() * 1000);
|
||||
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();
|
||||
});
|
||||
|
||||
58
WebHostLib/static/assets/tutorial.js
Normal file
@@ -0,0 +1,58 @@
|
||||
window.addEventListener('load', () => {
|
||||
const tutorialWrapper = document.getElementById('tutorial-wrapper');
|
||||
new Promise((resolve, reject) => {
|
||||
const ajax = new XMLHttpRequest();
|
||||
ajax.onreadystatechange = () => {
|
||||
if (ajax.readyState !== 4) { return; }
|
||||
if (ajax.status === 404) {
|
||||
reject("Sorry, the tutorial is not available in that language yet.");
|
||||
return;
|
||||
}
|
||||
if (ajax.status !== 200) {
|
||||
reject("Something went wrong while loading the tutorial.");
|
||||
return;
|
||||
}
|
||||
resolve(ajax.responseText);
|
||||
};
|
||||
ajax.open('GET', `${window.location.origin}/static/generated/docs/` +
|
||||
`${tutorialWrapper.getAttribute('data-game')}/${tutorialWrapper.getAttribute('data-file')}_` +
|
||||
`${tutorialWrapper.getAttribute('data-lang')}.md`, true);
|
||||
ajax.send();
|
||||
}).then((results) => {
|
||||
// Populate page with HTML generated from markdown
|
||||
showdown.setOption('tables', true);
|
||||
showdown.setOption('strikethrough', true);
|
||||
showdown.setOption('literalMidWordUnderscores', true);
|
||||
showdown.setOption('disableForced4SpacesIndentedSublists', true);
|
||||
tutorialWrapper.innerHTML += (new showdown.Converter()).makeHtml(results);
|
||||
adjustHeaderWidth();
|
||||
|
||||
const title = document.querySelector('h1')
|
||||
if (title) {
|
||||
document.title = title.textContent;
|
||||
}
|
||||
|
||||
// Reset the id of all header divs to something nicer
|
||||
for (const header of document.querySelectorAll('h1, h2, h3, h4, h5, h6')) {
|
||||
const headerId = header.innerText.replace(/\s+/g, '-').toLowerCase();
|
||||
header.setAttribute('id', headerId);
|
||||
header.addEventListener('click', () => {
|
||||
window.location.hash = `#${headerId}`;
|
||||
header.scrollIntoView();
|
||||
});
|
||||
}
|
||||
|
||||
// Manually scroll the user to the appropriate header if anchor navigation is used
|
||||
document.fonts.ready.finally(() => {
|
||||
if (window.location.hash) {
|
||||
const scrollTarget = document.getElementById(window.location.hash.substring(1));
|
||||
scrollTarget?.scrollIntoView();
|
||||
}
|
||||
});
|
||||
}).catch((error) => {
|
||||
console.error(error);
|
||||
tutorialWrapper.innerHTML =
|
||||
`<h2>This page is out of logic!</h2>
|
||||
<h3>Click <a href="${window.location.origin}/tutorial">here</a> to return to safety.</h3>`;
|
||||
});
|
||||
});
|
||||
81
WebHostLib/static/assets/tutorialLanding.js
Normal file
@@ -0,0 +1,81 @@
|
||||
const showError = () => {
|
||||
const tutorial = document.getElementById('tutorial-landing');
|
||||
document.getElementById('page-title').innerText = 'This page is out of logic!';
|
||||
tutorial.removeChild(document.getElementById('loading'));
|
||||
const userMessage = document.createElement('h3');
|
||||
const homepageLink = document.createElement('a');
|
||||
homepageLink.innerText = 'Click here';
|
||||
homepageLink.setAttribute('href', '/');
|
||||
userMessage.append(homepageLink);
|
||||
userMessage.append(' to go back to safety!');
|
||||
tutorial.append(userMessage);
|
||||
};
|
||||
|
||||
window.addEventListener('load', () => {
|
||||
const ajax = new XMLHttpRequest();
|
||||
ajax.onreadystatechange = () => {
|
||||
if (ajax.readyState !== 4) { return; }
|
||||
const tutorialDiv = document.getElementById('tutorial-landing');
|
||||
if (ajax.status !== 200) { return showError(); }
|
||||
|
||||
try {
|
||||
const games = JSON.parse(ajax.responseText);
|
||||
games.forEach((game) => {
|
||||
const gameTitle = document.createElement('h2');
|
||||
gameTitle.innerText = game.gameTitle;
|
||||
gameTitle.id = `${encodeURIComponent(game.gameTitle)}`;
|
||||
tutorialDiv.appendChild(gameTitle);
|
||||
|
||||
game.tutorials.forEach((tutorial) => {
|
||||
const tutorialName = document.createElement('h3');
|
||||
tutorialName.innerText = tutorial.name;
|
||||
tutorialDiv.appendChild(tutorialName);
|
||||
|
||||
const tutorialDescription = document.createElement('p');
|
||||
tutorialDescription.innerText = tutorial.description;
|
||||
tutorialDiv.appendChild(tutorialDescription);
|
||||
|
||||
const intro = document.createElement('p');
|
||||
intro.innerText = 'This guide is available in the following languages:';
|
||||
tutorialDiv.appendChild(intro);
|
||||
|
||||
const fileList = document.createElement('ul');
|
||||
tutorial.files.forEach((file) => {
|
||||
const listItem = document.createElement('li');
|
||||
const anchor = document.createElement('a');
|
||||
anchor.innerText = file.language;
|
||||
anchor.setAttribute('href', `${window.location.origin}/tutorial/${file.link}`);
|
||||
listItem.appendChild(anchor);
|
||||
|
||||
listItem.append(' by ');
|
||||
for (let author of file.authors) {
|
||||
listItem.append(author);
|
||||
if (file.authors.indexOf(author) !== (file.authors.length -1)) {
|
||||
listItem.append(', ');
|
||||
}
|
||||
}
|
||||
|
||||
fileList.appendChild(listItem);
|
||||
});
|
||||
tutorialDiv.appendChild(fileList);
|
||||
});
|
||||
});
|
||||
|
||||
tutorialDiv.removeChild(document.getElementById('loading'));
|
||||
} catch (error) {
|
||||
showError();
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
// Check if we are on an anchor when coming in, and scroll to it.
|
||||
const hash = window.location.hash;
|
||||
if (hash) {
|
||||
const offset = 128; // To account for navbar banner at top of page.
|
||||
window.scrollTo(0, 0);
|
||||
const rect = document.getElementById(hash.slice(1)).getBoundingClientRect();
|
||||
window.scrollTo(rect.left, rect.top - offset);
|
||||
}
|
||||
};
|
||||
ajax.open('GET', `${window.location.origin}/static/generated/tutorials.json`, true);
|
||||
ajax.send();
|
||||
});
|
||||
1190
WebHostLib/static/assets/weighted-options.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 |