mirror of
https://github.com/ArchipelagoMW/Archipelago.git
synced 2026-03-27 18:43:22 -07:00
Compare commits
3 Commits
active/rc-
...
NewSoupVi-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
930f627794 | ||
|
|
d4fc90410c | ||
|
|
484c5f2671 |
210
.dockerignore
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
|
|
||||||
1
.github/labeler.yml
vendored
1
.github/labeler.yml
vendored
@@ -21,6 +21,7 @@
|
|||||||
- '!data/**'
|
- '!data/**'
|
||||||
- '!.run/**'
|
- '!.run/**'
|
||||||
- '!.github/**'
|
- '!.github/**'
|
||||||
|
- '!worlds_disabled/**'
|
||||||
- '!worlds/**'
|
- '!worlds/**'
|
||||||
- '!WebHost.py'
|
- '!WebHost.py'
|
||||||
- '!WebHostLib/**'
|
- '!WebHostLib/**'
|
||||||
|
|||||||
7
.github/pyright-config.json
vendored
7
.github/pyright-config.json
vendored
@@ -2,15 +2,10 @@
|
|||||||
"include": [
|
"include": [
|
||||||
"../BizHawkClient.py",
|
"../BizHawkClient.py",
|
||||||
"../Patch.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_groups.py",
|
||||||
"../test/general/test_helpers.py",
|
"../test/general/test_helpers.py",
|
||||||
"../test/general/test_memory.py",
|
"../test/general/test_memory.py",
|
||||||
"../test/general/test_names.py",
|
"../test/general/test_names.py",
|
||||||
"../test/general/test_rule_builder.py",
|
|
||||||
"../test/multiworld/__init__.py",
|
"../test/multiworld/__init__.py",
|
||||||
"../test/multiworld/test_multiworlds.py",
|
"../test/multiworld/test_multiworlds.py",
|
||||||
"../test/netutils/__init__.py",
|
"../test/netutils/__init__.py",
|
||||||
@@ -33,7 +28,7 @@
|
|||||||
"reportMissingImports": true,
|
"reportMissingImports": true,
|
||||||
"reportMissingTypeStubs": true,
|
"reportMissingTypeStubs": true,
|
||||||
|
|
||||||
"pythonVersion": "3.11",
|
"pythonVersion": "3.10",
|
||||||
"pythonPlatform": "Windows",
|
"pythonPlatform": "Windows",
|
||||||
|
|
||||||
"executionEnvironments": [
|
"executionEnvironments": [
|
||||||
|
|||||||
4
.github/workflows/analyze-modified-files.yml
vendored
4
.github/workflows/analyze-modified-files.yml
vendored
@@ -53,7 +53,7 @@ jobs:
|
|||||||
- uses: actions/setup-python@v5
|
- uses: actions/setup-python@v5
|
||||||
if: env.diff != ''
|
if: env.diff != ''
|
||||||
with:
|
with:
|
||||||
python-version: '3.11'
|
python-version: '3.10'
|
||||||
|
|
||||||
- name: "Install dependencies"
|
- name: "Install dependencies"
|
||||||
if: env.diff != ''
|
if: env.diff != ''
|
||||||
@@ -65,7 +65,7 @@ jobs:
|
|||||||
continue-on-error: false
|
continue-on-error: false
|
||||||
if: env.diff != '' && matrix.task == 'flake8'
|
if: env.diff != '' && matrix.task == 'flake8'
|
||||||
run: |
|
run: |
|
||||||
flake8 --count --select=E9,F63,F7,F82 --ignore F824 --show-source --statistics ${{ env.diff }}
|
flake8 --count --select=E9,F63,F7,F82 --show-source --statistics ${{ env.diff }}
|
||||||
|
|
||||||
- name: "flake8: Lint modified files"
|
- name: "flake8: Lint modified files"
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
|
|||||||
59
.github/workflows/build.yml
vendored
59
.github/workflows/build.yml
vendored
@@ -1,5 +1,4 @@
|
|||||||
# This workflow will build a release-like distribution when manually dispatched:
|
# 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.
|
|
||||||
|
|
||||||
name: Build
|
name: Build
|
||||||
|
|
||||||
@@ -10,37 +9,24 @@ on:
|
|||||||
- 'setup.py'
|
- 'setup.py'
|
||||||
- 'requirements.txt'
|
- 'requirements.txt'
|
||||||
- '*.iss'
|
- '*.iss'
|
||||||
- 'worlds/*/archipelago.json'
|
|
||||||
pull_request:
|
pull_request:
|
||||||
paths:
|
paths:
|
||||||
- '.github/workflows/build.yml'
|
- '.github/workflows/build.yml'
|
||||||
- 'setup.py'
|
- 'setup.py'
|
||||||
- 'requirements.txt'
|
- 'requirements.txt'
|
||||||
- '*.iss'
|
- '*.iss'
|
||||||
- 'worlds/*/archipelago.json'
|
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
env:
|
env:
|
||||||
ENEMIZER_VERSION: 7.1
|
ENEMIZER_VERSION: 7.1
|
||||||
# NOTE: since appimage/appimagetool and appimage/type2-runtime does not have tags anymore,
|
APPIMAGETOOL_VERSION: 13
|
||||||
# we check the sha256 and require manual intervention if it was updated.
|
|
||||||
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'
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
# build-release-macos: # LF volunteer
|
# build-release-macos: # LF volunteer
|
||||||
|
|
||||||
build-win: # RCs and releases may still be built and signed by hand
|
build-win: # RCs will still be built and signed by hand
|
||||||
runs-on: windows-latest
|
runs-on: windows-latest
|
||||||
steps:
|
steps:
|
||||||
# - copy code below to release.yml -
|
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- name: Install python
|
- name: Install python
|
||||||
uses: actions/setup-python@v5
|
uses: actions/setup-python@v5
|
||||||
@@ -51,7 +37,7 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
Invoke-WebRequest -Uri https://github.com/Ijwu/Enemizer/releases/download/${Env:ENEMIZER_VERSION}/win-x64.zip -OutFile enemizer.zip
|
Invoke-WebRequest -Uri https://github.com/Ijwu/Enemizer/releases/download/${Env:ENEMIZER_VERSION}/win-x64.zip -OutFile enemizer.zip
|
||||||
Expand-Archive -Path enemizer.zip -DestinationPath EnemizerCLI -Force
|
Expand-Archive -Path enemizer.zip -DestinationPath EnemizerCLI -Force
|
||||||
choco install innosetup --version=6.7.0 --allow-downgrade
|
choco install innosetup --version=6.2.2 --allow-downgrade
|
||||||
- name: Build
|
- name: Build
|
||||||
run: |
|
run: |
|
||||||
python -m pip install --upgrade pip
|
python -m pip install --upgrade pip
|
||||||
@@ -79,18 +65,6 @@ jobs:
|
|||||||
$contents = Get-ChildItem -Path setups/*.exe -Force -Recurse
|
$contents = Get-ChildItem -Path setups/*.exe -Force -Recurse
|
||||||
$SETUP_NAME=$contents[0].Name
|
$SETUP_NAME=$contents[0].Name
|
||||||
echo "SETUP_NAME=$SETUP_NAME" >> $Env:GITHUB_ENV
|
echo "SETUP_NAME=$SETUP_NAME" >> $Env:GITHUB_ENV
|
||||||
# - copy code above to release.yml -
|
|
||||||
- name: Attest Build
|
|
||||||
if: ${{ github.event_name == 'workflow_dispatch' }}
|
|
||||||
uses: actions/attest-build-provenance@v2
|
|
||||||
with:
|
|
||||||
subject-path: |
|
|
||||||
build/exe.*/ArchipelagoLauncher.exe
|
|
||||||
build/exe.*/ArchipelagoLauncherDebug.exe
|
|
||||||
build/exe.*/ArchipelagoGenerate.exe
|
|
||||||
build/exe.*/ArchipelagoServer.exe
|
|
||||||
dist/${{ env.ZIP_NAME }}
|
|
||||||
setups/${{ env.SETUP_NAME }}
|
|
||||||
- name: Check build loads expected worlds
|
- name: Check build loads expected worlds
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
@@ -107,7 +81,7 @@ jobs:
|
|||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
cd build/exe*
|
cd build/exe*
|
||||||
cp Players/Templates/VVVVVV.yaml Players/
|
cp Players/Templates/Clique.yaml Players/
|
||||||
timeout 30 ./ArchipelagoGenerate
|
timeout 30 ./ArchipelagoGenerate
|
||||||
- name: Store 7z
|
- name: Store 7z
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
@@ -125,8 +99,8 @@ jobs:
|
|||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
retention-days: 7 # keep for 7 days, should be enough
|
retention-days: 7 # keep for 7 days, should be enough
|
||||||
|
|
||||||
build-ubuntu2204:
|
build-ubuntu2004:
|
||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-20.04
|
||||||
steps:
|
steps:
|
||||||
# - copy code below to release.yml -
|
# - copy code below to release.yml -
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
@@ -143,13 +117,10 @@ jobs:
|
|||||||
- name: Install build-time dependencies
|
- name: Install build-time dependencies
|
||||||
run: |
|
run: |
|
||||||
echo "PYTHON=python3.12" >> $GITHUB_ENV
|
echo "PYTHON=python3.12" >> $GITHUB_ENV
|
||||||
wget -nv https://github.com/$APPIMAGE_FORK/appimagetool/releases/download/$APPIMAGETOOL_VERSION/appimagetool-x86_64.AppImage
|
wget -nv https://github.com/AppImage/AppImageKit/releases/download/$APPIMAGETOOL_VERSION/appimagetool-x86_64.AppImage
|
||||||
echo "$APPIMAGETOOL_X86_64_HASH appimagetool-x86_64.AppImage" | sha256sum -c
|
|
||||||
wget -nv https://github.com/$APPIMAGE_FORK/type2-runtime/releases/download/$APPIMAGE_RUNTIME_VERSION/runtime-x86_64
|
|
||||||
echo "$APPIMAGE_RUNTIME_X86_64_HASH runtime-x86_64" | sha256sum -c
|
|
||||||
chmod a+rx appimagetool-x86_64.AppImage
|
chmod a+rx appimagetool-x86_64.AppImage
|
||||||
./appimagetool-x86_64.AppImage --appimage-extract
|
./appimagetool-x86_64.AppImage --appimage-extract
|
||||||
echo -e '#/bin/sh\n./squashfs-root/AppRun --runtime-file runtime-x86_64 "$@"' > appimagetool
|
echo -e '#/bin/sh\n./squashfs-root/AppRun "$@"' > appimagetool
|
||||||
chmod a+rx appimagetool
|
chmod a+rx appimagetool
|
||||||
- name: Download run-time dependencies
|
- name: Download run-time dependencies
|
||||||
run: |
|
run: |
|
||||||
@@ -171,16 +142,6 @@ jobs:
|
|||||||
echo "APPIMAGE_NAME=$APPIMAGE_NAME" >> $GITHUB_ENV
|
echo "APPIMAGE_NAME=$APPIMAGE_NAME" >> $GITHUB_ENV
|
||||||
echo "TAR_NAME=$TAR_NAME" >> $GITHUB_ENV
|
echo "TAR_NAME=$TAR_NAME" >> $GITHUB_ENV
|
||||||
# - copy code above to release.yml -
|
# - copy code above to release.yml -
|
||||||
- name: Attest Build
|
|
||||||
if: ${{ github.event_name == 'workflow_dispatch' }}
|
|
||||||
uses: actions/attest-build-provenance@v2
|
|
||||||
with:
|
|
||||||
subject-path: |
|
|
||||||
build/exe.*/ArchipelagoLauncher
|
|
||||||
build/exe.*/ArchipelagoGenerate
|
|
||||||
build/exe.*/ArchipelagoServer
|
|
||||||
dist/${{ env.APPIMAGE_NAME }}*
|
|
||||||
dist/${{ env.TAR_NAME }}
|
|
||||||
- name: Build Again
|
- name: Build Again
|
||||||
run: |
|
run: |
|
||||||
source venv/bin/activate
|
source venv/bin/activate
|
||||||
@@ -201,7 +162,7 @@ jobs:
|
|||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
cd build/exe*
|
cd build/exe*
|
||||||
cp Players/Templates/VVVVVV.yaml Players/
|
cp Players/Templates/Clique.yaml Players/
|
||||||
timeout 30 ./ArchipelagoGenerate
|
timeout 30 ./ArchipelagoGenerate
|
||||||
- name: Store AppImage
|
- name: Store AppImage
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
|
|||||||
4
.github/workflows/ctest.yml
vendored
4
.github/workflows/ctest.yml
vendored
@@ -36,9 +36,9 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: ilammy/msvc-dev-cmd@0b201ec74fa43914dc39ae48a89fd1d8cb592756
|
- uses: ilammy/msvc-dev-cmd@v1
|
||||||
if: startsWith(matrix.os,'windows')
|
if: startsWith(matrix.os,'windows')
|
||||||
- uses: Bacondish2023/setup-googletest@49065d1f7a6d21f6134864dd65980fe5dbe06c73
|
- uses: Bacondish2023/setup-googletest@v1
|
||||||
with:
|
with:
|
||||||
build-type: 'Release'
|
build-type: 'Release'
|
||||||
- name: Build tests
|
- name: Build tests
|
||||||
|
|||||||
154
.github/workflows/docker.yml
vendored
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
|
|
||||||
3
.github/workflows/label-pull-requests.yml
vendored
3
.github/workflows/label-pull-requests.yml
vendored
@@ -6,12 +6,11 @@ on:
|
|||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
pull-requests: write
|
pull-requests: write
|
||||||
env:
|
|
||||||
GH_REPO: ${{ github.repository }}
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
labeler:
|
labeler:
|
||||||
name: 'Apply content-based labels'
|
name: 'Apply content-based labels'
|
||||||
|
if: github.event.action == 'opened' || github.event.action == 'reopened' || github.event.action == 'synchronize'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/labeler@v5
|
- uses: actions/labeler@v5
|
||||||
|
|||||||
104
.github/workflows/release.yml
vendored
104
.github/workflows/release.yml
vendored
@@ -5,22 +5,11 @@ name: Release
|
|||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
tags:
|
tags:
|
||||||
- 'v?[0-9]+.[0-9]+.[0-9]*'
|
- '*.*.*'
|
||||||
|
|
||||||
env:
|
env:
|
||||||
ENEMIZER_VERSION: 7.1
|
ENEMIZER_VERSION: 7.1
|
||||||
# NOTE: since appimage/appimagetool and appimage/type2-runtime does not have tags anymore,
|
APPIMAGETOOL_VERSION: 13
|
||||||
# we check the sha256 and require manual intervention if it was updated.
|
|
||||||
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
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
create-release:
|
create-release:
|
||||||
@@ -37,79 +26,11 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
# build-release-windows: # this is done by hand because of signing
|
||||||
# build-release-macos: # LF volunteer
|
# build-release-macos: # LF volunteer
|
||||||
|
|
||||||
build-release-win:
|
build-release-ubuntu2004:
|
||||||
runs-on: windows-latest
|
runs-on: ubuntu-20.04
|
||||||
if: ${{ true }} # change to false to skip if release is built by hand
|
|
||||||
needs: create-release
|
|
||||||
steps:
|
|
||||||
- name: Set env
|
|
||||||
shell: bash
|
|
||||||
run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV
|
|
||||||
# - code below copied from build.yml -
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- name: Install python
|
|
||||||
uses: actions/setup-python@v5
|
|
||||||
with:
|
|
||||||
python-version: '~3.12.7'
|
|
||||||
check-latest: true
|
|
||||||
- name: Download run-time dependencies
|
|
||||||
run: |
|
|
||||||
Invoke-WebRequest -Uri https://github.com/Ijwu/Enemizer/releases/download/${Env:ENEMIZER_VERSION}/win-x64.zip -OutFile enemizer.zip
|
|
||||||
Expand-Archive -Path enemizer.zip -DestinationPath EnemizerCLI -Force
|
|
||||||
choco install innosetup --version=6.2.2 --allow-downgrade
|
|
||||||
- name: Build
|
|
||||||
run: |
|
|
||||||
python -m pip install --upgrade pip
|
|
||||||
python setup.py build_exe --yes
|
|
||||||
if ( $? -eq $false ) {
|
|
||||||
Write-Error "setup.py failed!"
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
$NAME="$(ls build | Select-String -Pattern 'exe')".Split('.',2)[1]
|
|
||||||
$ZIP_NAME="Archipelago_$NAME.7z"
|
|
||||||
echo "$NAME -> $ZIP_NAME"
|
|
||||||
echo "ZIP_NAME=$ZIP_NAME" >> $Env:GITHUB_ENV
|
|
||||||
New-Item -Path dist -ItemType Directory -Force
|
|
||||||
cd build
|
|
||||||
Rename-Item "exe.$NAME" Archipelago
|
|
||||||
7z a -mx=9 -mhe=on -ms "../dist/$ZIP_NAME" Archipelago
|
|
||||||
Rename-Item Archipelago "exe.$NAME" # inno_setup.iss expects the original name
|
|
||||||
- name: Build Setup
|
|
||||||
run: |
|
|
||||||
& "${env:ProgramFiles(x86)}\Inno Setup 6\iscc.exe" inno_setup.iss /DNO_SIGNTOOL
|
|
||||||
if ( $? -eq $false ) {
|
|
||||||
Write-Error "Building setup failed!"
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
$contents = Get-ChildItem -Path setups/*.exe -Force -Recurse
|
|
||||||
$SETUP_NAME=$contents[0].Name
|
|
||||||
echo "SETUP_NAME=$SETUP_NAME" >> $Env:GITHUB_ENV
|
|
||||||
# - code above copied from build.yml -
|
|
||||||
- name: Attest Build
|
|
||||||
uses: actions/attest-build-provenance@v2
|
|
||||||
with:
|
|
||||||
subject-path: |
|
|
||||||
build/exe.*/ArchipelagoLauncher.exe
|
|
||||||
build/exe.*/ArchipelagoLauncherDebug.exe
|
|
||||||
build/exe.*/ArchipelagoGenerate.exe
|
|
||||||
build/exe.*/ArchipelagoServer.exe
|
|
||||||
setups/*
|
|
||||||
- name: Add to Release
|
|
||||||
uses: softprops/action-gh-release@975c1b265e11dd76618af1c374e7981f9a6ff44a
|
|
||||||
with:
|
|
||||||
draft: true # see above
|
|
||||||
prerelease: false
|
|
||||||
name: Archipelago ${{ env.RELEASE_VERSION }}
|
|
||||||
files: |
|
|
||||||
setups/*
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|
||||||
build-release-ubuntu2204:
|
|
||||||
runs-on: ubuntu-22.04
|
|
||||||
needs: create-release
|
|
||||||
steps:
|
steps:
|
||||||
- name: Set env
|
- name: Set env
|
||||||
run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV
|
run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV
|
||||||
@@ -128,13 +49,10 @@ jobs:
|
|||||||
- name: Install build-time dependencies
|
- name: Install build-time dependencies
|
||||||
run: |
|
run: |
|
||||||
echo "PYTHON=python3.12" >> $GITHUB_ENV
|
echo "PYTHON=python3.12" >> $GITHUB_ENV
|
||||||
wget -nv https://github.com/$APPIMAGE_FORK/appimagetool/releases/download/$APPIMAGETOOL_VERSION/appimagetool-x86_64.AppImage
|
wget -nv https://github.com/AppImage/AppImageKit/releases/download/$APPIMAGETOOL_VERSION/appimagetool-x86_64.AppImage
|
||||||
echo "$APPIMAGETOOL_X86_64_HASH appimagetool-x86_64.AppImage" | sha256sum -c
|
|
||||||
wget -nv https://github.com/$APPIMAGE_FORK/type2-runtime/releases/download/$APPIMAGE_RUNTIME_VERSION/runtime-x86_64
|
|
||||||
echo "$APPIMAGE_RUNTIME_X86_64_HASH runtime-x86_64" | sha256sum -c
|
|
||||||
chmod a+rx appimagetool-x86_64.AppImage
|
chmod a+rx appimagetool-x86_64.AppImage
|
||||||
./appimagetool-x86_64.AppImage --appimage-extract
|
./appimagetool-x86_64.AppImage --appimage-extract
|
||||||
echo -e '#/bin/sh\n./squashfs-root/AppRun --runtime-file runtime-x86_64 "$@"' > appimagetool
|
echo -e '#/bin/sh\n./squashfs-root/AppRun "$@"' > appimagetool
|
||||||
chmod a+rx appimagetool
|
chmod a+rx appimagetool
|
||||||
- name: Download run-time dependencies
|
- name: Download run-time dependencies
|
||||||
run: |
|
run: |
|
||||||
@@ -156,14 +74,6 @@ jobs:
|
|||||||
echo "APPIMAGE_NAME=$APPIMAGE_NAME" >> $GITHUB_ENV
|
echo "APPIMAGE_NAME=$APPIMAGE_NAME" >> $GITHUB_ENV
|
||||||
echo "TAR_NAME=$TAR_NAME" >> $GITHUB_ENV
|
echo "TAR_NAME=$TAR_NAME" >> $GITHUB_ENV
|
||||||
# - code above copied from build.yml -
|
# - code above copied from build.yml -
|
||||||
- name: Attest Build
|
|
||||||
uses: actions/attest-build-provenance@v2
|
|
||||||
with:
|
|
||||||
subject-path: |
|
|
||||||
build/exe.*/ArchipelagoLauncher
|
|
||||||
build/exe.*/ArchipelagoGenerate
|
|
||||||
build/exe.*/ArchipelagoServer
|
|
||||||
dist/*
|
|
||||||
- name: Add to Release
|
- name: Add to Release
|
||||||
uses: softprops/action-gh-release@975c1b265e11dd76618af1c374e7981f9a6ff44a
|
uses: softprops/action-gh-release@975c1b265e11dd76618af1c374e7981f9a6ff44a
|
||||||
with:
|
with:
|
||||||
|
|||||||
20
.github/workflows/unittests.yml
vendored
20
.github/workflows/unittests.yml
vendored
@@ -8,24 +8,18 @@ on:
|
|||||||
paths:
|
paths:
|
||||||
- '**'
|
- '**'
|
||||||
- '!docs/**'
|
- '!docs/**'
|
||||||
- '!deploy/**'
|
|
||||||
- '!setup.py'
|
- '!setup.py'
|
||||||
- '!Dockerfile'
|
|
||||||
- '!*.iss'
|
- '!*.iss'
|
||||||
- '!.gitignore'
|
- '!.gitignore'
|
||||||
- '!.dockerignore'
|
|
||||||
- '!.github/workflows/**'
|
- '!.github/workflows/**'
|
||||||
- '.github/workflows/unittests.yml'
|
- '.github/workflows/unittests.yml'
|
||||||
pull_request:
|
pull_request:
|
||||||
paths:
|
paths:
|
||||||
- '**'
|
- '**'
|
||||||
- '!docs/**'
|
- '!docs/**'
|
||||||
- '!deploy/**'
|
|
||||||
- '!setup.py'
|
- '!setup.py'
|
||||||
- '!Dockerfile'
|
|
||||||
- '!*.iss'
|
- '!*.iss'
|
||||||
- '!.gitignore'
|
- '!.gitignore'
|
||||||
- '!.dockerignore'
|
|
||||||
- '!.github/workflows/**'
|
- '!.github/workflows/**'
|
||||||
- '.github/workflows/unittests.yml'
|
- '.github/workflows/unittests.yml'
|
||||||
|
|
||||||
@@ -39,15 +33,15 @@ jobs:
|
|||||||
matrix:
|
matrix:
|
||||||
os: [ubuntu-latest]
|
os: [ubuntu-latest]
|
||||||
python:
|
python:
|
||||||
- {version: '3.11.2'} # Change to '3.11' around 2026-06-10
|
- {version: '3.10'}
|
||||||
|
- {version: '3.11'}
|
||||||
- {version: '3.12'}
|
- {version: '3.12'}
|
||||||
- {version: '3.13'}
|
|
||||||
include:
|
include:
|
||||||
- python: {version: '3.11'} # old compat
|
- python: {version: '3.10'} # old compat
|
||||||
os: windows-latest
|
os: windows-latest
|
||||||
- python: {version: '3.13'} # current
|
- python: {version: '3.12'} # current
|
||||||
os: windows-latest
|
os: windows-latest
|
||||||
- python: {version: '3.13'} # current
|
- python: {version: '3.12'} # current
|
||||||
os: macos-latest
|
os: macos-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
@@ -59,7 +53,7 @@ jobs:
|
|||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: |
|
run: |
|
||||||
python -m pip install --upgrade pip
|
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 ModuleUpdate.py --yes --force --append "WebHostLib/requirements.txt"
|
||||||
python Launcher.py --update_settings # make sure host.yaml exists for tests
|
python Launcher.py --update_settings # make sure host.yaml exists for tests
|
||||||
- name: Unittests
|
- name: Unittests
|
||||||
@@ -75,7 +69,7 @@ jobs:
|
|||||||
os:
|
os:
|
||||||
- ubuntu-latest
|
- ubuntu-latest
|
||||||
python:
|
python:
|
||||||
- {version: '3.13'} # current
|
- {version: '3.12'} # current
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|||||||
12
.gitignore
vendored
12
.gitignore
vendored
@@ -4,13 +4,11 @@
|
|||||||
*_Spoiler.txt
|
*_Spoiler.txt
|
||||||
*.bmbp
|
*.bmbp
|
||||||
*.apbp
|
*.apbp
|
||||||
*.apcivvi
|
|
||||||
*.apl2ac
|
*.apl2ac
|
||||||
*.apm3
|
*.apm3
|
||||||
*.apmc
|
*.apmc
|
||||||
*.apz5
|
*.apz5
|
||||||
*.aptloz
|
*.aptloz
|
||||||
*.aptww
|
|
||||||
*.apemerald
|
*.apemerald
|
||||||
*.pyc
|
*.pyc
|
||||||
*.pyd
|
*.pyd
|
||||||
@@ -56,6 +54,7 @@ success.txt
|
|||||||
output/
|
output/
|
||||||
Output Logs/
|
Output Logs/
|
||||||
/factorio/
|
/factorio/
|
||||||
|
/Minecraft Forge Server/
|
||||||
/WebHostLib/static/generated
|
/WebHostLib/static/generated
|
||||||
/freeze_requirements.txt
|
/freeze_requirements.txt
|
||||||
/Archipelago.zip
|
/Archipelago.zip
|
||||||
@@ -63,10 +62,7 @@ Output Logs/
|
|||||||
/installdelete.iss
|
/installdelete.iss
|
||||||
/data/user.kv
|
/data/user.kv
|
||||||
/datapackage
|
/datapackage
|
||||||
/datapackage_export.json
|
|
||||||
/custom_worlds
|
/custom_worlds
|
||||||
# stubgen output
|
|
||||||
/out/
|
|
||||||
|
|
||||||
# Byte-compiled / optimized / DLL files
|
# Byte-compiled / optimized / DLL files
|
||||||
__pycache__/
|
__pycache__/
|
||||||
@@ -186,6 +182,12 @@ _speedups.c
|
|||||||
_speedups.cpp
|
_speedups.cpp
|
||||||
_speedups.html
|
_speedups.html
|
||||||
|
|
||||||
|
# minecraft server stuff
|
||||||
|
jdk*/
|
||||||
|
minecraft*/
|
||||||
|
minecraft_versions.json
|
||||||
|
!worlds/minecraft/
|
||||||
|
|
||||||
# pyenv
|
# pyenv
|
||||||
.python-version
|
.python-version
|
||||||
|
|
||||||
|
|||||||
@@ -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,4 +1,3 @@
|
|||||||
import sys
|
|
||||||
from worlds.ahit.Client import launch
|
from worlds.ahit.Client import launch
|
||||||
import Utils
|
import Utils
|
||||||
import ModuleUpdate
|
import ModuleUpdate
|
||||||
@@ -6,4 +5,4 @@ ModuleUpdate.update()
|
|||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
Utils.init_logging("AHITClient", exception_logger="Client")
|
Utils.init_logging("AHITClient", exception_logger="Client")
|
||||||
launch(*sys.argv[1:])
|
launch()
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ from typing import List
|
|||||||
|
|
||||||
|
|
||||||
import Utils
|
import Utils
|
||||||
from settings import get_settings
|
|
||||||
from NetUtils import ClientStatus
|
from NetUtils import ClientStatus
|
||||||
from Utils import async_start
|
from Utils import async_start
|
||||||
from CommonClient import CommonContext, server_loop, gui_enabled, ClientCommandProcessor, logger, \
|
from CommonClient import CommonContext, server_loop, gui_enabled, ClientCommandProcessor, logger, \
|
||||||
@@ -81,8 +80,8 @@ class AdventureContext(CommonContext):
|
|||||||
self.local_item_locations = {}
|
self.local_item_locations = {}
|
||||||
self.dragon_speed_info = {}
|
self.dragon_speed_info = {}
|
||||||
|
|
||||||
options = get_settings().adventure_options
|
options = Utils.get_settings()
|
||||||
self.display_msgs = options.display_msgs
|
self.display_msgs = options["adventure_options"]["display_msgs"]
|
||||||
|
|
||||||
async def server_auth(self, password_requested: bool = False):
|
async def server_auth(self, password_requested: bool = False):
|
||||||
if password_requested and not self.password:
|
if password_requested and not self.password:
|
||||||
@@ -103,7 +102,7 @@ class AdventureContext(CommonContext):
|
|||||||
def on_package(self, cmd: str, args: dict):
|
def on_package(self, cmd: str, args: dict):
|
||||||
if cmd == 'Connected':
|
if cmd == 'Connected':
|
||||||
self.locations_array = None
|
self.locations_array = None
|
||||||
if get_settings().adventure_options.as_dict().get("death_link", False):
|
if Utils.get_settings()["adventure_options"].get("death_link", False):
|
||||||
self.set_deathlink = True
|
self.set_deathlink = True
|
||||||
async_start(self.get_freeincarnates_used())
|
async_start(self.get_freeincarnates_used())
|
||||||
elif cmd == "RoomInfo":
|
elif cmd == "RoomInfo":
|
||||||
@@ -407,7 +406,6 @@ async def atari_sync_task(ctx: AdventureContext):
|
|||||||
except ConnectionRefusedError:
|
except ConnectionRefusedError:
|
||||||
logger.debug("Connection Refused, Trying Again")
|
logger.debug("Connection Refused, Trying Again")
|
||||||
ctx.atari_status = CONNECTION_REFUSED_STATUS
|
ctx.atari_status = CONNECTION_REFUSED_STATUS
|
||||||
await asyncio.sleep(1)
|
|
||||||
continue
|
continue
|
||||||
except CancelledError:
|
except CancelledError:
|
||||||
pass
|
pass
|
||||||
@@ -417,9 +415,8 @@ async def atari_sync_task(ctx: AdventureContext):
|
|||||||
|
|
||||||
|
|
||||||
async def run_game(romfile):
|
async def run_game(romfile):
|
||||||
options = get_settings().adventure_options
|
auto_start = Utils.get_settings()["adventure_options"].get("rom_start", True)
|
||||||
auto_start = options.rom_start
|
rom_args = Utils.get_settings()["adventure_options"].get("rom_args")
|
||||||
rom_args = options.rom_args
|
|
||||||
if auto_start is True:
|
if auto_start is True:
|
||||||
import webbrowser
|
import webbrowser
|
||||||
webbrowser.open(romfile)
|
webbrowser.open(romfile)
|
||||||
@@ -514,7 +511,7 @@ if __name__ == '__main__':
|
|||||||
|
|
||||||
import colorama
|
import colorama
|
||||||
|
|
||||||
colorama.just_fix_windows_console()
|
colorama.init()
|
||||||
|
|
||||||
asyncio.run(main())
|
asyncio.run(main())
|
||||||
colorama.deinit()
|
colorama.deinit()
|
||||||
|
|||||||
486
BaseClasses.py
486
BaseClasses.py
@@ -5,14 +5,12 @@ import functools
|
|||||||
import logging
|
import logging
|
||||||
import random
|
import random
|
||||||
import secrets
|
import secrets
|
||||||
import warnings
|
|
||||||
from argparse import Namespace
|
from argparse import Namespace
|
||||||
from collections import Counter, deque, defaultdict
|
from collections import Counter, deque
|
||||||
from collections.abc import Callable, Collection, Iterable, Iterator, Mapping, MutableSequence, Set
|
from collections.abc import Collection, MutableSequence
|
||||||
from enum import IntEnum, IntFlag
|
from enum import IntEnum, IntFlag
|
||||||
from typing import (AbstractSet, Any, ClassVar, Dict, List, Literal, NamedTuple,
|
from typing import (AbstractSet, Any, Callable, ClassVar, Dict, Iterable, Iterator, List, Mapping, NamedTuple,
|
||||||
Optional, Protocol, Tuple, Union, TYPE_CHECKING, overload)
|
Optional, Protocol, Set, Tuple, Union, TYPE_CHECKING)
|
||||||
import dataclasses
|
|
||||||
|
|
||||||
from typing_extensions import NotRequired, TypedDict
|
from typing_extensions import NotRequired, TypedDict
|
||||||
|
|
||||||
@@ -22,7 +20,6 @@ import Utils
|
|||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from entrance_rando import ERPlacementState
|
from entrance_rando import ERPlacementState
|
||||||
from rule_builder.rules import Rule
|
|
||||||
from worlds import AutoWorld
|
from worlds import AutoWorld
|
||||||
|
|
||||||
|
|
||||||
@@ -57,21 +54,12 @@ class HasNameAndPlayer(Protocol):
|
|||||||
player: int
|
player: int
|
||||||
|
|
||||||
|
|
||||||
@dataclasses.dataclass
|
|
||||||
class PlandoItemBlock:
|
|
||||||
player: int
|
|
||||||
from_pool: bool
|
|
||||||
force: bool | Literal["silent"]
|
|
||||||
worlds: set[int] = dataclasses.field(default_factory=set)
|
|
||||||
items: list[str] = dataclasses.field(default_factory=list)
|
|
||||||
locations: list[str] = dataclasses.field(default_factory=list)
|
|
||||||
resolved_locations: list[Location] = dataclasses.field(default_factory=list)
|
|
||||||
count: dict[str, int] = dataclasses.field(default_factory=dict)
|
|
||||||
|
|
||||||
|
|
||||||
class MultiWorld():
|
class MultiWorld():
|
||||||
debug_types = False
|
debug_types = False
|
||||||
player_name: Dict[int, str]
|
player_name: Dict[int, str]
|
||||||
|
plando_texts: List[Dict[str, str]]
|
||||||
|
plando_items: List[List[Dict[str, Any]]]
|
||||||
|
plando_connections: List
|
||||||
worlds: Dict[int, "AutoWorld.World"]
|
worlds: Dict[int, "AutoWorld.World"]
|
||||||
groups: Dict[int, Group]
|
groups: Dict[int, Group]
|
||||||
regions: RegionManager
|
regions: RegionManager
|
||||||
@@ -86,7 +74,7 @@ class MultiWorld():
|
|||||||
local_items: Dict[int, Options.LocalItems]
|
local_items: Dict[int, Options.LocalItems]
|
||||||
non_local_items: Dict[int, Options.NonLocalItems]
|
non_local_items: Dict[int, Options.NonLocalItems]
|
||||||
progression_balancing: Dict[int, Options.ProgressionBalancing]
|
progression_balancing: Dict[int, Options.ProgressionBalancing]
|
||||||
completion_condition: Dict[int, CollectionRule]
|
completion_condition: Dict[int, Callable[[CollectionState], bool]]
|
||||||
indirect_connections: Dict[Region, Set[Entrance]]
|
indirect_connections: Dict[Region, Set[Entrance]]
|
||||||
exclude_locations: Dict[int, Options.ExcludeLocations]
|
exclude_locations: Dict[int, Options.ExcludeLocations]
|
||||||
priority_locations: Dict[int, Options.PriorityLocations]
|
priority_locations: Dict[int, Options.PriorityLocations]
|
||||||
@@ -95,8 +83,6 @@ class MultiWorld():
|
|||||||
start_location_hints: Dict[int, Options.StartLocationHints]
|
start_location_hints: Dict[int, Options.StartLocationHints]
|
||||||
item_links: Dict[int, Options.ItemLinks]
|
item_links: Dict[int, Options.ItemLinks]
|
||||||
|
|
||||||
plando_item_blocks: Dict[int, List[PlandoItemBlock]]
|
|
||||||
|
|
||||||
game: Dict[int, str]
|
game: Dict[int, str]
|
||||||
|
|
||||||
random: random.Random
|
random: random.Random
|
||||||
@@ -155,11 +141,17 @@ class MultiWorld():
|
|||||||
self.algorithm = 'balanced'
|
self.algorithm = 'balanced'
|
||||||
self.groups = {}
|
self.groups = {}
|
||||||
self.regions = self.RegionManager(players)
|
self.regions = self.RegionManager(players)
|
||||||
|
self.shops = []
|
||||||
self.itempool = []
|
self.itempool = []
|
||||||
self.seed = None
|
self.seed = None
|
||||||
self.seed_name: str = "Unavailable"
|
self.seed_name: str = "Unavailable"
|
||||||
self.precollected_items = {player: [] for player in self.player_ids}
|
self.precollected_items = {player: [] for player in self.player_ids}
|
||||||
self.required_locations = []
|
self.required_locations = []
|
||||||
|
self.light_world_light_cone = False
|
||||||
|
self.dark_world_light_cone = False
|
||||||
|
self.rupoor_cost = 10
|
||||||
|
self.aga_randomness = True
|
||||||
|
self.save_and_quit_from_boss = True
|
||||||
self.custom = False
|
self.custom = False
|
||||||
self.customitemarray = []
|
self.customitemarray = []
|
||||||
self.shuffle_ganon = True
|
self.shuffle_ganon = True
|
||||||
@@ -168,17 +160,18 @@ class MultiWorld():
|
|||||||
self.local_early_items = {player: {} for player in self.player_ids}
|
self.local_early_items = {player: {} for player in self.player_ids}
|
||||||
self.indirect_connections = {}
|
self.indirect_connections = {}
|
||||||
self.start_inventory_from_pool: Dict[int, Options.StartInventoryPool] = {}
|
self.start_inventory_from_pool: Dict[int, Options.StartInventoryPool] = {}
|
||||||
self.plando_item_blocks = {}
|
|
||||||
|
|
||||||
for player in range(1, players + 1):
|
for player in range(1, players + 1):
|
||||||
def set_player_attr(attr: str, val) -> None:
|
def set_player_attr(attr: str, val) -> None:
|
||||||
self.__dict__.setdefault(attr, {})[player] = val
|
self.__dict__.setdefault(attr, {})[player] = val
|
||||||
set_player_attr('plando_item_blocks', [])
|
set_player_attr('plando_items', [])
|
||||||
|
set_player_attr('plando_texts', {})
|
||||||
|
set_player_attr('plando_connections', [])
|
||||||
set_player_attr('game', "Archipelago")
|
set_player_attr('game', "Archipelago")
|
||||||
set_player_attr('completion_condition', lambda state: True)
|
set_player_attr('completion_condition', lambda state: True)
|
||||||
self.worlds = {}
|
self.worlds = {}
|
||||||
self.per_slot_randoms = Utils.DeprecateDict("Using per_slot_randoms is now deprecated. Please use the "
|
self.per_slot_randoms = Utils.DeprecateDict("Using per_slot_randoms is now deprecated. Please use the "
|
||||||
"world's random object instead (usually self.random)", True)
|
"world's random object instead (usually self.random)")
|
||||||
self.plando_options = PlandoOptions.none
|
self.plando_options = PlandoOptions.none
|
||||||
|
|
||||||
def get_all_ids(self) -> Tuple[int, ...]:
|
def get_all_ids(self) -> Tuple[int, ...]:
|
||||||
@@ -223,8 +216,17 @@ class MultiWorld():
|
|||||||
self.seed_name = name if name else str(self.seed)
|
self.seed_name = name if name else str(self.seed)
|
||||||
|
|
||||||
def set_options(self, args: Namespace) -> None:
|
def set_options(self, args: Namespace) -> None:
|
||||||
|
# TODO - remove this section once all worlds use options dataclasses
|
||||||
from worlds import AutoWorld
|
from worlds import AutoWorld
|
||||||
|
|
||||||
|
all_keys: Set[str] = {key for player in self.player_ids for key in
|
||||||
|
AutoWorld.AutoWorldRegister.world_types[self.game[player]].options_dataclass.type_hints}
|
||||||
|
for option_key in all_keys:
|
||||||
|
option = Utils.DeprecateDict(f"Getting options from multiworld is now deprecated. "
|
||||||
|
f"Please use `self.options.{option_key}` instead.")
|
||||||
|
option.update(getattr(args, option_key, {}))
|
||||||
|
setattr(self, option_key, option)
|
||||||
|
|
||||||
for player in self.player_ids:
|
for player in self.player_ids:
|
||||||
world_type = AutoWorld.AutoWorldRegister.world_types[self.game[player]]
|
world_type = AutoWorld.AutoWorldRegister.world_types[self.game[player]]
|
||||||
self.worlds[player] = world_type(self, player)
|
self.worlds[player] = world_type(self, player)
|
||||||
@@ -262,7 +264,6 @@ class MultiWorld():
|
|||||||
"local_items": set(item_link.get("local_items", [])),
|
"local_items": set(item_link.get("local_items", [])),
|
||||||
"non_local_items": set(item_link.get("non_local_items", [])),
|
"non_local_items": set(item_link.get("non_local_items", [])),
|
||||||
"link_replacement": replacement_prio.index(item_link["link_replacement"]),
|
"link_replacement": replacement_prio.index(item_link["link_replacement"]),
|
||||||
"skip_if_solo": item_link.get("skip_if_solo", False),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for _name, item_link in item_links.items():
|
for _name, item_link in item_links.items():
|
||||||
@@ -286,8 +287,6 @@ class MultiWorld():
|
|||||||
|
|
||||||
for group_name, item_link in item_links.items():
|
for group_name, item_link in item_links.items():
|
||||||
game = item_link["game"]
|
game = item_link["game"]
|
||||||
if item_link["skip_if_solo"] and len(item_link["players"]) == 1:
|
|
||||||
continue
|
|
||||||
group_id, group = self.add_group(group_name, game, set(item_link["players"]))
|
group_id, group = self.add_group(group_name, game, set(item_link["players"]))
|
||||||
|
|
||||||
group["item_pool"] = item_link["item_pool"]
|
group["item_pool"] = item_link["item_pool"]
|
||||||
@@ -428,39 +427,23 @@ class MultiWorld():
|
|||||||
def get_location(self, location_name: str, player: int) -> Location:
|
def get_location(self, location_name: str, player: int) -> Location:
|
||||||
return self.regions.location_cache[player][location_name]
|
return self.regions.location_cache[player][location_name]
|
||||||
|
|
||||||
def get_all_state(self, use_cache: bool | None = None, allow_partial_entrances: bool = False,
|
def get_all_state(self, use_cache: bool, allow_partial_entrances: bool = False) -> CollectionState:
|
||||||
collect_pre_fill_items: bool = True, perform_sweep: bool = True) -> CollectionState:
|
cached = getattr(self, "_all_state", None)
|
||||||
"""
|
if use_cache and cached:
|
||||||
Creates a new CollectionState, and collects all precollected items, all items in the multiworld itempool, those
|
return cached.copy()
|
||||||
specified in each worlds' `get_pre_fill_items()`, and then sweeps the multiworld collecting any other items
|
|
||||||
it is able to reach, building as complete of a completed game state as possible.
|
|
||||||
|
|
||||||
:param use_cache: Deprecated and unused.
|
|
||||||
:param allow_partial_entrances: Whether the CollectionState should allow for disconnected entrances while
|
|
||||||
sweeping, such as before entrance randomization is complete.
|
|
||||||
:param collect_pre_fill_items: Whether the items in each worlds' `get_pre_fill_items()` should be added to this
|
|
||||||
state.
|
|
||||||
:param perform_sweep: Whether this state should perform a sweep for reachable locations, collecting any placed
|
|
||||||
items it can.
|
|
||||||
|
|
||||||
:return: The completed CollectionState.
|
|
||||||
"""
|
|
||||||
if __debug__ and use_cache is not None:
|
|
||||||
# TODO swap to Utils.deprecate when we want this to crash on source and warn on frozen
|
|
||||||
warnings.warn("multiworld.get_all_state no longer caches all_state and this argument will be removed.",
|
|
||||||
DeprecationWarning)
|
|
||||||
ret = CollectionState(self, allow_partial_entrances)
|
ret = CollectionState(self, allow_partial_entrances)
|
||||||
|
|
||||||
for item in self.itempool:
|
for item in self.itempool:
|
||||||
self.worlds[item.player].collect(ret, item)
|
self.worlds[item.player].collect(ret, item)
|
||||||
if collect_pre_fill_items:
|
for player in self.player_ids:
|
||||||
for player in self.player_ids:
|
subworld = self.worlds[player]
|
||||||
subworld = self.worlds[player]
|
for item in subworld.get_pre_fill_items():
|
||||||
for item in subworld.get_pre_fill_items():
|
subworld.collect(ret, item)
|
||||||
subworld.collect(ret, item)
|
ret.sweep_for_advancements()
|
||||||
if perform_sweep:
|
|
||||||
ret.sweep_for_advancements()
|
|
||||||
|
|
||||||
|
if use_cache:
|
||||||
|
self._all_state = ret
|
||||||
return ret
|
return ret
|
||||||
|
|
||||||
def get_items(self) -> List[Item]:
|
def get_items(self) -> List[Item]:
|
||||||
@@ -562,9 +545,7 @@ class MultiWorld():
|
|||||||
else:
|
else:
|
||||||
return all((self.has_beaten_game(state, p) for p in range(1, self.players + 1)))
|
return all((self.has_beaten_game(state, p) for p in range(1, self.players + 1)))
|
||||||
|
|
||||||
def can_beat_game(self,
|
def can_beat_game(self, starting_state: Optional[CollectionState] = None) -> bool:
|
||||||
starting_state: Optional[CollectionState] = None,
|
|
||||||
locations: Optional[Iterable[Location]] = None) -> bool:
|
|
||||||
if starting_state:
|
if starting_state:
|
||||||
if self.has_beaten_game(starting_state):
|
if self.has_beaten_game(starting_state):
|
||||||
return True
|
return True
|
||||||
@@ -573,10 +554,25 @@ class MultiWorld():
|
|||||||
state = CollectionState(self)
|
state = CollectionState(self)
|
||||||
if self.has_beaten_game(state):
|
if self.has_beaten_game(state):
|
||||||
return True
|
return True
|
||||||
|
prog_locations = {location for location in self.get_locations() if location.item
|
||||||
|
and location.item.advancement and location not in state.locations_checked}
|
||||||
|
|
||||||
|
while prog_locations:
|
||||||
|
sphere: Set[Location] = set()
|
||||||
|
# build up spheres of collection radius.
|
||||||
|
# Everything in each sphere is independent from each other in dependencies and only depends on lower spheres
|
||||||
|
for location in prog_locations:
|
||||||
|
if location.can_reach(state):
|
||||||
|
sphere.add(location)
|
||||||
|
|
||||||
|
if not sphere:
|
||||||
|
# ran out of places and did not finish yet, quit
|
||||||
|
return False
|
||||||
|
|
||||||
|
for location in sphere:
|
||||||
|
state.collect(location.item, True, location)
|
||||||
|
prog_locations -= sphere
|
||||||
|
|
||||||
for _ in state.sweep_for_advancements(locations,
|
|
||||||
yield_each_sweep=True,
|
|
||||||
checked_locations=state.locations_checked):
|
|
||||||
if self.has_beaten_game(state):
|
if self.has_beaten_game(state):
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -620,7 +616,7 @@ class MultiWorld():
|
|||||||
locations: Set[Location] = set()
|
locations: Set[Location] = set()
|
||||||
events: Set[Location] = set()
|
events: Set[Location] = set()
|
||||||
for location in self.get_filled_locations():
|
for location in self.get_filled_locations():
|
||||||
if type(location.item.code) is int and type(location.address) is int:
|
if type(location.item.code) is int:
|
||||||
locations.add(location)
|
locations.add(location)
|
||||||
else:
|
else:
|
||||||
events.add(location)
|
events.add(location)
|
||||||
@@ -692,12 +688,6 @@ class MultiWorld():
|
|||||||
sphere.append(locations.pop(n))
|
sphere.append(locations.pop(n))
|
||||||
|
|
||||||
if not sphere:
|
if not sphere:
|
||||||
if __debug__:
|
|
||||||
from Fill import FillError
|
|
||||||
raise FillError(
|
|
||||||
f"Could not access required locations for accessibility check. Missing: {locations}",
|
|
||||||
multiworld=self,
|
|
||||||
)
|
|
||||||
# ran out of places and did not finish yet, quit
|
# ran out of places and did not finish yet, quit
|
||||||
logging.warning(f"Could not access required locations for accessibility check."
|
logging.warning(f"Could not access required locations for accessibility check."
|
||||||
f" Missing: {locations}")
|
f" Missing: {locations}")
|
||||||
@@ -727,14 +717,12 @@ class CollectionState():
|
|||||||
advancements: Set[Location]
|
advancements: Set[Location]
|
||||||
path: Dict[Union[Region, Entrance], PathValue]
|
path: Dict[Union[Region, Entrance], PathValue]
|
||||||
locations_checked: Set[Location]
|
locations_checked: Set[Location]
|
||||||
"""Internal cache for Advancement Locations already checked by this CollectionState. Not for use in logic."""
|
|
||||||
stale: Dict[int, bool]
|
stale: Dict[int, bool]
|
||||||
allow_partial_entrances: bool
|
allow_partial_entrances: bool
|
||||||
additional_init_functions: List[Callable[[CollectionState, MultiWorld], None]] = []
|
additional_init_functions: List[Callable[[CollectionState, MultiWorld], None]] = []
|
||||||
additional_copy_functions: List[Callable[[CollectionState, CollectionState], CollectionState]] = []
|
additional_copy_functions: List[Callable[[CollectionState, CollectionState], CollectionState]] = []
|
||||||
|
|
||||||
def __init__(self, parent: MultiWorld, allow_partial_entrances: bool = False):
|
def __init__(self, parent: MultiWorld, allow_partial_entrances: bool = False):
|
||||||
assert parent.worlds, "CollectionState created without worlds initialized in parent"
|
|
||||||
self.prog_items = {player: Counter() for player in parent.get_all_ids()}
|
self.prog_items = {player: Counter() for player in parent.get_all_ids()}
|
||||||
self.multiworld = parent
|
self.multiworld = parent
|
||||||
self.reachable_regions = {player: set() for player in parent.get_all_ids()}
|
self.reachable_regions = {player: set() for player in parent.get_all_ids()}
|
||||||
@@ -768,7 +756,7 @@ class CollectionState():
|
|||||||
else:
|
else:
|
||||||
self._update_reachable_regions_auto_indirect_conditions(player, queue)
|
self._update_reachable_regions_auto_indirect_conditions(player, queue)
|
||||||
|
|
||||||
def _update_reachable_regions_explicit_indirect_conditions(self, player: int, queue: deque[Entrance]):
|
def _update_reachable_regions_explicit_indirect_conditions(self, player: int, queue: deque):
|
||||||
reachable_regions = self.reachable_regions[player]
|
reachable_regions = self.reachable_regions[player]
|
||||||
blocked_connections = self.blocked_connections[player]
|
blocked_connections = self.blocked_connections[player]
|
||||||
# run BFS on all connections, and keep track of those blocked by missing items
|
# run BFS on all connections, and keep track of those blocked by missing items
|
||||||
@@ -786,16 +774,13 @@ class CollectionState():
|
|||||||
blocked_connections.update(new_region.exits)
|
blocked_connections.update(new_region.exits)
|
||||||
queue.extend(new_region.exits)
|
queue.extend(new_region.exits)
|
||||||
self.path[new_region] = (new_region.name, self.path.get(connection, None))
|
self.path[new_region] = (new_region.name, self.path.get(connection, None))
|
||||||
self.multiworld.worlds[player].reached_region(self, new_region)
|
|
||||||
|
|
||||||
# Retry connections if the new region can unblock them
|
# Retry connections if the new region can unblock them
|
||||||
entrances = self.multiworld.indirect_connections.get(new_region)
|
for new_entrance in self.multiworld.indirect_connections.get(new_region, set()):
|
||||||
if entrances is not None:
|
if new_entrance in blocked_connections and new_entrance not in queue:
|
||||||
relevant_entrances = entrances.intersection(blocked_connections)
|
queue.append(new_entrance)
|
||||||
relevant_entrances.difference_update(queue)
|
|
||||||
queue.extend(relevant_entrances)
|
|
||||||
|
|
||||||
def _update_reachable_regions_auto_indirect_conditions(self, player: int, queue: deque[Entrance]):
|
def _update_reachable_regions_auto_indirect_conditions(self, player: int, queue: deque):
|
||||||
reachable_regions = self.reachable_regions[player]
|
reachable_regions = self.reachable_regions[player]
|
||||||
blocked_connections = self.blocked_connections[player]
|
blocked_connections = self.blocked_connections[player]
|
||||||
new_connection: bool = True
|
new_connection: bool = True
|
||||||
@@ -817,7 +802,6 @@ class CollectionState():
|
|||||||
queue.extend(new_region.exits)
|
queue.extend(new_region.exits)
|
||||||
self.path[new_region] = (new_region.name, self.path.get(connection, None))
|
self.path[new_region] = (new_region.name, self.path.get(connection, None))
|
||||||
new_connection = True
|
new_connection = True
|
||||||
self.multiworld.worlds[player].reached_region(self, new_region)
|
|
||||||
# sweep for indirect connections, mostly Entrance.can_reach(unrelated_Region)
|
# sweep for indirect connections, mostly Entrance.can_reach(unrelated_Region)
|
||||||
queue.extend(blocked_connections)
|
queue.extend(blocked_connections)
|
||||||
|
|
||||||
@@ -866,133 +850,20 @@ class CollectionState():
|
|||||||
"Please switch over to sweep_for_advancements.")
|
"Please switch over to sweep_for_advancements.")
|
||||||
return self.sweep_for_advancements(locations)
|
return self.sweep_for_advancements(locations)
|
||||||
|
|
||||||
def _sweep_for_advancements_impl(self, advancements_per_player: List[Tuple[int, List[Location]]],
|
def sweep_for_advancements(self, locations: Optional[Iterable[Location]] = None) -> None:
|
||||||
yield_each_sweep: bool) -> Iterator[None]:
|
|
||||||
"""
|
|
||||||
The implementation for sweep_for_advancements is separated here because it returns a generator due to the use
|
|
||||||
of a yield statement.
|
|
||||||
"""
|
|
||||||
all_players = {player for player, _ in advancements_per_player}
|
|
||||||
players_to_check = all_players
|
|
||||||
# As an optimization, it is assumed that each player's world only logically depends on itself. However, worlds
|
|
||||||
# are allowed to logically depend on other worlds, so once there are no more players that should be checked
|
|
||||||
# under this assumption, an extra sweep iteration is performed that checks every player, to confirm that the
|
|
||||||
# sweep is finished.
|
|
||||||
checking_if_finished = False
|
|
||||||
while players_to_check:
|
|
||||||
next_advancements_per_player: List[Tuple[int, List[Location]]] = []
|
|
||||||
next_players_to_check = set()
|
|
||||||
|
|
||||||
for player, locations in advancements_per_player:
|
|
||||||
if player not in players_to_check:
|
|
||||||
next_advancements_per_player.append((player, locations))
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Accessibility of each location is checked first because a player's region accessibility cache becomes
|
|
||||||
# stale whenever one of their own items is collected into the state.
|
|
||||||
reachable_locations: List[Location] = []
|
|
||||||
unreachable_locations: List[Location] = []
|
|
||||||
for location in locations:
|
|
||||||
if location.can_reach(self):
|
|
||||||
# Locations containing items that do not belong to `player` could be collected immediately
|
|
||||||
# because they won't stale `player`'s region accessibility cache, but, for simplicity, all the
|
|
||||||
# items at reachable locations are collected in a single loop.
|
|
||||||
reachable_locations.append(location)
|
|
||||||
else:
|
|
||||||
unreachable_locations.append(location)
|
|
||||||
if unreachable_locations:
|
|
||||||
next_advancements_per_player.append((player, unreachable_locations))
|
|
||||||
|
|
||||||
# A previous player's locations processed in the current `while players_to_check` iteration could have
|
|
||||||
# collected items belonging to `player`, but now that all of `player`'s reachable locations have been
|
|
||||||
# found, it can be assumed that `player` will not gain any more reachable locations until another one of
|
|
||||||
# their items is collected.
|
|
||||||
# It would be clearer to not add players to `next_players_to_check` in the first place if they have yet
|
|
||||||
# to be processed in the current `while players_to_check` iteration, but checking if a player should be
|
|
||||||
# added to `next_players_to_check` would need to be run once for every item that is collected, so it is
|
|
||||||
# more performant to instead discard `player` from `next_players_to_check` once their locations have
|
|
||||||
# been processed.
|
|
||||||
next_players_to_check.discard(player)
|
|
||||||
|
|
||||||
# Collect the items from the reachable locations.
|
|
||||||
for advancement in reachable_locations:
|
|
||||||
self.advancements.add(advancement)
|
|
||||||
item = advancement.item
|
|
||||||
assert isinstance(item, Item), "tried to collect advancement Location with no Item"
|
|
||||||
if self.collect(item, True, advancement):
|
|
||||||
# The player the item belongs to may be able to reach additional locations in the next sweep
|
|
||||||
# iteration.
|
|
||||||
next_players_to_check.add(item.player)
|
|
||||||
|
|
||||||
if not next_players_to_check:
|
|
||||||
if not checking_if_finished:
|
|
||||||
# It is assumed that each player's world only logically depends on itself, which may not be the
|
|
||||||
# case, so confirm that the sweep is finished by doing an extra iteration that checks every player.
|
|
||||||
checking_if_finished = True
|
|
||||||
next_players_to_check = all_players
|
|
||||||
else:
|
|
||||||
checking_if_finished = False
|
|
||||||
|
|
||||||
players_to_check = next_players_to_check
|
|
||||||
advancements_per_player = next_advancements_per_player
|
|
||||||
|
|
||||||
if yield_each_sweep:
|
|
||||||
yield
|
|
||||||
|
|
||||||
@overload
|
|
||||||
def sweep_for_advancements(self, locations: Optional[Iterable[Location]] = None, *,
|
|
||||||
yield_each_sweep: Literal[True],
|
|
||||||
checked_locations: Optional[Set[Location]] = None) -> Iterator[None]: ...
|
|
||||||
|
|
||||||
@overload
|
|
||||||
def sweep_for_advancements(self, locations: Optional[Iterable[Location]] = None,
|
|
||||||
yield_each_sweep: Literal[False] = False,
|
|
||||||
checked_locations: Optional[Set[Location]] = None) -> None: ...
|
|
||||||
|
|
||||||
def sweep_for_advancements(self, locations: Optional[Iterable[Location]] = None, yield_each_sweep: bool = False,
|
|
||||||
checked_locations: Optional[Set[Location]] = None) -> Optional[Iterator[None]]:
|
|
||||||
"""
|
|
||||||
Sweep through the locations that contain uncollected advancement items, collecting the items into the state
|
|
||||||
until there are no more reachable locations that contain uncollected advancement items.
|
|
||||||
|
|
||||||
:param locations: The locations to sweep through, defaulting to all locations in the multiworld.
|
|
||||||
:param yield_each_sweep: When True, return a generator that yields at the end of each sweep iteration.
|
|
||||||
:param checked_locations: Optional override of locations to filter out from the locations argument, defaults to
|
|
||||||
self.advancements when None.
|
|
||||||
"""
|
|
||||||
if checked_locations is None:
|
|
||||||
checked_locations = self.advancements
|
|
||||||
|
|
||||||
# Since the sweep loop usually performs many iterations, the locations are filtered in advance.
|
|
||||||
# A list of tuples is used, instead of a dictionary, because it is faster to iterate.
|
|
||||||
advancements_per_player: List[Tuple[int, List[Location]]]
|
|
||||||
if locations is None:
|
if locations is None:
|
||||||
# `location.advancement` can only be True for filled locations, so unfilled locations are filtered out.
|
locations = self.multiworld.get_filled_locations()
|
||||||
advancements_per_player = []
|
reachable_advancements = True
|
||||||
for player, locations_dict in self.multiworld.regions.location_cache.items():
|
# since the loop has a good chance to run more than once, only filter the advancements once
|
||||||
filtered_locations = [location for location in locations_dict.values()
|
locations = {location for location in locations if location.advancement and location not in self.advancements}
|
||||||
if location.advancement and location not in checked_locations]
|
|
||||||
if filtered_locations:
|
|
||||||
advancements_per_player.append((player, filtered_locations))
|
|
||||||
else:
|
|
||||||
# Filter and separate the locations into a list for each player.
|
|
||||||
advancements_per_player_dict: Dict[int, List[Location]] = defaultdict(list)
|
|
||||||
for location in locations:
|
|
||||||
if location.advancement and location not in checked_locations:
|
|
||||||
advancements_per_player_dict[location.player].append(location)
|
|
||||||
# Convert to a list of tuples.
|
|
||||||
advancements_per_player = list(advancements_per_player_dict.items())
|
|
||||||
del advancements_per_player_dict
|
|
||||||
|
|
||||||
if yield_each_sweep:
|
while reachable_advancements:
|
||||||
# Return a generator that will yield at the end of each sweep iteration.
|
reachable_advancements = {location for location in locations if location.can_reach(self)}
|
||||||
return self._sweep_for_advancements_impl(advancements_per_player, True)
|
locations -= reachable_advancements
|
||||||
else:
|
for advancement in reachable_advancements:
|
||||||
# Create the generator, but tell it not to yield anything, so it will run to completion in zero iterations
|
self.advancements.add(advancement)
|
||||||
# once started, then start and exhaust the generator by attempting to iterate it.
|
assert isinstance(advancement.item, Item), "tried to collect Event with no Item"
|
||||||
for _ in self._sweep_for_advancements_impl(advancements_per_player, False):
|
self.collect(advancement.item, True, advancement)
|
||||||
assert False, "Generator yielded when it should have run to completion without yielding"
|
|
||||||
return None
|
|
||||||
|
|
||||||
# item name related
|
# item name related
|
||||||
def has(self, item: str, player: int, count: int = 1) -> bool:
|
def has(self, item: str, player: int, count: int = 1) -> bool:
|
||||||
@@ -1128,17 +999,6 @@ class CollectionState():
|
|||||||
|
|
||||||
return changed
|
return changed
|
||||||
|
|
||||||
def add_item(self, item: str, player: int, count: int = 1) -> None:
|
|
||||||
"""
|
|
||||||
Adds the item to state.
|
|
||||||
|
|
||||||
:param item: The item to be added.
|
|
||||||
:param player: The player the item is for.
|
|
||||||
:param count: How many of the item to add.
|
|
||||||
"""
|
|
||||||
assert count > 0
|
|
||||||
self.prog_items[player][item] += count
|
|
||||||
|
|
||||||
def remove(self, item: Item):
|
def remove(self, item: Item):
|
||||||
changed = self.multiworld.worlds[item.player].remove(self, item)
|
changed = self.multiworld.worlds[item.player].remove(self, item)
|
||||||
if changed:
|
if changed:
|
||||||
@@ -1147,37 +1007,6 @@ class CollectionState():
|
|||||||
self.blocked_connections[item.player] = set()
|
self.blocked_connections[item.player] = set()
|
||||||
self.stale[item.player] = True
|
self.stale[item.player] = True
|
||||||
|
|
||||||
def remove_item(self, item: str, player: int, count: int = 1) -> None:
|
|
||||||
"""
|
|
||||||
Removes the item from state.
|
|
||||||
|
|
||||||
:param item: The item to be removed.
|
|
||||||
:param player: The player the item is for.
|
|
||||||
:param count: How many of the item to remove.
|
|
||||||
"""
|
|
||||||
assert count > 0
|
|
||||||
self.prog_items[player][item] -= count
|
|
||||||
if self.prog_items[player][item] < 1:
|
|
||||||
del (self.prog_items[player][item])
|
|
||||||
|
|
||||||
def set_item(self, item: str, player: int, count: int) -> None:
|
|
||||||
"""
|
|
||||||
Sets the item in state equal to the provided count.
|
|
||||||
|
|
||||||
:param item: The item to modify.
|
|
||||||
:param player: The player the item is for.
|
|
||||||
:param count: How many of the item to now have.
|
|
||||||
"""
|
|
||||||
assert count >= 0
|
|
||||||
if count == 0:
|
|
||||||
del (self.prog_items[player][item])
|
|
||||||
else:
|
|
||||||
self.prog_items[player][item] = count
|
|
||||||
|
|
||||||
|
|
||||||
CollectionRule = Callable[[CollectionState], bool]
|
|
||||||
DEFAULT_COLLECTION_RULE: CollectionRule = staticmethod(lambda state: True)
|
|
||||||
|
|
||||||
|
|
||||||
class EntranceType(IntEnum):
|
class EntranceType(IntEnum):
|
||||||
ONE_WAY = 1
|
ONE_WAY = 1
|
||||||
@@ -1185,7 +1014,7 @@ class EntranceType(IntEnum):
|
|||||||
|
|
||||||
|
|
||||||
class Entrance:
|
class Entrance:
|
||||||
access_rule: CollectionRule = DEFAULT_COLLECTION_RULE
|
access_rule: Callable[[CollectionState], bool] = staticmethod(lambda state: True)
|
||||||
hide_path: bool = False
|
hide_path: bool = False
|
||||||
player: int
|
player: int
|
||||||
name: str
|
name: str
|
||||||
@@ -1193,6 +1022,9 @@ class Entrance:
|
|||||||
connected_region: Optional[Region] = None
|
connected_region: Optional[Region] = None
|
||||||
randomization_group: int
|
randomization_group: int
|
||||||
randomization_type: EntranceType
|
randomization_type: EntranceType
|
||||||
|
# LttP specific, TODO: should make a LttPEntrance
|
||||||
|
addresses = None
|
||||||
|
target = None
|
||||||
|
|
||||||
def __init__(self, player: int, name: str = "", parent: Optional[Region] = None,
|
def __init__(self, player: int, name: str = "", parent: Optional[Region] = None,
|
||||||
randomization_group: int = 0, randomization_type: EntranceType = EntranceType.ONE_WAY) -> None:
|
randomization_group: int = 0, randomization_type: EntranceType = EntranceType.ONE_WAY) -> None:
|
||||||
@@ -1211,8 +1043,10 @@ class Entrance:
|
|||||||
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def connect(self, region: Region) -> None:
|
def connect(self, region: Region, addresses: Any = None, target: Any = None) -> None:
|
||||||
self.connected_region = region
|
self.connected_region = region
|
||||||
|
self.target = target
|
||||||
|
self.addresses = addresses
|
||||||
region.entrances.append(self)
|
region.entrances.append(self)
|
||||||
|
|
||||||
def is_valid_source_transition(self, er_state: "ERPlacementState") -> bool:
|
def is_valid_source_transition(self, er_state: "ERPlacementState") -> bool:
|
||||||
@@ -1264,16 +1098,13 @@ class Region:
|
|||||||
self.region_manager = region_manager
|
self.region_manager = region_manager
|
||||||
|
|
||||||
def __getitem__(self, index: int) -> Location:
|
def __getitem__(self, index: int) -> Location:
|
||||||
return self._list[index]
|
return self._list.__getitem__(index)
|
||||||
|
|
||||||
def __setitem__(self, index: int, value: Location) -> None:
|
def __setitem__(self, index: int, value: Location) -> None:
|
||||||
raise NotImplementedError()
|
raise NotImplementedError()
|
||||||
|
|
||||||
def __len__(self) -> int:
|
def __len__(self) -> int:
|
||||||
return len(self._list)
|
return self._list.__len__()
|
||||||
|
|
||||||
def __iter__(self):
|
|
||||||
return iter(self._list)
|
|
||||||
|
|
||||||
# This seems to not be needed, but that's a bit suspicious.
|
# This seems to not be needed, but that's a bit suspicious.
|
||||||
# def __del__(self):
|
# def __del__(self):
|
||||||
@@ -1284,8 +1115,8 @@ class Region:
|
|||||||
|
|
||||||
class LocationRegister(Register):
|
class LocationRegister(Register):
|
||||||
def __delitem__(self, index: int) -> None:
|
def __delitem__(self, index: int) -> None:
|
||||||
location: Location = self._list[index]
|
location: Location = self._list.__getitem__(index)
|
||||||
del self._list[index]
|
self._list.__delitem__(index)
|
||||||
del(self.region_manager.location_cache[location.player][location.name])
|
del(self.region_manager.location_cache[location.player][location.name])
|
||||||
|
|
||||||
def insert(self, index: int, value: Location) -> None:
|
def insert(self, index: int, value: Location) -> None:
|
||||||
@@ -1296,8 +1127,8 @@ class Region:
|
|||||||
|
|
||||||
class EntranceRegister(Register):
|
class EntranceRegister(Register):
|
||||||
def __delitem__(self, index: int) -> None:
|
def __delitem__(self, index: int) -> None:
|
||||||
entrance: Entrance = self._list[index]
|
entrance: Entrance = self._list.__getitem__(index)
|
||||||
del self._list[index]
|
self._list.__delitem__(index)
|
||||||
del(self.region_manager.entrance_cache[entrance.player][entrance.name])
|
del(self.region_manager.entrance_cache[entrance.player][entrance.name])
|
||||||
|
|
||||||
def insert(self, index: int, value: Entrance) -> None:
|
def insert(self, index: int, value: Entrance) -> None:
|
||||||
@@ -1356,7 +1187,8 @@ class Region:
|
|||||||
for entrance in self.entrances: # BFS might be better here, trying DFS for now.
|
for entrance in self.entrances: # BFS might be better here, trying DFS for now.
|
||||||
return entrance.parent_region.get_connecting_entrance(is_main_entrance)
|
return entrance.parent_region.get_connecting_entrance(is_main_entrance)
|
||||||
|
|
||||||
def add_locations(self, locations: Mapping[str, int | None], location_type: type[Location] | None = None) -> None:
|
def add_locations(self, locations: Dict[str, Optional[int]],
|
||||||
|
location_type: Optional[type[Location]] = None) -> None:
|
||||||
"""
|
"""
|
||||||
Adds locations to the Region object, where location_type is your Location class and locations is a dict of
|
Adds locations to the Region object, where location_type is your Location class and locations is a dict of
|
||||||
location names to address.
|
location names to address.
|
||||||
@@ -1368,50 +1200,8 @@ class Region:
|
|||||||
for location, address in locations.items():
|
for location, address in locations.items():
|
||||||
self.locations.append(location_type(self.player, location, address, self))
|
self.locations.append(location_type(self.player, location, address, self))
|
||||||
|
|
||||||
def add_event(
|
|
||||||
self,
|
|
||||||
location_name: str,
|
|
||||||
item_name: str | None = None,
|
|
||||||
rule: CollectionRule | Rule[Any] | None = None,
|
|
||||||
location_type: type[Location] | None = None,
|
|
||||||
item_type: type[Item] | None = None,
|
|
||||||
show_in_spoiler: bool = True,
|
|
||||||
) -> Item:
|
|
||||||
"""
|
|
||||||
Adds an event location/item pair to the region.
|
|
||||||
|
|
||||||
:param location_name: Name for the event location.
|
|
||||||
:param item_name: Name for the event item. If not provided, defaults to location_name.
|
|
||||||
:param rule: Callable to determine access for this event location within its region.
|
|
||||||
:param location_type: Location class to create the event location with. Defaults to BaseClasses.Location.
|
|
||||||
:param item_type: Item class to create the event item with. Defaults to BaseClasses.Item.
|
|
||||||
:param show_in_spoiler: Will be passed along to the created event Location's show_in_spoiler attribute.
|
|
||||||
:return: The created Event Item
|
|
||||||
"""
|
|
||||||
if location_type is None:
|
|
||||||
location_type = Location
|
|
||||||
|
|
||||||
if item_name is None:
|
|
||||||
item_name = location_name
|
|
||||||
|
|
||||||
if item_type is None:
|
|
||||||
item_type = Item
|
|
||||||
|
|
||||||
event_location = location_type(self.player, location_name, None, self)
|
|
||||||
event_location.show_in_spoiler = show_in_spoiler
|
|
||||||
if rule is not None:
|
|
||||||
self.multiworld.worlds[self.player].set_rule(event_location, rule)
|
|
||||||
|
|
||||||
event_item = item_type(item_name, ItemClassification.progression, None, self.player)
|
|
||||||
|
|
||||||
event_location.place_locked_item(event_item)
|
|
||||||
|
|
||||||
self.locations.append(event_location)
|
|
||||||
|
|
||||||
return event_item
|
|
||||||
|
|
||||||
def connect(self, connecting_region: Region, name: Optional[str] = None,
|
def connect(self, connecting_region: Region, name: Optional[str] = None,
|
||||||
rule: Optional[CollectionRule | Rule[Any]] = None) -> Entrance:
|
rule: Optional[Callable[[CollectionState], bool]] = None) -> Entrance:
|
||||||
"""
|
"""
|
||||||
Connects this Region to another Region, placing the provided rule on the connection.
|
Connects this Region to another Region, placing the provided rule on the connection.
|
||||||
|
|
||||||
@@ -1419,8 +1209,8 @@ class Region:
|
|||||||
:param name: name of the connection being created
|
:param name: name of the connection being created
|
||||||
:param rule: callable to determine access of this connection to go from self to the exiting_region"""
|
:param rule: callable to determine access of this connection to go from self to the exiting_region"""
|
||||||
exit_ = self.create_exit(name if name else f"{self.name} -> {connecting_region.name}")
|
exit_ = self.create_exit(name if name else f"{self.name} -> {connecting_region.name}")
|
||||||
if rule is not None:
|
if rule:
|
||||||
self.multiworld.worlds[self.player].set_rule(exit_, rule)
|
exit_.access_rule = rule
|
||||||
exit_.connect(connecting_region)
|
exit_.connect(connecting_region)
|
||||||
return exit_
|
return exit_
|
||||||
|
|
||||||
@@ -1444,16 +1234,16 @@ class Region:
|
|||||||
entrance.connect(self)
|
entrance.connect(self)
|
||||||
return entrance
|
return entrance
|
||||||
|
|
||||||
def add_exits(self, exits: Iterable[str] | Mapping[str, str | None],
|
def add_exits(self, exits: Union[Iterable[str], Dict[str, Optional[str]]],
|
||||||
rules: Mapping[str, CollectionRule | Rule[Any]] | None = None) -> List[Entrance]:
|
rules: Dict[str, Callable[[CollectionState], bool]] = None) -> List[Entrance]:
|
||||||
"""
|
"""
|
||||||
Connects current region to regions in exit dictionary. Passed region names must exist first.
|
Connects current region to regions in exit dictionary. Passed region names must exist first.
|
||||||
|
|
||||||
:param exits: exits from the region. format is {"connecting_region": "exit_name"}. if a non dict is provided,
|
:param exits: exits from the region. format is {"connecting_region": "exit_name"}. if a non dict is provided,
|
||||||
created entrances will be named "self.name -> connecting_region"
|
created entrances will be named "self.name -> connecting_region"
|
||||||
:param rules: rules for the exits from this region. format is {"connecting_region": rule}
|
:param rules: rules for the exits from this region. format is {"connecting_region", rule}
|
||||||
"""
|
"""
|
||||||
if not isinstance(exits, Mapping):
|
if not isinstance(exits, Dict):
|
||||||
exits = dict.fromkeys(exits)
|
exits = dict.fromkeys(exits)
|
||||||
return [
|
return [
|
||||||
self.connect(
|
self.connect(
|
||||||
@@ -1484,7 +1274,7 @@ class Location:
|
|||||||
show_in_spoiler: bool = True
|
show_in_spoiler: bool = True
|
||||||
progress_type: LocationProgressType = LocationProgressType.DEFAULT
|
progress_type: LocationProgressType = LocationProgressType.DEFAULT
|
||||||
always_allow: Callable[[CollectionState, Item], bool] = staticmethod(lambda state, item: False)
|
always_allow: Callable[[CollectionState, Item], bool] = staticmethod(lambda state, item: False)
|
||||||
access_rule: CollectionRule = DEFAULT_COLLECTION_RULE
|
access_rule: Callable[[CollectionState], bool] = staticmethod(lambda state: True)
|
||||||
item_rule: Callable[[Item], bool] = staticmethod(lambda item: True)
|
item_rule: Callable[[Item], bool] = staticmethod(lambda item: True)
|
||||||
item: Optional[Item] = None
|
item: Optional[Item] = None
|
||||||
|
|
||||||
@@ -1520,6 +1310,9 @@ class Location:
|
|||||||
multiworld = self.parent_region.multiworld if self.parent_region and self.parent_region.multiworld else None
|
multiworld = self.parent_region.multiworld if self.parent_region and self.parent_region.multiworld else None
|
||||||
return multiworld.get_name_string_for_object(self) if multiworld else f'{self.name} (Player {self.player})'
|
return multiworld.get_name_string_for_object(self) if multiworld else f'{self.name} (Player {self.player})'
|
||||||
|
|
||||||
|
def __hash__(self):
|
||||||
|
return hash((self.name, self.player))
|
||||||
|
|
||||||
def __lt__(self, other: Location):
|
def __lt__(self, other: Location):
|
||||||
return (self.player, self.name) < (other.player, other.name)
|
return (self.player, self.name) < (other.player, other.name)
|
||||||
|
|
||||||
@@ -1543,47 +1336,31 @@ class Location:
|
|||||||
|
|
||||||
|
|
||||||
class ItemClassification(IntFlag):
|
class ItemClassification(IntFlag):
|
||||||
filler = 0b00000
|
filler = 0b0000
|
||||||
""" aka trash, as in filler items like ammo, currency etc """
|
""" aka trash, as in filler items like ammo, currency etc """
|
||||||
|
|
||||||
progression = 0b00001
|
progression = 0b0001
|
||||||
""" Item that is logically relevant.
|
""" Item that is logically relevant.
|
||||||
Protects this item from being placed on excluded or unreachable locations. """
|
Protects this item from being placed on excluded or unreachable locations. """
|
||||||
|
|
||||||
useful = 0b00010
|
useful = 0b0010
|
||||||
""" Item that is especially useful.
|
""" Item that is especially useful.
|
||||||
Protects this item from being placed on excluded or unreachable locations.
|
Protects this item from being placed on excluded or unreachable locations.
|
||||||
When combined with another flag like "progression", it means "an especially useful progression item". """
|
When combined with another flag like "progression", it means "an especially useful progression item". """
|
||||||
|
|
||||||
trap = 0b00100
|
trap = 0b0100
|
||||||
""" Item that is detrimental in some way. """
|
""" Item that is detrimental in some way. """
|
||||||
|
|
||||||
skip_balancing = 0b01000
|
skip_balancing = 0b1000
|
||||||
""" should technically never occur on its own
|
""" should technically never occur on its own
|
||||||
Item that is logically relevant, but progression balancing should not touch.
|
Item that is logically relevant, but progression balancing should not touch.
|
||||||
|
Typically currency or other counted items. """
|
||||||
|
|
||||||
Possible reasons for why an item should not be pulled ahead by progression balancing:
|
progression_skip_balancing = 0b1001 # only progression gets balanced
|
||||||
1. This item is quite insignificant, so pulling it earlier doesn't help (currency/etc.)
|
|
||||||
2. It is important for the player experience that this item is evenly distributed in the seed (e.g. goal items) """
|
|
||||||
|
|
||||||
deprioritized = 0b10000
|
|
||||||
""" Should technically never occur on its own.
|
|
||||||
Will not be considered for priority locations,
|
|
||||||
unless Priority Locations Fill runs out of regular progression items before filling all priority locations.
|
|
||||||
|
|
||||||
Should be used for items that would feel bad for the player to find on a priority location.
|
|
||||||
Usually, these are items that are plentiful or insignificant. """
|
|
||||||
|
|
||||||
progression_deprioritized_skip_balancing = 0b11001
|
|
||||||
""" Since a common case of both skip_balancing and deprioritized is "insignificant progression",
|
|
||||||
these items often want both flags. """
|
|
||||||
|
|
||||||
progression_skip_balancing = 0b01001 # only progression gets balanced
|
|
||||||
progression_deprioritized = 0b10001 # only progression can be placed during priority fill
|
|
||||||
|
|
||||||
def as_flag(self) -> int:
|
def as_flag(self) -> int:
|
||||||
"""As Network API flag int."""
|
"""As Network API flag int."""
|
||||||
return int(self & 0b00111)
|
return int(self & 0b0111)
|
||||||
|
|
||||||
|
|
||||||
class Item:
|
class Item:
|
||||||
@@ -1627,10 +1404,6 @@ class Item:
|
|||||||
def trap(self) -> bool:
|
def trap(self) -> bool:
|
||||||
return ItemClassification.trap in self.classification
|
return ItemClassification.trap in self.classification
|
||||||
|
|
||||||
@property
|
|
||||||
def deprioritized(self) -> bool:
|
|
||||||
return ItemClassification.deprioritized in self.classification
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def filler(self) -> bool:
|
def filler(self) -> bool:
|
||||||
return not (self.advancement or self.useful or self.trap)
|
return not (self.advancement or self.useful or self.trap)
|
||||||
@@ -1643,10 +1416,6 @@ class Item:
|
|||||||
def flags(self) -> int:
|
def flags(self) -> int:
|
||||||
return self.classification.as_flag()
|
return self.classification.as_flag()
|
||||||
|
|
||||||
@property
|
|
||||||
def is_event(self) -> bool:
|
|
||||||
return self.code is None
|
|
||||||
|
|
||||||
def __eq__(self, other: object) -> bool:
|
def __eq__(self, other: object) -> bool:
|
||||||
if not isinstance(other, Item):
|
if not isinstance(other, Item):
|
||||||
return NotImplemented
|
return NotImplemented
|
||||||
@@ -1731,29 +1500,30 @@ class Spoiler:
|
|||||||
logging.debug('The following items could not be reached: %s', ['%s (Player %d) at %s (Player %d)' % (
|
logging.debug('The following items could not be reached: %s', ['%s (Player %d) at %s (Player %d)' % (
|
||||||
location.item.name, location.item.player, location.name, location.player) for location in
|
location.item.name, location.item.player, location.name, location.player) for location in
|
||||||
sphere_candidates])
|
sphere_candidates])
|
||||||
if not multiworld.has_beaten_game(state):
|
if any([multiworld.worlds[location.item.player].options.accessibility != 'minimal' for location in sphere_candidates]):
|
||||||
raise RuntimeError("During playthrough generation, the game was determined to be unbeatable. "
|
raise RuntimeError(f'Not all progression items reachable ({sphere_candidates}). '
|
||||||
"Something went terribly wrong here. "
|
f'Something went terribly wrong here.')
|
||||||
f"Unreachable progression items: {sphere_candidates}")
|
|
||||||
else:
|
else:
|
||||||
self.unreachables = sphere_candidates
|
self.unreachables = sphere_candidates
|
||||||
break
|
break
|
||||||
|
|
||||||
# in the second phase, we cull each sphere such that the game is still beatable,
|
# in the second phase, we cull each sphere such that the game is still beatable,
|
||||||
# reducing each range of influence to the bare minimum required inside it
|
# reducing each range of influence to the bare minimum required inside it
|
||||||
required_locations = {location for sphere in collection_spheres for location in sphere}
|
restore_later: Dict[Location, Item] = {}
|
||||||
for num, sphere in reversed(tuple(enumerate(collection_spheres))):
|
for num, sphere in reversed(tuple(enumerate(collection_spheres))):
|
||||||
to_delete: Set[Location] = set()
|
to_delete: Set[Location] = set()
|
||||||
for location in sphere:
|
for location in sphere:
|
||||||
# we remove the location from required_locations to sweep from, and check if the game is still beatable
|
# we remove the item at location and check if game is still beatable
|
||||||
logging.debug('Checking if %s (Player %d) is required to beat the game.', location.item.name,
|
logging.debug('Checking if %s (Player %d) is required to beat the game.', location.item.name,
|
||||||
location.item.player)
|
location.item.player)
|
||||||
required_locations.remove(location)
|
old_item = location.item
|
||||||
if multiworld.can_beat_game(state_cache[num], required_locations):
|
location.item = None
|
||||||
|
if multiworld.can_beat_game(state_cache[num]):
|
||||||
to_delete.add(location)
|
to_delete.add(location)
|
||||||
|
restore_later[location] = old_item
|
||||||
else:
|
else:
|
||||||
# still required, got to keep it around
|
# still required, got to keep it around
|
||||||
required_locations.add(location)
|
location.item = old_item
|
||||||
|
|
||||||
# cull entries in spheres for spoiler walkthrough at end
|
# cull entries in spheres for spoiler walkthrough at end
|
||||||
sphere -= to_delete
|
sphere -= to_delete
|
||||||
@@ -1770,7 +1540,7 @@ class Spoiler:
|
|||||||
logging.debug('Checking if %s (Player %d) is required to beat the game.', item.name, item.player)
|
logging.debug('Checking if %s (Player %d) is required to beat the game.', item.name, item.player)
|
||||||
precollected_items.remove(item)
|
precollected_items.remove(item)
|
||||||
multiworld.state.remove(item)
|
multiworld.state.remove(item)
|
||||||
if not multiworld.can_beat_game(multiworld.state, required_locations):
|
if not multiworld.can_beat_game():
|
||||||
# Add the item back into `precollected_items` and collect it into `multiworld.state`.
|
# Add the item back into `precollected_items` and collect it into `multiworld.state`.
|
||||||
multiworld.push_precollected(item)
|
multiworld.push_precollected(item)
|
||||||
else:
|
else:
|
||||||
@@ -1812,6 +1582,9 @@ class Spoiler:
|
|||||||
self.create_paths(state, collection_spheres)
|
self.create_paths(state, collection_spheres)
|
||||||
|
|
||||||
# repair the multiworld again
|
# repair the multiworld again
|
||||||
|
for location, item in restore_later.items():
|
||||||
|
location.item = item
|
||||||
|
|
||||||
for item in removed_precollected:
|
for item in removed_precollected:
|
||||||
multiworld.push_precollected(item)
|
multiworld.push_precollected(item)
|
||||||
|
|
||||||
@@ -1868,9 +1641,6 @@ class Spoiler:
|
|||||||
Utils.__version__, self.multiworld.seed))
|
Utils.__version__, self.multiworld.seed))
|
||||||
outfile.write('Filling Algorithm: %s\n' % self.multiworld.algorithm)
|
outfile.write('Filling Algorithm: %s\n' % self.multiworld.algorithm)
|
||||||
outfile.write('Players: %d\n' % self.multiworld.players)
|
outfile.write('Players: %d\n' % self.multiworld.players)
|
||||||
if self.multiworld.players > 1:
|
|
||||||
loc_count = len([loc for loc in self.multiworld.get_locations() if not loc.is_event])
|
|
||||||
outfile.write('Total Location Count: %d\n' % loc_count)
|
|
||||||
outfile.write(f'Plando Options: {self.multiworld.plando_options}\n')
|
outfile.write(f'Plando Options: {self.multiworld.plando_options}\n')
|
||||||
AutoWorld.call_stage(self.multiworld, "write_spoiler_header", outfile)
|
AutoWorld.call_stage(self.multiworld, "write_spoiler_header", outfile)
|
||||||
|
|
||||||
@@ -1879,9 +1649,6 @@ class Spoiler:
|
|||||||
outfile.write('\nPlayer %d: %s\n' % (player, self.multiworld.get_player_name(player)))
|
outfile.write('\nPlayer %d: %s\n' % (player, self.multiworld.get_player_name(player)))
|
||||||
outfile.write('Game: %s\n' % self.multiworld.game[player])
|
outfile.write('Game: %s\n' % self.multiworld.game[player])
|
||||||
|
|
||||||
loc_count = len([loc for loc in self.multiworld.get_locations(player) if not loc.is_event])
|
|
||||||
outfile.write('Location Count: %d\n' % loc_count)
|
|
||||||
|
|
||||||
for f_option, option in self.multiworld.worlds[player].options_dataclass.type_hints.items():
|
for f_option, option in self.multiworld.worlds[player].options_dataclass.type_hints.items():
|
||||||
write_option(f_option, option)
|
write_option(f_option, option)
|
||||||
|
|
||||||
@@ -1918,8 +1685,7 @@ class Spoiler:
|
|||||||
if self.unreachables:
|
if self.unreachables:
|
||||||
outfile.write('\n\nUnreachable Progression Items:\n\n')
|
outfile.write('\n\nUnreachable Progression Items:\n\n')
|
||||||
outfile.write(
|
outfile.write(
|
||||||
'\n'.join(['%s: %s' % (unreachable.item, unreachable)
|
'\n'.join(['%s: %s' % (unreachable.item, unreachable) for unreachable in self.unreachables]))
|
||||||
for unreachable in sorted(self.unreachables)]))
|
|
||||||
|
|
||||||
if self.paths:
|
if self.paths:
|
||||||
outfile.write('\n\nPaths:\n\n')
|
outfile.write('\n\nPaths:\n\n')
|
||||||
@@ -1946,7 +1712,7 @@ class Tutorial(NamedTuple):
|
|||||||
description: str
|
description: str
|
||||||
language: str
|
language: str
|
||||||
file_name: str
|
file_name: str
|
||||||
link: str # unused
|
link: str
|
||||||
authors: List[str]
|
authors: List[str]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
284
CommonClient.py
Executable file → Normal file
284
CommonClient.py
Executable file → Normal file
@@ -21,10 +21,10 @@ import Utils
|
|||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
Utils.init_logging("TextClient", exception_logger="Client")
|
Utils.init_logging("TextClient", exception_logger="Client")
|
||||||
|
|
||||||
from MultiServer import CommandProcessor, mark_raw
|
from MultiServer import CommandProcessor
|
||||||
from NetUtils import (Endpoint, decode, NetworkItem, encode, JSONtoTextParser, ClientStatus, Permission, NetworkSlot,
|
from NetUtils import (Endpoint, decode, NetworkItem, encode, JSONtoTextParser, ClientStatus, Permission, NetworkSlot,
|
||||||
RawJSONtoTextParser, add_json_text, add_json_location, add_json_item, JSONTypes, HintStatus, SlotType)
|
RawJSONtoTextParser, add_json_text, add_json_location, add_json_item, JSONTypes, HintStatus, SlotType)
|
||||||
from Utils import gui_enabled, Version, stream_input, async_start
|
from Utils import Version, stream_input, async_start
|
||||||
from worlds import network_data_package, AutoWorldRegister
|
from worlds import network_data_package, AutoWorldRegister
|
||||||
import os
|
import os
|
||||||
import ssl
|
import ssl
|
||||||
@@ -35,6 +35,9 @@ if typing.TYPE_CHECKING:
|
|||||||
|
|
||||||
logger = logging.getLogger("Client")
|
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
|
@Utils.cache_argsless
|
||||||
def get_ssl_context():
|
def get_ssl_context():
|
||||||
@@ -62,8 +65,6 @@ class ClientCommandProcessor(CommandProcessor):
|
|||||||
|
|
||||||
def _cmd_exit(self) -> bool:
|
def _cmd_exit(self) -> bool:
|
||||||
"""Close connections and client"""
|
"""Close connections and client"""
|
||||||
if self.ctx.ui:
|
|
||||||
self.ctx.ui.stop()
|
|
||||||
self.ctx.exit_event.set()
|
self.ctx.exit_event.set()
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -106,9 +107,7 @@ class ClientCommandProcessor(CommandProcessor):
|
|||||||
return False
|
return False
|
||||||
count = 0
|
count = 0
|
||||||
checked_count = 0
|
checked_count = 0
|
||||||
|
for location, location_id in AutoWorldRegister.world_types[self.ctx.game].location_name_to_id.items():
|
||||||
lookup = self.ctx.location_names[self.ctx.game]
|
|
||||||
for location_id, location in lookup.items():
|
|
||||||
if filter_text and filter_text not in location:
|
if filter_text and filter_text not in location:
|
||||||
continue
|
continue
|
||||||
if location_id < 0:
|
if location_id < 0:
|
||||||
@@ -129,87 +128,43 @@ class ClientCommandProcessor(CommandProcessor):
|
|||||||
self.output("No missing location checks found.")
|
self.output("No missing location checks found.")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def output_datapackage_part(self, name: typing.Literal["Item Names", "Location Names"]) -> bool:
|
def _cmd_items(self):
|
||||||
"""
|
|
||||||
Helper to digest a specific section of this game's datapackage.
|
|
||||||
|
|
||||||
:param name: Printed to the user as context for the part.
|
|
||||||
|
|
||||||
:return: Whether the process was successful.
|
|
||||||
"""
|
|
||||||
if not self.ctx.game:
|
|
||||||
self.output(f"No game set, cannot determine {name}.")
|
|
||||||
return False
|
|
||||||
|
|
||||||
lookup = self.ctx.item_names if name == "Item Names" else self.ctx.location_names
|
|
||||||
lookup = lookup[self.ctx.game]
|
|
||||||
self.output(f"{name} for {self.ctx.game}")
|
|
||||||
for name in lookup.values():
|
|
||||||
self.output(name)
|
|
||||||
return True
|
|
||||||
|
|
||||||
def _cmd_items(self) -> bool:
|
|
||||||
"""List all item names for the currently running game."""
|
"""List all item names for the currently running game."""
|
||||||
return self.output_datapackage_part("Item Names")
|
|
||||||
|
|
||||||
def _cmd_locations(self) -> bool:
|
|
||||||
"""List all location names for the currently running game."""
|
|
||||||
return self.output_datapackage_part("Location Names")
|
|
||||||
|
|
||||||
def output_group_part(self, group_key: typing.Literal["item_name_groups", "location_name_groups"],
|
|
||||||
filter_key: str,
|
|
||||||
name: str) -> bool:
|
|
||||||
"""
|
|
||||||
Logs an item or location group from the player's game's datapackage.
|
|
||||||
|
|
||||||
:param group_key: Either Item or Location group to be processed.
|
|
||||||
:param filter_key: Which group key to filter to. If an empty string is passed will log all item/location groups.
|
|
||||||
:param name: Printed to the user as context for the part.
|
|
||||||
|
|
||||||
:return: Whether the process was successful.
|
|
||||||
"""
|
|
||||||
if not self.ctx.game:
|
if not self.ctx.game:
|
||||||
self.output(f"No game set, cannot determine existing {name} Groups.")
|
self.output("No game set, cannot determine existing items.")
|
||||||
return False
|
return False
|
||||||
lookup = Utils.persistent_load().get("groups_by_checksum", {}).get(self.ctx.checksums[self.ctx.game], {})\
|
self.output(f"Item Names for {self.ctx.game}")
|
||||||
.get(self.ctx.game, {}).get(group_key, {})
|
for item_name in AutoWorldRegister.world_types[self.ctx.game].item_name_to_id:
|
||||||
if lookup is None:
|
self.output(item_name)
|
||||||
self.output("datapackage not yet loaded, try again")
|
|
||||||
|
def _cmd_item_groups(self):
|
||||||
|
"""List all item group names for the currently running game."""
|
||||||
|
if not self.ctx.game:
|
||||||
|
self.output("No game set, cannot determine existing item groups.")
|
||||||
return False
|
return False
|
||||||
|
self.output(f"Item Group Names for {self.ctx.game}")
|
||||||
|
for group_name in AutoWorldRegister.world_types[self.ctx.game].item_name_groups:
|
||||||
|
self.output(group_name)
|
||||||
|
|
||||||
if filter_key:
|
def _cmd_locations(self):
|
||||||
if filter_key not in lookup:
|
"""List all location names for the currently running game."""
|
||||||
self.output(f"Unknown {name} Group {filter_key}")
|
if not self.ctx.game:
|
||||||
return False
|
self.output("No game set, cannot determine existing locations.")
|
||||||
|
return False
|
||||||
|
self.output(f"Location Names for {self.ctx.game}")
|
||||||
|
for location_name in AutoWorldRegister.world_types[self.ctx.game].location_name_to_id:
|
||||||
|
self.output(location_name)
|
||||||
|
|
||||||
self.output(f"{name}s for {name} Group \"{filter_key}\"")
|
def _cmd_location_groups(self):
|
||||||
for entry in lookup[filter_key]:
|
"""List all location group names for the currently running game."""
|
||||||
self.output(entry)
|
if not self.ctx.game:
|
||||||
else:
|
self.output("No game set, cannot determine existing location groups.")
|
||||||
self.output(f"{name} Groups for {self.ctx.game}")
|
return False
|
||||||
for group in lookup:
|
self.output(f"Location Group Names for {self.ctx.game}")
|
||||||
self.output(group)
|
for group_name in AutoWorldRegister.world_types[self.ctx.game].location_name_groups:
|
||||||
return True
|
self.output(group_name)
|
||||||
|
|
||||||
@mark_raw
|
def _cmd_ready(self):
|
||||||
def _cmd_item_groups(self, key: str = "") -> bool:
|
|
||||||
"""
|
|
||||||
List all item group names for the currently running game.
|
|
||||||
|
|
||||||
:param key: Which item group to filter to. Will log all groups if empty.
|
|
||||||
"""
|
|
||||||
return self.output_group_part("item_name_groups", key, "Item")
|
|
||||||
|
|
||||||
@mark_raw
|
|
||||||
def _cmd_location_groups(self, key: str = "") -> bool:
|
|
||||||
"""
|
|
||||||
List all location group names for the currently running game.
|
|
||||||
|
|
||||||
:param key: Which item group to filter to. Will log all groups if empty.
|
|
||||||
"""
|
|
||||||
return self.output_group_part("location_name_groups", key, "Location")
|
|
||||||
|
|
||||||
def _cmd_ready(self) -> bool:
|
|
||||||
"""Send ready status to server."""
|
"""Send ready status to server."""
|
||||||
self.ctx.ready = not self.ctx.ready
|
self.ctx.ready = not self.ctx.ready
|
||||||
if self.ctx.ready:
|
if self.ctx.ready:
|
||||||
@@ -219,7 +174,6 @@ class ClientCommandProcessor(CommandProcessor):
|
|||||||
state = ClientStatus.CLIENT_CONNECTED
|
state = ClientStatus.CLIENT_CONNECTED
|
||||||
self.output("Unreadied.")
|
self.output("Unreadied.")
|
||||||
async_start(self.ctx.send_msgs([{"cmd": "StatusUpdate", "status": state}]), name="send StatusUpdate")
|
async_start(self.ctx.send_msgs([{"cmd": "StatusUpdate", "status": state}]), name="send StatusUpdate")
|
||||||
return True
|
|
||||||
|
|
||||||
def default(self, raw: str):
|
def default(self, raw: str):
|
||||||
"""The default message parser to be used when parsing any messages that do not match a command"""
|
"""The default message parser to be used when parsing any messages that do not match a command"""
|
||||||
@@ -242,12 +196,25 @@ class CommonContext:
|
|||||||
self.lookup_type: typing.Literal["item", "location"] = lookup_type
|
self.lookup_type: typing.Literal["item", "location"] = lookup_type
|
||||||
self._unknown_item: typing.Callable[[int], str] = lambda key: f"Unknown {lookup_type} (ID: {key})"
|
self._unknown_item: typing.Callable[[int], str] = lambda key: f"Unknown {lookup_type} (ID: {key})"
|
||||||
self._archipelago_lookup: typing.Dict[int, str] = {}
|
self._archipelago_lookup: typing.Dict[int, str] = {}
|
||||||
|
self._flat_store: typing.Dict[int, str] = Utils.KeyedDefaultDict(self._unknown_item)
|
||||||
self._game_store: typing.Dict[str, typing.ChainMap[int, str]] = collections.defaultdict(
|
self._game_store: typing.Dict[str, typing.ChainMap[int, str]] = collections.defaultdict(
|
||||||
lambda: collections.ChainMap(self._archipelago_lookup, Utils.KeyedDefaultDict(self._unknown_item)))
|
lambda: collections.ChainMap(self._archipelago_lookup, Utils.KeyedDefaultDict(self._unknown_item)))
|
||||||
|
self.warned: bool = False
|
||||||
|
|
||||||
# noinspection PyTypeChecker
|
# noinspection PyTypeChecker
|
||||||
def __getitem__(self, key: str) -> typing.Mapping[int, str]:
|
def __getitem__(self, key: str) -> typing.Mapping[int, str]:
|
||||||
assert isinstance(key, str), f"ctx.{self.lookup_type}_names used with an id, use the lookup_in_ helpers instead"
|
# TODO: In a future version (0.6.0?) this should be simplified by removing implicit id lookups support.
|
||||||
|
if isinstance(key, int):
|
||||||
|
if not self.warned:
|
||||||
|
# Use warnings instead of logger to avoid deprecation message from appearing on user side.
|
||||||
|
self.warned = True
|
||||||
|
warnings.warn(f"Implicit name lookup by id only is deprecated and only supported to maintain "
|
||||||
|
f"backwards compatibility for now. If multiple games share the same id for a "
|
||||||
|
f"{self.lookup_type}, name could be incorrect. Please use "
|
||||||
|
f"`{self.lookup_type}_names.lookup_in_game()` or "
|
||||||
|
f"`{self.lookup_type}_names.lookup_in_slot()` instead.")
|
||||||
|
return self._flat_store[key] # type: ignore
|
||||||
|
|
||||||
return self._game_store[key]
|
return self._game_store[key]
|
||||||
|
|
||||||
def __len__(self) -> int:
|
def __len__(self) -> int:
|
||||||
@@ -257,7 +224,7 @@ class CommonContext:
|
|||||||
return iter(self._game_store)
|
return iter(self._game_store)
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
def __repr__(self) -> str:
|
||||||
return repr(self._game_store)
|
return self._game_store.__repr__()
|
||||||
|
|
||||||
def lookup_in_game(self, code: int, game_name: typing.Optional[str] = None) -> str:
|
def lookup_in_game(self, code: int, game_name: typing.Optional[str] = None) -> str:
|
||||||
"""Returns the name for an item/location id in the context of a specific game or own game if `game` is
|
"""Returns the name for an item/location id in the context of a specific game or own game if `game` is
|
||||||
@@ -287,6 +254,7 @@ class CommonContext:
|
|||||||
id_to_name_lookup_table = Utils.KeyedDefaultDict(self._unknown_item)
|
id_to_name_lookup_table = Utils.KeyedDefaultDict(self._unknown_item)
|
||||||
id_to_name_lookup_table.update({code: name for name, code in name_to_id_lookup_table.items()})
|
id_to_name_lookup_table.update({code: name for name, code in name_to_id_lookup_table.items()})
|
||||||
self._game_store[game] = collections.ChainMap(self._archipelago_lookup, id_to_name_lookup_table)
|
self._game_store[game] = collections.ChainMap(self._archipelago_lookup, id_to_name_lookup_table)
|
||||||
|
self._flat_store.update(id_to_name_lookup_table) # Only needed for legacy lookup method.
|
||||||
if game == "Archipelago":
|
if game == "Archipelago":
|
||||||
# Keep track of the Archipelago data package separately so if it gets updated in a custom datapackage,
|
# Keep track of the Archipelago data package separately so if it gets updated in a custom datapackage,
|
||||||
# it updates in all chain maps automatically.
|
# it updates in all chain maps automatically.
|
||||||
@@ -313,71 +281,38 @@ class CommonContext:
|
|||||||
last_death_link: float = time.time() # last send/received death link on AP layer
|
last_death_link: float = time.time() # last send/received death link on AP layer
|
||||||
|
|
||||||
# remaining type info
|
# remaining type info
|
||||||
slot_info: dict[int, NetworkSlot]
|
slot_info: typing.Dict[int, NetworkSlot]
|
||||||
"""Slot Info from the server for the current connection"""
|
server_address: typing.Optional[str]
|
||||||
server_address: str | None
|
password: typing.Optional[str]
|
||||||
"""Autoconnect address provided by the ctx constructor"""
|
hint_cost: typing.Optional[int]
|
||||||
password: str | None
|
hint_points: typing.Optional[int]
|
||||||
"""Password used for Connecting, expected by server_auth"""
|
player_names: typing.Dict[int, str]
|
||||||
hint_cost: int | None
|
|
||||||
"""Current Hint Cost per Hint from the server"""
|
|
||||||
hint_points: int | None
|
|
||||||
"""Current available Hint Points from the server"""
|
|
||||||
player_names: dict[int, str]
|
|
||||||
"""Current lookup of slot number to player display name from server (includes aliases)"""
|
|
||||||
|
|
||||||
finished_game: bool
|
finished_game: bool
|
||||||
"""
|
|
||||||
Bool to signal that status should be updated to Goal after reconnecting
|
|
||||||
to be used to ensure that a StatusUpdate packet does not get lost when disconnected
|
|
||||||
"""
|
|
||||||
ready: bool
|
ready: bool
|
||||||
"""Bool to keep track of state for the /ready command"""
|
team: typing.Optional[int]
|
||||||
team: int | None
|
slot: typing.Optional[int]
|
||||||
"""Team number of currently connected slot"""
|
auth: typing.Optional[str]
|
||||||
slot: int | None
|
seed_name: typing.Optional[str]
|
||||||
"""Slot number of currently connected slot"""
|
|
||||||
auth: str | None
|
|
||||||
"""Name used in Connect packet"""
|
|
||||||
seed_name: str | None
|
|
||||||
"""Seed name that will be validated on opening a socket if present"""
|
|
||||||
|
|
||||||
# locations
|
# locations
|
||||||
locations_checked: set[int]
|
locations_checked: typing.Set[int] # local state
|
||||||
"""
|
locations_scouted: typing.Set[int]
|
||||||
Local container of location ids checked to signal that LocationChecks should be resent after reconnecting
|
items_received: typing.List[NetworkItem]
|
||||||
to be used to ensure that a LocationChecks packet does not get lost when disconnected
|
missing_locations: typing.Set[int] # server state
|
||||||
"""
|
checked_locations: typing.Set[int] # server state
|
||||||
locations_scouted: set[int]
|
server_locations: typing.Set[int] # all locations the server knows of, missing_location | checked_locations
|
||||||
"""
|
locations_info: typing.Dict[int, NetworkItem]
|
||||||
Local container of location ids scouted to signal that LocationScouts should be resent after reconnecting
|
|
||||||
to be used to ensure that a LocationScouts packet does not get lost when disconnected
|
|
||||||
"""
|
|
||||||
items_received: list[NetworkItem]
|
|
||||||
"""List of NetworkItems recieved from the server"""
|
|
||||||
missing_locations: set[int]
|
|
||||||
"""Container of Locations that are unchecked per server state"""
|
|
||||||
checked_locations: set[int]
|
|
||||||
"""Container of Locations that are checked per server state"""
|
|
||||||
server_locations: set[int]
|
|
||||||
"""Container of Locations that exist per server state; a combination between missing and checked locations"""
|
|
||||||
locations_info: dict[int, NetworkItem]
|
|
||||||
"""Dict of location id: NetworkItem info from LocationScouts request"""
|
|
||||||
|
|
||||||
# data storage
|
# data storage
|
||||||
stored_data: dict[str, typing.Any]
|
stored_data: typing.Dict[str, typing.Any]
|
||||||
"""
|
stored_data_notification_keys: typing.Set[str]
|
||||||
Data Storage values by key that were retrieved from the server
|
|
||||||
any keys subscribed to with SetNotify will be kept up to date
|
|
||||||
"""
|
|
||||||
stored_data_notification_keys: set[str]
|
|
||||||
"""Current container of watched Data Storage keys, managed by ctx.set_notify"""
|
|
||||||
|
|
||||||
# internals
|
# internals
|
||||||
|
# current message box through kvui
|
||||||
_messagebox: typing.Optional["kvui.MessageBox"] = None
|
_messagebox: typing.Optional["kvui.MessageBox"] = None
|
||||||
"""Current message box through kvui"""
|
# message box reporting a loss of connection
|
||||||
_messagebox_connection_loss: typing.Optional["kvui.MessageBox"] = None
|
_messagebox_connection_loss: typing.Optional["kvui.MessageBox"] = None
|
||||||
"""Message box reporting a loss of connection"""
|
|
||||||
|
|
||||||
def __init__(self, server_address: typing.Optional[str] = None, password: typing.Optional[str] = None) -> None:
|
def __init__(self, server_address: typing.Optional[str] = None, password: typing.Optional[str] = None) -> None:
|
||||||
# server state
|
# server state
|
||||||
@@ -421,12 +356,11 @@ class CommonContext:
|
|||||||
|
|
||||||
self.item_names = self.NameLookupDict(self, "item")
|
self.item_names = self.NameLookupDict(self, "item")
|
||||||
self.location_names = self.NameLookupDict(self, "location")
|
self.location_names = self.NameLookupDict(self, "location")
|
||||||
|
self.versions = {}
|
||||||
self.checksums = {}
|
self.checksums = {}
|
||||||
|
|
||||||
self.jsontotextparser = JSONtoTextParser(self)
|
self.jsontotextparser = JSONtoTextParser(self)
|
||||||
self.rawjsontotextparser = RawJSONtoTextParser(self)
|
self.rawjsontotextparser = RawJSONtoTextParser(self)
|
||||||
if self.game:
|
|
||||||
self.checksums[self.game] = network_data_package["games"][self.game]["checksum"]
|
|
||||||
self.update_data_package(network_data_package)
|
self.update_data_package(network_data_package)
|
||||||
|
|
||||||
# execution
|
# execution
|
||||||
@@ -479,8 +413,7 @@ class CommonContext:
|
|||||||
await self.server.socket.close()
|
await self.server.socket.close()
|
||||||
if self.server_task is not None:
|
if self.server_task is not None:
|
||||||
await self.server_task
|
await self.server_task
|
||||||
if self.ui:
|
self.ui.update_hints()
|
||||||
self.ui.update_hints()
|
|
||||||
|
|
||||||
async def send_msgs(self, msgs: typing.List[typing.Any]) -> None:
|
async def send_msgs(self, msgs: typing.List[typing.Any]) -> None:
|
||||||
""" `msgs` JSON serializable """
|
""" `msgs` JSON serializable """
|
||||||
@@ -571,10 +504,6 @@ class CommonContext:
|
|||||||
return print_json_packet.get("type", "") == "ItemSend" \
|
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["receiving"]) \
|
||||||
and not self.slot_concerns_self(print_json_packet["item"].player)
|
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):
|
def on_print(self, args: dict):
|
||||||
logger.info(args["text"])
|
logger.info(args["text"])
|
||||||
@@ -641,6 +570,7 @@ class CommonContext:
|
|||||||
|
|
||||||
# DataPackage
|
# DataPackage
|
||||||
async def prepare_data_package(self, relevant_games: typing.Set[str],
|
async def prepare_data_package(self, relevant_games: typing.Set[str],
|
||||||
|
remote_date_package_versions: typing.Dict[str, int],
|
||||||
remote_data_package_checksums: typing.Dict[str, str]):
|
remote_data_package_checksums: typing.Dict[str, str]):
|
||||||
"""Validate that all data is present for the current multiworld.
|
"""Validate that all data is present for the current multiworld.
|
||||||
Download, assimilate and cache missing data from the server."""
|
Download, assimilate and cache missing data from the server."""
|
||||||
@@ -649,26 +579,33 @@ class CommonContext:
|
|||||||
|
|
||||||
needed_updates: typing.Set[str] = set()
|
needed_updates: typing.Set[str] = set()
|
||||||
for game in relevant_games:
|
for game in relevant_games:
|
||||||
if game not in remote_data_package_checksums:
|
if game not in remote_date_package_versions and game not in remote_data_package_checksums:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
remote_version: int = remote_date_package_versions.get(game, 0)
|
||||||
remote_checksum: typing.Optional[str] = remote_data_package_checksums.get(game)
|
remote_checksum: typing.Optional[str] = remote_data_package_checksums.get(game)
|
||||||
|
|
||||||
if not remote_checksum: # custom data package and no checksum for this game
|
if remote_version == 0 and not remote_checksum: # custom data package and no checksum for this game
|
||||||
needed_updates.add(game)
|
needed_updates.add(game)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
cached_version: int = self.versions.get(game, 0)
|
||||||
cached_checksum: typing.Optional[str] = self.checksums.get(game)
|
cached_checksum: typing.Optional[str] = self.checksums.get(game)
|
||||||
# no action required if cached version is new enough
|
# no action required if cached version is new enough
|
||||||
if remote_checksum != cached_checksum:
|
if (not remote_checksum and (remote_version > cached_version or remote_version == 0)) \
|
||||||
|
or remote_checksum != cached_checksum:
|
||||||
|
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")
|
local_checksum: typing.Optional[str] = network_data_package["games"].get(game, {}).get("checksum")
|
||||||
if remote_checksum == local_checksum:
|
if ((remote_checksum or remote_version <= local_version and remote_version != 0)
|
||||||
|
and remote_checksum == local_checksum):
|
||||||
self.update_game(network_data_package["games"][game], game)
|
self.update_game(network_data_package["games"][game], game)
|
||||||
else:
|
else:
|
||||||
cached_game = Utils.load_data_package_for_checksum(game, remote_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")
|
cache_checksum: typing.Optional[str] = cached_game.get("checksum")
|
||||||
# download remote version if cache is not new enough
|
# download remote version if cache is not new enough
|
||||||
if remote_checksum != cache_checksum:
|
if (not remote_checksum and (remote_version > cache_version or remote_version == 0)) \
|
||||||
|
or remote_checksum != cache_checksum:
|
||||||
needed_updates.add(game)
|
needed_updates.add(game)
|
||||||
else:
|
else:
|
||||||
self.update_game(cached_game, game)
|
self.update_game(cached_game, game)
|
||||||
@@ -678,6 +615,7 @@ class CommonContext:
|
|||||||
def update_game(self, game_package: dict, game: str):
|
def update_game(self, game_package: dict, game: str):
|
||||||
self.item_names.update_game(game, game_package["item_name_to_id"])
|
self.item_names.update_game(game, game_package["item_name_to_id"])
|
||||||
self.location_names.update_game(game, game_package["location_name_to_id"])
|
self.location_names.update_game(game, game_package["location_name_to_id"])
|
||||||
|
self.versions[game] = game_package.get("version", 0)
|
||||||
self.checksums[game] = game_package.get("checksum")
|
self.checksums[game] = game_package.get("checksum")
|
||||||
|
|
||||||
def update_data_package(self, data_package: dict):
|
def update_data_package(self, data_package: dict):
|
||||||
@@ -686,28 +624,13 @@ class CommonContext:
|
|||||||
|
|
||||||
def consume_network_data_package(self, data_package: dict):
|
def consume_network_data_package(self, data_package: dict):
|
||||||
self.update_data_package(data_package)
|
self.update_data_package(data_package)
|
||||||
|
current_cache = Utils.persistent_load().get("datapackage", {}).get("games", {})
|
||||||
|
current_cache.update(data_package["games"])
|
||||||
|
Utils.persistent_store("datapackage", "games", current_cache)
|
||||||
logger.info(f"Got new ID/Name DataPackage for {', '.join(data_package['games'])}")
|
logger.info(f"Got new ID/Name DataPackage for {', '.join(data_package['games'])}")
|
||||||
for game, game_data in data_package["games"].items():
|
for game, game_data in data_package["games"].items():
|
||||||
Utils.store_data_package_for_checksum(game, game_data)
|
Utils.store_data_package_for_checksum(game, game_data)
|
||||||
|
|
||||||
def consume_network_item_groups(self):
|
|
||||||
data = {"item_name_groups": self.stored_data[f"_read_item_name_groups_{self.game}"]}
|
|
||||||
current_cache = Utils.persistent_load().get("groups_by_checksum", {}).get(self.checksums[self.game], {})
|
|
||||||
if self.game in current_cache:
|
|
||||||
current_cache[self.game].update(data)
|
|
||||||
else:
|
|
||||||
current_cache[self.game] = data
|
|
||||||
Utils.persistent_store("groups_by_checksum", self.checksums[self.game], current_cache)
|
|
||||||
|
|
||||||
def consume_network_location_groups(self):
|
|
||||||
data = {"location_name_groups": self.stored_data[f"_read_location_name_groups_{self.game}"]}
|
|
||||||
current_cache = Utils.persistent_load().get("groups_by_checksum", {}).get(self.checksums[self.game], {})
|
|
||||||
if self.game in current_cache:
|
|
||||||
current_cache[self.game].update(data)
|
|
||||||
else:
|
|
||||||
current_cache[self.game] = data
|
|
||||||
Utils.persistent_store("groups_by_checksum", self.checksums[self.game], current_cache)
|
|
||||||
|
|
||||||
# data storage
|
# data storage
|
||||||
|
|
||||||
def set_notify(self, *keys: str) -> None:
|
def set_notify(self, *keys: str) -> None:
|
||||||
@@ -859,9 +782,9 @@ async def server_loop(ctx: CommonContext, address: typing.Optional[str] = None)
|
|||||||
|
|
||||||
server_url = urllib.parse.urlparse(address)
|
server_url = urllib.parse.urlparse(address)
|
||||||
if server_url.username:
|
if server_url.username:
|
||||||
ctx.username = urllib.parse.unquote(server_url.username)
|
ctx.username = server_url.username
|
||||||
if server_url.password:
|
if server_url.password:
|
||||||
ctx.password = urllib.parse.unquote(server_url.password)
|
ctx.password = server_url.password
|
||||||
|
|
||||||
def reconnect_hint() -> str:
|
def reconnect_hint() -> str:
|
||||||
return ", type /connect to reconnect" if ctx.server_address else ""
|
return ", type /connect to reconnect" if ctx.server_address else ""
|
||||||
@@ -966,8 +889,9 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
|
|||||||
logger.info(' %s (Player %d)' % (network_player.alias, network_player.slot))
|
logger.info(' %s (Player %d)' % (network_player.alias, network_player.slot))
|
||||||
|
|
||||||
# update data package
|
# update data package
|
||||||
|
data_package_versions = args.get("datapackage_versions", {})
|
||||||
data_package_checksums = args.get("datapackage_checksums", {})
|
data_package_checksums = args.get("datapackage_checksums", {})
|
||||||
await ctx.prepare_data_package(set(args["games"]), data_package_checksums)
|
await ctx.prepare_data_package(set(args["games"]), data_package_versions, data_package_checksums)
|
||||||
|
|
||||||
await ctx.server_auth(args['password'])
|
await ctx.server_auth(args['password'])
|
||||||
|
|
||||||
@@ -1008,12 +932,6 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
|
|||||||
ctx.hint_points = args.get("hint_points", 0)
|
ctx.hint_points = args.get("hint_points", 0)
|
||||||
ctx.consume_players_package(args["players"])
|
ctx.consume_players_package(args["players"])
|
||||||
ctx.stored_data_notification_keys.add(f"_read_hints_{ctx.team}_{ctx.slot}")
|
ctx.stored_data_notification_keys.add(f"_read_hints_{ctx.team}_{ctx.slot}")
|
||||||
if ctx.game:
|
|
||||||
game = ctx.game
|
|
||||||
else:
|
|
||||||
game = ctx.slot_info[ctx.slot][1]
|
|
||||||
ctx.stored_data_notification_keys.add(f"_read_item_name_groups_{game}")
|
|
||||||
ctx.stored_data_notification_keys.add(f"_read_location_name_groups_{game}")
|
|
||||||
msgs = []
|
msgs = []
|
||||||
if ctx.locations_checked:
|
if ctx.locations_checked:
|
||||||
msgs.append({"cmd": "LocationChecks",
|
msgs.append({"cmd": "LocationChecks",
|
||||||
@@ -1094,19 +1012,11 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
|
|||||||
ctx.stored_data.update(args["keys"])
|
ctx.stored_data.update(args["keys"])
|
||||||
if ctx.ui and f"_read_hints_{ctx.team}_{ctx.slot}" in args["keys"]:
|
if ctx.ui and f"_read_hints_{ctx.team}_{ctx.slot}" in args["keys"]:
|
||||||
ctx.ui.update_hints()
|
ctx.ui.update_hints()
|
||||||
if f"_read_item_name_groups_{ctx.game}" in args["keys"]:
|
|
||||||
ctx.consume_network_item_groups()
|
|
||||||
if f"_read_location_name_groups_{ctx.game}" in args["keys"]:
|
|
||||||
ctx.consume_network_location_groups()
|
|
||||||
|
|
||||||
elif cmd == "SetReply":
|
elif cmd == "SetReply":
|
||||||
ctx.stored_data[args["key"]] = args["value"]
|
ctx.stored_data[args["key"]] = args["value"]
|
||||||
if ctx.ui and f"_read_hints_{ctx.team}_{ctx.slot}" == args["key"]:
|
if ctx.ui and f"_read_hints_{ctx.team}_{ctx.slot}" == args["key"]:
|
||||||
ctx.ui.update_hints()
|
ctx.ui.update_hints()
|
||||||
elif f"_read_item_name_groups_{ctx.game}" == args["key"]:
|
|
||||||
ctx.consume_network_item_groups()
|
|
||||||
elif f"_read_location_name_groups_{ctx.game}" == args["key"]:
|
|
||||||
ctx.consume_network_location_groups()
|
|
||||||
elif args["key"].startswith("EnergyLink"):
|
elif args["key"].startswith("EnergyLink"):
|
||||||
ctx.current_energy_link_value = args["value"]
|
ctx.current_energy_link_value = args["value"]
|
||||||
if ctx.ui:
|
if ctx.ui:
|
||||||
@@ -1218,7 +1128,7 @@ def run_as_textclient(*args):
|
|||||||
args = handle_url_arg(args, parser=parser)
|
args = handle_url_arg(args, parser=parser)
|
||||||
|
|
||||||
# use colorama to display colored text highlighting on windows
|
# use colorama to display colored text highlighting on windows
|
||||||
colorama.just_fix_windows_console()
|
colorama.init()
|
||||||
|
|
||||||
asyncio.run(main(args))
|
asyncio.run(main(args))
|
||||||
colorama.deinit()
|
colorama.deinit()
|
||||||
|
|||||||
100
Dockerfile
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
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
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()
|
||||||
498
Fill.py
498
Fill.py
@@ -4,7 +4,7 @@ import logging
|
|||||||
import typing
|
import typing
|
||||||
from collections import Counter, deque
|
from collections import Counter, deque
|
||||||
|
|
||||||
from BaseClasses import CollectionState, Item, Location, LocationProgressType, MultiWorld, PlandoItemBlock
|
from BaseClasses import CollectionState, Item, Location, LocationProgressType, MultiWorld
|
||||||
from Options import Accessibility
|
from Options import Accessibility
|
||||||
|
|
||||||
from worlds.AutoWorld import call_all
|
from worlds.AutoWorld import call_all
|
||||||
@@ -75,11 +75,9 @@ def fill_restrictive(multiworld: MultiWorld, base_state: CollectionState, locati
|
|||||||
items_to_place.append(reachable_items[next_player].pop())
|
items_to_place.append(reachable_items[next_player].pop())
|
||||||
|
|
||||||
for item in items_to_place:
|
for item in items_to_place:
|
||||||
# The items added into `reachable_items` are placed starting from the end of each deque in
|
for p, pool_item in enumerate(item_pool):
|
||||||
# `reachable_items`, so the items being placed are more likely to be found towards the end of `item_pool`.
|
|
||||||
for p, pool_item in enumerate(reversed(item_pool), start=1):
|
|
||||||
if pool_item is item:
|
if pool_item is item:
|
||||||
del item_pool[-p]
|
item_pool.pop(p)
|
||||||
break
|
break
|
||||||
|
|
||||||
maximum_exploration_state = sweep_from_pool(
|
maximum_exploration_state = sweep_from_pool(
|
||||||
@@ -100,7 +98,7 @@ def fill_restrictive(multiworld: MultiWorld, base_state: CollectionState, locati
|
|||||||
# if minimal accessibility, only check whether location is reachable if game not beatable
|
# if minimal accessibility, only check whether location is reachable if game not beatable
|
||||||
if multiworld.worlds[item_to_place.player].options.accessibility == Accessibility.option_minimal:
|
if multiworld.worlds[item_to_place.player].options.accessibility == Accessibility.option_minimal:
|
||||||
perform_access_check = not multiworld.has_beaten_game(maximum_exploration_state,
|
perform_access_check = not multiworld.has_beaten_game(maximum_exploration_state,
|
||||||
item_to_place.player) \
|
item_to_place.player) \
|
||||||
if single_player_placement else not has_beaten_game
|
if single_player_placement else not has_beaten_game
|
||||||
else:
|
else:
|
||||||
perform_access_check = True
|
perform_access_check = True
|
||||||
@@ -116,23 +114,12 @@ def fill_restrictive(multiworld: MultiWorld, base_state: CollectionState, locati
|
|||||||
else:
|
else:
|
||||||
# we filled all reachable spots.
|
# we filled all reachable spots.
|
||||||
if swap:
|
if swap:
|
||||||
# Keep a cache of previous safe swap states that might be usable to sweep from to produce the next
|
|
||||||
# swap state, instead of sweeping from `base_state` each time.
|
|
||||||
previous_safe_swap_state_cache: typing.Deque[CollectionState] = deque()
|
|
||||||
# Almost never are more than 2 states needed. The rare cases that do are usually highly restrictive
|
|
||||||
# single_player_placement=True pre-fills which can go through more than 10 states in some seeds.
|
|
||||||
max_swap_base_state_cache_length = 3
|
|
||||||
|
|
||||||
# try swapping this item with previously placed items in a safe way then in an unsafe way
|
# try swapping this item with previously placed items in a safe way then in an unsafe way
|
||||||
swap_attempts = ((i, location, unsafe)
|
swap_attempts = ((i, location, unsafe)
|
||||||
for unsafe in (False, True)
|
for unsafe in (False, True)
|
||||||
for i, location in enumerate(placements))
|
for i, location in enumerate(placements))
|
||||||
for (i, location, unsafe) in swap_attempts:
|
for (i, location, unsafe) in swap_attempts:
|
||||||
placed_item = location.item
|
placed_item = location.item
|
||||||
if item_to_place == placed_item:
|
|
||||||
# The number of allowed swaps is limited, so do not allow a swap of an item with a copy of
|
|
||||||
# itself.
|
|
||||||
continue
|
|
||||||
# Unplaceable items can sometimes be swapped infinitely. Limit the
|
# Unplaceable items can sometimes be swapped infinitely. Limit the
|
||||||
# number of times we will swap an individual item to prevent this
|
# number of times we will swap an individual item to prevent this
|
||||||
swap_count = swapped_items[placed_item.player, placed_item.name, unsafe]
|
swap_count = swapped_items[placed_item.player, placed_item.name, unsafe]
|
||||||
@@ -141,50 +128,40 @@ def fill_restrictive(multiworld: MultiWorld, base_state: CollectionState, locati
|
|||||||
|
|
||||||
location.item = None
|
location.item = None
|
||||||
placed_item.location = None
|
placed_item.location = None
|
||||||
|
swap_state = sweep_from_pool(base_state, [placed_item, *item_pool] if unsafe else item_pool,
|
||||||
for previous_safe_swap_state in previous_safe_swap_state_cache:
|
multiworld.get_filled_locations(item.player)
|
||||||
# If a state has already checked the location of the swap, then it cannot be used.
|
if single_player_placement else None)
|
||||||
if location not in previous_safe_swap_state.advancements:
|
|
||||||
# Previous swap states will have collected all items in `item_pool`, so the new
|
|
||||||
# `swap_state` can skip having to collect them again.
|
|
||||||
# Previous swap states will also have already checked many locations, making the sweep
|
|
||||||
# faster.
|
|
||||||
swap_state = sweep_from_pool(previous_safe_swap_state, (placed_item,) if unsafe else (),
|
|
||||||
multiworld.get_filled_locations(item.player)
|
|
||||||
if single_player_placement else None)
|
|
||||||
break
|
|
||||||
else:
|
|
||||||
# No previous swap_state was usable as a base state to sweep from, so create a new one.
|
|
||||||
swap_state = sweep_from_pool(base_state, [placed_item, *item_pool] if unsafe else item_pool,
|
|
||||||
multiworld.get_filled_locations(item.player)
|
|
||||||
if single_player_placement else None)
|
|
||||||
# Unsafe states should not be added to the cache because they have collected `placed_item`.
|
|
||||||
if not unsafe:
|
|
||||||
if len(previous_safe_swap_state_cache) >= max_swap_base_state_cache_length:
|
|
||||||
# Remove the oldest cached state.
|
|
||||||
previous_safe_swap_state_cache.pop()
|
|
||||||
# Add the new state to the start of the cache.
|
|
||||||
previous_safe_swap_state_cache.appendleft(swap_state)
|
|
||||||
# unsafe means swap_state assumes we can somehow collect placed_item before item_to_place
|
# unsafe means swap_state assumes we can somehow collect placed_item before item_to_place
|
||||||
# by continuing to swap, which is not guaranteed. This is unsafe because there is no mechanic
|
# by continuing to swap, which is not guaranteed. This is unsafe because there is no mechanic
|
||||||
# to clean that up later, so there is a chance generation fails.
|
# to clean that up later, so there is a chance generation fails.
|
||||||
if (not single_player_placement or location.player == item_to_place.player) \
|
if (not single_player_placement or location.player == item_to_place.player) \
|
||||||
and location.can_fill(swap_state, item_to_place, perform_access_check):
|
and location.can_fill(swap_state, item_to_place, perform_access_check):
|
||||||
# Add this item to the existing placement, and
|
|
||||||
# add the old item to the back of the queue
|
|
||||||
spot_to_fill = placements.pop(i)
|
|
||||||
|
|
||||||
swap_count += 1
|
# Verify placing this item won't reduce available locations, which would be a useless swap.
|
||||||
swapped_items[placed_item.player, placed_item.name, unsafe] = swap_count
|
prev_state = swap_state.copy()
|
||||||
|
prev_loc_count = len(
|
||||||
|
multiworld.get_reachable_locations(prev_state))
|
||||||
|
|
||||||
reachable_items[placed_item.player].appendleft(
|
swap_state.collect(item_to_place, True)
|
||||||
placed_item)
|
new_loc_count = len(
|
||||||
item_pool.append(placed_item)
|
multiworld.get_reachable_locations(swap_state))
|
||||||
|
|
||||||
# cleanup at the end to hopefully get better errors
|
if new_loc_count >= prev_loc_count:
|
||||||
cleanup_required = True
|
# Add this item to the existing placement, and
|
||||||
|
# add the old item to the back of the queue
|
||||||
|
spot_to_fill = placements.pop(i)
|
||||||
|
|
||||||
break
|
swap_count += 1
|
||||||
|
swapped_items[placed_item.player, placed_item.name, unsafe] = swap_count
|
||||||
|
|
||||||
|
reachable_items[placed_item.player].appendleft(
|
||||||
|
placed_item)
|
||||||
|
item_pool.append(placed_item)
|
||||||
|
|
||||||
|
# cleanup at the end to hopefully get better errors
|
||||||
|
cleanup_required = True
|
||||||
|
|
||||||
|
break
|
||||||
|
|
||||||
# Item can't be placed here, restore original item
|
# Item can't be placed here, restore original item
|
||||||
location.item = placed_item
|
location.item = placed_item
|
||||||
@@ -263,7 +240,7 @@ def remaining_fill(multiworld: MultiWorld,
|
|||||||
unplaced_items: typing.List[Item] = []
|
unplaced_items: typing.List[Item] = []
|
||||||
placements: typing.List[Location] = []
|
placements: typing.List[Location] = []
|
||||||
swapped_items: typing.Counter[typing.Tuple[int, str]] = Counter()
|
swapped_items: typing.Counter[typing.Tuple[int, str]] = Counter()
|
||||||
total = min(len(itempool), len(locations))
|
total = min(len(itempool), len(locations))
|
||||||
placed = 0
|
placed = 0
|
||||||
|
|
||||||
# Optimisation: Decide whether to do full location.can_fill check (respect excluded), or only check the item rule
|
# Optimisation: Decide whether to do full location.can_fill check (respect excluded), or only check the item rule
|
||||||
@@ -280,7 +257,6 @@ def remaining_fill(multiworld: MultiWorld,
|
|||||||
item_to_place = itempool.pop()
|
item_to_place = itempool.pop()
|
||||||
spot_to_fill: typing.Optional[Location] = None
|
spot_to_fill: typing.Optional[Location] = None
|
||||||
|
|
||||||
# going through locations in the same order as the provided `locations` argument
|
|
||||||
for i, location in enumerate(locations):
|
for i, location in enumerate(locations):
|
||||||
if location_can_fill_item(location, item_to_place):
|
if location_can_fill_item(location, item_to_place):
|
||||||
# popping by index is faster than removing by content,
|
# popping by index is faster than removing by content,
|
||||||
@@ -363,26 +339,19 @@ def fast_fill(multiworld: MultiWorld,
|
|||||||
return item_pool[placing:], fill_locations[placing:]
|
return item_pool[placing:], fill_locations[placing:]
|
||||||
|
|
||||||
|
|
||||||
def accessibility_corrections(multiworld: MultiWorld,
|
def accessibility_corrections(multiworld: MultiWorld, state: CollectionState, locations, pool=[]):
|
||||||
state: CollectionState,
|
|
||||||
locations: list[Location],
|
|
||||||
pool: list[Item] | None = None) -> None:
|
|
||||||
if pool is None:
|
|
||||||
pool = []
|
|
||||||
maximum_exploration_state = sweep_from_pool(state, pool)
|
maximum_exploration_state = sweep_from_pool(state, pool)
|
||||||
minimal_players = {player for player in multiworld.player_ids if
|
minimal_players = {player for player in multiworld.player_ids if multiworld.worlds[player].options.accessibility == "minimal"}
|
||||||
multiworld.worlds[player].options.accessibility == "minimal"}
|
unreachable_locations = [location for location in multiworld.get_locations() if location.player in minimal_players and
|
||||||
unreachable_locations = [location for location in multiworld.get_locations() if
|
|
||||||
location.player in minimal_players and
|
|
||||||
not location.can_reach(maximum_exploration_state)]
|
not location.can_reach(maximum_exploration_state)]
|
||||||
for location in unreachable_locations:
|
for location in unreachable_locations:
|
||||||
if (location.item is not None and location.item.advancement and location.address is not None and not
|
if (location.item is not None and location.item.advancement and location.address is not None and not
|
||||||
location.locked and location.item.player not in minimal_players):
|
location.locked and location.item.player not in minimal_players):
|
||||||
pool.append(location.item)
|
pool.append(location.item)
|
||||||
|
state.remove(location.item)
|
||||||
location.item = None
|
location.item = None
|
||||||
if location in state.advancements:
|
if location in state.advancements:
|
||||||
state.advancements.remove(location)
|
state.advancements.remove(location)
|
||||||
state.remove(location.item)
|
|
||||||
locations.append(location)
|
locations.append(location)
|
||||||
if pool and locations:
|
if pool and locations:
|
||||||
locations.sort(key=lambda loc: loc.progress_type != LocationProgressType.PRIORITY)
|
locations.sort(key=lambda loc: loc.progress_type != LocationProgressType.PRIORITY)
|
||||||
@@ -394,7 +363,7 @@ def inaccessible_location_rules(multiworld: MultiWorld, state: CollectionState,
|
|||||||
unreachable_locations = [location for location in locations if not location.can_reach(maximum_exploration_state)]
|
unreachable_locations = [location for location in locations if not location.can_reach(maximum_exploration_state)]
|
||||||
if unreachable_locations:
|
if unreachable_locations:
|
||||||
def forbid_important_item_rule(item: Item):
|
def forbid_important_item_rule(item: Item):
|
||||||
return not ((item.classification & 0b0011) and multiworld.worlds[item.player].options.accessibility != "minimal")
|
return not ((item.classification & 0b0011) and multiworld.worlds[item.player].options.accessibility != 'minimal')
|
||||||
|
|
||||||
for location in unreachable_locations:
|
for location in unreachable_locations:
|
||||||
add_item_rule(location, forbid_important_item_rule)
|
add_item_rule(location, forbid_important_item_rule)
|
||||||
@@ -488,12 +457,6 @@ def distribute_early_items(multiworld: MultiWorld,
|
|||||||
|
|
||||||
def distribute_items_restrictive(multiworld: MultiWorld,
|
def distribute_items_restrictive(multiworld: MultiWorld,
|
||||||
panic_method: typing.Literal["swap", "raise", "start_inventory"] = "swap") -> None:
|
panic_method: typing.Literal["swap", "raise", "start_inventory"] = "swap") -> None:
|
||||||
assert all(item.location is None for item in multiworld.itempool), (
|
|
||||||
"At the start of distribute_items_restrictive, "
|
|
||||||
"there are items in the multiworld itempool that are already placed on locations:\n"
|
|
||||||
f"{[(item.location, item) for item in multiworld.itempool if item.location is not None]}"
|
|
||||||
)
|
|
||||||
|
|
||||||
fill_locations = sorted(multiworld.get_unfilled_locations())
|
fill_locations = sorted(multiworld.get_unfilled_locations())
|
||||||
multiworld.random.shuffle(fill_locations)
|
multiworld.random.shuffle(fill_locations)
|
||||||
# get items to distribute
|
# get items to distribute
|
||||||
@@ -536,64 +499,29 @@ def distribute_items_restrictive(multiworld: MultiWorld,
|
|||||||
single_player = multiworld.players == 1 and not multiworld.groups
|
single_player = multiworld.players == 1 and not multiworld.groups
|
||||||
|
|
||||||
if prioritylocations:
|
if prioritylocations:
|
||||||
regular_progression = []
|
|
||||||
deprioritized_progression = []
|
|
||||||
for item in progitempool:
|
|
||||||
if item.deprioritized:
|
|
||||||
deprioritized_progression.append(item)
|
|
||||||
else:
|
|
||||||
regular_progression.append(item)
|
|
||||||
|
|
||||||
# "priority fill"
|
# "priority fill"
|
||||||
# try without deprioritized items in the mix at all. This means they need to be collected into state first.
|
fill_restrictive(multiworld, multiworld.state, prioritylocations, progitempool,
|
||||||
priority_fill_state = sweep_from_pool(multiworld.state, deprioritized_progression)
|
|
||||||
fill_restrictive(multiworld, priority_fill_state, prioritylocations, regular_progression,
|
|
||||||
single_player_placement=single_player, swap=False, on_place=mark_for_locking,
|
single_player_placement=single_player, swap=False, on_place=mark_for_locking,
|
||||||
name="Priority", one_item_per_player=True, allow_partial=True)
|
name="Priority", one_item_per_player=True, allow_partial=True)
|
||||||
|
|
||||||
if prioritylocations and regular_progression:
|
if prioritylocations:
|
||||||
# retry with one_item_per_player off because some priority fills can fail to fill with that optimization
|
# retry with one_item_per_player off because some priority fills can fail to fill with that optimization
|
||||||
# deprioritized items are still not in the mix, so they need to be collected into state first.
|
fill_restrictive(multiworld, multiworld.state, prioritylocations, progitempool,
|
||||||
# allow_partial should only be set if there is deprioritized progression to fall back on.
|
single_player_placement=single_player, swap=False, on_place=mark_for_locking,
|
||||||
priority_retry_state = sweep_from_pool(multiworld.state, deprioritized_progression)
|
name="Priority Retry", one_item_per_player=False)
|
||||||
fill_restrictive(multiworld, priority_retry_state, prioritylocations, regular_progression,
|
|
||||||
single_player_placement=single_player, swap=False, on_place=mark_for_locking,
|
|
||||||
name="Priority Retry", one_item_per_player=False,
|
|
||||||
allow_partial=bool(deprioritized_progression))
|
|
||||||
|
|
||||||
if prioritylocations and deprioritized_progression:
|
|
||||||
# There are no more regular progression items that can be placed on any priority locations.
|
|
||||||
# We'd still prefer to place deprioritized progression items on priority locations over filler items.
|
|
||||||
# Since we're leaving out the remaining regular progression now, we need to collect it into state first.
|
|
||||||
priority_retry_2_state = sweep_from_pool(multiworld.state, regular_progression)
|
|
||||||
fill_restrictive(multiworld, priority_retry_2_state, prioritylocations, deprioritized_progression,
|
|
||||||
single_player_placement=single_player, swap=False, on_place=mark_for_locking,
|
|
||||||
name="Priority Retry 2", one_item_per_player=True, allow_partial=True)
|
|
||||||
|
|
||||||
if prioritylocations and deprioritized_progression:
|
|
||||||
# retry with deprioritized items AND without one_item_per_player optimisation
|
|
||||||
# Since we're leaving out the remaining regular progression now, we need to collect it into state first.
|
|
||||||
priority_retry_3_state = sweep_from_pool(multiworld.state, regular_progression)
|
|
||||||
fill_restrictive(multiworld, priority_retry_3_state, prioritylocations, deprioritized_progression,
|
|
||||||
single_player_placement=single_player, swap=False, on_place=mark_for_locking,
|
|
||||||
name="Priority Retry 3", one_item_per_player=False)
|
|
||||||
|
|
||||||
# restore original order of progitempool
|
|
||||||
progitempool[:] = [item for item in progitempool if not item.location]
|
|
||||||
accessibility_corrections(multiworld, multiworld.state, prioritylocations, progitempool)
|
accessibility_corrections(multiworld, multiworld.state, prioritylocations, progitempool)
|
||||||
defaultlocations = prioritylocations + defaultlocations
|
defaultlocations = prioritylocations + defaultlocations
|
||||||
|
|
||||||
if progitempool:
|
if progitempool:
|
||||||
# "advancement/progression fill"
|
# "advancement/progression fill"
|
||||||
maximum_exploration_state = sweep_from_pool(multiworld.state)
|
|
||||||
if panic_method == "swap":
|
if panic_method == "swap":
|
||||||
fill_restrictive(multiworld, maximum_exploration_state, defaultlocations, progitempool, swap=True,
|
fill_restrictive(multiworld, multiworld.state, defaultlocations, progitempool, swap=True,
|
||||||
name="Progression", single_player_placement=single_player)
|
name="Progression", single_player_placement=single_player)
|
||||||
elif panic_method == "raise":
|
elif panic_method == "raise":
|
||||||
fill_restrictive(multiworld, maximum_exploration_state, defaultlocations, progitempool, swap=False,
|
fill_restrictive(multiworld, multiworld.state, defaultlocations, progitempool, swap=False,
|
||||||
name="Progression", single_player_placement=single_player)
|
name="Progression", single_player_placement=single_player)
|
||||||
elif panic_method == "start_inventory":
|
elif panic_method == "start_inventory":
|
||||||
fill_restrictive(multiworld, maximum_exploration_state, defaultlocations, progitempool, swap=False,
|
fill_restrictive(multiworld, multiworld.state, defaultlocations, progitempool, swap=False,
|
||||||
allow_partial=True, name="Progression", single_player_placement=single_player)
|
allow_partial=True, name="Progression", single_player_placement=single_player)
|
||||||
if progitempool:
|
if progitempool:
|
||||||
for item in progitempool:
|
for item in progitempool:
|
||||||
@@ -744,9 +672,9 @@ def balance_multiworld_progression(multiworld: MultiWorld) -> None:
|
|||||||
if multiworld.worlds[player].options.progression_balancing > 0
|
if multiworld.worlds[player].options.progression_balancing > 0
|
||||||
}
|
}
|
||||||
if not balanceable_players:
|
if not balanceable_players:
|
||||||
logging.info("Skipping multiworld progression balancing.")
|
logging.info('Skipping multiworld progression balancing.')
|
||||||
else:
|
else:
|
||||||
logging.info(f"Balancing multiworld progression for {len(balanceable_players)} Players.")
|
logging.info(f'Balancing multiworld progression for {len(balanceable_players)} Players.')
|
||||||
logging.debug(balanceable_players)
|
logging.debug(balanceable_players)
|
||||||
state: CollectionState = CollectionState(multiworld)
|
state: CollectionState = CollectionState(multiworld)
|
||||||
checked_locations: typing.Set[Location] = set()
|
checked_locations: typing.Set[Location] = set()
|
||||||
@@ -844,7 +772,7 @@ def balance_multiworld_progression(multiworld: MultiWorld) -> None:
|
|||||||
if player in threshold_percentages):
|
if player in threshold_percentages):
|
||||||
break
|
break
|
||||||
elif not balancing_sphere:
|
elif not balancing_sphere:
|
||||||
raise RuntimeError("Not all required items reachable. Something went terribly wrong here.")
|
raise RuntimeError('Not all required items reachable. Something went terribly wrong here.')
|
||||||
# Gather a set of locations which we can swap items into
|
# Gather a set of locations which we can swap items into
|
||||||
unlocked_locations: typing.Dict[int, typing.Set[Location]] = collections.defaultdict(set)
|
unlocked_locations: typing.Dict[int, typing.Set[Location]] = collections.defaultdict(set)
|
||||||
for l in unchecked_locations:
|
for l in unchecked_locations:
|
||||||
@@ -860,8 +788,8 @@ def balance_multiworld_progression(multiworld: MultiWorld) -> None:
|
|||||||
testing = items_to_test.pop()
|
testing = items_to_test.pop()
|
||||||
reducing_state = state.copy()
|
reducing_state = state.copy()
|
||||||
for location in itertools.chain((
|
for location in itertools.chain((
|
||||||
l for l in items_to_replace
|
l for l in items_to_replace
|
||||||
if l.item.player == player
|
if l.item.player == player
|
||||||
), items_to_test):
|
), items_to_test):
|
||||||
reducing_state.collect(location.item, True, location)
|
reducing_state.collect(location.item, True, location)
|
||||||
|
|
||||||
@@ -934,30 +862,52 @@ def swap_location_item(location_1: Location, location_2: Location, check_locked:
|
|||||||
location_2.item.location = location_2
|
location_2.item.location = location_2
|
||||||
|
|
||||||
|
|
||||||
def parse_planned_blocks(multiworld: MultiWorld) -> dict[int, list[PlandoItemBlock]]:
|
def distribute_planned(multiworld: MultiWorld) -> None:
|
||||||
def warn(warning: str, force: bool | str) -> None:
|
def warn(warning: str, force: typing.Union[bool, str]) -> None:
|
||||||
if isinstance(force, bool):
|
if force in [True, 'fail', 'failure', 'none', False, 'warn', 'warning']:
|
||||||
logging.warning(f"{warning}")
|
logging.warning(f'{warning}')
|
||||||
else:
|
else:
|
||||||
logging.debug(f"{warning}")
|
logging.debug(f'{warning}')
|
||||||
|
|
||||||
def failed(warning: str, force: bool | str) -> None:
|
def failed(warning: str, force: typing.Union[bool, str]) -> None:
|
||||||
if force is True:
|
if force in [True, 'fail', 'failure']:
|
||||||
raise Exception(warning)
|
raise Exception(warning)
|
||||||
else:
|
else:
|
||||||
warn(warning, force)
|
warn(warning, force)
|
||||||
|
|
||||||
|
swept_state = multiworld.state.copy()
|
||||||
|
swept_state.sweep_for_advancements()
|
||||||
|
reachable = frozenset(multiworld.get_reachable_locations(swept_state))
|
||||||
|
early_locations: typing.Dict[int, typing.List[str]] = collections.defaultdict(list)
|
||||||
|
non_early_locations: typing.Dict[int, typing.List[str]] = collections.defaultdict(list)
|
||||||
|
for loc in multiworld.get_unfilled_locations():
|
||||||
|
if loc in reachable:
|
||||||
|
early_locations[loc.player].append(loc.name)
|
||||||
|
else: # not reachable with swept state
|
||||||
|
non_early_locations[loc.player].append(loc.name)
|
||||||
|
|
||||||
world_name_lookup = multiworld.world_name_lookup
|
world_name_lookup = multiworld.world_name_lookup
|
||||||
|
|
||||||
plando_blocks: dict[int, list[PlandoItemBlock]] = dict()
|
block_value = typing.Union[typing.List[str], typing.Dict[str, typing.Any], str]
|
||||||
player_ids: set[int] = set(multiworld.player_ids)
|
plando_blocks: typing.List[typing.Dict[str, typing.Any]] = []
|
||||||
|
player_ids = set(multiworld.player_ids)
|
||||||
for player in player_ids:
|
for player in player_ids:
|
||||||
plando_blocks[player] = []
|
for block in multiworld.plando_items[player]:
|
||||||
for block in multiworld.worlds[player].options.plando_items:
|
block['player'] = player
|
||||||
new_block: PlandoItemBlock = PlandoItemBlock(player, block.from_pool, block.force)
|
if 'force' not in block:
|
||||||
target_world = block.world
|
block['force'] = 'silent'
|
||||||
|
if 'from_pool' not in block:
|
||||||
|
block['from_pool'] = True
|
||||||
|
elif not isinstance(block['from_pool'], bool):
|
||||||
|
from_pool_type = type(block['from_pool'])
|
||||||
|
raise Exception(f'Plando "from_pool" has to be boolean, not {from_pool_type} for player {player}.')
|
||||||
|
if 'world' not in block:
|
||||||
|
target_world = False
|
||||||
|
else:
|
||||||
|
target_world = block['world']
|
||||||
|
|
||||||
if target_world is False or multiworld.players == 1: # target own world
|
if target_world is False or multiworld.players == 1: # target own world
|
||||||
worlds: set[int] = {player}
|
worlds: typing.Set[int] = {player}
|
||||||
elif target_world is True: # target any worlds besides own
|
elif target_world is True: # target any worlds besides own
|
||||||
worlds = set(multiworld.player_ids) - {player}
|
worlds = set(multiworld.player_ids) - {player}
|
||||||
elif target_world is None: # target all worlds
|
elif target_world is None: # target all worlds
|
||||||
@@ -966,201 +916,173 @@ def parse_planned_blocks(multiworld: MultiWorld) -> dict[int, list[PlandoItemBlo
|
|||||||
worlds = set()
|
worlds = set()
|
||||||
for listed_world in target_world:
|
for listed_world in target_world:
|
||||||
if listed_world not in world_name_lookup:
|
if listed_world not in world_name_lookup:
|
||||||
failed(f"Cannot place item to {listed_world}'s world as that world does not exist.",
|
failed(f"Cannot place item to {target_world}'s world as that world does not exist.",
|
||||||
block.force)
|
block['force'])
|
||||||
continue
|
continue
|
||||||
worlds.add(world_name_lookup[listed_world])
|
worlds.add(world_name_lookup[listed_world])
|
||||||
elif type(target_world) == int: # target world by slot number
|
elif type(target_world) == int: # target world by slot number
|
||||||
if target_world not in range(1, multiworld.players + 1):
|
if target_world not in range(1, multiworld.players + 1):
|
||||||
failed(
|
failed(
|
||||||
f"Cannot place item in world {target_world} as it is not in range of (1, {multiworld.players})",
|
f"Cannot place item in world {target_world} as it is not in range of (1, {multiworld.players})",
|
||||||
block.force)
|
block['force'])
|
||||||
continue
|
continue
|
||||||
worlds = {target_world}
|
worlds = {target_world}
|
||||||
else: # target world by slot name
|
else: # target world by slot name
|
||||||
if target_world not in world_name_lookup:
|
if target_world not in world_name_lookup:
|
||||||
failed(f"Cannot place item to {target_world}'s world as that world does not exist.",
|
failed(f"Cannot place item to {target_world}'s world as that world does not exist.",
|
||||||
block.force)
|
block['force'])
|
||||||
continue
|
continue
|
||||||
worlds = {world_name_lookup[target_world]}
|
worlds = {world_name_lookup[target_world]}
|
||||||
new_block.worlds = worlds
|
block['world'] = worlds
|
||||||
|
|
||||||
items: list[str] | dict[str, typing.Any] = block.items
|
items: block_value = []
|
||||||
|
if "items" in block:
|
||||||
|
items = block["items"]
|
||||||
|
if 'count' not in block:
|
||||||
|
block['count'] = False
|
||||||
|
elif "item" in block:
|
||||||
|
items = block["item"]
|
||||||
|
if 'count' not in block:
|
||||||
|
block['count'] = 1
|
||||||
|
else:
|
||||||
|
failed("You must specify at least one item to place items with plando.", block['force'])
|
||||||
|
continue
|
||||||
if isinstance(items, dict):
|
if isinstance(items, dict):
|
||||||
item_list: list[str] = []
|
item_list: typing.List[str] = []
|
||||||
for key, value in items.items():
|
for key, value in items.items():
|
||||||
if value is True:
|
if value is True:
|
||||||
value = multiworld.itempool.count(multiworld.worlds[player].create_item(key))
|
value = multiworld.itempool.count(multiworld.worlds[player].create_item(key))
|
||||||
item_list += [key] * value
|
item_list += [key] * value
|
||||||
items = item_list
|
items = item_list
|
||||||
new_block.items = items
|
if isinstance(items, str):
|
||||||
|
items = [items]
|
||||||
|
block['items'] = items
|
||||||
|
|
||||||
locations: list[str] = block.locations
|
locations: block_value = []
|
||||||
|
if 'location' in block:
|
||||||
|
locations = block['location'] # just allow 'location' to keep old yamls compatible
|
||||||
|
elif 'locations' in block:
|
||||||
|
locations = block['locations']
|
||||||
if isinstance(locations, str):
|
if isinstance(locations, str):
|
||||||
locations = [locations]
|
locations = [locations]
|
||||||
|
|
||||||
resolved_locations: list[Location] = []
|
if isinstance(locations, dict):
|
||||||
for target_player in worlds:
|
location_list = []
|
||||||
locations_from_groups: list[str] = []
|
for key, value in locations.items():
|
||||||
world_locations = multiworld.get_unfilled_locations(target_player)
|
location_list += [key] * value
|
||||||
for group in multiworld.worlds[target_player].location_name_groups:
|
locations = location_list
|
||||||
if group in locations:
|
|
||||||
locations_from_groups.extend(multiworld.worlds[target_player].location_name_groups[group])
|
|
||||||
resolved_locations.extend(location for location in world_locations
|
|
||||||
if location.name in [*locations, *locations_from_groups])
|
|
||||||
new_block.locations = sorted(dict.fromkeys(locations))
|
|
||||||
new_block.resolved_locations = sorted(set(resolved_locations))
|
|
||||||
|
|
||||||
count = block.count
|
|
||||||
if not count:
|
|
||||||
count = (min(len(new_block.items), len(new_block.resolved_locations))
|
|
||||||
if new_block.resolved_locations else len(new_block.items))
|
|
||||||
if isinstance(count, int):
|
|
||||||
count = {"min": count, "max": count}
|
|
||||||
if "min" not in count:
|
|
||||||
count["min"] = 0
|
|
||||||
if "max" not in count:
|
|
||||||
count["max"] = (min(len(new_block.items), len(new_block.resolved_locations))
|
|
||||||
if new_block.resolved_locations else len(new_block.items))
|
|
||||||
|
|
||||||
|
|
||||||
new_block.count = count
|
|
||||||
plando_blocks[player].append(new_block)
|
|
||||||
|
|
||||||
return plando_blocks
|
|
||||||
|
|
||||||
|
|
||||||
def resolve_early_locations_for_planned(multiworld: MultiWorld):
|
|
||||||
def warn(warning: str, force: bool | str) -> None:
|
|
||||||
if isinstance(force, bool):
|
|
||||||
logging.warning(f"{warning}")
|
|
||||||
else:
|
|
||||||
logging.debug(f"{warning}")
|
|
||||||
|
|
||||||
def failed(warning: str, force: bool | str) -> None:
|
|
||||||
if force is True:
|
|
||||||
raise Exception(warning)
|
|
||||||
else:
|
|
||||||
warn(warning, force)
|
|
||||||
|
|
||||||
swept_state = multiworld.state.copy()
|
|
||||||
swept_state.sweep_for_advancements()
|
|
||||||
reachable = frozenset(multiworld.get_reachable_locations(swept_state))
|
|
||||||
early_locations: dict[int, list[Location]] = collections.defaultdict(list)
|
|
||||||
non_early_locations: dict[int, list[Location]] = collections.defaultdict(list)
|
|
||||||
for loc in multiworld.get_unfilled_locations():
|
|
||||||
if loc in reachable:
|
|
||||||
early_locations[loc.player].append(loc)
|
|
||||||
else: # not reachable with swept state
|
|
||||||
non_early_locations[loc.player].append(loc)
|
|
||||||
|
|
||||||
for player in multiworld.plando_item_blocks:
|
|
||||||
removed = []
|
|
||||||
for block in multiworld.plando_item_blocks[player]:
|
|
||||||
locations = block.locations
|
|
||||||
resolved_locations = block.resolved_locations
|
|
||||||
worlds = block.worlds
|
|
||||||
if "early_locations" in locations:
|
if "early_locations" in locations:
|
||||||
|
locations.remove("early_locations")
|
||||||
for target_player in worlds:
|
for target_player in worlds:
|
||||||
resolved_locations += early_locations[target_player]
|
locations += early_locations[target_player]
|
||||||
if "non_early_locations" in locations:
|
if "non_early_locations" in locations:
|
||||||
|
locations.remove("non_early_locations")
|
||||||
for target_player in worlds:
|
for target_player in worlds:
|
||||||
resolved_locations += non_early_locations[target_player]
|
locations += non_early_locations[target_player]
|
||||||
|
|
||||||
if block.count["max"] > len(block.items):
|
block['locations'] = list(dict.fromkeys(locations))
|
||||||
count = block.count["max"]
|
|
||||||
failed(f"Plando count {count} greater than items specified", block.force)
|
|
||||||
block.count["max"] = len(block.items)
|
|
||||||
if block.count["min"] > len(block.items):
|
|
||||||
block.count["min"] = len(block.items)
|
|
||||||
if block.count["max"] > len(block.resolved_locations) > 0:
|
|
||||||
count = block.count["max"]
|
|
||||||
failed(f"Plando count {count} greater than locations specified", block.force)
|
|
||||||
block.count["max"] = len(block.resolved_locations)
|
|
||||||
if block.count["min"] > len(block.resolved_locations):
|
|
||||||
block.count["min"] = len(block.resolved_locations)
|
|
||||||
block.count["target"] = multiworld.random.randint(block.count["min"],
|
|
||||||
block.count["max"])
|
|
||||||
|
|
||||||
if not block.count["target"]:
|
if not block['count']:
|
||||||
removed.append(block)
|
block['count'] = (min(len(block['items']), len(block['locations'])) if
|
||||||
|
len(block['locations']) > 0 else len(block['items']))
|
||||||
|
if isinstance(block['count'], int):
|
||||||
|
block['count'] = {'min': block['count'], 'max': block['count']}
|
||||||
|
if 'min' not in block['count']:
|
||||||
|
block['count']['min'] = 0
|
||||||
|
if 'max' not in block['count']:
|
||||||
|
block['count']['max'] = (min(len(block['items']), len(block['locations'])) if
|
||||||
|
len(block['locations']) > 0 else len(block['items']))
|
||||||
|
if block['count']['max'] > len(block['items']):
|
||||||
|
count = block['count']
|
||||||
|
failed(f"Plando count {count} greater than items specified", block['force'])
|
||||||
|
block['count'] = len(block['items'])
|
||||||
|
if block['count']['max'] > len(block['locations']) > 0:
|
||||||
|
count = block['count']
|
||||||
|
failed(f"Plando count {count} greater than locations specified", block['force'])
|
||||||
|
block['count'] = len(block['locations'])
|
||||||
|
block['count']['target'] = multiworld.random.randint(block['count']['min'], block['count']['max'])
|
||||||
|
|
||||||
for block in removed:
|
if block['count']['target'] > 0:
|
||||||
multiworld.plando_item_blocks[player].remove(block)
|
plando_blocks.append(block)
|
||||||
|
|
||||||
|
|
||||||
def distribute_planned_blocks(multiworld: MultiWorld, plando_blocks: list[PlandoItemBlock]):
|
|
||||||
def warn(warning: str, force: bool | str) -> None:
|
|
||||||
if isinstance(force, bool):
|
|
||||||
logging.warning(f"{warning}")
|
|
||||||
else:
|
|
||||||
logging.debug(f"{warning}")
|
|
||||||
|
|
||||||
def failed(warning: str, force: bool | str) -> None:
|
|
||||||
if force is True:
|
|
||||||
raise Exception(warning)
|
|
||||||
else:
|
|
||||||
warn(warning, force)
|
|
||||||
|
|
||||||
# shuffle, but then sort blocks by number of locations minus number of items,
|
# shuffle, but then sort blocks by number of locations minus number of items,
|
||||||
# so less-flexible blocks get priority
|
# so less-flexible blocks get priority
|
||||||
multiworld.random.shuffle(plando_blocks)
|
multiworld.random.shuffle(plando_blocks)
|
||||||
plando_blocks.sort(key=lambda block: (len(block.resolved_locations) - block.count["target"]
|
plando_blocks.sort(key=lambda block: (len(block['locations']) - block['count']['target']
|
||||||
if len(block.resolved_locations) > 0
|
if len(block['locations']) > 0
|
||||||
else len(multiworld.get_unfilled_locations(block.player)) -
|
else len(multiworld.get_unfilled_locations(player)) - block['count']['target']))
|
||||||
block.count["target"]))
|
|
||||||
for placement in plando_blocks:
|
for placement in plando_blocks:
|
||||||
player = placement.player
|
player = placement['player']
|
||||||
try:
|
try:
|
||||||
worlds = placement.worlds
|
worlds = placement['world']
|
||||||
locations = placement.resolved_locations
|
locations = placement['locations']
|
||||||
items = placement.items
|
items = placement['items']
|
||||||
maxcount = placement.count["target"]
|
maxcount = placement['count']['target']
|
||||||
from_pool = placement.from_pool
|
from_pool = placement['from_pool']
|
||||||
|
|
||||||
item_candidates = []
|
candidates = list(multiworld.get_unfilled_locations_for_players(locations, sorted(worlds)))
|
||||||
if from_pool:
|
|
||||||
instances = [item for item in multiworld.itempool if item.player == player and item.name in items]
|
|
||||||
for item in multiworld.random.sample(items, maxcount):
|
|
||||||
candidate = next((i for i in instances if i.name == item), None)
|
|
||||||
if candidate is None:
|
|
||||||
warn(f"Could not remove {item} from pool for {multiworld.player_name[player]} as "
|
|
||||||
f"it's already missing from it", placement.force)
|
|
||||||
candidate = multiworld.worlds[player].create_item(item)
|
|
||||||
else:
|
|
||||||
multiworld.itempool.remove(candidate)
|
|
||||||
instances.remove(candidate)
|
|
||||||
item_candidates.append(candidate)
|
|
||||||
else:
|
|
||||||
item_candidates = [multiworld.worlds[player].create_item(item)
|
|
||||||
for item in multiworld.random.sample(items, maxcount)]
|
|
||||||
if any(item.code is None for item in item_candidates) \
|
|
||||||
and not all(item.code is None for item in item_candidates):
|
|
||||||
failed(f"Plando block for player {player} ({multiworld.player_name[player]}) contains both "
|
|
||||||
f"event items and non-event items. "
|
|
||||||
f"Event items: {[item for item in item_candidates if item.code is None]}, "
|
|
||||||
f"Non-event items: {[item for item in item_candidates if item.code is not None]}",
|
|
||||||
placement.force)
|
|
||||||
continue
|
|
||||||
else:
|
|
||||||
is_real = item_candidates[0].code is not None
|
|
||||||
candidates = [candidate for candidate in locations if candidate.item is None
|
|
||||||
and bool(candidate.address) == is_real]
|
|
||||||
multiworld.random.shuffle(candidates)
|
multiworld.random.shuffle(candidates)
|
||||||
allstate = multiworld.get_all_state(False)
|
multiworld.random.shuffle(items)
|
||||||
mincount = placement.count["min"]
|
count = 0
|
||||||
allowed_margin = len(item_candidates) - mincount
|
err: typing.List[str] = []
|
||||||
fill_restrictive(multiworld, allstate, candidates, item_candidates, lock=True,
|
successful_pairs: typing.List[typing.Tuple[int, Item, Location]] = []
|
||||||
allow_partial=True, name="Plando Main Fill")
|
claimed_indices: typing.Set[typing.Optional[int]] = set()
|
||||||
|
for item_name in items:
|
||||||
|
index_to_delete: typing.Optional[int] = None
|
||||||
|
if from_pool:
|
||||||
|
try:
|
||||||
|
# If from_pool, try to find an existing item with this name & player in the itempool and use it
|
||||||
|
index_to_delete, item = next(
|
||||||
|
(i, item) for i, item in enumerate(multiworld.itempool)
|
||||||
|
if item.player == player and item.name == item_name and i not in claimed_indices
|
||||||
|
)
|
||||||
|
except StopIteration:
|
||||||
|
warn(
|
||||||
|
f"Could not remove {item_name} from pool for {multiworld.player_name[player]} as it's already missing from it.",
|
||||||
|
placement['force'])
|
||||||
|
item = multiworld.worlds[player].create_item(item_name)
|
||||||
|
else:
|
||||||
|
item = multiworld.worlds[player].create_item(item_name)
|
||||||
|
|
||||||
|
for location in reversed(candidates):
|
||||||
|
if (location.address is None) == (item.code is None): # either both None or both not None
|
||||||
|
if not location.item:
|
||||||
|
if location.item_rule(item):
|
||||||
|
if location.can_fill(multiworld.state, item, False):
|
||||||
|
successful_pairs.append((index_to_delete, item, location))
|
||||||
|
claimed_indices.add(index_to_delete)
|
||||||
|
candidates.remove(location)
|
||||||
|
count = count + 1
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
err.append(f"Can't place item at {location} due to fill condition not met.")
|
||||||
|
else:
|
||||||
|
err.append(f"{item_name} not allowed at {location}.")
|
||||||
|
else:
|
||||||
|
err.append(f"Cannot place {item_name} into already filled location {location}.")
|
||||||
|
else:
|
||||||
|
err.append(f"Mismatch between {item_name} and {location}, only one is an event.")
|
||||||
|
|
||||||
|
if count == maxcount:
|
||||||
|
break
|
||||||
|
if count < placement['count']['min']:
|
||||||
|
m = placement['count']['min']
|
||||||
|
failed(
|
||||||
|
f"Plando block failed to place {m - count} of {m} item(s) for {multiworld.player_name[player]}, error(s): {' '.join(err)}",
|
||||||
|
placement['force'])
|
||||||
|
|
||||||
|
# Sort indices in reverse so we can remove them one by one
|
||||||
|
successful_pairs = sorted(successful_pairs, key=lambda successful_pair: successful_pair[0] or 0, reverse=True)
|
||||||
|
|
||||||
|
for (index, item, location) in successful_pairs:
|
||||||
|
multiworld.push_item(location, item, collect=False)
|
||||||
|
location.locked = True
|
||||||
|
logging.debug(f"Plando placed {item} at {location}")
|
||||||
|
if index is not None: # If this item is from_pool and was found in the pool, remove it.
|
||||||
|
multiworld.itempool.pop(index)
|
||||||
|
|
||||||
if len(item_candidates) > allowed_margin:
|
|
||||||
failed(f"Could not place {len(item_candidates)} "
|
|
||||||
f"of {mincount + allowed_margin} item(s) "
|
|
||||||
f"for {multiworld.player_name[player]}, "
|
|
||||||
f"remaining items: {item_candidates}",
|
|
||||||
placement.force)
|
|
||||||
if from_pool:
|
|
||||||
multiworld.itempool.extend([item for item in item_candidates if item.code is not None])
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise Exception(
|
raise Exception(
|
||||||
f"Error running plando for player {player} ({multiworld.player_name[player]})") from e
|
f"Error running plando for player {player} ({multiworld.player_name[player]})") from e
|
||||||
|
|||||||
264
Generate.py
264
Generate.py
@@ -10,8 +10,8 @@ import sys
|
|||||||
import urllib.parse
|
import urllib.parse
|
||||||
import urllib.request
|
import urllib.request
|
||||||
from collections import Counter
|
from collections import Counter
|
||||||
|
from typing import Any, Dict, Tuple, Union
|
||||||
from itertools import chain
|
from itertools import chain
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
import ModuleUpdate
|
import ModuleUpdate
|
||||||
|
|
||||||
@@ -23,7 +23,7 @@ from BaseClasses import seeddigits, get_seed, PlandoOptions
|
|||||||
from Utils import parse_yamls, version_tuple, __version__, tuplize_version
|
from Utils import parse_yamls, version_tuple, __version__, tuplize_version
|
||||||
|
|
||||||
|
|
||||||
def mystery_argparse(argv: list[str] | None = None) -> argparse.Namespace:
|
def mystery_argparse():
|
||||||
from settings import get_settings
|
from settings import get_settings
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
defaults = settings.generator
|
defaults = settings.generator
|
||||||
@@ -54,22 +54,12 @@ def mystery_argparse(argv: list[str] | None = None) -> argparse.Namespace:
|
|||||||
parser.add_argument("--skip_output", action="store_true",
|
parser.add_argument("--skip_output", action="store_true",
|
||||||
help="Skips generation assertion and output stages and skips multidata and spoiler output. "
|
help="Skips generation assertion and output stages and skips multidata and spoiler output. "
|
||||||
"Intended for debugging and testing purposes.")
|
"Intended for debugging and testing purposes.")
|
||||||
parser.add_argument("--spoiler_only", action="store_true",
|
args = parser.parse_args()
|
||||||
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")
|
|
||||||
|
|
||||||
if not os.path.isabs(args.weights_file_path):
|
if not os.path.isabs(args.weights_file_path):
|
||||||
args.weights_file_path = os.path.join(args.player_files_path, args.weights_file_path)
|
args.weights_file_path = os.path.join(args.player_files_path, args.weights_file_path)
|
||||||
if not os.path.isabs(args.meta_file_path):
|
if not os.path.isabs(args.meta_file_path):
|
||||||
args.meta_file_path = os.path.join(args.player_files_path, args.meta_file_path)
|
args.meta_file_path = os.path.join(args.player_files_path, args.meta_file_path)
|
||||||
args.plando = PlandoOptions.from_option_string(args.plando)
|
args.plando: PlandoOptions = PlandoOptions.from_option_string(args.plando)
|
||||||
|
|
||||||
return args
|
return args
|
||||||
|
|
||||||
|
|
||||||
@@ -77,7 +67,7 @@ def get_seed_name(random_source) -> str:
|
|||||||
return f"{random_source.randint(0, pow(10, seeddigits) - 1)}".zfill(seeddigits)
|
return f"{random_source.randint(0, pow(10, seeddigits) - 1)}".zfill(seeddigits)
|
||||||
|
|
||||||
|
|
||||||
def main(args=None) -> tuple[argparse.Namespace, int]:
|
def main(args=None) -> Tuple[argparse.Namespace, int]:
|
||||||
# __name__ == "__main__" check so unittests that already imported worlds don't trip this.
|
# __name__ == "__main__" check so unittests that already imported worlds don't trip this.
|
||||||
if __name__ == "__main__" and "worlds" in sys.modules:
|
if __name__ == "__main__" and "worlds" in sys.modules:
|
||||||
raise Exception("Worlds system should not be loaded before logging init.")
|
raise Exception("Worlds system should not be loaded before logging init.")
|
||||||
@@ -95,7 +85,7 @@ def main(args=None) -> tuple[argparse.Namespace, int]:
|
|||||||
logging.info("Race mode enabled. Using non-deterministic random source.")
|
logging.info("Race mode enabled. Using non-deterministic random source.")
|
||||||
random.seed() # reset to time-based random source
|
random.seed() # reset to time-based random source
|
||||||
|
|
||||||
weights_cache: dict[str, tuple[Any, ...]] = {}
|
weights_cache: Dict[str, Tuple[Any, ...]] = {}
|
||||||
if args.weights_file_path and os.path.exists(args.weights_file_path):
|
if args.weights_file_path and os.path.exists(args.weights_file_path):
|
||||||
try:
|
try:
|
||||||
weights_cache[args.weights_file_path] = read_weights_yamls(args.weights_file_path)
|
weights_cache[args.weights_file_path] = read_weights_yamls(args.weights_file_path)
|
||||||
@@ -118,10 +108,8 @@ def main(args=None) -> tuple[argparse.Namespace, int]:
|
|||||||
raise Exception("Cannot mix --sameoptions with --meta")
|
raise Exception("Cannot mix --sameoptions with --meta")
|
||||||
else:
|
else:
|
||||||
meta_weights = None
|
meta_weights = None
|
||||||
|
player_id = 1
|
||||||
player_id: int = 1
|
player_files = {}
|
||||||
player_files: dict[int, str] = {}
|
|
||||||
player_errors: list[str] = []
|
|
||||||
for file in os.scandir(args.player_files_path):
|
for file in os.scandir(args.player_files_path):
|
||||||
fname = file.name
|
fname = file.name
|
||||||
if file.is_file() and not fname.startswith(".") and not fname.lower().endswith(".ini") and \
|
if file.is_file() and not fname.startswith(".") and not fname.lower().endswith(".ini") and \
|
||||||
@@ -135,13 +123,9 @@ def main(args=None) -> tuple[argparse.Namespace, int]:
|
|||||||
else:
|
else:
|
||||||
weights_for_file.append(yaml)
|
weights_for_file.append(yaml)
|
||||||
weights_cache[fname] = tuple(weights_for_file)
|
weights_cache[fname] = tuple(weights_for_file)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.exception(f"Exception reading weights in file {fname}")
|
raise ValueError(f"File {fname} is invalid. Please fix your yaml.") from e
|
||||||
player_errors.append(
|
|
||||||
f"{len(player_errors) + 1}. "
|
|
||||||
f"File {fname} is invalid. Please fix your yaml.\n{Utils.get_all_causes(e)}"
|
|
||||||
)
|
|
||||||
|
|
||||||
# sort dict for consistent results across platforms:
|
# sort dict for consistent results across platforms:
|
||||||
weights_cache = {key: value for key, value in sorted(weights_cache.items(), key=lambda k: k[0].casefold())}
|
weights_cache = {key: value for key, value in sorted(weights_cache.items(), key=lambda k: k[0].casefold())}
|
||||||
@@ -156,10 +140,6 @@ def main(args=None) -> tuple[argparse.Namespace, int]:
|
|||||||
args.multi = max(player_id - 1, args.multi)
|
args.multi = max(player_id - 1, args.multi)
|
||||||
|
|
||||||
if args.multi == 0:
|
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(
|
raise ValueError(
|
||||||
"No individual player files found and number of players is 0. "
|
"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."
|
"Provide individual player files or specify the number of players via host.yaml or --multi."
|
||||||
@@ -169,19 +149,27 @@ def main(args=None) -> tuple[argparse.Namespace, int]:
|
|||||||
f"{seed_name} Seed {seed} with plando: {args.plando}")
|
f"{seed_name} Seed {seed} with plando: {args.plando}")
|
||||||
|
|
||||||
if not weights_cache:
|
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. "
|
raise Exception(f"No weights found. "
|
||||||
f"Provide a general weights file ({args.weights_file_path}) or individual player files. "
|
f"Provide a general weights file ({args.weights_file_path}) or individual player files. "
|
||||||
f"A mix is also permitted.")
|
f"A mix is also permitted.")
|
||||||
|
|
||||||
from worlds.AutoWorld import AutoWorldRegister
|
from worlds.AutoWorld import AutoWorldRegister
|
||||||
args.outputname = seed_name
|
from worlds.alttp.EntranceRandomizer import parse_arguments
|
||||||
args.sprite = dict.fromkeys(range(1, args.multi+1), None)
|
erargs = parse_arguments(['--multi', str(args.multi)])
|
||||||
args.sprite_pool = dict.fromkeys(range(1, args.multi+1), None)
|
erargs.seed = seed
|
||||||
args.name = {}
|
erargs.plando_options = args.plando
|
||||||
|
erargs.spoiler = args.spoiler
|
||||||
|
erargs.race = args.race
|
||||||
|
erargs.outputname = seed_name
|
||||||
|
erargs.outputpath = args.outputpath
|
||||||
|
erargs.skip_prog_balancing = args.skip_prog_balancing
|
||||||
|
erargs.skip_output = args.skip_output
|
||||||
|
erargs.name = {}
|
||||||
|
erargs.csv_output = args.csv_output
|
||||||
|
|
||||||
|
settings_cache: Dict[str, Tuple[argparse.Namespace, ...]] = \
|
||||||
|
{fname: (tuple(roll_settings(yaml, args.plando) for yaml in yamls) if args.sameoptions else None)
|
||||||
|
for fname, yamls in weights_cache.items()}
|
||||||
|
|
||||||
if meta_weights:
|
if meta_weights:
|
||||||
for category_name, category_dict in meta_weights.items():
|
for category_name, category_dict in meta_weights.items():
|
||||||
@@ -197,98 +185,51 @@ def main(args=None) -> tuple[argparse.Namespace, int]:
|
|||||||
yaml[category][key] = option
|
yaml[category][key] = option
|
||||||
elif category_name not in yaml:
|
elif category_name not in yaml:
|
||||||
logging.warning(f"Meta: Category {category_name} is not present in {path}.")
|
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:
|
else:
|
||||||
yaml[category_name][key] = option
|
yaml[category_name][key] = option
|
||||||
|
|
||||||
settings_cache: dict[str, tuple[argparse.Namespace, ...] | None] = {fname: None for fname in weights_cache}
|
player_path_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] = {}
|
|
||||||
for player in range(1, args.multi + 1):
|
for player in range(1, args.multi + 1):
|
||||||
player_path_cache[player] = player_files.get(player, args.weights_file_path)
|
player_path_cache[player] = player_files.get(player, args.weights_file_path)
|
||||||
name_counter: Counter[str] = Counter()
|
name_counter = Counter()
|
||||||
args.player_options = {}
|
erargs.player_options = {}
|
||||||
|
|
||||||
player = 1
|
player = 1
|
||||||
while player <= args.multi:
|
while player <= args.multi:
|
||||||
path = player_path_cache[player]
|
path = player_path_cache[player]
|
||||||
if not path:
|
if 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")
|
|
||||||
try:
|
try:
|
||||||
# Use the cached settings object if it exists, otherwise roll settings within the try-catch
|
settings: Tuple[argparse.Namespace, ...] = settings_cache[path] if settings_cache[path] else \
|
||||||
# Invariant: settings_cache[path] and weights_cache[path] have the same length
|
tuple(roll_settings(yaml, args.plando) for yaml in weights_cache[path])
|
||||||
cached = settings_cache[path]
|
for settingsObject in settings:
|
||||||
settings_object: argparse.Namespace = (cached[doc_index] if cached else roll_settings(yaml, args.plando))
|
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 path == args.weights_file_path: # if name came from the weights file, just use base player name
|
||||||
if v is not None:
|
erargs.name[player] = f"Player{player}"
|
||||||
try:
|
elif player not in erargs.name: # if name was not specified, generate it from filename
|
||||||
getattr(args, k)[player] = v
|
erargs.name[player] = os.path.splitext(os.path.split(path)[-1])[0]
|
||||||
except AttributeError:
|
erargs.name[player] = handle_name(erargs.name[player], player, name_counter)
|
||||||
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)
|
|
||||||
|
|
||||||
|
player += 1
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.exception(f"Exception reading settings in file {path} document #{doc_index + 1} "
|
raise ValueError(f"File {path} is invalid. Please fix your yaml.") from e
|
||||||
f"(name: {args.name.get(player, name)})")
|
else:
|
||||||
player_errors.append(
|
raise RuntimeError(f'No weights specified for player {player}')
|
||||||
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)}")
|
|
||||||
|
|
||||||
# increment for each yaml document in the file
|
if len(set(name.lower() for name in erargs.name.values())) != len(erargs.name):
|
||||||
player += 1
|
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):
|
return erargs, seed
|
||||||
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 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}")
|
|
||||||
|
|
||||||
return args, seed
|
|
||||||
|
|
||||||
|
|
||||||
def read_weights_yamls(path) -> tuple[Any, ...]:
|
def read_weights_yamls(path) -> Tuple[Any, ...]:
|
||||||
try:
|
try:
|
||||||
if urllib.parse.urlparse(path).scheme in ('https', 'file'):
|
if urllib.parse.urlparse(path).scheme in ('https', 'file'):
|
||||||
yaml = str(urllib.request.urlopen(path).read(), "utf-8-sig")
|
yaml = str(urllib.request.urlopen(path).read(), "utf-8-sig")
|
||||||
@@ -298,20 +239,7 @@ def read_weights_yamls(path) -> tuple[Any, ...]:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise Exception(f"Failed to read weights ({path})") from e
|
raise Exception(f"Failed to read weights ({path})") from e
|
||||||
|
|
||||||
from yaml.error import MarkedYAMLError
|
return tuple(parse_yamls(yaml))
|
||||||
try:
|
|
||||||
return tuple(parse_yamls(yaml))
|
|
||||||
except MarkedYAMLError as ex:
|
|
||||||
if ex.problem_mark:
|
|
||||||
lines = yaml.splitlines()
|
|
||||||
if ex.context_mark:
|
|
||||||
relevant_lines = "\n".join(lines[ex.context_mark.line:ex.problem_mark.line+1])
|
|
||||||
else:
|
|
||||||
relevant_lines = lines[ex.problem_mark.line]
|
|
||||||
error_line = " " * ex.problem_mark.column + "^"
|
|
||||||
raise Exception(f"{ex.context} {ex.problem} on line {ex.problem_mark.line}:"
|
|
||||||
f"\n{relevant_lines}\n{error_line}")
|
|
||||||
raise ex
|
|
||||||
|
|
||||||
|
|
||||||
def interpret_on_off(value) -> bool:
|
def interpret_on_off(value) -> bool:
|
||||||
@@ -351,35 +279,33 @@ def get_choice(option, root, value=None) -> Any:
|
|||||||
raise RuntimeError(f"All options specified in \"{option}\" are weighted as zero.")
|
raise RuntimeError(f"All options specified in \"{option}\" are weighted as zero.")
|
||||||
|
|
||||||
|
|
||||||
class SafeFormatter(string.Formatter):
|
class SafeDict(dict):
|
||||||
def get_value(self, key, args, kwargs):
|
def __missing__(self, key):
|
||||||
if isinstance(key, int):
|
return '{' + key + '}'
|
||||||
if key < len(args):
|
|
||||||
return args[key]
|
|
||||||
else:
|
|
||||||
return "{" + str(key) + "}"
|
|
||||||
else:
|
|
||||||
return kwargs.get(key, "{" + key + "}")
|
|
||||||
|
|
||||||
|
|
||||||
def handle_name(name: str, player: int, name_counter: Counter[str]):
|
def handle_name(name: str, player: int, name_counter: Counter):
|
||||||
name_counter[name.lower()] += 1
|
name_counter[name.lower()] += 1
|
||||||
number = name_counter[name.lower()]
|
number = name_counter[name.lower()]
|
||||||
new_name = "%".join([x.replace("%number%", "{number}").replace("%player%", "{player}") for x in name.split("%%")])
|
new_name = "%".join([x.replace("%number%", "{number}").replace("%player%", "{player}") for x in name.split("%%")])
|
||||||
|
new_name = string.Formatter().vformat(new_name, (), SafeDict(number=number,
|
||||||
new_name = SafeFormatter().vformat(new_name, (), {"number": number,
|
NUMBER=(number if number > 1 else ''),
|
||||||
"NUMBER": (number if number > 1 else ''),
|
player=player,
|
||||||
"player": player,
|
PLAYER=(player if player > 1 else '')))
|
||||||
"PLAYER": (player if player > 1 else '')})
|
|
||||||
# Run .strip twice for edge case where after the initial .slice new_name has a leading whitespace.
|
# Run .strip twice for edge case where after the initial .slice new_name has a leading whitespace.
|
||||||
# Could cause issues for some clients that cannot handle the additional whitespace.
|
# Could cause issues for some clients that cannot handle the additional whitespace.
|
||||||
new_name = new_name.strip()[:16].strip()
|
new_name = new_name.strip()[:16].strip()
|
||||||
|
|
||||||
if new_name == "Archipelago":
|
if new_name == "Archipelago":
|
||||||
raise Exception(f"You cannot name yourself \"{new_name}\"")
|
raise Exception(f"You cannot name yourself \"{new_name}\"")
|
||||||
return new_name
|
return new_name
|
||||||
|
|
||||||
|
|
||||||
|
def roll_percentage(percentage: Union[int, float]) -> bool:
|
||||||
|
"""Roll a percentage chance.
|
||||||
|
percentage is expected to be in range [0, 100]"""
|
||||||
|
return random.random() < (float(percentage) / 100)
|
||||||
|
|
||||||
|
|
||||||
def update_weights(weights: dict, new_weights: dict, update_type: str, name: str) -> dict:
|
def update_weights(weights: dict, new_weights: dict, update_type: str, name: str) -> dict:
|
||||||
logging.debug(f'Applying {new_weights}')
|
logging.debug(f'Applying {new_weights}')
|
||||||
cleaned_weights = {}
|
cleaned_weights = {}
|
||||||
@@ -393,9 +319,7 @@ def update_weights(weights: dict, new_weights: dict, update_type: str, name: str
|
|||||||
elif isinstance(new_value, list):
|
elif isinstance(new_value, list):
|
||||||
cleaned_value.extend(new_value)
|
cleaned_value.extend(new_value)
|
||||||
elif isinstance(new_value, dict):
|
elif isinstance(new_value, dict):
|
||||||
counter_value = Counter(cleaned_value)
|
cleaned_value = dict(Counter(cleaned_value) + Counter(new_value))
|
||||||
counter_value.update(new_value)
|
|
||||||
cleaned_value = dict(counter_value)
|
|
||||||
else:
|
else:
|
||||||
raise Exception(f"Cannot apply merge to non-dict, set, or list type {option_name},"
|
raise Exception(f"Cannot apply merge to non-dict, set, or list type {option_name},"
|
||||||
f" received {type(new_value).__name__}.")
|
f" received {type(new_value).__name__}.")
|
||||||
@@ -409,18 +333,13 @@ def update_weights(weights: dict, new_weights: dict, update_type: str, name: str
|
|||||||
for element in new_value:
|
for element in new_value:
|
||||||
cleaned_value.remove(element)
|
cleaned_value.remove(element)
|
||||||
elif isinstance(new_value, dict):
|
elif isinstance(new_value, dict):
|
||||||
counter_value = Counter(cleaned_value)
|
cleaned_value = dict(Counter(cleaned_value) - Counter(new_value))
|
||||||
counter_value.subtract(new_value)
|
|
||||||
cleaned_value = dict(counter_value)
|
|
||||||
else:
|
else:
|
||||||
raise Exception(f"Cannot apply remove to non-dict, set, or list type {option_name},"
|
raise Exception(f"Cannot apply remove to non-dict, set, or list type {option_name},"
|
||||||
f" received {type(new_value).__name__}.")
|
f" received {type(new_value).__name__}.")
|
||||||
cleaned_weights[option_name] = cleaned_value
|
cleaned_weights[option_name] = cleaned_value
|
||||||
else:
|
else:
|
||||||
# Options starting with + and - may modify values in-place, and new_weights may be shared by multiple slots
|
cleaned_weights[option_name] = new_weights[option]
|
||||||
# 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)
|
new_options = set(cleaned_weights) - set(weights)
|
||||||
weights.update(cleaned_weights)
|
weights.update(cleaned_weights)
|
||||||
if new_options:
|
if new_options:
|
||||||
@@ -431,7 +350,7 @@ def update_weights(weights: dict, new_weights: dict, update_type: str, name: str
|
|||||||
return weights
|
return weights
|
||||||
|
|
||||||
|
|
||||||
def roll_meta_option(option_key, game: str, category_dict: dict) -> Any:
|
def roll_meta_option(option_key, game: str, category_dict: Dict) -> Any:
|
||||||
from worlds import AutoWorldRegister
|
from worlds import AutoWorldRegister
|
||||||
|
|
||||||
if not game:
|
if not game:
|
||||||
@@ -443,8 +362,6 @@ def roll_meta_option(option_key, game: str, category_dict: dict) -> Any:
|
|||||||
if options[option_key].supports_weighting:
|
if options[option_key].supports_weighting:
|
||||||
return get_choice(option_key, category_dict)
|
return get_choice(option_key, category_dict)
|
||||||
return category_dict[option_key]
|
return category_dict[option_key]
|
||||||
if option_key == "triggers":
|
|
||||||
return category_dict[option_key]
|
|
||||||
raise Options.OptionError(f"Error generating meta option {option_key} for {game}.")
|
raise Options.OptionError(f"Error generating meta option {option_key} for {game}.")
|
||||||
|
|
||||||
|
|
||||||
@@ -454,7 +371,7 @@ def roll_linked_options(weights: dict) -> dict:
|
|||||||
if "name" not in option_set:
|
if "name" not in option_set:
|
||||||
raise ValueError("One of your linked options does not have a name.")
|
raise ValueError("One of your linked options does not have a name.")
|
||||||
try:
|
try:
|
||||||
if Options.roll_percentage(option_set["percentage"]):
|
if roll_percentage(option_set["percentage"]):
|
||||||
logging.debug(f"Linked option {option_set['name']} triggered.")
|
logging.debug(f"Linked option {option_set['name']} triggered.")
|
||||||
new_options = option_set["options"]
|
new_options = option_set["options"]
|
||||||
for category_name, category_options in new_options.items():
|
for category_name, category_options in new_options.items():
|
||||||
@@ -487,7 +404,7 @@ def roll_triggers(weights: dict, triggers: list, valid_keys: set) -> dict:
|
|||||||
trigger_result = get_choice("option_result", option_set)
|
trigger_result = get_choice("option_result", option_set)
|
||||||
result = get_choice(key, currently_targeted_weights)
|
result = get_choice(key, currently_targeted_weights)
|
||||||
currently_targeted_weights[key] = result
|
currently_targeted_weights[key] = result
|
||||||
if result == trigger_result and Options.roll_percentage(get_choice("percentage", option_set, 100)):
|
if result == trigger_result and roll_percentage(get_choice("percentage", option_set, 100)):
|
||||||
for category_name, category_options in option_set["options"].items():
|
for category_name, category_options in option_set["options"].items():
|
||||||
currently_targeted_weights = weights
|
currently_targeted_weights = weights
|
||||||
if category_name:
|
if category_name:
|
||||||
@@ -500,7 +417,7 @@ def roll_triggers(weights: dict, triggers: list, valid_keys: set) -> dict:
|
|||||||
return weights
|
return weights
|
||||||
|
|
||||||
|
|
||||||
def handle_option(ret: argparse.Namespace, game_weights: dict, option_key: str, option: type[Options.Option], plando_options: PlandoOptions):
|
def handle_option(ret: argparse.Namespace, game_weights: dict, option_key: str, option: type(Options.Option), plando_options: PlandoOptions):
|
||||||
try:
|
try:
|
||||||
if option_key in game_weights:
|
if option_key in game_weights:
|
||||||
if not option.supports_weighting:
|
if not option.supports_weighting:
|
||||||
@@ -518,14 +435,6 @@ def handle_option(ret: argparse.Namespace, game_weights: dict, option_key: str,
|
|||||||
|
|
||||||
|
|
||||||
def roll_settings(weights: dict, plando_options: PlandoOptions = PlandoOptions.bosses):
|
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
|
from worlds import AutoWorldRegister
|
||||||
|
|
||||||
if "linked_options" in weights:
|
if "linked_options" in weights:
|
||||||
@@ -546,22 +455,7 @@ def roll_settings(weights: dict, plando_options: PlandoOptions = PlandoOptions.b
|
|||||||
if required_plando_options:
|
if required_plando_options:
|
||||||
raise Exception(f"Settings reports required plando module {str(required_plando_options)}, "
|
raise Exception(f"Settings reports required plando module {str(required_plando_options)}, "
|
||||||
f"which is not enabled.")
|
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()
|
ret = argparse.Namespace()
|
||||||
for option_key in Options.PerGameCommonOptions.type_hints:
|
for option_key in Options.PerGameCommonOptions.type_hints:
|
||||||
if option_key in weights and option_key not in Options.CommonOptions.type_hints:
|
if option_key in weights and option_key not in Options.CommonOptions.type_hints:
|
||||||
@@ -606,6 +500,10 @@ def roll_settings(weights: dict, plando_options: PlandoOptions = PlandoOptions.b
|
|||||||
handle_option(ret, game_weights, option_key, option, plando_options)
|
handle_option(ret, game_weights, option_key, option, plando_options)
|
||||||
valid_keys.add(option_key)
|
valid_keys.add(option_key)
|
||||||
|
|
||||||
|
# TODO remove plando_items after moving it to the options system
|
||||||
|
valid_keys.add("plando_items")
|
||||||
|
if PlandoOptions.items in plando_options:
|
||||||
|
ret.plando_items = copy.deepcopy(game_weights.get("plando_items", []))
|
||||||
if ret.game == "A Link to the Past":
|
if ret.game == "A Link to the Past":
|
||||||
# TODO there are still more LTTP options not on the options system
|
# TODO there are still more LTTP options not on the options system
|
||||||
valid_keys |= {"sprite_pool", "sprite", "random_sprite_on_event"}
|
valid_keys |= {"sprite_pool", "sprite", "random_sprite_on_event"}
|
||||||
|
|||||||
9
KH1Client.py
Normal file
9
KH1Client.py
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
if __name__ == '__main__':
|
||||||
|
import ModuleUpdate
|
||||||
|
ModuleUpdate.update()
|
||||||
|
|
||||||
|
import Utils
|
||||||
|
Utils.init_logging("KH1Client", exception_logger="Client")
|
||||||
|
|
||||||
|
from worlds.kh1.Client import launch
|
||||||
|
launch()
|
||||||
8
KH2Client.py
Normal file
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()
|
||||||
413
Launcher.py
413
Launcher.py
@@ -1,40 +1,35 @@
|
|||||||
"""
|
"""
|
||||||
Archipelago Launcher
|
Archipelago launcher for bundled app.
|
||||||
|
|
||||||
* If run with a patch file as argument, launch corresponding client with the patch file as an argument.
|
* if run with APBP as argument, launch corresponding client.
|
||||||
* If run with component name as argument, run it passing argv[2:] as arguments.
|
* if run with executable as argument, run it passing argv[2:] as arguments
|
||||||
* If run without arguments or unknown arguments, open launcher GUI.
|
* if run without arguments, open launcher GUI
|
||||||
|
|
||||||
Additional components can be added to worlds.LauncherComponents.components.
|
Scroll down to components= to add components to the launcher as well as setup.py
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
|
import itertools
|
||||||
import logging
|
import logging
|
||||||
import multiprocessing
|
import multiprocessing
|
||||||
import os
|
|
||||||
import shlex
|
import shlex
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
import webbrowser
|
import webbrowser
|
||||||
from collections.abc import Callable, Sequence
|
|
||||||
from os.path import isfile
|
from os.path import isfile
|
||||||
from shutil import which
|
from shutil import which
|
||||||
from typing import Any
|
from typing import Callable, Optional, Sequence, Tuple, Union
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
import ModuleUpdate
|
import ModuleUpdate
|
||||||
|
|
||||||
ModuleUpdate.update()
|
ModuleUpdate.update()
|
||||||
|
|
||||||
import settings
|
import settings
|
||||||
import Utils
|
import Utils
|
||||||
from Utils import (init_logging, is_frozen, is_linux, is_macos, is_windows, local_path, messagebox, open_filename,
|
from Utils import (init_logging, is_frozen, is_linux, is_macos, is_windows, local_path, messagebox, open_filename,
|
||||||
user_path)
|
user_path)
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
init_logging('Launcher')
|
|
||||||
|
|
||||||
from worlds.LauncherComponents import Component, components, icon_paths, SuffixIdentifier, Type
|
from worlds.LauncherComponents import Component, components, icon_paths, SuffixIdentifier, Type
|
||||||
|
|
||||||
|
|
||||||
@@ -46,17 +41,13 @@ def open_host_yaml():
|
|||||||
if is_linux:
|
if is_linux:
|
||||||
exe = which('sensible-editor') or which('gedit') or \
|
exe = which('sensible-editor') or which('gedit') or \
|
||||||
which('xdg-open') or which('gnome-open') or which('kde-open')
|
which('xdg-open') or which('gnome-open') or which('kde-open')
|
||||||
|
subprocess.Popen([exe, file])
|
||||||
elif is_macos:
|
elif is_macos:
|
||||||
exe = which("open")
|
exe = which("open")
|
||||||
|
subprocess.Popen([exe, file])
|
||||||
else:
|
else:
|
||||||
webbrowser.open(file)
|
webbrowser.open(file)
|
||||||
return
|
|
||||||
|
|
||||||
env = os.environ
|
|
||||||
if "LD_LIBRARY_PATH" in env:
|
|
||||||
env = env.copy()
|
|
||||||
del env["LD_LIBRARY_PATH"] # exe is a system binary, so reset LD_LIBRARY_PATH
|
|
||||||
subprocess.Popen([exe, file], env=env)
|
|
||||||
|
|
||||||
def open_patch():
|
def open_patch():
|
||||||
suffixes = []
|
suffixes = []
|
||||||
@@ -79,17 +70,12 @@ def open_patch():
|
|||||||
launch([*exe, file], component.cli)
|
launch([*exe, file], component.cli)
|
||||||
|
|
||||||
|
|
||||||
def generate_yamls(*args):
|
def generate_yamls():
|
||||||
from Options import generate_yaml_templates
|
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")
|
target = Utils.user_path("Players", "Templates")
|
||||||
generate_yaml_templates(target, False)
|
generate_yaml_templates(target, False)
|
||||||
if not args.skip_open_folder:
|
open_folder(target)
|
||||||
open_folder(target)
|
|
||||||
|
|
||||||
|
|
||||||
def browse_files():
|
def browse_files():
|
||||||
@@ -99,20 +85,12 @@ def browse_files():
|
|||||||
def open_folder(folder_path):
|
def open_folder(folder_path):
|
||||||
if is_linux:
|
if is_linux:
|
||||||
exe = which('xdg-open') or which('gnome-open') or which('kde-open')
|
exe = which('xdg-open') or which('gnome-open') or which('kde-open')
|
||||||
|
subprocess.Popen([exe, folder_path])
|
||||||
elif is_macos:
|
elif is_macos:
|
||||||
exe = which("open")
|
exe = which("open")
|
||||||
|
subprocess.Popen([exe, folder_path])
|
||||||
else:
|
else:
|
||||||
webbrowser.open(folder_path)
|
webbrowser.open(folder_path)
|
||||||
return
|
|
||||||
|
|
||||||
if exe:
|
|
||||||
env = os.environ
|
|
||||||
if "LD_LIBRARY_PATH" in env:
|
|
||||||
env = env.copy()
|
|
||||||
del env["LD_LIBRARY_PATH"] # exe is a system binary, so reset LD_LIBRARY_PATH
|
|
||||||
subprocess.Popen([exe, folder_path], env=env)
|
|
||||||
else:
|
|
||||||
logging.warning(f"No file browser available to open {folder_path}")
|
|
||||||
|
|
||||||
|
|
||||||
def update_settings():
|
def update_settings():
|
||||||
@@ -122,51 +100,74 @@ def update_settings():
|
|||||||
|
|
||||||
components.extend([
|
components.extend([
|
||||||
# Functions
|
# Functions
|
||||||
Component("Open host.yaml", func=open_host_yaml,
|
Component("Open host.yaml", func=open_host_yaml),
|
||||||
description="Open the host.yaml file to change settings for generation, games, and more."),
|
Component("Open Patch", func=open_patch),
|
||||||
Component("Open Patch", func=open_patch,
|
Component("Generate Template Options", func=generate_yamls),
|
||||||
description="Open a patch file, downloaded from the room page or provided by the host."),
|
Component("Archipelago Website", func=lambda: webbrowser.open("https://archipelago.gg/")),
|
||||||
Component("Generate Template Options", func=generate_yamls,
|
Component("Discord Server", icon="discord", func=lambda: webbrowser.open("https://discord.gg/8Z65BR2")),
|
||||||
description="Generate template YAMLs for currently installed games."),
|
Component("Unrated/18+ Discord Server", icon="discord", func=lambda: webbrowser.open("https://discord.gg/fqvNCCRsu4")),
|
||||||
Component("Archipelago Website", func=lambda: webbrowser.open("https://archipelago.gg/"),
|
Component("Browse Files", func=browse_files),
|
||||||
description="Open archipelago.gg in your browser."),
|
|
||||||
Component("Discord Server", icon="discord", func=lambda: webbrowser.open("https://discord.gg/8Z65BR2"),
|
|
||||||
description="Join the Discord server to play public multiworlds, report issues, or just chat!"),
|
|
||||||
Component("Unrated/18+ Discord Server", icon="discord",
|
|
||||||
func=lambda: webbrowser.open("https://discord.gg/fqvNCCRsu4"),
|
|
||||||
description="Find unrated and 18+ games in the After Dark Discord server."),
|
|
||||||
Component("Browse Files", func=browse_files,
|
|
||||||
description="Open the Archipelago installation folder in your file browser."),
|
|
||||||
])
|
])
|
||||||
|
|
||||||
|
|
||||||
def handle_uri(path: str) -> tuple[list[Component], Component]:
|
def handle_uri(path: str, launch_args: Tuple[str, ...]) -> None:
|
||||||
url = urllib.parse.urlparse(path)
|
url = urllib.parse.urlparse(path)
|
||||||
queries = urllib.parse.parse_qs(url.query)
|
queries = urllib.parse.parse_qs(url.query)
|
||||||
client_components = []
|
launch_args = (path, *launch_args)
|
||||||
|
client_component = None
|
||||||
text_client_component = None
|
text_client_component = None
|
||||||
game = queries["game"][0]
|
if "game" in queries:
|
||||||
|
game = queries["game"][0]
|
||||||
|
else: # TODO around 0.6.0 - this is for pre this change webhost uri's
|
||||||
|
game = "Archipelago"
|
||||||
for component in components:
|
for component in components:
|
||||||
if component.supports_uri and component.game_name == game:
|
if component.supports_uri and component.game_name == game:
|
||||||
client_components.append(component)
|
client_component = component
|
||||||
elif component.display_name == "Text Client":
|
elif component.display_name == "Text Client":
|
||||||
text_client_component = component
|
text_client_component = component
|
||||||
return client_components, text_client_component
|
|
||||||
|
if client_component is None:
|
||||||
|
run_component(text_client_component, *launch_args)
|
||||||
|
return
|
||||||
|
|
||||||
|
from kvui import App, Button, BoxLayout, Label, Window
|
||||||
|
|
||||||
|
class Popup(App):
|
||||||
|
def __init__(self):
|
||||||
|
self.title = "Connect to Multiworld"
|
||||||
|
self.icon = r"data/icon.png"
|
||||||
|
super().__init__()
|
||||||
|
|
||||||
|
def build(self):
|
||||||
|
layout = BoxLayout(orientation="vertical")
|
||||||
|
layout.add_widget(Label(text="Select client to open and connect with."))
|
||||||
|
button_row = BoxLayout(orientation="horizontal", size_hint=(1, 0.4))
|
||||||
|
|
||||||
|
text_client_button = Button(
|
||||||
|
text=text_client_component.display_name,
|
||||||
|
on_release=lambda *args: run_component(text_client_component, *launch_args)
|
||||||
|
)
|
||||||
|
button_row.add_widget(text_client_button)
|
||||||
|
|
||||||
|
game_client_button = Button(
|
||||||
|
text=client_component.display_name,
|
||||||
|
on_release=lambda *args: run_component(client_component, *launch_args)
|
||||||
|
)
|
||||||
|
button_row.add_widget(game_client_button)
|
||||||
|
|
||||||
|
layout.add_widget(button_row)
|
||||||
|
|
||||||
|
return layout
|
||||||
|
|
||||||
|
def _stop(self, *largs):
|
||||||
|
# see run_gui Launcher _stop comment for details
|
||||||
|
self.root_window.close()
|
||||||
|
super()._stop(*largs)
|
||||||
|
|
||||||
|
Popup().run()
|
||||||
|
|
||||||
|
|
||||||
def build_uri_popup(component_list: list[Component], launch_args: tuple[str, ...]) -> None:
|
def identify(path: Union[None, str]) -> Tuple[Union[None, str], Union[None, Component]]:
|
||||||
from kvui import ButtonsPrompt
|
|
||||||
component_options = {
|
|
||||||
component.display_name: component for component in component_list
|
|
||||||
}
|
|
||||||
popup = ButtonsPrompt("Connect to Multiworld",
|
|
||||||
"Select client to open and connect with.",
|
|
||||||
lambda component_name: run_component(component_options[component_name], *launch_args),
|
|
||||||
*component_options.keys())
|
|
||||||
popup.open()
|
|
||||||
|
|
||||||
|
|
||||||
def identify(path: None | str) -> tuple[None | str, None | Component]:
|
|
||||||
if path is None:
|
if path is None:
|
||||||
return None, None
|
return None, None
|
||||||
for component in components:
|
for component in components:
|
||||||
@@ -177,7 +178,7 @@ def identify(path: None | str) -> tuple[None | str, None | Component]:
|
|||||||
return None, None
|
return None, None
|
||||||
|
|
||||||
|
|
||||||
def get_exe(component: str | Component) -> Sequence[str] | None:
|
def get_exe(component: Union[str, Component]) -> Optional[Sequence[str]]:
|
||||||
if isinstance(component, str):
|
if isinstance(component, str):
|
||||||
name = component
|
name = component
|
||||||
component = None
|
component = None
|
||||||
@@ -205,8 +206,7 @@ def get_exe(component: str | Component) -> Sequence[str] | None:
|
|||||||
def launch(exe, in_terminal=False):
|
def launch(exe, in_terminal=False):
|
||||||
if in_terminal:
|
if in_terminal:
|
||||||
if is_windows:
|
if is_windows:
|
||||||
# intentionally using a window title with a space so it gets quoted and treated as a title
|
subprocess.Popen(['start', *exe], shell=True)
|
||||||
subprocess.Popen(["start", "Running Archipelago", *exe], shell=True)
|
|
||||||
return
|
return
|
||||||
elif is_linux:
|
elif is_linux:
|
||||||
terminal = which('x-terminal-emulator') or which('gnome-terminal') or which('xterm')
|
terminal = which('x-terminal-emulator') or which('gnome-terminal') or which('xterm')
|
||||||
@@ -220,194 +220,100 @@ def launch(exe, in_terminal=False):
|
|||||||
subprocess.Popen(exe)
|
subprocess.Popen(exe)
|
||||||
|
|
||||||
|
|
||||||
def create_shortcut(button: Any, component: Component) -> None:
|
refresh_components: Optional[Callable[[], None]] = None
|
||||||
from pyshortcuts import make_shortcut
|
|
||||||
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()
|
|
||||||
|
|
||||||
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():
|
||||||
|
from kvui import App, ContainerLayout, GridLayout, Button, Label, ScrollBox, Widget, ApAsyncImage
|
||||||
|
|
||||||
def run_gui(launch_components: list[Component], args: Any) -> None:
|
|
||||||
from kvui import (ThemedApp, MDFloatLayout, MDGridLayout, ScrollBox)
|
|
||||||
from kivy.properties import ObjectProperty
|
|
||||||
from kivy.core.window import Window
|
from kivy.core.window import Window
|
||||||
from kivy.metrics import dp
|
from kivy.uix.relativelayout import RelativeLayout
|
||||||
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 Launcher(App):
|
||||||
|
|
||||||
class LauncherCard(MDCard):
|
|
||||||
component: Component | None
|
|
||||||
image: str
|
|
||||||
context_button: MDIconButton = ObjectProperty(None)
|
|
||||||
|
|
||||||
def __init__(self, *args, component: Component | None = None, image_path: str = "", **kwargs):
|
|
||||||
self.component = component
|
|
||||||
self.image = image_path
|
|
||||||
super().__init__(args, kwargs)
|
|
||||||
|
|
||||||
class Launcher(ThemedApp):
|
|
||||||
base_title: str = "Archipelago Launcher"
|
base_title: str = "Archipelago Launcher"
|
||||||
top_screen: MDFloatLayout = ObjectProperty(None)
|
container: ContainerLayout
|
||||||
navigation: MDGridLayout = ObjectProperty(None)
|
grid: GridLayout
|
||||||
grid: MDGridLayout = ObjectProperty(None)
|
_tool_layout: Optional[ScrollBox] = None
|
||||||
button_layout: ScrollBox = ObjectProperty(None)
|
_client_layout: Optional[ScrollBox] = None
|
||||||
search_box: MDTextField = ObjectProperty(None)
|
|
||||||
cards: list[LauncherCard]
|
|
||||||
current_filter: Sequence[str | Type] | None
|
|
||||||
|
|
||||||
def __init__(self, ctx=None, components=None, args=None):
|
def __init__(self, ctx=None):
|
||||||
self.title = self.base_title + " " + Utils.__version__
|
self.title = self.base_title + " " + Utils.__version__
|
||||||
self.ctx = ctx
|
self.ctx = ctx
|
||||||
self.icon = r"data/icon.png"
|
self.icon = r"data/icon.png"
|
||||||
self.favorites = []
|
|
||||||
self.launch_components = components
|
|
||||||
self.launch_args = args
|
|
||||||
self.cards = []
|
|
||||||
self.current_filter = (Type.CLIENT, Type.TOOL, Type.ADJUSTER, Type.MISC)
|
|
||||||
persistent = Utils.persistent_load()
|
|
||||||
if "launcher" in persistent:
|
|
||||||
if "favorites" in persistent["launcher"]:
|
|
||||||
self.favorites.extend(persistent["launcher"]["favorites"])
|
|
||||||
if "filter" in persistent["launcher"]:
|
|
||||||
if persistent["launcher"]["filter"]:
|
|
||||||
filters = []
|
|
||||||
for filter in persistent["launcher"]["filter"].split(", "):
|
|
||||||
if filter == "favorites":
|
|
||||||
filters.append(filter)
|
|
||||||
else:
|
|
||||||
filters.append(Type[filter])
|
|
||||||
self.current_filter = filters
|
|
||||||
super().__init__()
|
super().__init__()
|
||||||
|
|
||||||
def set_favorite(self, caller):
|
def _refresh_components(self) -> None:
|
||||||
if caller.component.display_name in self.favorites:
|
|
||||||
self.favorites.remove(caller.component.display_name)
|
|
||||||
caller.icon = "star-outline"
|
|
||||||
else:
|
|
||||||
self.favorites.append(caller.component.display_name)
|
|
||||||
caller.icon = "star"
|
|
||||||
|
|
||||||
def build_card(self, component: Component) -> LauncherCard:
|
def build_button(component: Component) -> Widget:
|
||||||
"""
|
|
||||||
Builds a card widget for a given component.
|
|
||||||
|
|
||||||
:param component: The component associated with the button.
|
|
||||||
|
|
||||||
:return: The created Card Widget.
|
|
||||||
"""
|
"""
|
||||||
button_card = LauncherCard(component=component,
|
Builds a button widget for a given component.
|
||||||
image_path=icon_paths[component.icon])
|
|
||||||
|
|
||||||
def open_menu(caller):
|
Args:
|
||||||
caller.menu.open()
|
component (Component): The component associated with the button.
|
||||||
|
|
||||||
menu_items = [
|
Returns:
|
||||||
{
|
None. The button is added to the parent grid layout.
|
||||||
"text": "Add shortcut on desktop",
|
|
||||||
"leading_icon": "laptop",
|
|
||||||
"on_release": lambda: create_shortcut(button_card.context_button, component)
|
|
||||||
}
|
|
||||||
]
|
|
||||||
button_card.context_button.menu = MDDropdownMenu(caller=button_card.context_button, items=menu_items)
|
|
||||||
button_card.context_button.bind(on_release=open_menu)
|
|
||||||
|
|
||||||
return button_card
|
"""
|
||||||
|
button = Button(text=component.display_name, size_hint_y=None, height=40)
|
||||||
def _refresh_components(self, type_filter: Sequence[str | Type] | None = None) -> None:
|
button.component = component
|
||||||
if not type_filter:
|
button.bind(on_release=self.component_action)
|
||||||
type_filter = [Type.CLIENT, Type.ADJUSTER, Type.TOOL, Type.MISC]
|
if component.icon != "icon":
|
||||||
favorites = "favorites" in type_filter
|
image = ApAsyncImage(source=icon_paths[component.icon],
|
||||||
|
size=(38, 38), size_hint=(None, 1), pos=(5, 0))
|
||||||
|
box_layout = RelativeLayout(size_hint_y=None, height=40)
|
||||||
|
box_layout.add_widget(button)
|
||||||
|
box_layout.add_widget(image)
|
||||||
|
return box_layout
|
||||||
|
return button
|
||||||
|
|
||||||
# clear before repopulating
|
# clear before repopulating
|
||||||
assert self.button_layout, "must call `build` first"
|
assert self._tool_layout and self._client_layout, "must call `build` first"
|
||||||
tool_children = reversed(self.button_layout.layout.children)
|
tool_children = reversed(self._tool_layout.layout.children)
|
||||||
for child in tool_children:
|
for child in tool_children:
|
||||||
self.button_layout.layout.remove_widget(child)
|
self._tool_layout.layout.remove_widget(child)
|
||||||
|
client_children = reversed(self._client_layout.layout.children)
|
||||||
|
for child in client_children:
|
||||||
|
self._client_layout.layout.remove_widget(child)
|
||||||
|
|
||||||
cards = [card for card in self.cards if card.component.type in type_filter
|
_tools = {c.display_name: c for c in components if c.type == Type.TOOL}
|
||||||
or favorites and card.component.display_name in self.favorites]
|
_clients = {c.display_name: c for c in components if c.type == Type.CLIENT}
|
||||||
|
_adjusters = {c.display_name: c for c in components if c.type == Type.ADJUSTER}
|
||||||
|
_miscs = {c.display_name: c for c in components if c.type == Type.MISC}
|
||||||
|
|
||||||
self.current_filter = type_filter
|
for (tool, client) in itertools.zip_longest(itertools.chain(
|
||||||
|
_tools.items(), _miscs.items(), _adjusters.items()
|
||||||
for card in cards:
|
), _clients.items()):
|
||||||
self.button_layout.layout.add_widget(card)
|
# column 1
|
||||||
|
if tool:
|
||||||
top = self.button_layout.children[0].y + self.button_layout.children[0].height \
|
self._tool_layout.layout.add_widget(build_button(tool[1]))
|
||||||
- self.button_layout.height
|
# column 2
|
||||||
scroll_percent = self.button_layout.convert_distance_to_scroll(0, top)
|
if client:
|
||||||
self.button_layout.scroll_y = max(0, min(1, scroll_percent[1]))
|
self._client_layout.layout.add_widget(build_button(client[1]))
|
||||||
|
|
||||||
def filter_clients_by_type(self, caller: MDButton):
|
|
||||||
self._refresh_components(caller.type)
|
|
||||||
self.search_box.text = ""
|
|
||||||
|
|
||||||
def filter_clients_by_name(self, caller: MDTextField, name: str) -> None:
|
|
||||||
if len(name) == 0:
|
|
||||||
self._refresh_components(self.current_filter)
|
|
||||||
return
|
|
||||||
|
|
||||||
sub_matches = [
|
|
||||||
card for card in self.cards
|
|
||||||
if name.lower() in card.component.display_name.lower() and card.component.type != Type.HIDDEN
|
|
||||||
]
|
|
||||||
self.button_layout.layout.clear_widgets()
|
|
||||||
for card in sub_matches:
|
|
||||||
self.button_layout.layout.add_widget(card)
|
|
||||||
|
|
||||||
def build(self):
|
def build(self):
|
||||||
self.top_screen = Builder.load_file(Utils.local_path("data/launcher.kv"))
|
self.container = ContainerLayout()
|
||||||
self.grid = self.top_screen.ids.grid
|
self.grid = GridLayout(cols=2)
|
||||||
self.navigation = self.top_screen.ids.navigation
|
self.container.add_widget(self.grid)
|
||||||
self.button_layout = self.top_screen.ids.button_layout
|
self.grid.add_widget(Label(text="General", size_hint_y=None, height=40))
|
||||||
self.search_box = self.top_screen.ids.search_box
|
self.grid.add_widget(Label(text="Clients", size_hint_y=None, height=40))
|
||||||
self.set_colors()
|
self._tool_layout = ScrollBox()
|
||||||
self.top_screen.md_bg_color = self.theme_cls.backgroundColor
|
self._tool_layout.layout.orientation = "vertical"
|
||||||
|
self.grid.add_widget(self._tool_layout)
|
||||||
|
self._client_layout = ScrollBox()
|
||||||
|
self._client_layout.layout.orientation = "vertical"
|
||||||
|
self.grid.add_widget(self._client_layout)
|
||||||
|
|
||||||
|
self._refresh_components()
|
||||||
|
|
||||||
global refresh_components
|
global refresh_components
|
||||||
refresh_components = self._refresh_components
|
refresh_components = self._refresh_components
|
||||||
|
|
||||||
Window.bind(on_drop_file=self._on_drop_file)
|
Window.bind(on_drop_file=self._on_drop_file)
|
||||||
Window.bind(on_keyboard=self._on_keyboard)
|
|
||||||
|
|
||||||
for component in components:
|
return self.container
|
||||||
self.cards.append(self.build_card(component))
|
|
||||||
|
|
||||||
self._refresh_components(self.current_filter)
|
|
||||||
|
|
||||||
# Uncomment to re-enable the Kivy console/live editor
|
|
||||||
# Ctrl-E to enable it, make sure numlock/capslock is disabled
|
|
||||||
# from kivy.modules.console import create_console
|
|
||||||
# create_console(Window, self.top_screen)
|
|
||||||
|
|
||||||
return self.top_screen
|
|
||||||
|
|
||||||
def on_start(self):
|
|
||||||
if self.launch_components:
|
|
||||||
build_uri_popup(self.launch_components, self.launch_args)
|
|
||||||
self.launch_components = None
|
|
||||||
self.launch_args = None
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def component_action(button):
|
def component_action(button):
|
||||||
MDSnackbar(MDSnackbarText(text="Opening in a new window..."), y=dp(24), pos_hint={"center_x": 0.5},
|
|
||||||
size_hint_x=0.5).open()
|
|
||||||
if button.component.func:
|
if button.component.func:
|
||||||
button.component.func()
|
button.component.func()
|
||||||
else:
|
else:
|
||||||
@@ -419,16 +325,7 @@ def run_gui(launch_components: list[Component], args: Any) -> None:
|
|||||||
if file and component:
|
if file and component:
|
||||||
run_component(component, file)
|
run_component(component, file)
|
||||||
else:
|
else:
|
||||||
logging.warning(f"unable to identify component for {filename}")
|
logging.warning(f"unable to identify component for {file}")
|
||||||
|
|
||||||
def _on_keyboard(self, window: Window, key: int, scancode: int, codepoint: str, modifier: list[str]):
|
|
||||||
# Activate search as soon as we start typing, no matter if we are focused on the search box or not.
|
|
||||||
# Focus first, then capture the first character we type, otherwise it gets swallowed and lost.
|
|
||||||
# Limit text input to ASCII non-control characters (space bar to tilde).
|
|
||||||
if not self.search_box.focus:
|
|
||||||
self.search_box.focus = True
|
|
||||||
if key in range(32, 126):
|
|
||||||
self.search_box.text += codepoint
|
|
||||||
|
|
||||||
def _stop(self, *largs):
|
def _stop(self, *largs):
|
||||||
# ran into what appears to be https://groups.google.com/g/kivy-users/c/saWDLoYCSZ4 with PyCharm.
|
# ran into what appears to be https://groups.google.com/g/kivy-users/c/saWDLoYCSZ4 with PyCharm.
|
||||||
@@ -436,13 +333,7 @@ def run_gui(launch_components: list[Component], args: Any) -> None:
|
|||||||
self.root_window.close()
|
self.root_window.close()
|
||||||
super()._stop(*largs)
|
super()._stop(*largs)
|
||||||
|
|
||||||
def on_stop(self):
|
Launcher().run()
|
||||||
Utils.persistent_store("launcher", "favorites", self.favorites)
|
|
||||||
Utils.persistent_store("launcher", "filter", ", ".join(filter.name if isinstance(filter, Type) else filter
|
|
||||||
for filter in self.current_filter))
|
|
||||||
super().on_stop()
|
|
||||||
|
|
||||||
Launcher(components=launch_components, args=args).run()
|
|
||||||
|
|
||||||
# avoiding Launcher reference leak
|
# avoiding Launcher reference leak
|
||||||
# and don't try to do something with widgets after window closed
|
# and don't try to do something with widgets after window closed
|
||||||
@@ -461,7 +352,7 @@ def run_component(component: Component, *args):
|
|||||||
logging.warning(f"Component {component} does not appear to be executable.")
|
logging.warning(f"Component {component} does not appear to be executable.")
|
||||||
|
|
||||||
|
|
||||||
def main(args: argparse.Namespace | dict | None = None):
|
def main(args: Optional[Union[argparse.Namespace, dict]] = None):
|
||||||
if isinstance(args, argparse.Namespace):
|
if isinstance(args, argparse.Namespace):
|
||||||
args = {k: v for k, v in args._get_kwargs()}
|
args = {k: v for k, v in args._get_kwargs()}
|
||||||
elif not args:
|
elif not args:
|
||||||
@@ -470,21 +361,15 @@ def main(args: argparse.Namespace | dict | None = None):
|
|||||||
path = args.get("Patch|Game|Component|url", None)
|
path = args.get("Patch|Game|Component|url", None)
|
||||||
if path is not None:
|
if path is not None:
|
||||||
if path.startswith("archipelago://"):
|
if path.startswith("archipelago://"):
|
||||||
args["args"] = (path, *args.get("args", ()))
|
handle_uri(path, args.get("args", ()))
|
||||||
# add the url arg to the passthrough args
|
return
|
||||||
components, text_client_component = handle_uri(path)
|
file, component = identify(path)
|
||||||
if not components:
|
if file:
|
||||||
args["component"] = text_client_component
|
args['file'] = file
|
||||||
else:
|
if component:
|
||||||
args['launch_components'] = [text_client_component, *components]
|
args['component'] = component
|
||||||
else:
|
if not component:
|
||||||
file, component = identify(path)
|
logging.warning(f"Could not identify Component responsible for {path}")
|
||||||
if file:
|
|
||||||
args['file'] = file
|
|
||||||
if component:
|
|
||||||
args['component'] = component
|
|
||||||
if not component:
|
|
||||||
logging.warning(f"Could not identify Component responsible for {path}")
|
|
||||||
|
|
||||||
if args["update_settings"]:
|
if args["update_settings"]:
|
||||||
update_settings()
|
update_settings()
|
||||||
@@ -493,11 +378,12 @@ def main(args: argparse.Namespace | dict | None = None):
|
|||||||
elif "component" in args:
|
elif "component" in args:
|
||||||
run_component(args["component"], *args["args"])
|
run_component(args["component"], *args["args"])
|
||||||
elif not args["update_settings"]:
|
elif not args["update_settings"]:
|
||||||
run_gui(args.get("launch_components", None), args.get("args", ()))
|
run_gui()
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
multiprocessing.freeze_support()
|
init_logging('Launcher')
|
||||||
|
Utils.freeze_support()
|
||||||
multiprocessing.set_start_method("spawn") # if launched process uses kivy, fork won't work
|
multiprocessing.set_start_method("spawn") # if launched process uses kivy, fork won't work
|
||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(
|
||||||
description='Archipelago Launcher',
|
description='Archipelago Launcher',
|
||||||
@@ -514,7 +400,6 @@ if __name__ == '__main__':
|
|||||||
main(parser.parse_args())
|
main(parser.parse_args())
|
||||||
|
|
||||||
from worlds.LauncherComponents import processes
|
from worlds.LauncherComponents import processes
|
||||||
|
|
||||||
for process in processes:
|
for process in processes:
|
||||||
# we await all child processes to close before we tear down the process host
|
# we await all child processes to close before we tear down the process host
|
||||||
# this makes it feel like each one is its own program, as the Launcher is closed now
|
# this makes it feel like each one is its own program, as the Launcher is closed now
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ ModuleUpdate.update()
|
|||||||
|
|
||||||
import Utils
|
import Utils
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
Utils.init_logging("LinksAwakeningContext", exception_logger="Client")
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import base64
|
import base64
|
||||||
import binascii
|
import binascii
|
||||||
@@ -23,14 +26,15 @@ import typing
|
|||||||
from CommonClient import (CommonContext, get_base_parser, gui_enabled, logger,
|
from CommonClient import (CommonContext, get_base_parser, gui_enabled, logger,
|
||||||
server_loop)
|
server_loop)
|
||||||
from NetUtils import ClientStatus
|
from NetUtils import ClientStatus
|
||||||
from . import LinksAwakeningWorld
|
from worlds.ladx.Common import BASE_ID as LABaseID
|
||||||
from .Common import BASE_ID as LABaseID
|
from worlds.ladx.GpsTracker import GpsTracker
|
||||||
from .GpsTracker import GpsTracker
|
from worlds.ladx.TrackerConsts import storage_key
|
||||||
from .TrackerConsts import storage_key
|
from worlds.ladx.ItemTracker import ItemTracker
|
||||||
from .ItemTracker import ItemTracker
|
from worlds.ladx.LADXR.checkMetadata import checkMetadataTable
|
||||||
from .LADXR.checkMetadata import checkMetadataTable
|
from worlds.ladx.Locations import get_locations_to_id, meta_to_name
|
||||||
from .Locations import get_locations_to_id, meta_to_name
|
from worlds.ladx.Tracker import LocationTracker, MagpieBridge
|
||||||
from .Tracker import LocationTracker, MagpieBridge, Check
|
|
||||||
|
|
||||||
class GameboyException(Exception):
|
class GameboyException(Exception):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -47,8 +51,20 @@ class BadRetroArchResponse(GameboyException):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class VersionError(Exception):
|
def magpie_logo():
|
||||||
pass
|
from kivy.uix.image import CoreImage
|
||||||
|
binary_data = """
|
||||||
|
iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAIAAACQkWg2AAAAAXN
|
||||||
|
SR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA
|
||||||
|
7DAcdvqGQAAADGSURBVDhPhVLBEcIwDHOYhjHCBuXHj2OTbAL8+
|
||||||
|
MEGZIxOQ1CinOOk0Op0bmo7tlXXeR9FJMYDLOD9mwcLjQK7+hSZ
|
||||||
|
wgcWMZJOAGeGKtChNHFL0j+FZD3jSCuo0w7l03wDrWdg00C4/aW
|
||||||
|
eDEYNenuzPOfPspBnxf0kssE80vN0L8361j10P03DK4x6FHabuV
|
||||||
|
ear8fHme+b17rwSjbAXeUMLb+EVTV2QHm46MWQanmnydA98KsVS
|
||||||
|
XkV+qFpGQXrLhT/fqraQeQLuplpNH5g+WkAAAAASUVORK5CYII="""
|
||||||
|
binary_data = base64.b64decode(binary_data)
|
||||||
|
data = io.BytesIO(binary_data)
|
||||||
|
return CoreImage(data, ext="png").texture
|
||||||
|
|
||||||
|
|
||||||
class LAClientConstants:
|
class LAClientConstants:
|
||||||
@@ -123,7 +139,7 @@ class RAGameboy():
|
|||||||
def set_checks_range(self, checks_start, checks_size):
|
def set_checks_range(self, checks_start, checks_size):
|
||||||
self.checks_start = checks_start
|
self.checks_start = checks_start
|
||||||
self.checks_size = checks_size
|
self.checks_size = checks_size
|
||||||
|
|
||||||
def set_location_range(self, location_start, location_size, critical_addresses):
|
def set_location_range(self, location_start, location_size, critical_addresses):
|
||||||
self.location_start = location_start
|
self.location_start = location_start
|
||||||
self.location_size = location_size
|
self.location_size = location_size
|
||||||
@@ -140,7 +156,7 @@ class RAGameboy():
|
|||||||
return response
|
return response
|
||||||
|
|
||||||
async def async_recv(self, timeout=1.0):
|
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
|
return response
|
||||||
|
|
||||||
async def check_safe_gameplay(self, throw=True):
|
async def check_safe_gameplay(self, throw=True):
|
||||||
@@ -221,7 +237,7 @@ class RAGameboy():
|
|||||||
self.cache[start:start + len(hram_block)] = hram_block
|
self.cache[start:start + len(hram_block)] = hram_block
|
||||||
|
|
||||||
self.last_cache_read = time.time()
|
self.last_cache_read = time.time()
|
||||||
|
|
||||||
async def read_memory_block(self, address: int, size: int):
|
async def read_memory_block(self, address: int, size: int):
|
||||||
block = bytearray()
|
block = bytearray()
|
||||||
remaining_size = size
|
remaining_size = size
|
||||||
@@ -229,7 +245,7 @@ class RAGameboy():
|
|||||||
chunk = await self.async_read_memory(address + len(block), remaining_size)
|
chunk = await self.async_read_memory(address + len(block), remaining_size)
|
||||||
remaining_size -= len(chunk)
|
remaining_size -= len(chunk)
|
||||||
block += chunk
|
block += chunk
|
||||||
|
|
||||||
return block
|
return block
|
||||||
|
|
||||||
async def read_memory_cache(self, addresses):
|
async def read_memory_cache(self, addresses):
|
||||||
@@ -411,10 +427,10 @@ class LinksAwakeningClient():
|
|||||||
status = (await self.gameboy.async_read_memory_safe(LAClientConstants.wLinkStatusBits))[0]
|
status = (await self.gameboy.async_read_memory_safe(LAClientConstants.wLinkStatusBits))[0]
|
||||||
|
|
||||||
item_id -= LABaseID
|
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
|
# Even if it didn't, the remote player _index_ byte is just a byte, so 255 max
|
||||||
if from_player > 101:
|
if from_player > 100:
|
||||||
from_player = 101
|
from_player = 100
|
||||||
|
|
||||||
next_index += 1
|
next_index += 1
|
||||||
self.gameboy.write_memory(LAClientConstants.wLinkGiveItem, [
|
self.gameboy.write_memory(LAClientConstants.wLinkGiveItem, [
|
||||||
@@ -490,7 +506,7 @@ class LinksAwakeningContext(CommonContext):
|
|||||||
la_task = None
|
la_task = None
|
||||||
client = None
|
client = None
|
||||||
# TODO: does this need to re-read on reset?
|
# TODO: does this need to re-read on reset?
|
||||||
found_checks = set()
|
found_checks = []
|
||||||
last_resend = time.time()
|
last_resend = time.time()
|
||||||
|
|
||||||
magpie_enabled = False
|
magpie_enabled = False
|
||||||
@@ -498,8 +514,8 @@ class LinksAwakeningContext(CommonContext):
|
|||||||
magpie_task = None
|
magpie_task = None
|
||||||
won = False
|
won = False
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def slot_storage_key(self):
|
def slot_storage_key(self):
|
||||||
return f"{self.slot_info[self.slot].name}_{storage_key}"
|
return f"{self.slot_info[self.slot].name}_{storage_key}"
|
||||||
|
|
||||||
def __init__(self, server_address: typing.Optional[str], password: typing.Optional[str], magpie: typing.Optional[bool]) -> None:
|
def __init__(self, server_address: typing.Optional[str], password: typing.Optional[str], magpie: typing.Optional[bool]) -> None:
|
||||||
@@ -513,32 +529,40 @@ class LinksAwakeningContext(CommonContext):
|
|||||||
|
|
||||||
def run_gui(self) -> None:
|
def run_gui(self) -> None:
|
||||||
import webbrowser
|
import webbrowser
|
||||||
from kvui import GameManager
|
import kvui
|
||||||
from kivy.metrics import dp
|
from kvui import Button, GameManager
|
||||||
from kivymd.uix.button import MDButton, MDButtonText
|
from kivy.uix.image import Image
|
||||||
|
|
||||||
class LADXManager(GameManager):
|
class LADXManager(GameManager):
|
||||||
logging_pairs = [
|
logging_pairs = [
|
||||||
("Client", "Archipelago"),
|
("Client", "Archipelago"),
|
||||||
("Tracker", "Tracker"),
|
("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):
|
def build(self):
|
||||||
b = super().build()
|
b = super().build()
|
||||||
|
|
||||||
if self.ctx.magpie_enabled:
|
if self.ctx.magpie_enabled:
|
||||||
button = MDButton(MDButtonText(text="Open Tracker"), style="filled", size=(dp(100), dp(70)), radius=5,
|
button = Button(text="", size=(30, 30), size_hint_x=None,
|
||||||
size_hint_x=None, size_hint_y=None, pos_hint={"center_y": 0.55},
|
on_press=lambda _: webbrowser.open('https://magpietracker.us/?enable_autotracker=1'))
|
||||||
on_press=lambda _: webbrowser.open('https://magpietracker.us/?enable_autotracker=1'))
|
image = Image(size=(16, 16), texture=magpie_logo())
|
||||||
button.height = self.server_connect_bar.height
|
button.add_widget(image)
|
||||||
self.connect_layout.add_widget(button)
|
|
||||||
|
|
||||||
|
def set_center(_, center):
|
||||||
|
image.center = center
|
||||||
|
button.bind(center=set_center)
|
||||||
|
|
||||||
|
self.connect_layout.add_widget(button)
|
||||||
return b
|
return b
|
||||||
|
|
||||||
self.ui = LADXManager(self)
|
self.ui = LADXManager(self)
|
||||||
self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI")
|
self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI")
|
||||||
|
|
||||||
|
async def send_checks(self):
|
||||||
|
message = [{"cmd": "LocationChecks", "locations": self.found_checks}]
|
||||||
|
await self.send_msgs(message)
|
||||||
|
|
||||||
async def send_new_entrances(self, entrances: typing.Dict[str, str]):
|
async def send_new_entrances(self, entrances: typing.Dict[str, str]):
|
||||||
# Store the entrances we find on the server for future sessions
|
# Store the entrances we find on the server for future sessions
|
||||||
message = [{
|
message = [{
|
||||||
@@ -577,20 +601,20 @@ class LinksAwakeningContext(CommonContext):
|
|||||||
logger.info("victory!")
|
logger.info("victory!")
|
||||||
await self.send_msgs(message)
|
await self.send_msgs(message)
|
||||||
self.won = True
|
self.won = True
|
||||||
|
|
||||||
async def request_found_entrances(self):
|
async def request_found_entrances(self):
|
||||||
await self.send_msgs([{"cmd": "Get", "keys": [self.slot_storage_key]}])
|
await self.send_msgs([{"cmd": "Get", "keys": [self.slot_storage_key]}])
|
||||||
|
|
||||||
# Ask for updates so that players can co-op entrances in a seed
|
# Ask for updates so that players can co-op entrances in a seed
|
||||||
await self.send_msgs([{"cmd": "SetNotify", "keys": [self.slot_storage_key]}])
|
await self.send_msgs([{"cmd": "SetNotify", "keys": [self.slot_storage_key]}])
|
||||||
|
|
||||||
async def on_deathlink(self, data: typing.Dict[str, typing.Any]) -> None:
|
async def on_deathlink(self, data: typing.Dict[str, typing.Any]) -> None:
|
||||||
if self.ENABLE_DEATHLINK:
|
if self.ENABLE_DEATHLINK:
|
||||||
self.client.pending_deathlink = True
|
self.client.pending_deathlink = True
|
||||||
|
|
||||||
def new_checks(self, item_ids, ladxr_ids):
|
def new_checks(self, item_ids, ladxr_ids):
|
||||||
self.found_checks.update(item_ids)
|
self.found_checks += item_ids
|
||||||
create_task_log_exception(self.check_locations(self.found_checks))
|
create_task_log_exception(self.send_checks())
|
||||||
if self.magpie_enabled:
|
if self.magpie_enabled:
|
||||||
create_task_log_exception(self.magpie.send_new_checks(ladxr_ids))
|
create_task_log_exception(self.magpie.send_new_checks(ladxr_ids))
|
||||||
|
|
||||||
@@ -618,32 +642,12 @@ class LinksAwakeningContext(CommonContext):
|
|||||||
if cmd == "Connected":
|
if cmd == "Connected":
|
||||||
self.game = self.slot_info[self.slot].game
|
self.game = self.slot_info[self.slot].game
|
||||||
self.slot_data = args.get("slot_data", {})
|
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
|
# TODO - use watcher_event
|
||||||
if cmd == "ReceivedItems":
|
if cmd == "ReceivedItems":
|
||||||
for index, item in enumerate(args["items"], start=args["index"]):
|
for index, item in enumerate(args["items"], start=args["index"]):
|
||||||
self.client.recvd_checks[index] = item
|
self.client.recvd_checks[index] = item
|
||||||
|
|
||||||
if cmd == "Retrieved" and self.magpie_enabled and self.slot_storage_key in args["keys"]:
|
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])
|
self.client.gps_tracker.receive_found_entrances(args["keys"][self.slot_storage_key])
|
||||||
|
|
||||||
@@ -654,13 +658,6 @@ class LinksAwakeningContext(CommonContext):
|
|||||||
sync_msg = [{'cmd': 'Sync'}]
|
sync_msg = [{'cmd': 'Sync'}]
|
||||||
await self.send_msgs(sync_msg)
|
await self.send_msgs(sync_msg)
|
||||||
|
|
||||||
def add_linked_items(self, checks: typing.List[Check]):
|
|
||||||
for check in checks:
|
|
||||||
if check.value and check.linkedItem:
|
|
||||||
linkedItem = check.linkedItem
|
|
||||||
if 'condition' not in linkedItem or (self.slot_data and linkedItem['condition'](self.slot_data)):
|
|
||||||
self.client.item_tracker.setExtraItem(check.linkedItem['item'], check.linkedItem['qty'])
|
|
||||||
|
|
||||||
item_id_lookup = get_locations_to_id()
|
item_id_lookup = get_locations_to_id()
|
||||||
|
|
||||||
async def run_game_loop(self):
|
async def run_game_loop(self):
|
||||||
@@ -669,7 +666,11 @@ class LinksAwakeningContext(CommonContext):
|
|||||||
checkMetadataTable[check.id])] for check in ladxr_checks]
|
checkMetadataTable[check.id])] for check in ladxr_checks]
|
||||||
self.new_checks(checks, [check.id for check in ladxr_checks])
|
self.new_checks(checks, [check.id for check in ladxr_checks])
|
||||||
|
|
||||||
self.add_linked_items(ladxr_checks)
|
for check in ladxr_checks:
|
||||||
|
if check.value and check.linkedItem:
|
||||||
|
linkedItem = check.linkedItem
|
||||||
|
if 'condition' not in linkedItem or linkedItem['condition'](self.slot_data):
|
||||||
|
self.client.item_tracker.setExtraItem(check.linkedItem['item'], check.linkedItem['qty'])
|
||||||
|
|
||||||
async def victory():
|
async def victory():
|
||||||
await self.send_victory()
|
await self.send_victory()
|
||||||
@@ -720,15 +721,13 @@ class LinksAwakeningContext(CommonContext):
|
|||||||
|
|
||||||
if self.last_resend + 5.0 < now:
|
if self.last_resend + 5.0 < now:
|
||||||
self.last_resend = now
|
self.last_resend = now
|
||||||
await self.check_locations(self.found_checks)
|
await self.send_checks()
|
||||||
if self.magpie_enabled:
|
if self.magpie_enabled:
|
||||||
try:
|
try:
|
||||||
self.magpie.set_checks(self.client.tracker.all_checks)
|
self.magpie.set_checks(self.client.tracker.all_checks)
|
||||||
await self.magpie.set_item_tracker(self.client.item_tracker)
|
await self.magpie.set_item_tracker(self.client.item_tracker)
|
||||||
if self.slot_data and "slot_data" in self.magpie.features and not self.magpie.has_sent_slot_data:
|
self.magpie.slot_data = self.slot_data
|
||||||
self.magpie.slot_data = self.slot_data
|
|
||||||
await self.magpie.send_slot_data()
|
|
||||||
|
|
||||||
if self.client.gps_tracker.needs_found_entrances:
|
if self.client.gps_tracker.needs_found_entrances:
|
||||||
await self.request_found_entrances()
|
await self.request_found_entrances()
|
||||||
self.client.gps_tracker.needs_found_entrances = False
|
self.client.gps_tracker.needs_found_entrances = False
|
||||||
@@ -746,8 +745,8 @@ class LinksAwakeningContext(CommonContext):
|
|||||||
await asyncio.sleep(1.0)
|
await asyncio.sleep(1.0)
|
||||||
|
|
||||||
def run_game(romfile: str) -> None:
|
def run_game(romfile: str) -> None:
|
||||||
auto_start = LinksAwakeningWorld.settings.rom_start
|
auto_start = typing.cast(typing.Union[bool, str],
|
||||||
|
Utils.get_options()["ladx_options"].get("rom_start", True))
|
||||||
if auto_start is True:
|
if auto_start is True:
|
||||||
import webbrowser
|
import webbrowser
|
||||||
webbrowser.open(romfile)
|
webbrowser.open(romfile)
|
||||||
@@ -768,44 +767,42 @@ def run_game(romfile: str) -> None:
|
|||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
logger.error(f"Couldn't launch ROM, {args[0]} is missing")
|
logger.error(f"Couldn't launch ROM, {args[0]} is missing")
|
||||||
|
|
||||||
def launch(*launch_args):
|
async def main():
|
||||||
async def main():
|
parser = get_base_parser(description="Link's Awakening Client.")
|
||||||
parser = get_base_parser(description="Link's Awakening Client.")
|
parser.add_argument("--url", help="Archipelago connection url")
|
||||||
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("--no-magpie", dest='magpie', default=True, action='store_false', help="Disable magpie bridge")
|
parser.add_argument('diff_file', default="", type=str, nargs="?",
|
||||||
parser.add_argument('diff_file', default="", type=str, nargs="?",
|
help='Path to a .apladx Archipelago Binary Patch file')
|
||||||
help='Path to a .apladx Archipelago Binary Patch file')
|
|
||||||
|
|
||||||
args = parser.parse_args(launch_args)
|
args = parser.parse_args()
|
||||||
|
|
||||||
if args.diff_file:
|
if args.diff_file:
|
||||||
import Patch
|
import Patch
|
||||||
logger.info("patch file was supplied - creating rom...")
|
logger.info("patch file was supplied - creating rom...")
|
||||||
meta, rom_file = Patch.create_rom_file(args.diff_file)
|
meta, rom_file = Patch.create_rom_file(args.diff_file)
|
||||||
if "server" in meta and not args.connect:
|
if "server" in meta and not args.connect:
|
||||||
args.connect = meta["server"]
|
args.connect = meta["server"]
|
||||||
logger.info(f"wrote rom file to {rom_file}")
|
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
|
# TODO: nothing about the lambda about has to be in a lambda
|
||||||
ctx.la_task = create_task_log_exception(ctx.run_game_loop())
|
ctx.la_task = create_task_log_exception(ctx.run_game_loop())
|
||||||
if gui_enabled:
|
if gui_enabled:
|
||||||
ctx.run_gui()
|
ctx.run_gui()
|
||||||
ctx.run_cli()
|
ctx.run_cli()
|
||||||
|
|
||||||
# Down below run_gui so that we get errors out of the process
|
# Down below run_gui so that we get errors out of the process
|
||||||
if args.diff_file:
|
if args.diff_file:
|
||||||
run_game(rom_file)
|
run_game(rom_file)
|
||||||
|
|
||||||
await ctx.exit_event.wait()
|
await ctx.exit_event.wait()
|
||||||
await ctx.shutdown()
|
await ctx.shutdown()
|
||||||
|
|
||||||
Utils.init_logging("LinksAwakeningContext", exception_logger="Client")
|
if __name__ == '__main__':
|
||||||
|
colorama.init()
|
||||||
colorama.just_fix_windows_console()
|
|
||||||
asyncio.run(main())
|
asyncio.run(main())
|
||||||
colorama.deinit()
|
colorama.deinit()
|
||||||
@@ -32,7 +32,6 @@ GAME_ALTTP = "A Link to the Past"
|
|||||||
WINDOW_MIN_HEIGHT = 525
|
WINDOW_MIN_HEIGHT = 525
|
||||||
WINDOW_MIN_WIDTH = 425
|
WINDOW_MIN_WIDTH = 425
|
||||||
|
|
||||||
|
|
||||||
class AdjusterWorld(object):
|
class AdjusterWorld(object):
|
||||||
class AdjusterSubWorld(object):
|
class AdjusterSubWorld(object):
|
||||||
def __init__(self, random):
|
def __init__(self, random):
|
||||||
@@ -41,6 +40,7 @@ class AdjusterWorld(object):
|
|||||||
def __init__(self, sprite_pool):
|
def __init__(self, sprite_pool):
|
||||||
import random
|
import random
|
||||||
self.sprite_pool = {1: sprite_pool}
|
self.sprite_pool = {1: sprite_pool}
|
||||||
|
self.per_slot_randoms = {1: random}
|
||||||
self.worlds = {1: self.AdjusterSubWorld(random)}
|
self.worlds = {1: self.AdjusterSubWorld(random)}
|
||||||
|
|
||||||
|
|
||||||
@@ -49,7 +49,6 @@ class ArgumentDefaultsHelpFormatter(argparse.RawTextHelpFormatter):
|
|||||||
def _get_help_string(self, action):
|
def _get_help_string(self, action):
|
||||||
return textwrap.dedent(action.help)
|
return textwrap.dedent(action.help)
|
||||||
|
|
||||||
|
|
||||||
# See argparse.BooleanOptionalAction
|
# See argparse.BooleanOptionalAction
|
||||||
class BooleanOptionalActionWithDisable(argparse.Action):
|
class BooleanOptionalActionWithDisable(argparse.Action):
|
||||||
def __init__(self,
|
def __init__(self,
|
||||||
@@ -365,10 +364,10 @@ def run_sprite_update():
|
|||||||
logging.info("Done updating sprites")
|
logging.info("Done updating sprites")
|
||||||
|
|
||||||
|
|
||||||
def update_sprites(task, on_finish=None, repository_url: str = "https://alttpr.com/sprites"):
|
def update_sprites(task, on_finish=None):
|
||||||
resultmessage = ""
|
resultmessage = ""
|
||||||
successful = True
|
successful = True
|
||||||
sprite_dir = user_path("data", "sprites", "alttp", "remote")
|
sprite_dir = user_path("data", "sprites", "alttpr")
|
||||||
os.makedirs(sprite_dir, exist_ok=True)
|
os.makedirs(sprite_dir, exist_ok=True)
|
||||||
ctx = get_cert_none_ssl_context()
|
ctx = get_cert_none_ssl_context()
|
||||||
|
|
||||||
@@ -378,11 +377,11 @@ def update_sprites(task, on_finish=None, repository_url: str = "https://alttpr.c
|
|||||||
on_finish(successful, resultmessage)
|
on_finish(successful, resultmessage)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
task.update_status("Downloading remote sprites list")
|
task.update_status("Downloading alttpr sprites list")
|
||||||
with urlopen(repository_url, context=ctx) as response:
|
with urlopen('https://alttpr.com/sprites', context=ctx) as response:
|
||||||
sprites_arr = json.loads(response.read().decode("utf-8"))
|
sprites_arr = json.loads(response.read().decode("utf-8"))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
resultmessage = "Error getting list of remote sprites. Sprites not updated.\n\n%s: %s" % (type(e).__name__, e)
|
resultmessage = "Error getting list of alttpr sprites. Sprites not updated.\n\n%s: %s" % (type(e).__name__, e)
|
||||||
successful = False
|
successful = False
|
||||||
task.queue_event(finished)
|
task.queue_event(finished)
|
||||||
return
|
return
|
||||||
@@ -390,13 +389,13 @@ def update_sprites(task, on_finish=None, repository_url: str = "https://alttpr.c
|
|||||||
try:
|
try:
|
||||||
task.update_status("Determining needed sprites")
|
task.update_status("Determining needed sprites")
|
||||||
current_sprites = [os.path.basename(file) for file in glob(sprite_dir + '/*')]
|
current_sprites = [os.path.basename(file) for file in glob(sprite_dir + '/*')]
|
||||||
remote_sprites = [(sprite['file'], os.path.basename(urlparse(sprite['file']).path))
|
alttpr_sprites = [(sprite['file'], os.path.basename(urlparse(sprite['file']).path))
|
||||||
for sprite in sprites_arr if sprite["author"] != "Nintendo"]
|
for sprite in sprites_arr if sprite["author"] != "Nintendo"]
|
||||||
needed_sprites = [(sprite_url, filename) for (sprite_url, filename) in remote_sprites if
|
needed_sprites = [(sprite_url, filename) for (sprite_url, filename) in alttpr_sprites if
|
||||||
filename not in current_sprites]
|
filename not in current_sprites]
|
||||||
|
|
||||||
remote_filenames = [filename for (_, filename) in remote_sprites]
|
alttpr_filenames = [filename for (_, filename) in alttpr_sprites]
|
||||||
obsolete_sprites = [sprite for sprite in current_sprites if sprite not in remote_filenames]
|
obsolete_sprites = [sprite for sprite in current_sprites if sprite not in alttpr_filenames]
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
resultmessage = "Error Determining which sprites to update. Sprites not updated.\n\n%s: %s" % (
|
resultmessage = "Error Determining which sprites to update. Sprites not updated.\n\n%s: %s" % (
|
||||||
type(e).__name__, e)
|
type(e).__name__, e)
|
||||||
@@ -448,7 +447,7 @@ def update_sprites(task, on_finish=None, repository_url: str = "https://alttpr.c
|
|||||||
successful = False
|
successful = False
|
||||||
|
|
||||||
if successful:
|
if successful:
|
||||||
resultmessage = "Remote sprites updated successfully"
|
resultmessage = "alttpr sprites updated successfully"
|
||||||
|
|
||||||
task.queue_event(finished)
|
task.queue_event(finished)
|
||||||
|
|
||||||
@@ -869,7 +868,7 @@ class SpriteSelector():
|
|||||||
def open_custom_sprite_dir(_evt):
|
def open_custom_sprite_dir(_evt):
|
||||||
open_file(self.custom_sprite_dir)
|
open_file(self.custom_sprite_dir)
|
||||||
|
|
||||||
remote_frametitle = Label(self.window, text='Remote Sprites')
|
alttpr_frametitle = Label(self.window, text='ALTTPR Sprites')
|
||||||
|
|
||||||
custom_frametitle = Frame(self.window)
|
custom_frametitle = Frame(self.window)
|
||||||
title_text = Label(custom_frametitle, text="Custom Sprites")
|
title_text = Label(custom_frametitle, text="Custom Sprites")
|
||||||
@@ -878,8 +877,8 @@ class SpriteSelector():
|
|||||||
title_link.pack(side=LEFT)
|
title_link.pack(side=LEFT)
|
||||||
title_link.bind("<Button-1>", open_custom_sprite_dir)
|
title_link.bind("<Button-1>", open_custom_sprite_dir)
|
||||||
|
|
||||||
self.icon_section(remote_frametitle, self.remote_sprite_dir,
|
self.icon_section(alttpr_frametitle, self.alttpr_sprite_dir,
|
||||||
'Remote sprites not found. Click "Update remote sprites" to download them.')
|
'ALTTPR sprites not found. Click "Update alttpr sprites" to download them.')
|
||||||
self.icon_section(custom_frametitle, self.custom_sprite_dir,
|
self.icon_section(custom_frametitle, self.custom_sprite_dir,
|
||||||
'Put sprites in the custom sprites folder (see open link above) to have them appear here.')
|
'Put sprites in the custom sprites folder (see open link above) to have them appear here.')
|
||||||
if not randomOnEvent:
|
if not randomOnEvent:
|
||||||
@@ -892,18 +891,11 @@ class SpriteSelector():
|
|||||||
button = Button(frame, text="Browse for file...", command=self.browse_for_sprite)
|
button = Button(frame, text="Browse for file...", command=self.browse_for_sprite)
|
||||||
button.pack(side=RIGHT, padx=(5, 0))
|
button.pack(side=RIGHT, padx=(5, 0))
|
||||||
|
|
||||||
button = Button(frame, text="Update remote sprites", command=self.update_remote_sprites)
|
button = Button(frame, text="Update alttpr sprites", command=self.update_alttpr_sprites)
|
||||||
button.pack(side=RIGHT, padx=(5, 0))
|
button.pack(side=RIGHT, padx=(5, 0))
|
||||||
|
|
||||||
repository_label = Label(frame, text='Sprite Repository:')
|
|
||||||
self.repository_url = StringVar(frame, "https://alttpr.com/sprites")
|
|
||||||
repository_entry = Entry(frame, textvariable=self.repository_url)
|
|
||||||
|
|
||||||
repository_entry.pack(side=RIGHT, expand=True, fill=BOTH, pady=1)
|
|
||||||
repository_label.pack(side=RIGHT, expand=False, padx=(0, 5))
|
|
||||||
|
|
||||||
button = Button(frame, text="Do not adjust sprite",command=self.use_default_sprite)
|
button = Button(frame, text="Do not adjust sprite",command=self.use_default_sprite)
|
||||||
button.pack(side=LEFT, padx=(0, 5))
|
button.pack(side=LEFT,padx=(0,5))
|
||||||
|
|
||||||
button = Button(frame, text="Default Link sprite", command=self.use_default_link_sprite)
|
button = Button(frame, text="Default Link sprite", command=self.use_default_link_sprite)
|
||||||
button.pack(side=LEFT, padx=(0, 5))
|
button.pack(side=LEFT, padx=(0, 5))
|
||||||
@@ -1063,7 +1055,7 @@ class SpriteSelector():
|
|||||||
for i, button in enumerate(frame.buttons):
|
for i, button in enumerate(frame.buttons):
|
||||||
button.grid(row=i // self.spritesPerRow, column=i % self.spritesPerRow)
|
button.grid(row=i // self.spritesPerRow, column=i % self.spritesPerRow)
|
||||||
|
|
||||||
def update_remote_sprites(self):
|
def update_alttpr_sprites(self):
|
||||||
# need to wrap in try catch. We don't want errors getting the json or downloading the files to break us.
|
# need to wrap in try catch. We don't want errors getting the json or downloading the files to break us.
|
||||||
self.window.destroy()
|
self.window.destroy()
|
||||||
self.parent.update()
|
self.parent.update()
|
||||||
@@ -1076,8 +1068,7 @@ class SpriteSelector():
|
|||||||
messagebox.showerror("Sprite Updater", resultmessage)
|
messagebox.showerror("Sprite Updater", resultmessage)
|
||||||
SpriteSelector(self.parent, self.callback, self.adjuster)
|
SpriteSelector(self.parent, self.callback, self.adjuster)
|
||||||
|
|
||||||
BackgroundTaskProgress(self.parent, update_sprites, "Updating Sprites",
|
BackgroundTaskProgress(self.parent, update_sprites, "Updating Sprites", on_finish)
|
||||||
on_finish, self.repository_url.get())
|
|
||||||
|
|
||||||
def browse_for_sprite(self):
|
def browse_for_sprite(self):
|
||||||
sprite = filedialog.askopenfilename(
|
sprite = filedialog.askopenfilename(
|
||||||
@@ -1167,13 +1158,12 @@ class SpriteSelector():
|
|||||||
os.makedirs(self.custom_sprite_dir)
|
os.makedirs(self.custom_sprite_dir)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def remote_sprite_dir(self):
|
def alttpr_sprite_dir(self):
|
||||||
return user_path("data", "sprites", "alttp", "remote")
|
return user_path("data", "sprites", "alttpr")
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def custom_sprite_dir(self):
|
def custom_sprite_dir(self):
|
||||||
return user_path("data", "sprites", "alttp", "custom")
|
return user_path("data", "sprites", "custom")
|
||||||
|
|
||||||
|
|
||||||
def get_image_for_sprite(sprite, gif_only: bool = False):
|
def get_image_for_sprite(sprite, gif_only: bool = False):
|
||||||
if not sprite.valid:
|
if not sprite.valid:
|
||||||
|
|||||||
@@ -286,14 +286,16 @@ async def gba_sync_task(ctx: MMBN3Context):
|
|||||||
except ConnectionRefusedError:
|
except ConnectionRefusedError:
|
||||||
logger.debug("Connection Refused, Trying Again")
|
logger.debug("Connection Refused, Trying Again")
|
||||||
ctx.gba_status = CONNECTION_REFUSED_STATUS
|
ctx.gba_status = CONNECTION_REFUSED_STATUS
|
||||||
await asyncio.sleep(1)
|
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
|
||||||
async def run_game(romfile):
|
async def run_game(romfile):
|
||||||
from worlds.mmbn3 import MMBN3World
|
options = Utils.get_options().get("mmbn3_options", None)
|
||||||
auto_start = MMBN3World.settings.rom_start
|
if options is None:
|
||||||
if auto_start is True:
|
auto_start = True
|
||||||
|
else:
|
||||||
|
auto_start = options.get("rom_start", True)
|
||||||
|
if auto_start:
|
||||||
import webbrowser
|
import webbrowser
|
||||||
webbrowser.open(romfile)
|
webbrowser.open(romfile)
|
||||||
elif os.path.isfile(auto_start):
|
elif os.path.isfile(auto_start):
|
||||||
@@ -368,7 +370,7 @@ if __name__ == "__main__":
|
|||||||
|
|
||||||
import colorama
|
import colorama
|
||||||
|
|
||||||
colorama.just_fix_windows_console()
|
colorama.init()
|
||||||
|
|
||||||
asyncio.run(main())
|
asyncio.run(main())
|
||||||
colorama.deinit()
|
colorama.deinit()
|
||||||
|
|||||||
132
Main.py
132
Main.py
@@ -1,21 +1,20 @@
|
|||||||
import collections
|
import collections
|
||||||
from collections.abc import Mapping
|
|
||||||
import concurrent.futures
|
import concurrent.futures
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import pickle
|
||||||
import tempfile
|
import tempfile
|
||||||
import time
|
import time
|
||||||
from typing import Any
|
|
||||||
import zipfile
|
import zipfile
|
||||||
import zlib
|
import zlib
|
||||||
|
from typing import Dict, List, Optional, Set, Tuple, Union
|
||||||
|
|
||||||
import worlds
|
import worlds
|
||||||
from BaseClasses import CollectionState, Item, Location, LocationProgressType, MultiWorld
|
from BaseClasses import CollectionState, Item, Location, LocationProgressType, MultiWorld, Region
|
||||||
from Fill import FillError, balance_multiworld_progression, distribute_items_restrictive, flood_items, \
|
from Fill import FillError, balance_multiworld_progression, distribute_items_restrictive, distribute_planned, \
|
||||||
parse_planned_blocks, distribute_planned_blocks, resolve_early_locations_for_planned
|
flood_items
|
||||||
from NetUtils import convert_to_base_types
|
|
||||||
from Options import StartInventoryPool
|
from Options import StartInventoryPool
|
||||||
from Utils import __version__, output_path, restricted_dumps, version_tuple
|
from Utils import __version__, output_path, version_tuple, get_settings
|
||||||
from settings import get_settings
|
from settings import get_settings
|
||||||
from worlds import AutoWorld
|
from worlds import AutoWorld
|
||||||
from worlds.generic.Rules import exclusion_rules, locality_rules
|
from worlds.generic.Rules import exclusion_rules, locality_rules
|
||||||
@@ -23,7 +22,7 @@ from worlds.generic.Rules import exclusion_rules, locality_rules
|
|||||||
__all__ = ["main"]
|
__all__ = ["main"]
|
||||||
|
|
||||||
|
|
||||||
def main(args, seed=None, baked_server_options: dict[str, object] | None = None):
|
def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = None):
|
||||||
if not baked_server_options:
|
if not baked_server_options:
|
||||||
baked_server_options = get_settings().server_options.as_dict()
|
baked_server_options = get_settings().server_options.as_dict()
|
||||||
assert isinstance(baked_server_options, dict)
|
assert isinstance(baked_server_options, dict)
|
||||||
@@ -37,7 +36,10 @@ def main(args, seed=None, baked_server_options: dict[str, object] | None = None)
|
|||||||
|
|
||||||
logger = logging.getLogger()
|
logger = logging.getLogger()
|
||||||
multiworld.set_seed(seed, args.race, str(args.outputname) if args.outputname else None)
|
multiworld.set_seed(seed, args.race, str(args.outputname) if args.outputname else None)
|
||||||
multiworld.plando_options = args.plando
|
multiworld.plando_options = args.plando_options
|
||||||
|
multiworld.plando_items = args.plando_items.copy()
|
||||||
|
multiworld.plando_texts = args.plando_texts.copy()
|
||||||
|
multiworld.plando_connections = args.plando_connections.copy()
|
||||||
multiworld.game = args.game.copy()
|
multiworld.game = args.game.copy()
|
||||||
multiworld.player_name = args.name.copy()
|
multiworld.player_name = args.name.copy()
|
||||||
multiworld.sprite = args.sprite.copy()
|
multiworld.sprite = args.sprite.copy()
|
||||||
@@ -54,23 +56,32 @@ def main(args, seed=None, baked_server_options: dict[str, object] | None = None)
|
|||||||
logger.info(f"Found {len(AutoWorld.AutoWorldRegister.world_types)} World Types:")
|
logger.info(f"Found {len(AutoWorld.AutoWorldRegister.world_types)} World Types:")
|
||||||
longest_name = max(len(text) for text in AutoWorld.AutoWorldRegister.world_types)
|
longest_name = max(len(text) for text in AutoWorld.AutoWorldRegister.world_types)
|
||||||
|
|
||||||
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_digits = len(str(max_item))
|
||||||
item_count = len(str(max(len(cls.item_names) for cls in world_classes)))
|
location_digits = len(str(max_location))
|
||||||
location_count = len(str(max(len(cls.location_names) for cls in world_classes)))
|
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():
|
for name, cls in AutoWorld.AutoWorldRegister.world_types.items():
|
||||||
if not cls.hidden and len(cls.item_names) > 0:
|
if not cls.hidden and len(cls.item_names) > 0:
|
||||||
logger.info(f" {name:{longest_name}}: "
|
logger.info(f" {name:{longest_name}}: {len(cls.item_names):{item_count}} "
|
||||||
f"v{cls.world_version.as_simple_string():{version_count}} | "
|
f"Items (IDs: {min(cls.item_id_to_name):{item_digits}} - "
|
||||||
f"Items: {len(cls.item_names):{item_count}} | "
|
f"{max(cls.item_id_to_name):{item_digits}}) | "
|
||||||
f"Locations: {len(cls.location_names):{location_count}}")
|
f"{len(cls.location_names):{location_count}} "
|
||||||
|
f"Locations (IDs: {min(cls.location_id_to_name):{location_digits}} - "
|
||||||
|
f"{max(cls.location_id_to_name):{location_digits}})")
|
||||||
|
|
||||||
del item_count, location_count
|
del item_digits, location_digits, item_count, location_count
|
||||||
|
|
||||||
# This assertion method should not be necessary to run if we are not outputting any multidata.
|
# This assertion method should not be necessary to run if we are not outputting any multidata.
|
||||||
if not args.skip_output and not args.spoiler_only:
|
if not args.skip_output:
|
||||||
AutoWorld.call_stage(multiworld, "assert_generate")
|
AutoWorld.call_stage(multiworld, "assert_generate")
|
||||||
|
|
||||||
AutoWorld.call_all(multiworld, "generate_early")
|
AutoWorld.call_all(multiworld, "generate_early")
|
||||||
@@ -99,15 +110,6 @@ def main(args, seed=None, baked_server_options: dict[str, object] | None = None)
|
|||||||
del local_early
|
del local_early
|
||||||
del early
|
del early
|
||||||
|
|
||||||
# items can't be both local and non-local, prefer local
|
|
||||||
multiworld.worlds[player].options.non_local_items.value -= multiworld.worlds[player].options.local_items.value
|
|
||||||
multiworld.worlds[player].options.non_local_items.value -= set(multiworld.local_early_items[player])
|
|
||||||
|
|
||||||
# Clear non-applicable local and non-local items.
|
|
||||||
if multiworld.players == 1:
|
|
||||||
multiworld.worlds[1].options.non_local_items.value = set()
|
|
||||||
multiworld.worlds[1].options.local_items.value = set()
|
|
||||||
|
|
||||||
logger.info('Creating MultiWorld.')
|
logger.info('Creating MultiWorld.')
|
||||||
AutoWorld.call_all(multiworld, "create_regions")
|
AutoWorld.call_all(multiworld, "create_regions")
|
||||||
|
|
||||||
@@ -115,6 +117,12 @@ def main(args, seed=None, baked_server_options: dict[str, object] | None = None)
|
|||||||
AutoWorld.call_all(multiworld, "create_items")
|
AutoWorld.call_all(multiworld, "create_items")
|
||||||
|
|
||||||
logger.info('Calculating Access Rules.')
|
logger.info('Calculating Access Rules.')
|
||||||
|
|
||||||
|
for player in multiworld.player_ids:
|
||||||
|
# items can't be both local and non-local, prefer local
|
||||||
|
multiworld.worlds[player].options.non_local_items.value -= multiworld.worlds[player].options.local_items.value
|
||||||
|
multiworld.worlds[player].options.non_local_items.value -= set(multiworld.local_early_items[player])
|
||||||
|
|
||||||
AutoWorld.call_all(multiworld, "set_rules")
|
AutoWorld.call_all(multiworld, "set_rules")
|
||||||
|
|
||||||
for player in multiworld.player_ids:
|
for player in multiworld.player_ids:
|
||||||
@@ -135,11 +143,11 @@ def main(args, seed=None, baked_server_options: dict[str, object] | None = None)
|
|||||||
multiworld.worlds[player].options.priority_locations.value -= world_excluded_locations
|
multiworld.worlds[player].options.priority_locations.value -= world_excluded_locations
|
||||||
|
|
||||||
# Set local and non-local item rules.
|
# Set local and non-local item rules.
|
||||||
# This function is called so late because worlds might otherwise overwrite item_rules which are how locality works
|
|
||||||
if multiworld.players > 1:
|
if multiworld.players > 1:
|
||||||
locality_rules(multiworld)
|
locality_rules(multiworld)
|
||||||
|
else:
|
||||||
multiworld.plando_item_blocks = parse_planned_blocks(multiworld)
|
multiworld.worlds[1].options.non_local_items.value = set()
|
||||||
|
multiworld.worlds[1].options.local_items.value = set()
|
||||||
|
|
||||||
AutoWorld.call_all(multiworld, "connect_entrances")
|
AutoWorld.call_all(multiworld, "connect_entrances")
|
||||||
AutoWorld.call_all(multiworld, "generate_basic")
|
AutoWorld.call_all(multiworld, "generate_basic")
|
||||||
@@ -147,7 +155,7 @@ def main(args, seed=None, baked_server_options: dict[str, object] | None = None)
|
|||||||
# remove starting inventory from pool items.
|
# remove starting inventory from pool items.
|
||||||
# Because some worlds don't actually create items during create_items this has to be as late as possible.
|
# Because some worlds don't actually create items during create_items this has to be as late as possible.
|
||||||
fallback_inventory = StartInventoryPool({})
|
fallback_inventory = StartInventoryPool({})
|
||||||
depletion_pool: dict[int, dict[str, int]] = {
|
depletion_pool: Dict[int, Dict[str, int]] = {
|
||||||
player: getattr(multiworld.worlds[player].options, "start_inventory_from_pool", fallback_inventory).value.copy()
|
player: getattr(multiworld.worlds[player].options, "start_inventory_from_pool", fallback_inventory).value.copy()
|
||||||
for player in multiworld.player_ids
|
for player in multiworld.player_ids
|
||||||
}
|
}
|
||||||
@@ -156,7 +164,7 @@ def main(args, seed=None, baked_server_options: dict[str, object] | None = None)
|
|||||||
}
|
}
|
||||||
|
|
||||||
if target_per_player:
|
if target_per_player:
|
||||||
new_itempool: list[Item] = []
|
new_itempool: List[Item] = []
|
||||||
|
|
||||||
# Make new itempool with start_inventory_from_pool items removed
|
# Make new itempool with start_inventory_from_pool items removed
|
||||||
for item in multiworld.itempool:
|
for item in multiworld.itempool:
|
||||||
@@ -181,13 +189,12 @@ def main(args, seed=None, baked_server_options: dict[str, object] | None = None)
|
|||||||
|
|
||||||
multiworld.link_items()
|
multiworld.link_items()
|
||||||
|
|
||||||
if any(world.options.item_links for world in multiworld.worlds.values()):
|
if any(multiworld.item_links.values()):
|
||||||
multiworld._all_state = None
|
multiworld._all_state = None
|
||||||
|
|
||||||
logger.info("Running Item Plando.")
|
logger.info("Running Item Plando.")
|
||||||
resolve_early_locations_for_planned(multiworld)
|
|
||||||
distribute_planned_blocks(multiworld, [x for player in multiworld.plando_item_blocks
|
distribute_planned(multiworld)
|
||||||
for x in multiworld.plando_item_blocks[player]])
|
|
||||||
|
|
||||||
logger.info('Running Pre Main Fill.')
|
logger.info('Running Pre Main Fill.')
|
||||||
|
|
||||||
@@ -207,9 +214,6 @@ def main(args, seed=None, baked_server_options: dict[str, object] | None = None)
|
|||||||
else:
|
else:
|
||||||
logger.info("Progression balancing skipped.")
|
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
|
# we're about to output using multithreading, so we're removing the global random state to prevent accidental use
|
||||||
multiworld.random.passthrough = False
|
multiworld.random.passthrough = False
|
||||||
|
|
||||||
@@ -220,15 +224,6 @@ def main(args, seed=None, baked_server_options: dict[str, object] | None = None)
|
|||||||
logger.info(f'Beginning output...')
|
logger.info(f'Beginning output...')
|
||||||
outfilebase = 'AP_' + multiworld.seed_name
|
outfilebase = 'AP_' + multiworld.seed_name
|
||||||
|
|
||||||
if args.spoiler_only:
|
|
||||||
if args.spoiler > 1:
|
|
||||||
logger.info('Calculating playthrough.')
|
|
||||||
multiworld.spoiler.create_playthrough(create_paths=args.spoiler > 2)
|
|
||||||
|
|
||||||
multiworld.spoiler.to_file(output_path('%s_Spoiler.txt' % outfilebase))
|
|
||||||
logger.info('Done. Skipped multidata modification. Total time: %s', time.perf_counter() - start)
|
|
||||||
return multiworld
|
|
||||||
|
|
||||||
output = tempfile.TemporaryDirectory()
|
output = tempfile.TemporaryDirectory()
|
||||||
with output as temp_dir:
|
with output as temp_dir:
|
||||||
output_players = [player for player in multiworld.player_ids if AutoWorld.World.generate_output.__code__
|
output_players = [player for player in multiworld.player_ids if AutoWorld.World.generate_output.__code__
|
||||||
@@ -243,19 +238,17 @@ def main(args, seed=None, baked_server_options: dict[str, object] | None = None)
|
|||||||
pool.submit(AutoWorld.call_single, multiworld, "generate_output", player, temp_dir))
|
pool.submit(AutoWorld.call_single, multiworld, "generate_output", player, temp_dir))
|
||||||
|
|
||||||
# collect ER hint info
|
# collect ER hint info
|
||||||
er_hint_data: dict[int, dict[int, str]] = {}
|
er_hint_data: Dict[int, Dict[int, str]] = {}
|
||||||
AutoWorld.call_all(multiworld, 'extend_hint_information', er_hint_data)
|
AutoWorld.call_all(multiworld, 'extend_hint_information', er_hint_data)
|
||||||
|
|
||||||
def write_multidata():
|
def write_multidata():
|
||||||
import NetUtils
|
import NetUtils
|
||||||
from NetUtils import HintStatus
|
from NetUtils import HintStatus
|
||||||
slot_data: dict[int, Mapping[str, Any]] = {}
|
slot_data = {}
|
||||||
client_versions: dict[int, tuple[int, int, int]] = {}
|
client_versions = {}
|
||||||
games: dict[int, str] = {}
|
games = {}
|
||||||
minimum_versions: NetUtils.MinimumVersions = {
|
minimum_versions = {"server": AutoWorld.World.required_server_version, "clients": client_versions}
|
||||||
"server": AutoWorld.World.required_server_version, "clients": client_versions
|
slot_info = {}
|
||||||
}
|
|
||||||
slot_info: dict[int, NetUtils.NetworkSlot] = {}
|
|
||||||
names = [[name for player, name in sorted(multiworld.player_name.items())]]
|
names = [[name for player, name in sorted(multiworld.player_name.items())]]
|
||||||
for slot in multiworld.player_ids:
|
for slot in multiworld.player_ids:
|
||||||
player_world: AutoWorld.World = multiworld.worlds[slot]
|
player_world: AutoWorld.World = multiworld.worlds[slot]
|
||||||
@@ -270,9 +263,7 @@ def main(args, seed=None, baked_server_options: dict[str, object] | None = None)
|
|||||||
group_members=sorted(group["players"]))
|
group_members=sorted(group["players"]))
|
||||||
precollected_items = {player: [item.code for item in world_precollected if type(item.code) == int]
|
precollected_items = {player: [item.code for item in world_precollected if type(item.code) == int]
|
||||||
for player, world_precollected in multiworld.precollected_items.items()}
|
for player, world_precollected in multiworld.precollected_items.items()}
|
||||||
precollected_hints: dict[int, set[NetUtils.Hint]] = {
|
precollected_hints = {player: set() for player in range(1, multiworld.players + 1 + len(multiworld.groups))}
|
||||||
player: set() for player in range(1, multiworld.players + 1 + len(multiworld.groups))
|
|
||||||
}
|
|
||||||
|
|
||||||
for slot in multiworld.player_ids:
|
for slot in multiworld.player_ids:
|
||||||
slot_data[slot] = multiworld.worlds[slot].fill_slot_data()
|
slot_data[slot] = multiworld.worlds[slot].fill_slot_data()
|
||||||
@@ -288,7 +279,7 @@ def main(args, seed=None, baked_server_options: dict[str, object] | None = None)
|
|||||||
for player in multiworld.groups[location.item.player]["players"]:
|
for player in multiworld.groups[location.item.player]["players"]:
|
||||||
precollected_hints[player].add(hint)
|
precollected_hints[player].add(hint)
|
||||||
|
|
||||||
locations_data: dict[int, dict[int, tuple[int, int, int]]] = {player: {} for player in multiworld.player_ids}
|
locations_data: Dict[int, Dict[int, Tuple[int, int, int]]] = {player: {} for player in multiworld.player_ids}
|
||||||
for location in multiworld.get_filled_locations():
|
for location in multiworld.get_filled_locations():
|
||||||
if type(location.address) == int:
|
if type(location.address) == int:
|
||||||
assert location.item.code is not None, "item code None should be event, " \
|
assert location.item.code is not None, "item code None should be event, " \
|
||||||
@@ -315,21 +306,20 @@ def main(args, seed=None, baked_server_options: dict[str, object] | None = None)
|
|||||||
game_world.game: worlds.network_data_package["games"][game_world.game]
|
game_world.game: worlds.network_data_package["games"][game_world.game]
|
||||||
for game_world in multiworld.worlds.values()
|
for game_world in multiworld.worlds.values()
|
||||||
}
|
}
|
||||||
data_package["Archipelago"] = worlds.network_data_package["games"]["Archipelago"]
|
|
||||||
|
|
||||||
checks_in_area: dict[int, dict[str, int | list[int]]] = {}
|
checks_in_area: Dict[int, Dict[str, Union[int, List[int]]]] = {}
|
||||||
|
|
||||||
# get spheres -> filter address==None -> skip empty
|
# get spheres -> filter address==None -> skip empty
|
||||||
spheres: list[dict[int, set[int]]] = []
|
spheres: List[Dict[int, Set[int]]] = []
|
||||||
for sphere in multiworld.get_sendable_spheres():
|
for sphere in multiworld.get_sendable_spheres():
|
||||||
current_sphere: dict[int, set[int]] = collections.defaultdict(set)
|
current_sphere: Dict[int, Set[int]] = collections.defaultdict(set)
|
||||||
for sphere_location in sphere:
|
for sphere_location in sphere:
|
||||||
current_sphere[sphere_location.player].add(sphere_location.address)
|
current_sphere[sphere_location.player].add(sphere_location.address)
|
||||||
|
|
||||||
if current_sphere:
|
if current_sphere:
|
||||||
spheres.append(dict(current_sphere))
|
spheres.append(dict(current_sphere))
|
||||||
|
|
||||||
multidata: NetUtils.MultiData = {
|
multidata = {
|
||||||
"slot_data": slot_data,
|
"slot_data": slot_data,
|
||||||
"slot_info": slot_info,
|
"slot_info": slot_info,
|
||||||
"connect_names": {name: (0, player) for player, name in multiworld.player_name.items()},
|
"connect_names": {name: (0, player) for player, name in multiworld.player_name.items()},
|
||||||
@@ -339,7 +329,7 @@ def main(args, seed=None, baked_server_options: dict[str, object] | None = None)
|
|||||||
"er_hint_data": er_hint_data,
|
"er_hint_data": er_hint_data,
|
||||||
"precollected_items": precollected_items,
|
"precollected_items": precollected_items,
|
||||||
"precollected_hints": precollected_hints,
|
"precollected_hints": precollected_hints,
|
||||||
"version": (version_tuple.major, version_tuple.minor, version_tuple.build),
|
"version": tuple(version_tuple),
|
||||||
"tags": ["AP"],
|
"tags": ["AP"],
|
||||||
"minimum_versions": minimum_versions,
|
"minimum_versions": minimum_versions,
|
||||||
"seed_name": multiworld.seed_name,
|
"seed_name": multiworld.seed_name,
|
||||||
@@ -347,17 +337,13 @@ def main(args, seed=None, baked_server_options: dict[str, object] | None = None)
|
|||||||
"datapackage": data_package,
|
"datapackage": data_package,
|
||||||
"race_mode": int(multiworld.is_race),
|
"race_mode": int(multiworld.is_race),
|
||||||
}
|
}
|
||||||
# TODO: change to `"version": version_tuple` after getting better serialization
|
|
||||||
AutoWorld.call_all(multiworld, "modify_multidata", multidata)
|
AutoWorld.call_all(multiworld, "modify_multidata", multidata)
|
||||||
|
|
||||||
for key in ("slot_data", "er_hint_data"):
|
multidata = zlib.compress(pickle.dumps(multidata), 9)
|
||||||
multidata[key] = convert_to_base_types(multidata[key])
|
|
||||||
|
|
||||||
serialized_multidata = zlib.compress(restricted_dumps(multidata), 9)
|
|
||||||
|
|
||||||
with open(os.path.join(temp_dir, f'{outfilebase}.archipelago'), 'wb') as f:
|
with open(os.path.join(temp_dir, f'{outfilebase}.archipelago'), 'wb') as f:
|
||||||
f.write(bytes([3])) # version of format
|
f.write(bytes([3])) # version of format
|
||||||
f.write(serialized_multidata)
|
f.write(multidata)
|
||||||
|
|
||||||
output_file_futures.append(pool.submit(write_multidata))
|
output_file_futures.append(pool.submit(write_multidata))
|
||||||
if not check_accessibility_task.result():
|
if not check_accessibility_task.result():
|
||||||
|
|||||||
344
MinecraftClient.py
Normal file
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()
|
||||||
@@ -5,23 +5,18 @@ import multiprocessing
|
|||||||
import warnings
|
import warnings
|
||||||
|
|
||||||
|
|
||||||
if sys.platform in ("win32", "darwin") and not (3, 11, 9) <= sys.version_info < (3, 14, 0):
|
if sys.platform in ("win32", "darwin") and sys.version_info < (3, 10, 11):
|
||||||
# Official micro version updates. This should match the number in docs/running from source.md.
|
# 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}. "
|
raise RuntimeError(f"Incompatible Python Version found: {sys.version_info}. Official 3.10.15+ is supported.")
|
||||||
"Official 3.11.9 through 3.13.x is supported.")
|
elif sys.platform in ("win32", "darwin") and sys.version_info < (3, 10, 15):
|
||||||
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.
|
# 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.")
|
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):
|
elif sys.version_info < (3, 10, 1):
|
||||||
# Other platforms may get security backports instead of micro updates, so the number is unreliable.
|
# 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.")
|
raise RuntimeError(f"Incompatible Python Version found: {sys.version_info}. 3.10.1+ is supported.")
|
||||||
|
|
||||||
# don't run update if environment is frozen/compiled or if not the parent process (skip in subprocess)
|
# don't run update if environment is frozen/compiled or if not the parent process (skip in subprocess)
|
||||||
_skip_update = bool(
|
_skip_update = bool(getattr(sys, "frozen", False) or multiprocessing.parent_process())
|
||||||
getattr(sys, "frozen", False) or
|
|
||||||
multiprocessing.parent_process() or
|
|
||||||
os.environ.get("SKIP_REQUIREMENTS_UPDATE", "").lower() in ("1", "true", "yes")
|
|
||||||
)
|
|
||||||
update_ran = _skip_update
|
update_ran = _skip_update
|
||||||
|
|
||||||
|
|
||||||
@@ -75,11 +70,11 @@ def update_command():
|
|||||||
def install_pkg_resources(yes=False):
|
def install_pkg_resources(yes=False):
|
||||||
try:
|
try:
|
||||||
import pkg_resources # noqa: F401
|
import pkg_resources # noqa: F401
|
||||||
except (AttributeError, ImportError):
|
except ImportError:
|
||||||
check_pip()
|
check_pip()
|
||||||
if not yes:
|
if not yes:
|
||||||
confirm("pkg_resources not found, press enter to install it")
|
confirm("pkg_resources not found, press enter to install it")
|
||||||
subprocess.call([sys.executable, "-m", "pip", "install", "--upgrade", "setuptools>=75,<81"])
|
subprocess.call([sys.executable, "-m", "pip", "install", "--upgrade", "setuptools"])
|
||||||
|
|
||||||
|
|
||||||
def update(yes: bool = False, force: bool = False) -> None:
|
def update(yes: bool = False, force: bool = False) -> None:
|
||||||
|
|||||||
554
MultiServer.py
554
MultiServer.py
@@ -21,7 +21,6 @@ import time
|
|||||||
import typing
|
import typing
|
||||||
import weakref
|
import weakref
|
||||||
import zlib
|
import zlib
|
||||||
from signal import SIGINT, SIGTERM, signal
|
|
||||||
|
|
||||||
import ModuleUpdate
|
import ModuleUpdate
|
||||||
|
|
||||||
@@ -33,7 +32,7 @@ if typing.TYPE_CHECKING:
|
|||||||
|
|
||||||
import colorama
|
import colorama
|
||||||
import websockets
|
import websockets
|
||||||
from websockets.extensions.permessage_deflate import PerMessageDeflate, ServerPerMessageDeflateFactory
|
from websockets.extensions.permessage_deflate import PerMessageDeflate
|
||||||
try:
|
try:
|
||||||
# ponyorm is a requirement for webhost, not default server, so may not be importable
|
# ponyorm is a requirement for webhost, not default server, so may not be importable
|
||||||
from pony.orm.dbapiprovider import OperationalError
|
from pony.orm.dbapiprovider import OperationalError
|
||||||
@@ -44,22 +43,11 @@ import NetUtils
|
|||||||
import Utils
|
import Utils
|
||||||
from Utils import version_tuple, restricted_loads, Version, async_start, get_intended_text
|
from Utils import version_tuple, restricted_loads, Version, async_start, get_intended_text
|
||||||
from NetUtils import Endpoint, ClientStatus, NetworkItem, decode, encode, NetworkPlayer, Permission, NetworkSlot, \
|
from NetUtils import Endpoint, ClientStatus, NetworkItem, decode, encode, NetworkPlayer, Permission, NetworkSlot, \
|
||||||
SlotType, LocationStore, MultiData, Hint, HintStatus, GamesPackage
|
SlotType, LocationStore, Hint, HintStatus
|
||||||
from BaseClasses import ItemClassification
|
from BaseClasses import ItemClassification
|
||||||
from apmw.multiserver.gamespackagecache import GamesPackageCache
|
|
||||||
|
|
||||||
|
min_client_version = Version(0, 1, 6)
|
||||||
min_client_version = Version(0, 5, 0)
|
colorama.init()
|
||||||
colorama.just_fix_windows_console()
|
|
||||||
|
|
||||||
no_version = Version(0, 0, 0)
|
|
||||||
assert isinstance(no_version, tuple) # assert immutable
|
|
||||||
|
|
||||||
server_per_message_deflate_factory = ServerPerMessageDeflateFactory(
|
|
||||||
server_max_window_bits=11,
|
|
||||||
client_max_window_bits=11,
|
|
||||||
compress_settings={"memLevel": 4},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def remove_from_list(container, value):
|
def remove_from_list(container, value):
|
||||||
@@ -71,12 +59,6 @@ def remove_from_list(container, value):
|
|||||||
|
|
||||||
|
|
||||||
def pop_from_container(container, value):
|
def pop_from_container(container, value):
|
||||||
if isinstance(container, list) and isinstance(value, int) and len(container) <= value:
|
|
||||||
return container
|
|
||||||
|
|
||||||
if isinstance(container, dict) and value not in container:
|
|
||||||
return container
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
container.pop(value)
|
container.pop(value)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
@@ -84,13 +66,9 @@ def pop_from_container(container, value):
|
|||||||
return container
|
return container
|
||||||
|
|
||||||
|
|
||||||
def update_container_unique(container, entries):
|
def update_dict(dictionary, entries):
|
||||||
if isinstance(container, list):
|
dictionary.update(entries)
|
||||||
existing_container_as_set = set(container)
|
return dictionary
|
||||||
container.extend([entry for entry in entries if entry not in existing_container_as_set])
|
|
||||||
else:
|
|
||||||
container.update(entries)
|
|
||||||
return container
|
|
||||||
|
|
||||||
|
|
||||||
def queue_gc():
|
def queue_gc():
|
||||||
@@ -131,7 +109,7 @@ modify_functions = {
|
|||||||
# lists/dicts:
|
# lists/dicts:
|
||||||
"remove": remove_from_list,
|
"remove": remove_from_list,
|
||||||
"pop": pop_from_container,
|
"pop": pop_from_container,
|
||||||
"update": update_container_unique,
|
"update": update_dict,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -142,31 +120,8 @@ def get_saving_second(seed_name: str, interval: int = 60) -> int:
|
|||||||
|
|
||||||
|
|
||||||
class Client(Endpoint):
|
class Client(Endpoint):
|
||||||
__slots__ = (
|
version = Version(0, 0, 0)
|
||||||
"__weakref__",
|
tags: typing.List[str]
|
||||||
"version",
|
|
||||||
"auth",
|
|
||||||
"team",
|
|
||||||
"slot",
|
|
||||||
"send_index",
|
|
||||||
"tags",
|
|
||||||
"messageprocessor",
|
|
||||||
"ctx",
|
|
||||||
"remote_items",
|
|
||||||
"remote_start_inventory",
|
|
||||||
"no_items",
|
|
||||||
"no_locations",
|
|
||||||
"no_text",
|
|
||||||
)
|
|
||||||
|
|
||||||
version: Version
|
|
||||||
auth: bool
|
|
||||||
team: int | None
|
|
||||||
slot: int | None
|
|
||||||
send_index: int
|
|
||||||
tags: list[str]
|
|
||||||
messageprocessor: ClientMessageProcessor
|
|
||||||
ctx: weakref.ref[Context]
|
|
||||||
remote_items: bool
|
remote_items: bool
|
||||||
remote_start_inventory: bool
|
remote_start_inventory: bool
|
||||||
no_items: bool
|
no_items: bool
|
||||||
@@ -175,7 +130,6 @@ class Client(Endpoint):
|
|||||||
|
|
||||||
def __init__(self, socket: "ServerConnection", ctx: Context) -> None:
|
def __init__(self, socket: "ServerConnection", ctx: Context) -> None:
|
||||||
super().__init__(socket)
|
super().__init__(socket)
|
||||||
self.version = no_version
|
|
||||||
self.auth = False
|
self.auth = False
|
||||||
self.team = None
|
self.team = None
|
||||||
self.slot = None
|
self.slot = None
|
||||||
@@ -183,11 +137,6 @@ class Client(Endpoint):
|
|||||||
self.tags = []
|
self.tags = []
|
||||||
self.messageprocessor = client_message_processor(ctx, self)
|
self.messageprocessor = client_message_processor(ctx, self)
|
||||||
self.ctx = weakref.ref(ctx)
|
self.ctx = weakref.ref(ctx)
|
||||||
self.remote_items = False
|
|
||||||
self.remote_start_inventory = False
|
|
||||||
self.no_items = False
|
|
||||||
self.no_locations = False
|
|
||||||
self.no_text = False
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def items_handling(self):
|
def items_handling(self):
|
||||||
@@ -225,7 +174,6 @@ class Context:
|
|||||||
"release_mode": str,
|
"release_mode": str,
|
||||||
"remaining_mode": str,
|
"remaining_mode": str,
|
||||||
"collect_mode": str,
|
"collect_mode": str,
|
||||||
"countdown_mode": str,
|
|
||||||
"item_cheat": bool,
|
"item_cheat": bool,
|
||||||
"compatibility": int}
|
"compatibility": int}
|
||||||
# team -> slot id -> list of clients authenticated to slot.
|
# team -> slot id -> list of clients authenticated to slot.
|
||||||
@@ -242,38 +190,21 @@ class Context:
|
|||||||
slot_info: typing.Dict[int, NetworkSlot]
|
slot_info: typing.Dict[int, NetworkSlot]
|
||||||
generator_version = Version(0, 0, 0)
|
generator_version = Version(0, 0, 0)
|
||||||
checksums: typing.Dict[str, str]
|
checksums: typing.Dict[str, str]
|
||||||
played_games: set[str]
|
|
||||||
item_names: typing.Dict[str, typing.Dict[int, str]]
|
item_names: typing.Dict[str, typing.Dict[int, str]]
|
||||||
item_name_groups: typing.Dict[str, typing.Dict[str, list[str]]]
|
item_name_groups: typing.Dict[str, typing.Dict[str, typing.Set[str]]]
|
||||||
location_names: typing.Dict[str, typing.Dict[int, str]]
|
location_names: typing.Dict[str, typing.Dict[int, str]]
|
||||||
location_name_groups: typing.Dict[str, typing.Dict[str, list[str]]]
|
location_name_groups: typing.Dict[str, typing.Dict[str, typing.Set[str]]]
|
||||||
all_item_and_group_names: typing.Dict[str, typing.Set[str]]
|
all_item_and_group_names: typing.Dict[str, typing.Set[str]]
|
||||||
all_location_and_group_names: typing.Dict[str, typing.Set[str]]
|
all_location_and_group_names: typing.Dict[str, typing.Set[str]]
|
||||||
non_hintable_names: typing.Dict[str, typing.AbstractSet[str]]
|
non_hintable_names: typing.Dict[str, typing.AbstractSet[str]]
|
||||||
spheres: typing.List[typing.Dict[int, typing.Set[int]]]
|
spheres: typing.List[typing.Dict[int, typing.Set[int]]]
|
||||||
""" each sphere is { player: { location_id, ... } } """
|
""" each sphere is { player: { location_id, ... } } """
|
||||||
games_package_cache: GamesPackageCache
|
|
||||||
logger: logging.Logger
|
logger: logging.Logger
|
||||||
|
|
||||||
def __init__(
|
def __init__(self, host: str, port: int, server_password: str, password: str, location_check_points: int,
|
||||||
self,
|
hint_cost: int, item_cheat: bool, release_mode: str = "disabled", collect_mode="disabled",
|
||||||
host: str,
|
remaining_mode: str = "disabled", auto_shutdown: typing.SupportsFloat = 0, compatibility: int = 2,
|
||||||
port: int,
|
log_network: bool = False, logger: logging.Logger = logging.getLogger()):
|
||||||
server_password: str,
|
|
||||||
password: str,
|
|
||||||
location_check_points: int,
|
|
||||||
hint_cost: int,
|
|
||||||
item_cheat: bool,
|
|
||||||
release_mode: str = "disabled",
|
|
||||||
collect_mode="disabled",
|
|
||||||
countdown_mode: str = "auto",
|
|
||||||
remaining_mode: str = "disabled",
|
|
||||||
auto_shutdown: typing.SupportsFloat = 0,
|
|
||||||
compatibility: int = 2,
|
|
||||||
log_network: bool = False,
|
|
||||||
games_package_cache: GamesPackageCache | None = None,
|
|
||||||
logger: logging.Logger = logging.getLogger(),
|
|
||||||
) -> None:
|
|
||||||
self.logger = logger
|
self.logger = logger
|
||||||
super(Context, self).__init__()
|
super(Context, self).__init__()
|
||||||
self.slot_info = {}
|
self.slot_info = {}
|
||||||
@@ -306,7 +237,6 @@ class Context:
|
|||||||
self.release_mode: str = release_mode
|
self.release_mode: str = release_mode
|
||||||
self.remaining_mode: str = remaining_mode
|
self.remaining_mode: str = remaining_mode
|
||||||
self.collect_mode: str = collect_mode
|
self.collect_mode: str = collect_mode
|
||||||
self.countdown_mode: str = countdown_mode
|
|
||||||
self.item_cheat = item_cheat
|
self.item_cheat = item_cheat
|
||||||
self.exit_event = asyncio.Event()
|
self.exit_event = asyncio.Event()
|
||||||
self.client_activity_timers: typing.Dict[
|
self.client_activity_timers: typing.Dict[
|
||||||
@@ -324,7 +254,6 @@ class Context:
|
|||||||
self.save_dirty = False
|
self.save_dirty = False
|
||||||
self.tags = ['AP']
|
self.tags = ['AP']
|
||||||
self.games: typing.Dict[int, str] = {}
|
self.games: typing.Dict[int, str] = {}
|
||||||
self.played_games = set()
|
|
||||||
self.minimum_client_versions: typing.Dict[int, Version] = {}
|
self.minimum_client_versions: typing.Dict[int, Version] = {}
|
||||||
self.seed_name = ""
|
self.seed_name = ""
|
||||||
self.groups = {}
|
self.groups = {}
|
||||||
@@ -334,10 +263,9 @@ class Context:
|
|||||||
self.stored_data_notification_clients = collections.defaultdict(weakref.WeakSet)
|
self.stored_data_notification_clients = collections.defaultdict(weakref.WeakSet)
|
||||||
self.read_data = {}
|
self.read_data = {}
|
||||||
self.spheres = []
|
self.spheres = []
|
||||||
self.games_package_cache = games_package_cache or GamesPackageCache()
|
|
||||||
|
|
||||||
# init empty to satisfy linter, I suppose
|
# init empty to satisfy linter, I suppose
|
||||||
self.reduced_games_package = {}
|
self.gamespackage = {}
|
||||||
self.checksums = {}
|
self.checksums = {}
|
||||||
self.item_name_groups = {}
|
self.item_name_groups = {}
|
||||||
self.location_name_groups = {}
|
self.location_name_groups = {}
|
||||||
@@ -349,11 +277,50 @@ class Context:
|
|||||||
lambda: Utils.KeyedDefaultDict(lambda code: f'Unknown location (ID:{code})'))
|
lambda: Utils.KeyedDefaultDict(lambda code: f'Unknown location (ID:{code})'))
|
||||||
self.non_hintable_names = collections.defaultdict(frozenset)
|
self.non_hintable_names = collections.defaultdict(frozenset)
|
||||||
|
|
||||||
|
self._load_game_data()
|
||||||
|
|
||||||
|
# Data package retrieval
|
||||||
|
def _load_game_data(self):
|
||||||
|
import worlds
|
||||||
|
self.gamespackage = worlds.network_data_package["games"]
|
||||||
|
|
||||||
|
self.item_name_groups = {world_name: world.item_name_groups for world_name, world in
|
||||||
|
worlds.AutoWorldRegister.world_types.items()}
|
||||||
|
self.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():
|
||||||
|
self.non_hintable_names[world_name] = world.hint_blacklist
|
||||||
|
|
||||||
|
for game_package in self.gamespackage.values():
|
||||||
|
# remove groups from data sent to clients
|
||||||
|
del game_package["item_name_groups"]
|
||||||
|
del game_package["location_name_groups"]
|
||||||
|
|
||||||
|
def _init_game_data(self):
|
||||||
|
for game_name, game_package in self.gamespackage.items():
|
||||||
|
if "checksum" in game_package:
|
||||||
|
self.checksums[game_name] = game_package["checksum"]
|
||||||
|
for item_name, item_id in game_package["item_name_to_id"].items():
|
||||||
|
self.item_names[game_name][item_id] = item_name
|
||||||
|
for location_name, location_id in game_package["location_name_to_id"].items():
|
||||||
|
self.location_names[game_name][location_id] = location_name
|
||||||
|
self.all_item_and_group_names[game_name] = \
|
||||||
|
set(game_package["item_name_to_id"]) | set(self.item_name_groups[game_name])
|
||||||
|
self.all_location_and_group_names[game_name] = \
|
||||||
|
set(game_package["location_name_to_id"]) | set(self.location_name_groups.get(game_name, []))
|
||||||
|
|
||||||
|
archipelago_item_names = self.item_names["Archipelago"]
|
||||||
|
archipelago_location_names = self.location_names["Archipelago"]
|
||||||
|
for game in [game_name for game_name in self.gamespackage if game_name != "Archipelago"]:
|
||||||
|
# Add Archipelago items and locations to each data package.
|
||||||
|
self.item_names[game].update(archipelago_item_names)
|
||||||
|
self.location_names[game].update(archipelago_location_names)
|
||||||
|
|
||||||
def item_names_for_game(self, game: str) -> typing.Optional[typing.Dict[str, int]]:
|
def item_names_for_game(self, game: str) -> typing.Optional[typing.Dict[str, int]]:
|
||||||
return self.reduced_games_package[game]["item_name_to_id"] if game in self.reduced_games_package else None
|
return self.gamespackage[game]["item_name_to_id"] if game in self.gamespackage else None
|
||||||
|
|
||||||
def location_names_for_game(self, game: str) -> typing.Optional[typing.Dict[str, int]]:
|
def location_names_for_game(self, game: str) -> typing.Optional[typing.Dict[str, int]]:
|
||||||
return self.reduced_games_package[game]["location_name_to_id"] if game in self.reduced_games_package else None
|
return self.gamespackage[game]["location_name_to_id"] if game in self.gamespackage else None
|
||||||
|
|
||||||
# General networking
|
# General networking
|
||||||
async def send_msgs(self, endpoint: Endpoint, msgs: typing.Iterable[dict]) -> bool:
|
async def send_msgs(self, endpoint: Endpoint, msgs: typing.Iterable[dict]) -> bool:
|
||||||
@@ -463,38 +430,34 @@ class Context:
|
|||||||
with open(multidatapath, 'rb') as f:
|
with open(multidatapath, 'rb') as f:
|
||||||
data = f.read()
|
data = f.read()
|
||||||
|
|
||||||
self._load(self.decompress(data), use_embedded_server_options)
|
self._load(self.decompress(data), {}, use_embedded_server_options)
|
||||||
self.data_filename = multidatapath
|
self.data_filename = multidatapath
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def decompress(data: bytes) -> typing.Any:
|
def decompress(data: bytes) -> dict:
|
||||||
format_version = data[0]
|
format_version = data[0]
|
||||||
if format_version > 3:
|
if format_version > 3:
|
||||||
raise Utils.VersionException("Incompatible multidata.")
|
raise Utils.VersionException("Incompatible multidata.")
|
||||||
return restricted_loads(zlib.decompress(data[1:]))
|
return restricted_loads(zlib.decompress(data[1:]))
|
||||||
|
|
||||||
def _load(self, decoded_obj: MultiData, use_embedded_server_options: bool) -> None:
|
def _load(self, decoded_obj: dict, game_data_packages: typing.Dict[str, typing.Any],
|
||||||
|
use_embedded_server_options: bool):
|
||||||
|
|
||||||
self.read_data = {}
|
self.read_data = {}
|
||||||
# there might be a better place to put this.
|
# there might be a better place to put this.
|
||||||
race_mode = decoded_obj.get("race_mode", 0)
|
self.read_data["race_mode"] = lambda: decoded_obj.get("race_mode", 0)
|
||||||
self.read_data["race_mode"] = lambda: race_mode
|
|
||||||
mdata_ver = decoded_obj["minimum_versions"]["server"]
|
mdata_ver = decoded_obj["minimum_versions"]["server"]
|
||||||
if mdata_ver > version_tuple:
|
if mdata_ver > version_tuple:
|
||||||
raise RuntimeError(f"Supplied Multidata (.archipelago) requires a server of at least version {mdata_ver}, "
|
raise RuntimeError(f"Supplied Multidata (.archipelago) requires a server of at least version {mdata_ver},"
|
||||||
f"however this server is of version {version_tuple}")
|
f"however this server is of version {version_tuple}")
|
||||||
self.generator_version = Version(*decoded_obj["version"])
|
self.generator_version = Version(*decoded_obj["version"])
|
||||||
clients_ver = decoded_obj["minimum_versions"].get("clients", {})
|
clients_ver = decoded_obj["minimum_versions"].get("clients", {})
|
||||||
self.minimum_client_versions = {}
|
self.minimum_client_versions = {}
|
||||||
if self.generator_version < Version(0, 6, 2):
|
|
||||||
min_version = Version(0, 1, 6)
|
|
||||||
else:
|
|
||||||
min_version = min_client_version
|
|
||||||
for player, version in clients_ver.items():
|
for player, version in clients_ver.items():
|
||||||
self.minimum_client_versions[player] = max(Version(*version), min_version)
|
self.minimum_client_versions[player] = max(Version(*version), min_client_version)
|
||||||
|
|
||||||
self.slot_info = decoded_obj["slot_info"]
|
self.slot_info = decoded_obj["slot_info"]
|
||||||
self.games = {slot: slot_info.game for slot, slot_info in self.slot_info.items()}
|
self.games = {slot: slot_info.game for slot, slot_info in self.slot_info.items()}
|
||||||
self.played_games = {"Archipelago"} | {self.games[x] for x in range(1, len(self.games) + 1)}
|
|
||||||
self.groups = {slot: set(slot_info.group_members) for slot, slot_info in self.slot_info.items()
|
self.groups = {slot: set(slot_info.group_members) for slot, slot_info in self.slot_info.items()
|
||||||
if slot_info.type == SlotType.group}
|
if slot_info.type == SlotType.group}
|
||||||
|
|
||||||
@@ -539,11 +502,18 @@ class Context:
|
|||||||
server_options = decoded_obj.get("server_options", {})
|
server_options = decoded_obj.get("server_options", {})
|
||||||
self._set_options(server_options)
|
self._set_options(server_options)
|
||||||
|
|
||||||
# load and apply world data and (embedded) data package
|
# embedded data package
|
||||||
self._load_world_data()
|
for game_name, data in decoded_obj.get("datapackage", {}).items():
|
||||||
self._load_data_package(decoded_obj.get("datapackage", {}))
|
if game_name in game_data_packages:
|
||||||
|
data = game_data_packages[game_name]
|
||||||
|
self.logger.info(f"Loading embedded data package for game {game_name}")
|
||||||
|
self.gamespackage[game_name] = data
|
||||||
|
self.item_name_groups[game_name] = data["item_name_groups"]
|
||||||
|
if "location_name_groups" in data:
|
||||||
|
self.location_name_groups[game_name] = data["location_name_groups"]
|
||||||
|
del data["location_name_groups"]
|
||||||
|
del data["item_name_groups"] # remove from data package, but keep in self.item_name_groups
|
||||||
self._init_game_data()
|
self._init_game_data()
|
||||||
|
|
||||||
for game_name, data in self.item_name_groups.items():
|
for game_name, data in self.item_name_groups.items():
|
||||||
self.read_data[f"item_name_groups_{game_name}"] = lambda lgame=game_name: self.item_name_groups[lgame]
|
self.read_data[f"item_name_groups_{game_name}"] = lambda lgame=game_name: self.item_name_groups[lgame]
|
||||||
for game_name, data in self.location_name_groups.items():
|
for game_name, data in self.location_name_groups.items():
|
||||||
@@ -552,55 +522,6 @@ class Context:
|
|||||||
# sorted access spheres
|
# sorted access spheres
|
||||||
self.spheres = decoded_obj.get("spheres", [])
|
self.spheres = decoded_obj.get("spheres", [])
|
||||||
|
|
||||||
def _load_world_data(self) -> None:
|
|
||||||
import worlds
|
|
||||||
|
|
||||||
for world_name, world in worlds.AutoWorldRegister.world_types.items():
|
|
||||||
# TODO: move hint_blacklist into GamesPackage?
|
|
||||||
self.non_hintable_names[world_name] = world.hint_blacklist
|
|
||||||
|
|
||||||
def _load_data_package(self, data_package: dict[str, GamesPackage]) -> None:
|
|
||||||
"""Populates reduced_games_package, item_name_groups, location_name_groups from static data and data_package"""
|
|
||||||
# NOTE: for worlds loaded from db, only checksum is set in GamesPackage, but this is handled by cache
|
|
||||||
for game_name in sorted(self.played_games):
|
|
||||||
if game_name in data_package:
|
|
||||||
self.logger.info(f"Loading embedded data package for game {game_name}")
|
|
||||||
data = self.games_package_cache.get(game_name, data_package[game_name])
|
|
||||||
else:
|
|
||||||
# NOTE: we still allow uploading a game without datapackage. Once that is changed, we could drop this.
|
|
||||||
data = self.games_package_cache.get_static(game_name)
|
|
||||||
(
|
|
||||||
self.reduced_games_package[game_name],
|
|
||||||
self.item_name_groups[game_name],
|
|
||||||
self.location_name_groups[game_name],
|
|
||||||
) = data
|
|
||||||
|
|
||||||
del self.games_package_cache # Not used past this point. Free memory.
|
|
||||||
|
|
||||||
def _init_game_data(self) -> None:
|
|
||||||
"""Update internal values from previously loaded data packages"""
|
|
||||||
for game_name, game_package in self.reduced_games_package.items():
|
|
||||||
if game_name not in self.played_games:
|
|
||||||
continue
|
|
||||||
if "checksum" in game_package:
|
|
||||||
self.checksums[game_name] = game_package["checksum"]
|
|
||||||
# NOTE: we could save more memory by moving the stuff below to data package cache as well
|
|
||||||
for item_name, item_id in game_package["item_name_to_id"].items():
|
|
||||||
self.item_names[game_name][item_id] = item_name
|
|
||||||
for location_name, location_id in game_package["location_name_to_id"].items():
|
|
||||||
self.location_names[game_name][location_id] = location_name
|
|
||||||
self.all_item_and_group_names[game_name] = \
|
|
||||||
set(game_package["item_name_to_id"]) | set(self.item_name_groups[game_name])
|
|
||||||
self.all_location_and_group_names[game_name] = \
|
|
||||||
set(game_package["location_name_to_id"]) | set(self.location_name_groups.get(game_name, []))
|
|
||||||
|
|
||||||
archipelago_item_names = self.item_names["Archipelago"]
|
|
||||||
archipelago_location_names = self.location_names["Archipelago"]
|
|
||||||
for game in [game_name for game_name in self.reduced_games_package if game_name != "Archipelago"]:
|
|
||||||
# Add Archipelago items and locations to each data package.
|
|
||||||
self.item_names[game].update(archipelago_item_names)
|
|
||||||
self.location_names[game].update(archipelago_location_names)
|
|
||||||
|
|
||||||
# saving
|
# saving
|
||||||
|
|
||||||
def save(self, now=False) -> bool:
|
def save(self, now=False) -> bool:
|
||||||
@@ -616,7 +537,6 @@ class Context:
|
|||||||
|
|
||||||
def _save(self, exit_save: bool = False) -> bool:
|
def _save(self, exit_save: bool = False) -> bool:
|
||||||
try:
|
try:
|
||||||
# Does not use Utils.restricted_dumps because we'd rather make a save than not make one
|
|
||||||
encoded_save = pickle.dumps(self.get_save())
|
encoded_save = pickle.dumps(self.get_save())
|
||||||
with open(self.save_filename, "wb") as f:
|
with open(self.save_filename, "wb") as f:
|
||||||
f.write(zlib.compress(encoded_save))
|
f.write(zlib.compress(encoded_save))
|
||||||
@@ -697,7 +617,6 @@ class Context:
|
|||||||
"server_password": self.server_password, "password": self.password,
|
"server_password": self.server_password, "password": self.password,
|
||||||
"release_mode": self.release_mode,
|
"release_mode": self.release_mode,
|
||||||
"remaining_mode": self.remaining_mode, "collect_mode": self.collect_mode,
|
"remaining_mode": self.remaining_mode, "collect_mode": self.collect_mode,
|
||||||
"countdown_mode": self.countdown_mode,
|
|
||||||
"item_cheat": self.item_cheat, "compatibility": self.compatibility}
|
"item_cheat": self.item_cheat, "compatibility": self.compatibility}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -732,7 +651,6 @@ class Context:
|
|||||||
self.release_mode = savedata["game_options"]["release_mode"]
|
self.release_mode = savedata["game_options"]["release_mode"]
|
||||||
self.remaining_mode = savedata["game_options"]["remaining_mode"]
|
self.remaining_mode = savedata["game_options"]["remaining_mode"]
|
||||||
self.collect_mode = savedata["game_options"]["collect_mode"]
|
self.collect_mode = savedata["game_options"]["collect_mode"]
|
||||||
self.countdown_mode = savedata["game_options"].get("countdown_mode", self.countdown_mode)
|
|
||||||
self.item_cheat = savedata["game_options"]["item_cheat"]
|
self.item_cheat = savedata["game_options"]["item_cheat"]
|
||||||
self.compatibility = savedata["game_options"]["compatibility"]
|
self.compatibility = savedata["game_options"]["compatibility"]
|
||||||
|
|
||||||
@@ -825,7 +743,7 @@ class Context:
|
|||||||
return self.player_names[team, slot]
|
return self.player_names[team, slot]
|
||||||
|
|
||||||
def notify_hints(self, team: int, hints: typing.List[Hint], only_new: bool = False,
|
def notify_hints(self, team: int, hints: typing.List[Hint], only_new: bool = False,
|
||||||
persist_even_if_found: bool = False, recipients: typing.Sequence[int] = None):
|
recipients: typing.Sequence[int] = None):
|
||||||
"""Send and remember hints."""
|
"""Send and remember hints."""
|
||||||
if only_new:
|
if only_new:
|
||||||
hints = [hint for hint in hints if hint not in self.hints[team, hint.finding_player]]
|
hints = [hint for hint in hints if hint not in self.hints[team, hint.finding_player]]
|
||||||
@@ -840,9 +758,8 @@ class Context:
|
|||||||
if not hint.local and data not in concerns[hint.finding_player]:
|
if not hint.local and data not in concerns[hint.finding_player]:
|
||||||
concerns[hint.finding_player].append(data)
|
concerns[hint.finding_player].append(data)
|
||||||
|
|
||||||
# For !hint use cases, only hints that were not already found at the time of creation should be remembered
|
# only remember hints that were not already found at the time of creation
|
||||||
# For LocationScouts use-cases, all hints should be remembered
|
if not hint.found:
|
||||||
if not hint.found or persist_even_if_found:
|
|
||||||
# since hints are bidirectional, finding player and receiving player,
|
# since hints are bidirectional, finding player and receiving player,
|
||||||
# we can check once if hint already exists
|
# we can check once if hint already exists
|
||||||
if hint not in self.hints[team, hint.finding_player]:
|
if hint not in self.hints[team, hint.finding_player]:
|
||||||
@@ -941,10 +858,18 @@ async def server(websocket: "ServerConnection", path: str = "/", ctx: Context =
|
|||||||
|
|
||||||
|
|
||||||
async def on_client_connected(ctx: Context, client: Client):
|
async def on_client_connected(ctx: Context, client: Client):
|
||||||
|
players = []
|
||||||
|
for team, clients in ctx.clients.items():
|
||||||
|
for slot, connected_clients in clients.items():
|
||||||
|
if connected_clients:
|
||||||
|
name = ctx.player_names[team, slot]
|
||||||
|
players.append(NetworkPlayer(team, slot, ctx.name_aliases.get((team, slot), name), name))
|
||||||
|
games = {ctx.games[x] for x in range(1, len(ctx.games) + 1)}
|
||||||
|
games.add("Archipelago")
|
||||||
await ctx.send_msgs(client, [{
|
await ctx.send_msgs(client, [{
|
||||||
'cmd': 'RoomInfo',
|
'cmd': 'RoomInfo',
|
||||||
'password': bool(ctx.password),
|
'password': bool(ctx.password),
|
||||||
'games': sorted(ctx.played_games),
|
'games': games,
|
||||||
# tags are for additional features in the communication.
|
# tags are for additional features in the communication.
|
||||||
# Name them by feature or fork, as you feel is appropriate.
|
# Name them by feature or fork, as you feel is appropriate.
|
||||||
'tags': ctx.tags,
|
'tags': ctx.tags,
|
||||||
@@ -953,7 +878,8 @@ async def on_client_connected(ctx: Context, client: Client):
|
|||||||
'permissions': get_permissions(ctx),
|
'permissions': get_permissions(ctx),
|
||||||
'hint_cost': ctx.hint_cost,
|
'hint_cost': ctx.hint_cost,
|
||||||
'location_check_points': ctx.location_check_points,
|
'location_check_points': ctx.location_check_points,
|
||||||
'datapackage_checksums': ctx.checksums,
|
'datapackage_checksums': {game: game_data["checksum"] for game, game_data
|
||||||
|
in ctx.gamespackage.items() if game in games and "checksum" in game_data},
|
||||||
'seed_name': ctx.seed_name,
|
'seed_name': ctx.seed_name,
|
||||||
'time': time.time(),
|
'time': time.time(),
|
||||||
}])
|
}])
|
||||||
@@ -1198,13 +1124,8 @@ def register_location_checks(ctx: Context, team: int, slot: int, locations: typi
|
|||||||
ctx.save()
|
ctx.save()
|
||||||
|
|
||||||
|
|
||||||
def collect_hints(ctx: Context, team: int, slot: int, item: typing.Union[int, str],
|
def collect_hints(ctx: Context, team: int, slot: int, item: typing.Union[int, str], auto_status: HintStatus) \
|
||||||
status: HintStatus | None = None) -> typing.List[Hint]:
|
-> typing.List[Hint]:
|
||||||
"""
|
|
||||||
Collect a new hint for a given item id or name, with a given status.
|
|
||||||
If status is None (which is the default value), an automatic status will be determined from the item's quality.
|
|
||||||
"""
|
|
||||||
|
|
||||||
hints = []
|
hints = []
|
||||||
slots: typing.Set[int] = {slot}
|
slots: typing.Set[int] = {slot}
|
||||||
for group_id, group in ctx.groups.items():
|
for group_id, group in ctx.groups.items():
|
||||||
@@ -1220,39 +1141,25 @@ def collect_hints(ctx: Context, team: int, slot: int, item: typing.Union[int, st
|
|||||||
else:
|
else:
|
||||||
found = location_id in ctx.location_checks[team, finding_player]
|
found = location_id in ctx.location_checks[team, finding_player]
|
||||||
entrance = ctx.er_hint_data.get(finding_player, {}).get(location_id, "")
|
entrance = ctx.er_hint_data.get(finding_player, {}).get(location_id, "")
|
||||||
|
new_status = auto_status
|
||||||
hint_status = status # Assign again because we're in a for loop
|
|
||||||
if found:
|
if found:
|
||||||
hint_status = HintStatus.HINT_FOUND
|
new_status = HintStatus.HINT_FOUND
|
||||||
elif hint_status is None:
|
elif item_flags & ItemClassification.trap:
|
||||||
if item_flags & ItemClassification.trap:
|
new_status = HintStatus.HINT_AVOID
|
||||||
hint_status = HintStatus.HINT_AVOID
|
hints.append(Hint(receiving_player, finding_player, location_id, item_id, found, entrance,
|
||||||
else:
|
item_flags, new_status))
|
||||||
hint_status = HintStatus.HINT_PRIORITY
|
|
||||||
|
|
||||||
hints.append(
|
|
||||||
Hint(receiving_player, finding_player, location_id, item_id, found, entrance, item_flags, hint_status)
|
|
||||||
)
|
|
||||||
|
|
||||||
return hints
|
return hints
|
||||||
|
|
||||||
|
|
||||||
def collect_hint_location_name(ctx: Context, team: int, slot: int, location: str,
|
def collect_hint_location_name(ctx: Context, team: int, slot: int, location: str, auto_status: HintStatus) \
|
||||||
status: HintStatus | None = HintStatus.HINT_UNSPECIFIED) -> typing.List[Hint]:
|
-> typing.List[Hint]:
|
||||||
"""
|
|
||||||
Collect a new hint for a given location name, with a given status (defaults to "unspecified").
|
|
||||||
If None is passed for the status, then an automatic status will be determined from the item's quality.
|
|
||||||
"""
|
|
||||||
seeked_location: int = ctx.location_names_for_game(ctx.games[slot])[location]
|
seeked_location: int = ctx.location_names_for_game(ctx.games[slot])[location]
|
||||||
return collect_hint_location_id(ctx, team, slot, seeked_location, status)
|
return collect_hint_location_id(ctx, team, slot, seeked_location, auto_status)
|
||||||
|
|
||||||
|
|
||||||
def collect_hint_location_id(ctx: Context, team: int, slot: int, seeked_location: int,
|
def collect_hint_location_id(ctx: Context, team: int, slot: int, seeked_location: int, auto_status: HintStatus) \
|
||||||
status: HintStatus | None = HintStatus.HINT_UNSPECIFIED) -> typing.List[Hint]:
|
-> typing.List[Hint]:
|
||||||
"""
|
|
||||||
Collect a new hint for a given location id, with a given status (defaults to "unspecified").
|
|
||||||
If None is passed for the status, then an automatic status will be determined from the item's quality.
|
|
||||||
"""
|
|
||||||
prev_hint = ctx.get_hint(team, slot, seeked_location)
|
prev_hint = ctx.get_hint(team, slot, seeked_location)
|
||||||
if prev_hint:
|
if prev_hint:
|
||||||
return [prev_hint]
|
return [prev_hint]
|
||||||
@@ -1262,16 +1169,13 @@ def collect_hint_location_id(ctx: Context, team: int, slot: int, seeked_location
|
|||||||
|
|
||||||
found = seeked_location in ctx.location_checks[team, slot]
|
found = seeked_location in ctx.location_checks[team, slot]
|
||||||
entrance = ctx.er_hint_data.get(slot, {}).get(seeked_location, "")
|
entrance = ctx.er_hint_data.get(slot, {}).get(seeked_location, "")
|
||||||
|
new_status = auto_status
|
||||||
if found:
|
if found:
|
||||||
status = HintStatus.HINT_FOUND
|
new_status = HintStatus.HINT_FOUND
|
||||||
elif status is None:
|
elif item_flags & ItemClassification.trap:
|
||||||
if item_flags & ItemClassification.trap:
|
new_status = HintStatus.HINT_AVOID
|
||||||
status = HintStatus.HINT_AVOID
|
return [Hint(receiving_player, slot, seeked_location, item_id, found, entrance, item_flags,
|
||||||
else:
|
new_status)]
|
||||||
status = HintStatus.HINT_PRIORITY
|
|
||||||
|
|
||||||
return [Hint(receiving_player, slot, seeked_location, item_id, found, entrance, item_flags, status)]
|
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
||||||
@@ -1322,13 +1226,6 @@ class CommandMeta(type):
|
|||||||
commands.update(base.commands)
|
commands.update(base.commands)
|
||||||
commands.update({command_name[5:]: method for command_name, method in attrs.items() if
|
commands.update({command_name[5:]: method for command_name, method in attrs.items() if
|
||||||
command_name.startswith("_cmd_")})
|
command_name.startswith("_cmd_")})
|
||||||
for command_name, method in commands.items():
|
|
||||||
# wrap async def functions so they run on default asyncio loop
|
|
||||||
if inspect.iscoroutinefunction(method):
|
|
||||||
def _wrapper(self, *args, _method=method, **kwargs):
|
|
||||||
return async_start(_method(self, *args, **kwargs))
|
|
||||||
functools.update_wrapper(_wrapper, method)
|
|
||||||
commands[command_name] = _wrapper
|
|
||||||
return super(CommandMeta, cls).__new__(cls, name, bases, attrs)
|
return super(CommandMeta, cls).__new__(cls, name, bases, attrs)
|
||||||
|
|
||||||
|
|
||||||
@@ -1392,11 +1289,7 @@ class CommandProcessor(metaclass=CommandMeta):
|
|||||||
argname += "=" + parameter.default
|
argname += "=" + parameter.default
|
||||||
argtext += argname
|
argtext += argname
|
||||||
argtext += " "
|
argtext += " "
|
||||||
method_doc = inspect.getdoc(method)
|
s += f"{self.marker}{command} {argtext}\n {method.__doc__}\n"
|
||||||
if method_doc is None:
|
|
||||||
method_doc = "(missing help text)"
|
|
||||||
doctext = "\n ".join(method_doc.split("\n"))
|
|
||||||
s += f"{self.marker}{command} {argtext}\n {doctext}\n"
|
|
||||||
return s
|
return s
|
||||||
|
|
||||||
def _cmd_help(self):
|
def _cmd_help(self):
|
||||||
@@ -1425,6 +1318,19 @@ class CommandProcessor(metaclass=CommandMeta):
|
|||||||
class CommonCommandProcessor(CommandProcessor):
|
class CommonCommandProcessor(CommandProcessor):
|
||||||
ctx: Context
|
ctx: Context
|
||||||
|
|
||||||
|
def _cmd_countdown(self, seconds: str = "10") -> bool:
|
||||||
|
"""Start a countdown in seconds"""
|
||||||
|
try:
|
||||||
|
timer = int(seconds, 10)
|
||||||
|
except ValueError:
|
||||||
|
timer = 10
|
||||||
|
else:
|
||||||
|
if timer > 60 * 60:
|
||||||
|
raise ValueError(f"{timer} is invalid. Maximum is 1 hour.")
|
||||||
|
|
||||||
|
async_start(countdown(self.ctx, timer))
|
||||||
|
return True
|
||||||
|
|
||||||
def _cmd_options(self):
|
def _cmd_options(self):
|
||||||
"""List all current options. Warning: lists password."""
|
"""List all current options. Warning: lists password."""
|
||||||
self.output("Current options:")
|
self.output("Current options:")
|
||||||
@@ -1566,23 +1472,6 @@ class ClientMessageProcessor(CommonCommandProcessor):
|
|||||||
" You can ask the server admin for a /collect")
|
" You can ask the server admin for a /collect")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def _cmd_countdown(self, seconds: str = "10") -> bool:
|
|
||||||
"""Start a countdown in seconds"""
|
|
||||||
if self.ctx.countdown_mode == "disabled" or \
|
|
||||||
self.ctx.countdown_mode == "auto" and len(self.ctx.player_names) >= 30:
|
|
||||||
self.output("Sorry, client countdowns have been disabled on this server. You can ask the server admin for a /countdown")
|
|
||||||
return False
|
|
||||||
try:
|
|
||||||
timer = int(seconds, 10)
|
|
||||||
except ValueError:
|
|
||||||
timer = 10
|
|
||||||
else:
|
|
||||||
if timer > 60 * 60:
|
|
||||||
raise ValueError(f"{timer} is invalid. Maximum is 1 hour.")
|
|
||||||
|
|
||||||
async_start(countdown(self.ctx, timer))
|
|
||||||
return True
|
|
||||||
|
|
||||||
def _cmd_remaining(self) -> bool:
|
def _cmd_remaining(self) -> bool:
|
||||||
"""List remaining items in your game, but not their location or recipient"""
|
"""List remaining items in your game, but not their location or recipient"""
|
||||||
if self.ctx.remaining_mode == "enabled":
|
if self.ctx.remaining_mode == "enabled":
|
||||||
@@ -1710,6 +1599,7 @@ class ClientMessageProcessor(CommonCommandProcessor):
|
|||||||
def get_hints(self, input_text: str, for_location: bool = False) -> bool:
|
def get_hints(self, input_text: str, for_location: bool = False) -> bool:
|
||||||
points_available = get_client_points(self.ctx, self.client)
|
points_available = get_client_points(self.ctx, self.client)
|
||||||
cost = self.ctx.get_hint_cost(self.client.slot)
|
cost = self.ctx.get_hint_cost(self.client.slot)
|
||||||
|
auto_status = HintStatus.HINT_UNSPECIFIED if for_location else HintStatus.HINT_PRIORITY
|
||||||
if not input_text:
|
if not input_text:
|
||||||
hints = {hint.re_check(self.ctx, self.client.team) for hint in
|
hints = {hint.re_check(self.ctx, self.client.team) for hint in
|
||||||
self.ctx.hints[self.client.team, self.client.slot]}
|
self.ctx.hints[self.client.team, self.client.slot]}
|
||||||
@@ -1735,9 +1625,9 @@ class ClientMessageProcessor(CommonCommandProcessor):
|
|||||||
self.output(f"Sorry, \"{hint_name}\" is marked as non-hintable.")
|
self.output(f"Sorry, \"{hint_name}\" is marked as non-hintable.")
|
||||||
hints = []
|
hints = []
|
||||||
elif not for_location:
|
elif not for_location:
|
||||||
hints = collect_hints(self.ctx, self.client.team, self.client.slot, hint_id)
|
hints = collect_hints(self.ctx, self.client.team, self.client.slot, hint_id, auto_status)
|
||||||
else:
|
else:
|
||||||
hints = collect_hint_location_id(self.ctx, self.client.team, self.client.slot, hint_id)
|
hints = collect_hint_location_id(self.ctx, self.client.team, self.client.slot, hint_id, auto_status)
|
||||||
|
|
||||||
else:
|
else:
|
||||||
game = self.ctx.games[self.client.slot]
|
game = self.ctx.games[self.client.slot]
|
||||||
@@ -1757,18 +1647,16 @@ class ClientMessageProcessor(CommonCommandProcessor):
|
|||||||
hints = []
|
hints = []
|
||||||
for item_name in self.ctx.item_name_groups[game][hint_name]:
|
for item_name in self.ctx.item_name_groups[game][hint_name]:
|
||||||
if item_name in self.ctx.item_names_for_game(game): # ensure item has an ID
|
if item_name in self.ctx.item_names_for_game(game): # ensure item has an ID
|
||||||
hints.extend(collect_hints(self.ctx, self.client.team, self.client.slot, item_name))
|
hints.extend(collect_hints(self.ctx, self.client.team, self.client.slot, item_name, auto_status))
|
||||||
elif not for_location and hint_name in self.ctx.item_names_for_game(game): # item name
|
elif not for_location and hint_name in self.ctx.item_names_for_game(game): # item name
|
||||||
hints = collect_hints(self.ctx, self.client.team, self.client.slot, hint_name)
|
hints = collect_hints(self.ctx, self.client.team, self.client.slot, hint_name, auto_status)
|
||||||
elif hint_name in self.ctx.location_name_groups[game]: # location group name
|
elif hint_name in self.ctx.location_name_groups[game]: # location group name
|
||||||
hints = []
|
hints = []
|
||||||
for loc_name in self.ctx.location_name_groups[game][hint_name]:
|
for loc_name in self.ctx.location_name_groups[game][hint_name]:
|
||||||
if loc_name in self.ctx.location_names_for_game(game):
|
if loc_name in self.ctx.location_names_for_game(game):
|
||||||
hints.extend(
|
hints.extend(collect_hint_location_name(self.ctx, self.client.team, self.client.slot, loc_name, auto_status))
|
||||||
collect_hint_location_name(self.ctx, self.client.team, self.client.slot, loc_name)
|
|
||||||
)
|
|
||||||
else: # location name
|
else: # location name
|
||||||
hints = collect_hint_location_name(self.ctx, self.client.team, self.client.slot, hint_name)
|
hints = collect_hint_location_name(self.ctx, self.client.team, self.client.slot, hint_name, auto_status)
|
||||||
|
|
||||||
else:
|
else:
|
||||||
self.output(response)
|
self.output(response)
|
||||||
@@ -1933,7 +1821,7 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
|
|||||||
ctx.clients[team][slot].append(client)
|
ctx.clients[team][slot].append(client)
|
||||||
client.version = args['version']
|
client.version = args['version']
|
||||||
client.tags = args['tags']
|
client.tags = args['tags']
|
||||||
client.no_locations = bool(client.tags & _non_game_messages.keys())
|
client.no_locations = "TextOnly" in client.tags or "Tracker" in client.tags
|
||||||
# set NoText for old PopTracker clients that predate the tag to save traffic
|
# set NoText for old PopTracker clients that predate the tag to save traffic
|
||||||
client.no_text = "NoText" in client.tags or ("PopTracker" in client.tags and client.version < (0, 5, 1))
|
client.no_text = "NoText" in client.tags or ("PopTracker" in client.tags and client.version < (0, 5, 1))
|
||||||
connected_packet = {
|
connected_packet = {
|
||||||
@@ -1959,11 +1847,25 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
|
|||||||
await ctx.send_msgs(client, reply)
|
await ctx.send_msgs(client, reply)
|
||||||
|
|
||||||
elif cmd == "GetDataPackage":
|
elif cmd == "GetDataPackage":
|
||||||
games = {
|
exclusions = args.get("exclusions", [])
|
||||||
name: game_data for name, game_data in ctx.reduced_games_package.items()
|
if "games" in args:
|
||||||
if name in set(args.get("games", []))
|
games = {name: game_data for name, game_data in ctx.gamespackage.items()
|
||||||
}
|
if name in set(args.get("games", []))}
|
||||||
await ctx.send_msgs(client, [{"cmd": "DataPackage", "data": {"games": games}}])
|
await ctx.send_msgs(client, [{"cmd": "DataPackage",
|
||||||
|
"data": {"games": games}}])
|
||||||
|
# TODO: remove exclusions behaviour around 0.5.0
|
||||||
|
elif exclusions:
|
||||||
|
exclusions = set(exclusions)
|
||||||
|
games = {name: game_data for name, game_data in ctx.gamespackage.items()
|
||||||
|
if name not in exclusions}
|
||||||
|
|
||||||
|
package = {"games": games}
|
||||||
|
await ctx.send_msgs(client, [{"cmd": "DataPackage",
|
||||||
|
"data": package}])
|
||||||
|
|
||||||
|
else:
|
||||||
|
await ctx.send_msgs(client, [{"cmd": "DataPackage",
|
||||||
|
"data": {"games": ctx.gamespackage}}])
|
||||||
|
|
||||||
elif client.auth:
|
elif client.auth:
|
||||||
if cmd == "ConnectUpdate":
|
if cmd == "ConnectUpdate":
|
||||||
@@ -1993,7 +1895,7 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
|
|||||||
old_tags = client.tags
|
old_tags = client.tags
|
||||||
client.tags = args["tags"]
|
client.tags = args["tags"]
|
||||||
if set(old_tags) != set(client.tags):
|
if set(old_tags) != set(client.tags):
|
||||||
client.no_locations = bool(client.tags & _non_game_messages.keys())
|
client.no_locations = 'TextOnly' in client.tags or 'Tracker' in client.tags
|
||||||
client.no_text = "NoText" in client.tags or (
|
client.no_text = "NoText" in client.tags or (
|
||||||
"PopTracker" in client.tags and client.version < (0, 5, 1)
|
"PopTracker" in client.tags and client.version < (0, 5, 1)
|
||||||
)
|
)
|
||||||
@@ -2032,64 +1934,13 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
|
|||||||
|
|
||||||
target_item, target_player, flags = ctx.locations[client.slot][location]
|
target_item, target_player, flags = ctx.locations[client.slot][location]
|
||||||
if create_as_hint:
|
if create_as_hint:
|
||||||
hints.extend(collect_hint_location_id(ctx, client.team, client.slot, location))
|
hints.extend(collect_hint_location_id(ctx, client.team, client.slot, location,
|
||||||
|
HintStatus.HINT_UNSPECIFIED))
|
||||||
locs.append(NetworkItem(target_item, location, target_player, flags))
|
locs.append(NetworkItem(target_item, location, target_player, flags))
|
||||||
ctx.notify_hints(client.team, hints, only_new=create_as_hint == 2, persist_even_if_found=True)
|
ctx.notify_hints(client.team, hints, only_new=create_as_hint == 2)
|
||||||
if locs and create_as_hint:
|
if locs and create_as_hint:
|
||||||
ctx.save()
|
ctx.save()
|
||||||
await ctx.send_msgs(client, [{'cmd': 'LocationInfo', 'locations': locs}])
|
await ctx.send_msgs(client, [{'cmd': 'LocationInfo', 'locations': locs}])
|
||||||
|
|
||||||
elif cmd == 'CreateHints':
|
|
||||||
location_player = args.get("player", client.slot)
|
|
||||||
locations = args["locations"]
|
|
||||||
status = args.get("status", HintStatus.HINT_UNSPECIFIED)
|
|
||||||
|
|
||||||
if not locations:
|
|
||||||
await ctx.send_msgs(client, [{"cmd": "InvalidPacket", "type": "arguments",
|
|
||||||
"text": "CreateHints: No locations specified.", "original_cmd": cmd}])
|
|
||||||
return
|
|
||||||
|
|
||||||
try:
|
|
||||||
status = HintStatus(status)
|
|
||||||
except ValueError as err:
|
|
||||||
await ctx.send_msgs(client,
|
|
||||||
[{"cmd": "InvalidPacket", "type": "arguments",
|
|
||||||
"text": f"Unknown Status: {err}",
|
|
||||||
"original_cmd": cmd}])
|
|
||||||
return
|
|
||||||
|
|
||||||
hints = []
|
|
||||||
|
|
||||||
for location in locations:
|
|
||||||
if location_player != client.slot and location not in ctx.locations[location_player]:
|
|
||||||
error_text = (
|
|
||||||
"CreateHints: One or more of the locations do not exist for the specified off-world player. "
|
|
||||||
"Please refrain from hinting other slot's locations that you don't know contain your items."
|
|
||||||
)
|
|
||||||
await ctx.send_msgs(client, [{"cmd": "InvalidPacket", "type": "arguments",
|
|
||||||
"text": error_text, "original_cmd": cmd}])
|
|
||||||
return
|
|
||||||
|
|
||||||
target_item, item_player, flags = ctx.locations[location_player][location]
|
|
||||||
|
|
||||||
if client.slot not in ctx.slot_set(item_player):
|
|
||||||
if status != HintStatus.HINT_UNSPECIFIED:
|
|
||||||
error_text = 'CreateHints: Must use "unspecified"/None status for items from other players.'
|
|
||||||
await ctx.send_msgs(client, [{"cmd": "InvalidPacket", "type": "arguments",
|
|
||||||
"text": error_text, "original_cmd": cmd}])
|
|
||||||
return
|
|
||||||
|
|
||||||
if client.slot != location_player:
|
|
||||||
error_text = "CreateHints: Can only create hints for own items or own locations."
|
|
||||||
await ctx.send_msgs(client, [{"cmd": "InvalidPacket", "type": "arguments",
|
|
||||||
"text": error_text, "original_cmd": cmd}])
|
|
||||||
return
|
|
||||||
|
|
||||||
hints += collect_hint_location_id(ctx, client.team, location_player, location, status)
|
|
||||||
|
|
||||||
# As of writing this code, only_new=True does not update status for existing hints
|
|
||||||
ctx.notify_hints(client.team, hints, only_new=True, persist_even_if_found=True)
|
|
||||||
ctx.save()
|
|
||||||
|
|
||||||
elif cmd == 'UpdateHint':
|
elif cmd == 'UpdateHint':
|
||||||
location = args["location"]
|
location = args["location"]
|
||||||
@@ -2127,21 +1978,14 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
|
|||||||
new_hint = new_hint.re_prioritize(ctx, status)
|
new_hint = new_hint.re_prioritize(ctx, status)
|
||||||
if hint == new_hint:
|
if hint == new_hint:
|
||||||
return
|
return
|
||||||
|
ctx.replace_hint(client.team, hint.finding_player, hint, new_hint)
|
||||||
concerning_slots = ctx.slot_set(hint.receiving_player) | {hint.finding_player}
|
ctx.replace_hint(client.team, hint.receiving_player, hint, new_hint)
|
||||||
for slot in concerning_slots:
|
|
||||||
ctx.replace_hint(client.team, slot, hint, new_hint)
|
|
||||||
ctx.save()
|
ctx.save()
|
||||||
for slot in concerning_slots:
|
ctx.on_changed_hints(client.team, hint.finding_player)
|
||||||
ctx.on_changed_hints(client.team, slot)
|
ctx.on_changed_hints(client.team, hint.receiving_player)
|
||||||
|
|
||||||
elif cmd == 'StatusUpdate':
|
elif cmd == 'StatusUpdate':
|
||||||
if client.no_locations and args["status"] == ClientStatus.CLIENT_GOAL:
|
update_client_status(ctx, client, args["status"])
|
||||||
await ctx.send_msgs(client, [{'cmd': 'InvalidPacket', "type": "cmd",
|
|
||||||
"text": "Trackers can't register Goal Complete",
|
|
||||||
"original_cmd": cmd}])
|
|
||||||
else:
|
|
||||||
update_client_status(ctx, client, args["status"])
|
|
||||||
|
|
||||||
elif cmd == 'Say':
|
elif cmd == 'Say':
|
||||||
if "text" not in args or type(args["text"]) is not str or not args["text"].isprintable():
|
if "text" not in args or type(args["text"]) is not str or not args["text"].isprintable():
|
||||||
@@ -2193,7 +2037,7 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
|
|||||||
value = func(value, operation["value"])
|
value = func(value, operation["value"])
|
||||||
ctx.stored_data[args["key"]] = args["value"] = value
|
ctx.stored_data[args["key"]] = args["value"] = value
|
||||||
targets = set(ctx.stored_data_notification_clients[args["key"]])
|
targets = set(ctx.stored_data_notification_clients[args["key"]])
|
||||||
if args.get("want_reply", False):
|
if args.get("want_reply", True):
|
||||||
targets.add(client)
|
targets.add(client)
|
||||||
if targets:
|
if targets:
|
||||||
ctx.broadcast(targets, [args])
|
ctx.broadcast(targets, [args])
|
||||||
@@ -2324,19 +2168,6 @@ class ServerCommandProcessor(CommonCommandProcessor):
|
|||||||
self.output(f"Could not find player {player_name} to collect")
|
self.output(f"Could not find player {player_name} to collect")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def _cmd_countdown(self, seconds: str = "10") -> bool:
|
|
||||||
"""Start a countdown in seconds"""
|
|
||||||
try:
|
|
||||||
timer = int(seconds, 10)
|
|
||||||
except ValueError:
|
|
||||||
timer = 10
|
|
||||||
else:
|
|
||||||
if timer > 60 * 60:
|
|
||||||
raise ValueError(f"{timer} is invalid. Maximum is 1 hour.")
|
|
||||||
|
|
||||||
async_start(countdown(self.ctx, timer))
|
|
||||||
return True
|
|
||||||
|
|
||||||
@mark_raw
|
@mark_raw
|
||||||
def _cmd_release(self, player_name: str) -> bool:
|
def _cmd_release(self, player_name: str) -> bool:
|
||||||
"""Send out the remaining items from a player to their intended recipients."""
|
"""Send out the remaining items from a player to their intended recipients."""
|
||||||
@@ -2458,9 +2289,9 @@ class ServerCommandProcessor(CommonCommandProcessor):
|
|||||||
hints = []
|
hints = []
|
||||||
for item_name_from_group in self.ctx.item_name_groups[game][item]:
|
for item_name_from_group in self.ctx.item_name_groups[game][item]:
|
||||||
if item_name_from_group in self.ctx.item_names_for_game(game): # ensure item has an ID
|
if item_name_from_group in self.ctx.item_names_for_game(game): # ensure item has an ID
|
||||||
hints.extend(collect_hints(self.ctx, team, slot, item_name_from_group))
|
hints.extend(collect_hints(self.ctx, team, slot, item_name_from_group, HintStatus.HINT_PRIORITY))
|
||||||
else: # item name or id
|
else: # item name or id
|
||||||
hints = collect_hints(self.ctx, team, slot, item)
|
hints = collect_hints(self.ctx, team, slot, item, HintStatus.HINT_PRIORITY)
|
||||||
|
|
||||||
if hints:
|
if hints:
|
||||||
self.ctx.notify_hints(team, hints)
|
self.ctx.notify_hints(team, hints)
|
||||||
@@ -2494,14 +2325,17 @@ class ServerCommandProcessor(CommonCommandProcessor):
|
|||||||
|
|
||||||
if usable:
|
if usable:
|
||||||
if isinstance(location, int):
|
if isinstance(location, int):
|
||||||
hints = collect_hint_location_id(self.ctx, team, slot, location)
|
hints = collect_hint_location_id(self.ctx, team, slot, location,
|
||||||
|
HintStatus.HINT_UNSPECIFIED)
|
||||||
elif game in self.ctx.location_name_groups and location in self.ctx.location_name_groups[game]:
|
elif game in self.ctx.location_name_groups and location in self.ctx.location_name_groups[game]:
|
||||||
hints = []
|
hints = []
|
||||||
for loc_name_from_group in self.ctx.location_name_groups[game][location]:
|
for loc_name_from_group in self.ctx.location_name_groups[game][location]:
|
||||||
if loc_name_from_group in self.ctx.location_names_for_game(game):
|
if loc_name_from_group in self.ctx.location_names_for_game(game):
|
||||||
hints.extend(collect_hint_location_name(self.ctx, team, slot, loc_name_from_group))
|
hints.extend(collect_hint_location_name(self.ctx, team, slot, loc_name_from_group,
|
||||||
|
HintStatus.HINT_UNSPECIFIED))
|
||||||
else:
|
else:
|
||||||
hints = collect_hint_location_name(self.ctx, team, slot, location)
|
hints = collect_hint_location_name(self.ctx, team, slot, location,
|
||||||
|
HintStatus.HINT_UNSPECIFIED)
|
||||||
if hints:
|
if hints:
|
||||||
self.ctx.notify_hints(team, hints)
|
self.ctx.notify_hints(team, hints)
|
||||||
else:
|
else:
|
||||||
@@ -2529,11 +2363,6 @@ class ServerCommandProcessor(CommonCommandProcessor):
|
|||||||
elif value_type == str and option_name.endswith("password"):
|
elif value_type == str and option_name.endswith("password"):
|
||||||
def value_type(input_text: str):
|
def value_type(input_text: str):
|
||||||
return None if input_text.lower() in {"null", "none", '""', "''"} else input_text
|
return None if input_text.lower() in {"null", "none", '""', "''"} else input_text
|
||||||
elif option_name == "countdown_mode":
|
|
||||||
valid_values = {"enabled", "disabled", "auto"}
|
|
||||||
if option_value.lower() not in valid_values:
|
|
||||||
self.output(f"Unrecognized {option_name} value '{option_value}', known: {', '.join(valid_values)}")
|
|
||||||
return False
|
|
||||||
elif value_type == str and option_name.endswith("mode"):
|
elif value_type == str and option_name.endswith("mode"):
|
||||||
valid_values = {"goal", "enabled", "disabled"}
|
valid_values = {"goal", "enabled", "disabled"}
|
||||||
valid_values.update(("auto", "auto_enabled") if option_name != "remaining_mode" else [])
|
valid_values.update(("auto", "auto_enabled") if option_name != "remaining_mode" else [])
|
||||||
@@ -2577,18 +2406,14 @@ async def console(ctx: Context):
|
|||||||
input_text = await queue.get()
|
input_text = await queue.get()
|
||||||
queue.task_done()
|
queue.task_done()
|
||||||
ctx.commandprocessor(input_text)
|
ctx.commandprocessor(input_text)
|
||||||
except asyncio.exceptions.CancelledError:
|
|
||||||
ctx.logger.info("ConsoleTask cancelled")
|
|
||||||
except:
|
except:
|
||||||
import traceback
|
import traceback
|
||||||
traceback.print_exc()
|
traceback.print_exc()
|
||||||
|
|
||||||
|
|
||||||
def parse_args() -> argparse.Namespace:
|
def parse_args() -> argparse.Namespace:
|
||||||
from settings import get_settings
|
|
||||||
|
|
||||||
parser = argparse.ArgumentParser()
|
parser = argparse.ArgumentParser()
|
||||||
defaults = get_settings().server_options.as_dict()
|
defaults = Utils.get_settings()["server_options"].as_dict()
|
||||||
parser.add_argument('multidata', nargs="?", default=defaults["multidata"])
|
parser.add_argument('multidata', nargs="?", default=defaults["multidata"])
|
||||||
parser.add_argument('--host', default=defaults["host"])
|
parser.add_argument('--host', default=defaults["host"])
|
||||||
parser.add_argument('--port', default=defaults["port"], type=int)
|
parser.add_argument('--port', default=defaults["port"], type=int)
|
||||||
@@ -2623,13 +2448,6 @@ def parse_args() -> argparse.Namespace:
|
|||||||
goal: !collect can be used after goal completion
|
goal: !collect can be used after goal completion
|
||||||
auto-enabled: !collect is available and automatically triggered on goal completion
|
auto-enabled: !collect is available and automatically triggered on goal completion
|
||||||
''')
|
''')
|
||||||
parser.add_argument('--countdown_mode', default=defaults["countdown_mode"], nargs='?',
|
|
||||||
choices=['enabled', 'disabled', "auto"], help='''\
|
|
||||||
Select !countdown Accessibility. (default: %(default)s)
|
|
||||||
enabled: !countdown is always available
|
|
||||||
disabled: !countdown is never available
|
|
||||||
auto: !countdown is available for rooms with less than 30 players
|
|
||||||
''')
|
|
||||||
parser.add_argument('--remaining_mode', default=defaults["remaining_mode"], nargs='?',
|
parser.add_argument('--remaining_mode', default=defaults["remaining_mode"], nargs='?',
|
||||||
choices=['enabled', 'disabled', "goal"], help='''\
|
choices=['enabled', 'disabled', "goal"], help='''\
|
||||||
Select !remaining Accessibility. (default: %(default)s)
|
Select !remaining Accessibility. (default: %(default)s)
|
||||||
@@ -2695,7 +2513,7 @@ async def main(args: argparse.Namespace):
|
|||||||
|
|
||||||
ctx = Context(args.host, args.port, args.server_password, args.password, args.location_check_points,
|
ctx = Context(args.host, args.port, args.server_password, args.password, args.location_check_points,
|
||||||
args.hint_cost, not args.disable_item_cheat, args.release_mode, args.collect_mode,
|
args.hint_cost, not args.disable_item_cheat, args.release_mode, args.collect_mode,
|
||||||
args.countdown_mode, args.remaining_mode,
|
args.remaining_mode,
|
||||||
args.auto_shutdown, args.compatibility, args.log_network)
|
args.auto_shutdown, args.compatibility, args.log_network)
|
||||||
data_filename = args.multidata
|
data_filename = args.multidata
|
||||||
|
|
||||||
@@ -2730,13 +2548,7 @@ async def main(args: argparse.Namespace):
|
|||||||
|
|
||||||
ssl_context = load_server_cert(args.cert, args.cert_key) if args.cert else None
|
ssl_context = load_server_cert(args.cert, args.cert_key) if args.cert else None
|
||||||
|
|
||||||
ctx.server = websockets.serve(
|
ctx.server = websockets.serve(functools.partial(server, ctx=ctx), host=ctx.host, port=ctx.port, ssl=ssl_context)
|
||||||
functools.partial(server, ctx=ctx),
|
|
||||||
host=ctx.host,
|
|
||||||
port=ctx.port,
|
|
||||||
ssl=ssl_context,
|
|
||||||
extensions=[server_per_message_deflate_factory],
|
|
||||||
)
|
|
||||||
ip = args.host if args.host else Utils.get_public_ipv4()
|
ip = args.host if args.host else Utils.get_public_ipv4()
|
||||||
logging.info('Hosting game at %s:%d (%s)' % (ip, ctx.port,
|
logging.info('Hosting game at %s:%d (%s)' % (ip, ctx.port,
|
||||||
'No password' if not ctx.password else 'Password: %s' % ctx.password))
|
'No password' if not ctx.password else 'Password: %s' % ctx.password))
|
||||||
@@ -2745,26 +2557,6 @@ async def main(args: argparse.Namespace):
|
|||||||
console_task = asyncio.create_task(console(ctx))
|
console_task = asyncio.create_task(console(ctx))
|
||||||
if ctx.auto_shutdown:
|
if ctx.auto_shutdown:
|
||||||
ctx.shutdown_task = asyncio.create_task(auto_shutdown(ctx, [console_task]))
|
ctx.shutdown_task = asyncio.create_task(auto_shutdown(ctx, [console_task]))
|
||||||
|
|
||||||
def stop():
|
|
||||||
try:
|
|
||||||
for remove_signal in [SIGINT, SIGTERM]:
|
|
||||||
asyncio.get_event_loop().remove_signal_handler(remove_signal)
|
|
||||||
except NotImplementedError:
|
|
||||||
pass
|
|
||||||
ctx.commandprocessor._cmd_exit()
|
|
||||||
|
|
||||||
def shutdown(signum, frame):
|
|
||||||
stop()
|
|
||||||
|
|
||||||
try:
|
|
||||||
for sig in [SIGINT, SIGTERM]:
|
|
||||||
asyncio.get_event_loop().add_signal_handler(sig, stop)
|
|
||||||
except NotImplementedError:
|
|
||||||
# add_signal_handler is only implemented for UNIX platforms
|
|
||||||
for sig in [SIGINT, SIGTERM]:
|
|
||||||
signal(sig, shutdown)
|
|
||||||
|
|
||||||
await ctx.exit_event.wait()
|
await ctx.exit_event.wait()
|
||||||
console_task.cancel()
|
console_task.cancel()
|
||||||
if ctx.shutdown_task:
|
if ctx.shutdown_task:
|
||||||
|
|||||||
62
NetUtils.py
62
NetUtils.py
@@ -1,6 +1,5 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from collections.abc import Mapping, Sequence
|
|
||||||
import typing
|
import typing
|
||||||
import enum
|
import enum
|
||||||
import warnings
|
import warnings
|
||||||
@@ -84,7 +83,7 @@ class NetworkSlot(typing.NamedTuple):
|
|||||||
name: str
|
name: str
|
||||||
game: str
|
game: str
|
||||||
type: SlotType
|
type: SlotType
|
||||||
group_members: Sequence[int] = () # only populated if type == group
|
group_members: typing.Union[typing.List[int], typing.Tuple] = () # only populated if type == group
|
||||||
|
|
||||||
|
|
||||||
class NetworkItem(typing.NamedTuple):
|
class NetworkItem(typing.NamedTuple):
|
||||||
@@ -107,27 +106,6 @@ def _scan_for_TypedTuples(obj: typing.Any) -> typing.Any:
|
|||||||
return obj
|
return obj
|
||||||
|
|
||||||
|
|
||||||
_base_types = str | int | bool | float | None | tuple["_base_types", ...] | dict["_base_types", "base_types"]
|
|
||||||
|
|
||||||
|
|
||||||
def convert_to_base_types(obj: typing.Any) -> _base_types:
|
|
||||||
if isinstance(obj, (tuple, list, set, frozenset)):
|
|
||||||
return tuple(convert_to_base_types(o) for o in obj)
|
|
||||||
elif isinstance(obj, dict):
|
|
||||||
return {convert_to_base_types(key): convert_to_base_types(value) for key, value in obj.items()}
|
|
||||||
elif obj is None or type(obj) in (str, int, float, bool):
|
|
||||||
return obj
|
|
||||||
# unwrap simple types to their base, such as StrEnum
|
|
||||||
elif isinstance(obj, str):
|
|
||||||
return str(obj)
|
|
||||||
elif isinstance(obj, int):
|
|
||||||
return int(obj)
|
|
||||||
elif isinstance(obj, float):
|
|
||||||
return float(obj)
|
|
||||||
else:
|
|
||||||
raise Exception(f"Cannot handle {type(obj)}")
|
|
||||||
|
|
||||||
|
|
||||||
_encode = JSONEncoder(
|
_encode = JSONEncoder(
|
||||||
ensure_ascii=False,
|
ensure_ascii=False,
|
||||||
check_circular=False,
|
check_circular=False,
|
||||||
@@ -174,8 +152,6 @@ decode = JSONDecoder(object_hook=_object_hook).decode
|
|||||||
|
|
||||||
|
|
||||||
class Endpoint:
|
class Endpoint:
|
||||||
__slots__ = ("socket",)
|
|
||||||
|
|
||||||
socket: "ServerConnection"
|
socket: "ServerConnection"
|
||||||
|
|
||||||
def __init__(self, socket):
|
def __init__(self, socket):
|
||||||
@@ -474,42 +450,6 @@ class _LocationStore(dict, typing.MutableMapping[int, typing.Dict[int, typing.Tu
|
|||||||
location_id not in checked])
|
location_id not in checked])
|
||||||
|
|
||||||
|
|
||||||
class MinimumVersions(typing.TypedDict):
|
|
||||||
server: tuple[int, int, int]
|
|
||||||
clients: dict[int, tuple[int, int, int]]
|
|
||||||
|
|
||||||
|
|
||||||
class GamesPackage(typing.TypedDict, total=False):
|
|
||||||
item_name_groups: dict[str, list[str]]
|
|
||||||
item_name_to_id: dict[str, int]
|
|
||||||
location_name_groups: dict[str, list[str]]
|
|
||||||
location_name_to_id: dict[str, int]
|
|
||||||
checksum: str
|
|
||||||
|
|
||||||
|
|
||||||
class DataPackage(typing.TypedDict):
|
|
||||||
games: dict[str, GamesPackage]
|
|
||||||
|
|
||||||
|
|
||||||
class MultiData(typing.TypedDict):
|
|
||||||
slot_data: dict[int, Mapping[str, typing.Any]]
|
|
||||||
slot_info: dict[int, NetworkSlot]
|
|
||||||
connect_names: dict[str, tuple[int, int]]
|
|
||||||
locations: dict[int, dict[int, tuple[int, int, int]]]
|
|
||||||
checks_in_area: dict[int, dict[str, int | list[int]]]
|
|
||||||
server_options: dict[str, object]
|
|
||||||
er_hint_data: dict[int, dict[int, str]]
|
|
||||||
precollected_items: dict[int, list[int]]
|
|
||||||
precollected_hints: dict[int, set[Hint]]
|
|
||||||
version: tuple[int, int, int]
|
|
||||||
tags: list[str]
|
|
||||||
minimum_versions: MinimumVersions
|
|
||||||
seed_name: str
|
|
||||||
spheres: list[dict[int, set[int]]]
|
|
||||||
datapackage: dict[str, GamesPackage]
|
|
||||||
race_mode: int
|
|
||||||
|
|
||||||
|
|
||||||
if typing.TYPE_CHECKING: # type-check with pure python implementation until we have a typing stub
|
if typing.TYPE_CHECKING: # type-check with pure python implementation until we have a typing stub
|
||||||
LocationStore = _LocationStore
|
LocationStore = _LocationStore
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ from CommonClient import CommonContext, server_loop, gui_enabled, \
|
|||||||
import Utils
|
import Utils
|
||||||
from Utils import async_start
|
from Utils import async_start
|
||||||
from worlds import network_data_package
|
from worlds import network_data_package
|
||||||
from worlds.oot import OOTWorld
|
|
||||||
from worlds.oot.Rom import Rom, compress_rom_file
|
from worlds.oot.Rom import Rom, compress_rom_file
|
||||||
from worlds.oot.N64Patch import apply_patch_file
|
from worlds.oot.N64Patch import apply_patch_file
|
||||||
from worlds.oot.Utils import data_path
|
from worlds.oot.Utils import data_path
|
||||||
@@ -277,12 +276,11 @@ async def n64_sync_task(ctx: OoTContext):
|
|||||||
except ConnectionRefusedError:
|
except ConnectionRefusedError:
|
||||||
logger.debug("Connection Refused, Trying Again")
|
logger.debug("Connection Refused, Trying Again")
|
||||||
ctx.n64_status = CONNECTION_REFUSED_STATUS
|
ctx.n64_status = CONNECTION_REFUSED_STATUS
|
||||||
await asyncio.sleep(1)
|
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
|
||||||
async def run_game(romfile):
|
async def run_game(romfile):
|
||||||
auto_start = OOTWorld.settings.rom_start
|
auto_start = Utils.get_options()["oot_options"].get("rom_start", True)
|
||||||
if auto_start is True:
|
if auto_start is True:
|
||||||
import webbrowser
|
import webbrowser
|
||||||
webbrowser.open(romfile)
|
webbrowser.open(romfile)
|
||||||
@@ -297,7 +295,7 @@ async def patch_and_run_game(apz5_file):
|
|||||||
decomp_path = base_name + '-decomp.z64'
|
decomp_path = base_name + '-decomp.z64'
|
||||||
comp_path = base_name + '.z64'
|
comp_path = base_name + '.z64'
|
||||||
# Load vanilla ROM, patch file, compress ROM
|
# Load vanilla ROM, patch file, compress ROM
|
||||||
rom_file_name = OOTWorld.settings.rom_file
|
rom_file_name = Utils.get_options()["oot_options"]["rom_file"]
|
||||||
rom = Rom(rom_file_name)
|
rom = Rom(rom_file_name)
|
||||||
|
|
||||||
sub_file = None
|
sub_file = None
|
||||||
@@ -348,7 +346,7 @@ if __name__ == '__main__':
|
|||||||
|
|
||||||
import colorama
|
import colorama
|
||||||
|
|
||||||
colorama.just_fix_windows_console()
|
colorama.init()
|
||||||
|
|
||||||
asyncio.run(main())
|
asyncio.run(main())
|
||||||
colorama.deinit()
|
colorama.deinit()
|
||||||
|
|||||||
529
Options.py
529
Options.py
@@ -1,7 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import abc
|
import abc
|
||||||
import collections
|
|
||||||
import functools
|
import functools
|
||||||
import logging
|
import logging
|
||||||
import math
|
import math
|
||||||
@@ -24,45 +23,6 @@ if typing.TYPE_CHECKING:
|
|||||||
import pathlib
|
import pathlib
|
||||||
|
|
||||||
|
|
||||||
_RANDOM_OPTS = [
|
|
||||||
"random", "random-low", "random-middle", "random-high",
|
|
||||||
"random-range-low-<min>-<max>", "random-range-middle-<min>-<max>",
|
|
||||||
"random-range-high-<min>-<max>", "random-range-<min>-<max>",
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def triangular(lower: int, end: int, tri: float = 0.5) -> int:
|
|
||||||
"""
|
|
||||||
Integer triangular distribution for `lower` inclusive to `end` inclusive.
|
|
||||||
|
|
||||||
Expects `lower <= end` and `0.0 <= tri <= 1.0`. The result of other inputs is undefined.
|
|
||||||
"""
|
|
||||||
# Use the continuous range [lower, end + 1) to produce an integer result in [lower, end].
|
|
||||||
# random.triangular is actually [a, b] and not [a, b), so there is a very small chance of getting exactly b even
|
|
||||||
# when a != b, so ensure the result is never more than `end`.
|
|
||||||
return min(end, math.floor(random.triangular(0.0, 1.0, tri) * (end - lower + 1) + lower))
|
|
||||||
|
|
||||||
|
|
||||||
def random_weighted_range(text: str, range_start: int, range_end: int):
|
|
||||||
if text == "random-low":
|
|
||||||
return triangular(range_start, range_end, 0.0)
|
|
||||||
elif text == "random-high":
|
|
||||||
return triangular(range_start, range_end, 1.0)
|
|
||||||
elif text == "random-middle":
|
|
||||||
return triangular(range_start, range_end)
|
|
||||||
elif text == "random":
|
|
||||||
return random.randint(range_start, range_end)
|
|
||||||
else:
|
|
||||||
raise Exception(f"random text \"{text}\" did not resolve to a recognized pattern. "
|
|
||||||
f"Acceptable values are: {', '.join(_RANDOM_OPTS)}.")
|
|
||||||
|
|
||||||
|
|
||||||
def roll_percentage(percentage: int | float) -> bool:
|
|
||||||
"""Roll a percentage chance.
|
|
||||||
percentage is expected to be in range [0, 100]"""
|
|
||||||
return random.random() < (float(percentage) / 100)
|
|
||||||
|
|
||||||
|
|
||||||
class OptionError(ValueError):
|
class OptionError(ValueError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -450,12 +410,10 @@ class Toggle(NumericOption):
|
|||||||
def from_text(cls, text: str) -> Toggle:
|
def from_text(cls, text: str) -> Toggle:
|
||||||
if text == "random":
|
if text == "random":
|
||||||
return cls(random.choice(list(cls.name_lookup)))
|
return cls(random.choice(list(cls.name_lookup)))
|
||||||
elif text.lower() in {"off", "0", "false", "none", "null", "no", "disabled"}:
|
elif text.lower() in {"off", "0", "false", "none", "null", "no"}:
|
||||||
return cls(0)
|
return cls(0)
|
||||||
elif text.lower() in {"on", "1", "true", "yes", "enabled"}:
|
|
||||||
return cls(1)
|
|
||||||
else:
|
else:
|
||||||
raise OptionError(f"Option {cls.__name__} does not support a value of {text}")
|
return cls(1)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_any(cls, data: typing.Any):
|
def from_any(cls, data: typing.Any):
|
||||||
@@ -529,38 +487,14 @@ class Choice(NumericOption):
|
|||||||
else:
|
else:
|
||||||
raise TypeError(f"Can't compare {self.__class__.__name__} with {other.__class__.__name__}")
|
raise TypeError(f"Can't compare {self.__class__.__name__} with {other.__class__.__name__}")
|
||||||
|
|
||||||
def __lt__(self, other: typing.Union[Choice, int, str]):
|
|
||||||
if isinstance(other, str):
|
|
||||||
assert other in self.options, f"compared against an unknown string. {self} < {other}"
|
|
||||||
other = self.options[other]
|
|
||||||
return super(Choice, self).__lt__(other)
|
|
||||||
|
|
||||||
def __gt__(self, other: typing.Union[Choice, int, str]):
|
|
||||||
if isinstance(other, str):
|
|
||||||
assert other in self.options, f"compared against an unknown string. {self} > {other}"
|
|
||||||
other = self.options[other]
|
|
||||||
return super(Choice, self).__gt__(other)
|
|
||||||
|
|
||||||
def __le__(self, other: typing.Union[Choice, int, str]):
|
|
||||||
if isinstance(other, str):
|
|
||||||
assert other in self.options, f"compared against an unknown string. {self} <= {other}"
|
|
||||||
other = self.options[other]
|
|
||||||
return super(Choice, self).__le__(other)
|
|
||||||
|
|
||||||
def __ge__(self, other: typing.Union[Choice, int, str]):
|
|
||||||
if isinstance(other, str):
|
|
||||||
assert other in self.options, f"compared against an unknown string. {self} >= {other}"
|
|
||||||
other = self.options[other]
|
|
||||||
return super(Choice, self).__ge__(other)
|
|
||||||
|
|
||||||
__hash__ = Option.__hash__ # see https://docs.python.org/3/reference/datamodel.html#object.__hash__
|
__hash__ = Option.__hash__ # see https://docs.python.org/3/reference/datamodel.html#object.__hash__
|
||||||
|
|
||||||
|
|
||||||
class TextChoice(Choice):
|
class TextChoice(Choice):
|
||||||
"""Allows custom string input and offers choices. Choices will resolve to int and text will resolve to string"""
|
"""Allows custom string input and offers choices. Choices will resolve to int and text will resolve to string"""
|
||||||
value: str | int
|
value: typing.Union[str, int]
|
||||||
|
|
||||||
def __init__(self, value: str | int):
|
def __init__(self, value: typing.Union[str, int]):
|
||||||
assert isinstance(value, str) or isinstance(value, int), \
|
assert isinstance(value, str) or isinstance(value, int), \
|
||||||
f"'{value}' is not a valid option for '{self.__class__.__name__}'"
|
f"'{value}' is not a valid option for '{self.__class__.__name__}'"
|
||||||
self.value = value
|
self.value = value
|
||||||
@@ -581,7 +515,7 @@ class TextChoice(Choice):
|
|||||||
return cls(text)
|
return cls(text)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_option_name(cls, value: str | int) -> str:
|
def get_option_name(cls, value: T) -> str:
|
||||||
if isinstance(value, str):
|
if isinstance(value, str):
|
||||||
return value
|
return value
|
||||||
return super().get_option_name(value)
|
return super().get_option_name(value)
|
||||||
@@ -748,39 +682,33 @@ class Range(NumericOption):
|
|||||||
# these are the conditions where "true" and "false" make sense
|
# these are the conditions where "true" and "false" make sense
|
||||||
if text == "true":
|
if text == "true":
|
||||||
return cls.from_any(cls.default)
|
return cls.from_any(cls.default)
|
||||||
# "false"
|
else: # "false"
|
||||||
return cls(0)
|
return cls(0)
|
||||||
|
return cls(int(text))
|
||||||
try:
|
|
||||||
num = int(text)
|
|
||||||
except ValueError:
|
|
||||||
# text is not a number
|
|
||||||
# Handle conditionally acceptable values here rather than in the f-string
|
|
||||||
default = ""
|
|
||||||
truefalse = ""
|
|
||||||
if hasattr(cls, "default"):
|
|
||||||
default = ", default"
|
|
||||||
if cls.range_start == 0 and cls.default != 0:
|
|
||||||
truefalse = ", \"true\", \"false\""
|
|
||||||
raise Exception(f"Invalid range value {text!r}. Acceptable values are: "
|
|
||||||
f"<int>{default}, high, low{truefalse}, "
|
|
||||||
f"{', '.join(cls._RANDOM_OPTS)}.")
|
|
||||||
|
|
||||||
return cls(num)
|
|
||||||
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def weighted_range(cls, text) -> Range:
|
def weighted_range(cls, text) -> Range:
|
||||||
if text.startswith("random-range-"):
|
if text == "random-low":
|
||||||
|
return cls(cls.triangular(cls.range_start, cls.range_end, 0.0))
|
||||||
|
elif text == "random-high":
|
||||||
|
return cls(cls.triangular(cls.range_start, cls.range_end, 1.0))
|
||||||
|
elif text == "random-middle":
|
||||||
|
return cls(cls.triangular(cls.range_start, cls.range_end))
|
||||||
|
elif text.startswith("random-range-"):
|
||||||
return cls.custom_range(text)
|
return cls.custom_range(text)
|
||||||
|
elif text == "random":
|
||||||
|
return cls(random.randint(cls.range_start, cls.range_end))
|
||||||
else:
|
else:
|
||||||
return cls(random_weighted_range(text, cls.range_start, cls.range_end))
|
raise Exception(f"random text \"{text}\" did not resolve to a recognized pattern. "
|
||||||
|
f"Acceptable values are: random, random-high, random-middle, random-low, "
|
||||||
|
f"random-range-low-<min>-<max>, random-range-middle-<min>-<max>, "
|
||||||
|
f"random-range-high-<min>-<max>, or random-range-<min>-<max>.")
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def custom_range(cls, text) -> Range:
|
def custom_range(cls, text) -> Range:
|
||||||
textsplit = text.split("-")
|
textsplit = text.split("-")
|
||||||
try:
|
try:
|
||||||
random_range = [int(textsplit[-2]), int(textsplit[-1])]
|
random_range = [int(textsplit[len(textsplit) - 2]), int(textsplit[len(textsplit) - 1])]
|
||||||
except ValueError:
|
except ValueError:
|
||||||
raise ValueError(f"Invalid random range {text} for option {cls.__name__}")
|
raise ValueError(f"Invalid random range {text} for option {cls.__name__}")
|
||||||
random_range.sort()
|
random_range.sort()
|
||||||
@@ -788,9 +716,14 @@ class Range(NumericOption):
|
|||||||
raise Exception(
|
raise Exception(
|
||||||
f"{random_range[0]}-{random_range[1]} is outside allowed range "
|
f"{random_range[0]}-{random_range[1]} is outside allowed range "
|
||||||
f"{cls.range_start}-{cls.range_end} for option {cls.__name__}")
|
f"{cls.range_start}-{cls.range_end} for option {cls.__name__}")
|
||||||
if textsplit[2] in ("low", "middle", "high"):
|
if text.startswith("random-range-low"):
|
||||||
return cls(random_weighted_range(f"{textsplit[0]}-{textsplit[2]}", *random_range))
|
return cls(cls.triangular(random_range[0], random_range[1], 0.0))
|
||||||
return cls(random_weighted_range("random", *random_range))
|
elif text.startswith("random-range-middle"):
|
||||||
|
return cls(cls.triangular(random_range[0], random_range[1]))
|
||||||
|
elif text.startswith("random-range-high"):
|
||||||
|
return cls(cls.triangular(random_range[0], random_range[1], 1.0))
|
||||||
|
else:
|
||||||
|
return cls(random.randint(random_range[0], random_range[1]))
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_any(cls, data: typing.Any) -> Range:
|
def from_any(cls, data: typing.Any) -> Range:
|
||||||
@@ -805,6 +738,18 @@ class Range(NumericOption):
|
|||||||
def __str__(self) -> str:
|
def __str__(self) -> str:
|
||||||
return str(self.value)
|
return str(self.value)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def triangular(lower: int, end: int, tri: float = 0.5) -> int:
|
||||||
|
"""
|
||||||
|
Integer triangular distribution for `lower` inclusive to `end` inclusive.
|
||||||
|
|
||||||
|
Expects `lower <= end` and `0.0 <= tri <= 1.0`. The result of other inputs is undefined.
|
||||||
|
"""
|
||||||
|
# Use the continuous range [lower, end + 1) to produce an integer result in [lower, end].
|
||||||
|
# random.triangular is actually [a, b] and not [a, b), so there is a very small chance of getting exactly b even
|
||||||
|
# when a != b, so ensure the result is never more than `end`.
|
||||||
|
return min(end, math.floor(random.triangular(0.0, 1.0, tri) * (end - lower + 1) + lower))
|
||||||
|
|
||||||
|
|
||||||
class NamedRange(Range):
|
class NamedRange(Range):
|
||||||
special_range_names: typing.Dict[str, int] = {}
|
special_range_names: typing.Dict[str, int] = {}
|
||||||
@@ -894,7 +839,7 @@ class VerifyKeys(metaclass=FreezeValidKeys):
|
|||||||
def __iter__(self) -> typing.Iterator[typing.Any]:
|
def __iter__(self) -> typing.Iterator[typing.Any]:
|
||||||
return self.value.__iter__()
|
return self.value.__iter__()
|
||||||
|
|
||||||
|
|
||||||
class OptionDict(Option[typing.Dict[str, typing.Any]], VerifyKeys, typing.Mapping[str, typing.Any]):
|
class OptionDict(Option[typing.Dict[str, typing.Any]], VerifyKeys, typing.Mapping[str, typing.Any]):
|
||||||
default = {}
|
default = {}
|
||||||
supports_weighting = False
|
supports_weighting = False
|
||||||
@@ -909,62 +854,27 @@ class OptionDict(Option[typing.Dict[str, typing.Any]], VerifyKeys, typing.Mappin
|
|||||||
else:
|
else:
|
||||||
raise NotImplementedError(f"Cannot Convert from non-dictionary, got {type(data)}")
|
raise NotImplementedError(f"Cannot Convert from non-dictionary, got {type(data)}")
|
||||||
|
|
||||||
@classmethod
|
def get_option_name(self, value):
|
||||||
def get_option_name(cls, value):
|
|
||||||
return ", ".join(f"{key}: {v}" for key, v in value.items())
|
return ", ".join(f"{key}: {v}" for key, v in value.items())
|
||||||
|
|
||||||
def __getitem__(self, item: str) -> typing.Any:
|
def __getitem__(self, item: str) -> typing.Any:
|
||||||
return self.value[item]
|
return self.value.__getitem__(item)
|
||||||
|
|
||||||
def __iter__(self) -> typing.Iterator[str]:
|
def __iter__(self) -> typing.Iterator[str]:
|
||||||
return iter(self.value)
|
return self.value.__iter__()
|
||||||
|
|
||||||
def __len__(self) -> int:
|
def __len__(self) -> int:
|
||||||
return len(self.value)
|
return self.value.__len__()
|
||||||
|
|
||||||
# __getitem__ fallback fails for Counters, so we define this explicitly
|
|
||||||
def __contains__(self, item) -> bool:
|
|
||||||
return item in self.value
|
|
||||||
|
|
||||||
|
|
||||||
class OptionCounter(OptionDict):
|
class ItemDict(OptionDict):
|
||||||
min: int | None = None
|
|
||||||
max: int | None = None
|
|
||||||
|
|
||||||
def __init__(self, value: dict[str, int]) -> None:
|
|
||||||
super(OptionCounter, self).__init__(collections.Counter(value))
|
|
||||||
|
|
||||||
def verify(self, world: type[World], player_name: str, plando_options: PlandoOptions) -> None:
|
|
||||||
super(OptionCounter, self).verify(world, player_name, plando_options)
|
|
||||||
|
|
||||||
range_errors = []
|
|
||||||
|
|
||||||
if self.max is not None:
|
|
||||||
range_errors += [
|
|
||||||
f"\"{key}: {value}\" is higher than maximum allowed value {self.max}."
|
|
||||||
for key, value in self.value.items() if value > self.max
|
|
||||||
]
|
|
||||||
|
|
||||||
if self.min is not None:
|
|
||||||
range_errors += [
|
|
||||||
f"\"{key}: {value}\" is lower than minimum allowed value {self.min}."
|
|
||||||
for key, value in self.value.items() if value < self.min
|
|
||||||
]
|
|
||||||
|
|
||||||
if range_errors:
|
|
||||||
range_errors = [f"For option {getattr(self, 'display_name', self)}:"] + range_errors
|
|
||||||
raise OptionError("\n".join(range_errors))
|
|
||||||
|
|
||||||
|
|
||||||
class ItemDict(OptionCounter):
|
|
||||||
verify_item_name = True
|
verify_item_name = True
|
||||||
|
|
||||||
min = 0
|
def __init__(self, value: typing.Dict[str, int]):
|
||||||
|
if any(item_count is None for item_count in value.values()):
|
||||||
def __init__(self, value: dict[str, int]) -> None:
|
raise Exception("Items must have counts associated with them. Please provide positive integer values in the format \"item\": count .")
|
||||||
# Backwards compatibility: Cull 0s to make "in" checks behave the same as when this wasn't a OptionCounter
|
if any(item_count < 1 for item_count in value.values()):
|
||||||
value = {item_name: amount for item_name, amount in value.items() if amount != 0}
|
raise Exception("Cannot have non-positive item counts.")
|
||||||
|
|
||||||
super(ItemDict, self).__init__(value)
|
super(ItemDict, self).__init__(value)
|
||||||
|
|
||||||
|
|
||||||
@@ -990,8 +900,7 @@ class OptionList(Option[typing.List[typing.Any]], VerifyKeys):
|
|||||||
return cls(data)
|
return cls(data)
|
||||||
return cls.from_text(str(data))
|
return cls.from_text(str(data))
|
||||||
|
|
||||||
@classmethod
|
def get_option_name(self, value):
|
||||||
def get_option_name(cls, value):
|
|
||||||
return ", ".join(map(str, value))
|
return ", ".join(map(str, value))
|
||||||
|
|
||||||
def __contains__(self, item):
|
def __contains__(self, item):
|
||||||
@@ -1001,19 +910,13 @@ class OptionList(Option[typing.List[typing.Any]], VerifyKeys):
|
|||||||
class OptionSet(Option[typing.Set[str]], VerifyKeys):
|
class OptionSet(Option[typing.Set[str]], VerifyKeys):
|
||||||
default = frozenset()
|
default = frozenset()
|
||||||
supports_weighting = False
|
supports_weighting = False
|
||||||
random_str: str | None
|
|
||||||
|
|
||||||
def __init__(self, value: typing.Iterable[str], random_str: str | None = None):
|
def __init__(self, value: typing.Iterable[str]):
|
||||||
self.value = set(deepcopy(value))
|
self.value = set(deepcopy(value))
|
||||||
self.random_str = random_str
|
|
||||||
super(OptionSet, self).__init__()
|
super(OptionSet, self).__init__()
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_text(cls, text: str):
|
def from_text(cls, text: str):
|
||||||
check_text = text.lower().split(",")
|
|
||||||
if ((cls.valid_keys or cls.verify_item_name or cls.verify_location_name)
|
|
||||||
and len(check_text) == 1 and check_text[0].startswith("random")):
|
|
||||||
return cls((), check_text[0])
|
|
||||||
return cls([option.strip() for option in text.split(",")])
|
return cls([option.strip() for option in text.split(",")])
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -1022,37 +925,7 @@ class OptionSet(Option[typing.Set[str]], VerifyKeys):
|
|||||||
return cls(data)
|
return cls(data)
|
||||||
return cls.from_text(str(data))
|
return cls.from_text(str(data))
|
||||||
|
|
||||||
def verify(self, world: typing.Type[World], player_name: str, plando_options: PlandoOptions) -> None:
|
def get_option_name(self, value):
|
||||||
if self.random_str and not self.value:
|
|
||||||
choice_list = sorted(self.valid_keys)
|
|
||||||
if self.verify_item_name:
|
|
||||||
choice_list.extend(sorted(world.item_names))
|
|
||||||
if self.verify_location_name:
|
|
||||||
choice_list.extend(sorted(world.location_names))
|
|
||||||
if self.random_str.startswith("random-range-"):
|
|
||||||
textsplit = self.random_str.split("-")
|
|
||||||
try:
|
|
||||||
random_range = [int(textsplit[-2]), int(textsplit[-1])]
|
|
||||||
except ValueError:
|
|
||||||
raise ValueError(f"Invalid random range {self.random_str} for option {self.__class__.__name__} "
|
|
||||||
f"for player {player_name}")
|
|
||||||
random_range.sort()
|
|
||||||
if random_range[0] < 0 or random_range[1] > len(choice_list):
|
|
||||||
raise Exception(
|
|
||||||
f"{random_range[0]}-{random_range[1]} is outside allowed range "
|
|
||||||
f"0-{len(choice_list)} for option {self.__class__.__name__} for player {player_name}")
|
|
||||||
if textsplit[2] in ("low", "middle", "high"):
|
|
||||||
choice_count = random_weighted_range(f"{textsplit[0]}-{textsplit[2]}",
|
|
||||||
random_range[0], random_range[1])
|
|
||||||
else:
|
|
||||||
choice_count = random_weighted_range("random", random_range[0], random_range[1])
|
|
||||||
else:
|
|
||||||
choice_count = random_weighted_range(self.random_str, 0, len(choice_list))
|
|
||||||
self.value = set(random.sample(choice_list, k=choice_count))
|
|
||||||
super(Option, self).verify(world, player_name, plando_options)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def get_option_name(cls, value):
|
|
||||||
return ", ".join(sorted(value))
|
return ", ".join(sorted(value))
|
||||||
|
|
||||||
def __contains__(self, item):
|
def __contains__(self, item):
|
||||||
@@ -1080,8 +953,6 @@ class PlandoTexts(Option[typing.List[PlandoText]], VerifyKeys):
|
|||||||
supports_weighting = False
|
supports_weighting = False
|
||||||
display_name = "Plando Texts"
|
display_name = "Plando Texts"
|
||||||
|
|
||||||
visibility = Visibility.template | Visibility.complex_ui | Visibility.spoiler
|
|
||||||
|
|
||||||
def __init__(self, value: typing.Iterable[PlandoText]) -> None:
|
def __init__(self, value: typing.Iterable[PlandoText]) -> None:
|
||||||
self.value = list(deepcopy(value))
|
self.value = list(deepcopy(value))
|
||||||
super().__init__()
|
super().__init__()
|
||||||
@@ -1113,7 +984,7 @@ class PlandoTexts(Option[typing.List[PlandoText]], VerifyKeys):
|
|||||||
if isinstance(data, typing.Iterable):
|
if isinstance(data, typing.Iterable):
|
||||||
for text in data:
|
for text in data:
|
||||||
if isinstance(text, typing.Mapping):
|
if isinstance(text, typing.Mapping):
|
||||||
if roll_percentage(text.get("percentage", 100)):
|
if random.random() < float(text.get("percentage", 100)/100):
|
||||||
at = text.get("at", None)
|
at = text.get("at", None)
|
||||||
if at is not None:
|
if at is not None:
|
||||||
if isinstance(at, dict):
|
if isinstance(at, dict):
|
||||||
@@ -1139,7 +1010,7 @@ class PlandoTexts(Option[typing.List[PlandoText]], VerifyKeys):
|
|||||||
else:
|
else:
|
||||||
raise OptionError("\"at\" must be a valid string or weighted list of strings!")
|
raise OptionError("\"at\" must be a valid string or weighted list of strings!")
|
||||||
elif isinstance(text, PlandoText):
|
elif isinstance(text, PlandoText):
|
||||||
if roll_percentage(text.percentage):
|
if random.random() < float(text.percentage/100):
|
||||||
texts.append(text)
|
texts.append(text)
|
||||||
else:
|
else:
|
||||||
raise Exception(f"Cannot create plando text from non-dictionary type, got {type(text)}")
|
raise Exception(f"Cannot create plando text from non-dictionary type, got {type(text)}")
|
||||||
@@ -1155,10 +1026,10 @@ class PlandoTexts(Option[typing.List[PlandoText]], VerifyKeys):
|
|||||||
yield from self.value
|
yield from self.value
|
||||||
|
|
||||||
def __getitem__(self, index: typing.SupportsIndex) -> PlandoText:
|
def __getitem__(self, index: typing.SupportsIndex) -> PlandoText:
|
||||||
return self.value[index]
|
return self.value.__getitem__(index)
|
||||||
|
|
||||||
def __len__(self) -> int:
|
def __len__(self) -> int:
|
||||||
return len(self.value)
|
return self.value.__len__()
|
||||||
|
|
||||||
|
|
||||||
class ConnectionsMeta(AssembleOptions):
|
class ConnectionsMeta(AssembleOptions):
|
||||||
@@ -1182,7 +1053,7 @@ class PlandoConnection(typing.NamedTuple):
|
|||||||
|
|
||||||
entrance: str
|
entrance: str
|
||||||
exit: str
|
exit: str
|
||||||
direction: typing.Literal["entrance", "exit", "both"] # TODO: convert Direction to StrEnum once 3.10 is dropped
|
direction: typing.Literal["entrance", "exit", "both"] # TODO: convert Direction to StrEnum once 3.8 is dropped
|
||||||
percentage: int = 100
|
percentage: int = 100
|
||||||
|
|
||||||
|
|
||||||
@@ -1208,8 +1079,6 @@ class PlandoConnections(Option[typing.List[PlandoConnection]], metaclass=Connect
|
|||||||
entrances: typing.ClassVar[typing.AbstractSet[str]]
|
entrances: typing.ClassVar[typing.AbstractSet[str]]
|
||||||
exits: typing.ClassVar[typing.AbstractSet[str]]
|
exits: typing.ClassVar[typing.AbstractSet[str]]
|
||||||
|
|
||||||
visibility = Visibility.template | Visibility.complex_ui | Visibility.spoiler
|
|
||||||
|
|
||||||
duplicate_exits: bool = False
|
duplicate_exits: bool = False
|
||||||
"""Whether or not exits should be allowed to be duplicate."""
|
"""Whether or not exits should be allowed to be duplicate."""
|
||||||
|
|
||||||
@@ -1265,7 +1134,7 @@ class PlandoConnections(Option[typing.List[PlandoConnection]], metaclass=Connect
|
|||||||
for connection in data:
|
for connection in data:
|
||||||
if isinstance(connection, typing.Mapping):
|
if isinstance(connection, typing.Mapping):
|
||||||
percentage = connection.get("percentage", 100)
|
percentage = connection.get("percentage", 100)
|
||||||
if roll_percentage(percentage):
|
if random.random() < float(percentage / 100):
|
||||||
entrance = connection.get("entrance", None)
|
entrance = connection.get("entrance", None)
|
||||||
if is_iterable_except_str(entrance):
|
if is_iterable_except_str(entrance):
|
||||||
entrance = random.choice(sorted(entrance))
|
entrance = random.choice(sorted(entrance))
|
||||||
@@ -1283,7 +1152,7 @@ class PlandoConnections(Option[typing.List[PlandoConnection]], metaclass=Connect
|
|||||||
percentage
|
percentage
|
||||||
))
|
))
|
||||||
elif isinstance(connection, PlandoConnection):
|
elif isinstance(connection, PlandoConnection):
|
||||||
if roll_percentage(connection.percentage):
|
if random.random() < float(connection.percentage / 100):
|
||||||
value.append(connection)
|
value.append(connection)
|
||||||
else:
|
else:
|
||||||
raise Exception(f"Cannot create connection from non-Dict type, got {type(connection)}.")
|
raise Exception(f"Cannot create connection from non-Dict type, got {type(connection)}.")
|
||||||
@@ -1307,7 +1176,7 @@ class PlandoConnections(Option[typing.List[PlandoConnection]], metaclass=Connect
|
|||||||
connection.exit) for connection in value])
|
connection.exit) for connection in value])
|
||||||
|
|
||||||
def __getitem__(self, index: typing.SupportsIndex) -> PlandoConnection:
|
def __getitem__(self, index: typing.SupportsIndex) -> PlandoConnection:
|
||||||
return self.value[index]
|
return self.value.__getitem__(index)
|
||||||
|
|
||||||
def __iter__(self) -> typing.Iterator[PlandoConnection]:
|
def __iter__(self) -> typing.Iterator[PlandoConnection]:
|
||||||
yield from self.value
|
yield from self.value
|
||||||
@@ -1388,48 +1257,42 @@ class CommonOptions(metaclass=OptionsMetaProperty):
|
|||||||
progression_balancing: ProgressionBalancing
|
progression_balancing: ProgressionBalancing
|
||||||
accessibility: Accessibility
|
accessibility: Accessibility
|
||||||
|
|
||||||
def as_dict(
|
def as_dict(self,
|
||||||
self,
|
*option_names: str,
|
||||||
*option_names: str,
|
casing: typing.Literal["snake", "camel", "pascal", "kebab"] = "snake",
|
||||||
casing: typing.Literal["snake", "camel", "pascal", "kebab"] = "snake",
|
toggles_as_bools: bool = False) -> typing.Dict[str, typing.Any]:
|
||||||
toggles_as_bools: bool = False,
|
|
||||||
) -> dict[str, typing.Any]:
|
|
||||||
"""
|
"""
|
||||||
Returns a dictionary of [str, Option.value]
|
Returns a dictionary of [str, Option.value]
|
||||||
|
|
||||||
:param option_names: Names of the options to get the values of.
|
:param option_names: names of the options to return
|
||||||
:param casing: Casing of the keys to return. Supports `snake`, `camel`, `pascal`, `kebab`.
|
:param casing: case of the keys to return. Supports `snake`, `camel`, `pascal`, `kebab`
|
||||||
:param toggles_as_bools: Whether toggle options should be returned as bools instead of ints.
|
:param toggles_as_bools: whether toggle options should be output as bools instead of strings
|
||||||
|
|
||||||
:return: A dictionary of each option name to the value of its Option. If the option is an OptionSet, the value
|
|
||||||
will be returned as a sorted list.
|
|
||||||
"""
|
"""
|
||||||
assert option_names, "options.as_dict() was used without any option names."
|
assert option_names, "options.as_dict() was used without any option names."
|
||||||
assert len(option_names) < len(self.__class__.type_hints), "Specify only options you need."
|
|
||||||
option_results = {}
|
option_results = {}
|
||||||
for option_name in option_names:
|
for option_name in option_names:
|
||||||
if option_name not in type(self).type_hints:
|
if option_name in type(self).type_hints:
|
||||||
raise ValueError(f"{option_name} not found in {tuple(type(self).type_hints)}")
|
if casing == "snake":
|
||||||
|
display_name = option_name
|
||||||
if casing == "snake":
|
elif casing == "camel":
|
||||||
display_name = option_name
|
split_name = [name.title() for name in option_name.split("_")]
|
||||||
elif casing == "camel":
|
split_name[0] = split_name[0].lower()
|
||||||
split_name = [name.title() for name in option_name.split("_")]
|
display_name = "".join(split_name)
|
||||||
split_name[0] = split_name[0].lower()
|
elif casing == "pascal":
|
||||||
display_name = "".join(split_name)
|
display_name = "".join([name.title() for name in option_name.split("_")])
|
||||||
elif casing == "pascal":
|
elif casing == "kebab":
|
||||||
display_name = "".join([name.title() for name in option_name.split("_")])
|
display_name = option_name.replace("_", "-")
|
||||||
elif casing == "kebab":
|
else:
|
||||||
display_name = option_name.replace("_", "-")
|
raise ValueError(f"{casing} is invalid casing for as_dict. "
|
||||||
|
"Valid names are 'snake', 'camel', 'pascal', 'kebab'.")
|
||||||
|
value = getattr(self, option_name).value
|
||||||
|
if isinstance(value, set):
|
||||||
|
value = sorted(value)
|
||||||
|
elif toggles_as_bools and issubclass(type(self).type_hints[option_name], Toggle):
|
||||||
|
value = bool(value)
|
||||||
|
option_results[display_name] = value
|
||||||
else:
|
else:
|
||||||
raise ValueError(f"{casing} is invalid casing for as_dict. "
|
raise ValueError(f"{option_name} not found in {tuple(type(self).type_hints)}")
|
||||||
"Valid names are 'snake', 'camel', 'pascal', 'kebab'.")
|
|
||||||
value = getattr(self, option_name).value
|
|
||||||
if isinstance(value, set):
|
|
||||||
value = sorted(value)
|
|
||||||
elif toggles_as_bools and issubclass(type(self).type_hints[option_name], Toggle):
|
|
||||||
value = bool(value)
|
|
||||||
option_results[display_name] = value
|
|
||||||
return option_results
|
return option_results
|
||||||
|
|
||||||
|
|
||||||
@@ -1446,15 +1309,14 @@ class NonLocalItems(ItemSet):
|
|||||||
|
|
||||||
|
|
||||||
class StartInventory(ItemDict):
|
class StartInventory(ItemDict):
|
||||||
"""Start with the specified amount of these items. Example: "Bomb: 1" """
|
"""Start with these items."""
|
||||||
verify_item_name = True
|
verify_item_name = True
|
||||||
display_name = "Start Inventory"
|
display_name = "Start Inventory"
|
||||||
rich_text_doc = True
|
rich_text_doc = True
|
||||||
max = 10000
|
|
||||||
|
|
||||||
|
|
||||||
class StartInventoryPool(StartInventory):
|
class StartInventoryPool(StartInventory):
|
||||||
"""Start with the specified amount of these items and don't place them in the world. Example: "Bomb: 1"
|
"""Start with these items and don't place them in the world.
|
||||||
|
|
||||||
The game decides what the replacement items will be.
|
The game decides what the replacement items will be.
|
||||||
"""
|
"""
|
||||||
@@ -1501,7 +1363,6 @@ class DeathLink(Toggle):
|
|||||||
class ItemLinks(OptionList):
|
class ItemLinks(OptionList):
|
||||||
"""Share part of your item pool with other players."""
|
"""Share part of your item pool with other players."""
|
||||||
display_name = "Item Links"
|
display_name = "Item Links"
|
||||||
visibility = Visibility.template | Visibility.complex_ui | Visibility.spoiler
|
|
||||||
rich_text_doc = True
|
rich_text_doc = True
|
||||||
default = []
|
default = []
|
||||||
schema = Schema([
|
schema = Schema([
|
||||||
@@ -1513,7 +1374,6 @@ class ItemLinks(OptionList):
|
|||||||
Optional("local_items"): [And(str, len)],
|
Optional("local_items"): [And(str, len)],
|
||||||
Optional("non_local_items"): [And(str, len)],
|
Optional("non_local_items"): [And(str, len)],
|
||||||
Optional("link_replacement"): Or(None, bool),
|
Optional("link_replacement"): Or(None, bool),
|
||||||
Optional("skip_if_solo"): Or(None, bool),
|
|
||||||
}
|
}
|
||||||
])
|
])
|
||||||
|
|
||||||
@@ -1541,10 +1401,8 @@ class ItemLinks(OptionList):
|
|||||||
super(ItemLinks, self).verify(world, player_name, plando_options)
|
super(ItemLinks, self).verify(world, player_name, plando_options)
|
||||||
existing_links = set()
|
existing_links = set()
|
||||||
for link in self.value:
|
for link in self.value:
|
||||||
link["name"] = link["name"].strip()[:16].strip()
|
|
||||||
if link["name"] in existing_links:
|
if link["name"] in existing_links:
|
||||||
raise Exception(f"Item link names are limited to their first 16 characters and must be unique. "
|
raise Exception(f"You cannot have more than one link named {link['name']}.")
|
||||||
f"You have more than one link named '{link['name']}'.")
|
|
||||||
existing_links.add(link["name"])
|
existing_links.add(link["name"])
|
||||||
|
|
||||||
pool = self.verify_items(link["item_pool"], link["name"], "item_pool", world)
|
pool = self.verify_items(link["item_pool"], link["name"], "item_pool", world)
|
||||||
@@ -1570,134 +1428,6 @@ class ItemLinks(OptionList):
|
|||||||
link["item_pool"] = list(pool)
|
link["item_pool"] = list(pool)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class PlandoItem:
|
|
||||||
items: list[str] | dict[str, typing.Any]
|
|
||||||
locations: list[str]
|
|
||||||
world: int | str | bool | None | typing.Iterable[str] | set[int] = False
|
|
||||||
from_pool: bool = True
|
|
||||||
force: bool | typing.Literal["silent"] = "silent"
|
|
||||||
count: int | bool | dict[str, int] = False
|
|
||||||
percentage: int = 100
|
|
||||||
|
|
||||||
|
|
||||||
class PlandoItems(Option[typing.List[PlandoItem]]):
|
|
||||||
"""Generic items plando."""
|
|
||||||
default = ()
|
|
||||||
supports_weighting = False
|
|
||||||
display_name = "Plando Items"
|
|
||||||
visibility = Visibility.template | Visibility.spoiler
|
|
||||||
|
|
||||||
def __init__(self, value: typing.Iterable[PlandoItem]) -> None:
|
|
||||||
self.value = list(deepcopy(value))
|
|
||||||
super().__init__()
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_any(cls, data: typing.Any) -> Option[typing.List[PlandoItem]]:
|
|
||||||
if not isinstance(data, typing.Iterable):
|
|
||||||
raise OptionError(f"Cannot create plando items from non-Iterable type, got {type(data)}")
|
|
||||||
|
|
||||||
value: typing.List[PlandoItem] = []
|
|
||||||
for item in data:
|
|
||||||
if isinstance(item, typing.Mapping):
|
|
||||||
percentage = item.get("percentage", 100)
|
|
||||||
if not isinstance(percentage, int):
|
|
||||||
raise OptionError(f"Plando `percentage` has to be int, not {type(percentage)}.")
|
|
||||||
if not (0 <= percentage <= 100):
|
|
||||||
raise OptionError(f"Plando `percentage` has to be between 0 and 100 (inclusive) not {percentage}.")
|
|
||||||
if roll_percentage(percentage):
|
|
||||||
count = item.get("count", False)
|
|
||||||
items = item.get("items", [])
|
|
||||||
if not items:
|
|
||||||
items = item.get("item", None) # explicitly throw an error here if not present
|
|
||||||
if not items:
|
|
||||||
raise OptionError("You must specify at least one item to place items with plando.")
|
|
||||||
count = 1
|
|
||||||
if isinstance(items, str):
|
|
||||||
items = [items]
|
|
||||||
elif not isinstance(items, (dict, list)):
|
|
||||||
raise OptionError(f"Plando 'items' has to be string, list, or "
|
|
||||||
f"dictionary, not {type(items)}")
|
|
||||||
locations = item.get("locations", [])
|
|
||||||
if not locations:
|
|
||||||
locations = item.get("location", [])
|
|
||||||
if locations:
|
|
||||||
count = 1
|
|
||||||
else:
|
|
||||||
locations = ["Everywhere"]
|
|
||||||
if isinstance(locations, str):
|
|
||||||
locations = [locations]
|
|
||||||
if not isinstance(locations, list):
|
|
||||||
raise OptionError(f"Plando `location` has to be string or list, not {type(locations)}")
|
|
||||||
world = item.get("world", False)
|
|
||||||
from_pool = item.get("from_pool", True)
|
|
||||||
force = item.get("force", "silent")
|
|
||||||
if not isinstance(from_pool, bool):
|
|
||||||
raise OptionError(f"Plando 'from_pool' has to be true or false, not {from_pool!r}.")
|
|
||||||
if not (isinstance(force, bool) or force == "silent"):
|
|
||||||
raise OptionError(f"Plando `force` has to be true or false or `silent`, not {force!r}.")
|
|
||||||
value.append(PlandoItem(items, locations, world, from_pool, force, count, percentage))
|
|
||||||
elif isinstance(item, PlandoItem):
|
|
||||||
if roll_percentage(item.percentage):
|
|
||||||
value.append(item)
|
|
||||||
else:
|
|
||||||
raise OptionError(f"Cannot create plando item from non-Dict type, got {type(item)}.")
|
|
||||||
return cls(value)
|
|
||||||
|
|
||||||
def verify(self, world: typing.Type[World], player_name: str, plando_options: "PlandoOptions") -> None:
|
|
||||||
if not self.value:
|
|
||||||
return
|
|
||||||
from BaseClasses import PlandoOptions
|
|
||||||
if not (PlandoOptions.items & plando_options):
|
|
||||||
# plando is disabled but plando options were given so overwrite the options
|
|
||||||
self.value = []
|
|
||||||
logging.warning(f"The plando items module is turned off, "
|
|
||||||
f"so items for {player_name} will be ignored.")
|
|
||||||
else:
|
|
||||||
# filter down item groups
|
|
||||||
for plando in self.value:
|
|
||||||
# confirm a valid count
|
|
||||||
if isinstance(plando.count, dict):
|
|
||||||
if "min" in plando.count and "max" in plando.count:
|
|
||||||
if plando.count["min"] > plando.count["max"]:
|
|
||||||
raise OptionError("Plando cannot have count `min` greater than `max`.")
|
|
||||||
items_copy = plando.items.copy()
|
|
||||||
if isinstance(plando.items, dict):
|
|
||||||
for item in items_copy:
|
|
||||||
if item in world.item_name_groups:
|
|
||||||
value = plando.items.pop(item)
|
|
||||||
group = world.item_name_groups[item]
|
|
||||||
filtered_items = sorted(group.difference(list(plando.items.keys())))
|
|
||||||
if not filtered_items:
|
|
||||||
raise OptionError(f"Plando `items` contains the group \"{item}\" "
|
|
||||||
f"and every item in it. This is not allowed.")
|
|
||||||
if value is True:
|
|
||||||
for key in filtered_items:
|
|
||||||
plando.items[key] = True
|
|
||||||
else:
|
|
||||||
for key in random.choices(filtered_items, k=value):
|
|
||||||
plando.items[key] = plando.items.get(key, 0) + 1
|
|
||||||
else:
|
|
||||||
assert isinstance(plando.items, list) # pycharm can't figure out the hinting without the hint
|
|
||||||
for item in items_copy:
|
|
||||||
if item in world.item_name_groups:
|
|
||||||
plando.items.remove(item)
|
|
||||||
plando.items.extend(sorted(world.item_name_groups[item]))
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def get_option_name(cls, value: list[PlandoItem]) -> str:
|
|
||||||
return ", ".join(["(%s: %s)" % (item.items, item.locations) for item in value]) #TODO: see what a better way to display would be
|
|
||||||
|
|
||||||
def __getitem__(self, index: typing.SupportsIndex) -> PlandoItem:
|
|
||||||
return self.value.__getitem__(index)
|
|
||||||
|
|
||||||
def __iter__(self) -> typing.Iterator[PlandoItem]:
|
|
||||||
yield from self.value
|
|
||||||
|
|
||||||
def __len__(self) -> int:
|
|
||||||
return len(self.value)
|
|
||||||
|
|
||||||
|
|
||||||
class Removed(FreeText):
|
class Removed(FreeText):
|
||||||
"""This Option has been Removed."""
|
"""This Option has been Removed."""
|
||||||
rich_text_doc = True
|
rich_text_doc = True
|
||||||
@@ -1720,7 +1450,6 @@ class PerGameCommonOptions(CommonOptions):
|
|||||||
exclude_locations: ExcludeLocations
|
exclude_locations: ExcludeLocations
|
||||||
priority_locations: PriorityLocations
|
priority_locations: PriorityLocations
|
||||||
item_links: ItemLinks
|
item_links: ItemLinks
|
||||||
plando_items: PlandoItems
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -1739,7 +1468,7 @@ class OptionGroup(typing.NamedTuple):
|
|||||||
|
|
||||||
|
|
||||||
item_and_loc_options = [LocalItems, NonLocalItems, StartInventory, StartInventoryPool, StartHints,
|
item_and_loc_options = [LocalItems, NonLocalItems, StartInventory, StartInventoryPool, StartHints,
|
||||||
StartLocationHints, ExcludeLocations, PriorityLocations, ItemLinks, PlandoItems]
|
StartLocationHints, ExcludeLocations, PriorityLocations, ItemLinks]
|
||||||
"""
|
"""
|
||||||
Options that are always populated in "Item & Location Options" Option Group. Cannot be moved to another group.
|
Options that are always populated in "Item & Location Options" Option Group. Cannot be moved to another group.
|
||||||
If desired, a custom "Item & Location Options" Option Group can be defined, but only for adding additional options to
|
If desired, a custom "Item & Location Options" Option Group can be defined, but only for adding additional options to
|
||||||
@@ -1774,7 +1503,6 @@ def get_option_groups(world: typing.Type[World], visibility_level: Visibility =
|
|||||||
|
|
||||||
def generate_yaml_templates(target_folder: typing.Union[str, "pathlib.Path"], generate_hidden: bool = True) -> None:
|
def generate_yaml_templates(target_folder: typing.Union[str, "pathlib.Path"], generate_hidden: bool = True) -> None:
|
||||||
import os
|
import os
|
||||||
from inspect import cleandoc
|
|
||||||
|
|
||||||
import yaml
|
import yaml
|
||||||
from jinja2 import Template
|
from jinja2 import Template
|
||||||
@@ -1783,10 +1511,8 @@ def generate_yaml_templates(target_folder: typing.Union[str, "pathlib.Path"], ge
|
|||||||
from Utils import local_path, __version__
|
from Utils import local_path, __version__
|
||||||
|
|
||||||
full_path: str
|
full_path: str
|
||||||
preset_folder = os.path.join(target_folder, "Presets")
|
|
||||||
|
|
||||||
os.makedirs(target_folder, exist_ok=True)
|
os.makedirs(target_folder, exist_ok=True)
|
||||||
os.makedirs(preset_folder, exist_ok=True)
|
|
||||||
|
|
||||||
# clean out old
|
# clean out old
|
||||||
for file in os.listdir(target_folder):
|
for file in os.listdir(target_folder):
|
||||||
@@ -1794,30 +1520,18 @@ def generate_yaml_templates(target_folder: typing.Union[str, "pathlib.Path"], ge
|
|||||||
if os.path.isfile(full_path) and full_path.endswith(".yaml"):
|
if os.path.isfile(full_path) and full_path.endswith(".yaml"):
|
||||||
os.unlink(full_path)
|
os.unlink(full_path)
|
||||||
|
|
||||||
for file in os.listdir(preset_folder):
|
def dictify_range(option: Range):
|
||||||
full_path = os.path.join(preset_folder, file)
|
data = {option.default: 50}
|
||||||
if os.path.isfile(full_path) and full_path.endswith(".yaml"):
|
for sub_option in ["random", "random-low", "random-high"]:
|
||||||
os.unlink(full_path)
|
if sub_option != option.default:
|
||||||
|
|
||||||
def dictify_range(option: Range, option_val: int | str):
|
|
||||||
data = {option_val: 50}
|
|
||||||
for sub_option in ["random", "random-low", "random-high",
|
|
||||||
f"random-range-{option.range_start}-{option.range_end}"]:
|
|
||||||
if sub_option != option_val:
|
|
||||||
data[sub_option] = 0
|
data[sub_option] = 0
|
||||||
notes = {
|
|
||||||
"random-low": "random value weighted towards lower values",
|
notes = {}
|
||||||
"random-high": "random value weighted towards higher values",
|
|
||||||
f"random-range-{option.range_start}-{option.range_end}": f"random value between "
|
|
||||||
f"{option.range_start} and {option.range_end}"
|
|
||||||
}
|
|
||||||
for name, number in getattr(option, "special_range_names", {}).items():
|
for name, number in getattr(option, "special_range_names", {}).items():
|
||||||
notes[name] = f"equivalent to {number}"
|
notes[name] = f"equivalent to {number}"
|
||||||
if number in data:
|
if number in data:
|
||||||
data[name] = data[number]
|
data[name] = data[number]
|
||||||
del data[number]
|
del data[number]
|
||||||
elif name in data:
|
|
||||||
pass
|
|
||||||
else:
|
else:
|
||||||
data[name] = 0
|
data[name] = 0
|
||||||
|
|
||||||
@@ -1827,33 +1541,21 @@ def generate_yaml_templates(target_folder: typing.Union[str, "pathlib.Path"], ge
|
|||||||
# yaml dump may add end of document marker and newlines.
|
# yaml dump may add end of document marker and newlines.
|
||||||
return yaml.dump(scalar).replace("...\n", "").strip()
|
return yaml.dump(scalar).replace("...\n", "").strip()
|
||||||
|
|
||||||
with open(local_path("data", "options.yaml")) as f:
|
|
||||||
file_data = f.read()
|
|
||||||
template = Template(file_data)
|
|
||||||
|
|
||||||
for game_name, world in AutoWorldRegister.world_types.items():
|
for game_name, world in AutoWorldRegister.world_types.items():
|
||||||
if not world.hidden or generate_hidden:
|
if not world.hidden or generate_hidden:
|
||||||
presets = world.web.options_presets.copy()
|
|
||||||
presets.update({"": {}})
|
|
||||||
|
|
||||||
option_groups = get_option_groups(world)
|
option_groups = get_option_groups(world)
|
||||||
for name, preset in presets.items():
|
with open(local_path("data", "options.yaml")) as f:
|
||||||
res = template.render(
|
file_data = f.read()
|
||||||
option_groups=option_groups,
|
res = Template(file_data).render(
|
||||||
__version__=__version__,
|
option_groups=option_groups,
|
||||||
game=game_name,
|
__version__=__version__, game=game_name, yaml_dump=yaml_dump_scalar,
|
||||||
world_version=world.world_version.as_simple_string(),
|
dictify_range=dictify_range,
|
||||||
yaml_dump=yaml_dump_scalar,
|
)
|
||||||
dictify_range=dictify_range,
|
|
||||||
cleandoc=cleandoc,
|
del file_data
|
||||||
preset_name=name,
|
|
||||||
preset=preset,
|
with open(os.path.join(target_folder, get_file_safe_name(game_name) + ".yaml"), "w", encoding="utf-8-sig") as f:
|
||||||
)
|
f.write(res)
|
||||||
preset_name = f" - {name}" if name else ""
|
|
||||||
with open(os.path.join(preset_folder if name else target_folder,
|
|
||||||
get_file_safe_name(game_name + preset_name) + ".yaml"),
|
|
||||||
"w", encoding="utf-8-sig") as f:
|
|
||||||
f.write(res)
|
|
||||||
|
|
||||||
|
|
||||||
def dump_player_options(multiworld: MultiWorld) -> None:
|
def dump_player_options(multiworld: MultiWorld) -> None:
|
||||||
@@ -1877,7 +1579,6 @@ def dump_player_options(multiworld: MultiWorld) -> None:
|
|||||||
player_output = {
|
player_output = {
|
||||||
"Game": multiworld.game[player],
|
"Game": multiworld.game[player],
|
||||||
"Name": multiworld.get_player_name(player),
|
"Name": multiworld.get_player_name(player),
|
||||||
"ID": player,
|
|
||||||
}
|
}
|
||||||
output.append(player_output)
|
output.append(player_output)
|
||||||
for option_key, option in world.options_dataclass.type_hints.items():
|
for option_key, option in world.options_dataclass.type_hints.items():
|
||||||
@@ -1890,7 +1591,7 @@ def dump_player_options(multiworld: MultiWorld) -> None:
|
|||||||
game_option_names.append(display_name)
|
game_option_names.append(display_name)
|
||||||
|
|
||||||
with open(output_path(f"generate_{multiworld.seed_name}.csv"), mode="w", newline="") as file:
|
with open(output_path(f"generate_{multiworld.seed_name}.csv"), mode="w", newline="") as file:
|
||||||
fields = ["ID", "Game", "Name", *all_option_names]
|
fields = ["Game", "Name", *all_option_names]
|
||||||
writer = DictWriter(file, fields)
|
writer = DictWriter(file, fields)
|
||||||
writer.writeheader()
|
writer.writeheader()
|
||||||
writer.writerows(output)
|
writer.writerows(output)
|
||||||
|
|||||||
@@ -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()
|
|
||||||
18
README.md
18
README.md
@@ -7,19 +7,23 @@ Currently, the following games are supported:
|
|||||||
|
|
||||||
* The Legend of Zelda: A Link to the Past
|
* The Legend of Zelda: A Link to the Past
|
||||||
* Factorio
|
* Factorio
|
||||||
|
* Minecraft
|
||||||
* Subnautica
|
* Subnautica
|
||||||
|
* Slay the Spire
|
||||||
* Risk of Rain 2
|
* Risk of Rain 2
|
||||||
* The Legend of Zelda: Ocarina of Time
|
* The Legend of Zelda: Ocarina of Time
|
||||||
* Timespinner
|
* Timespinner
|
||||||
* Super Metroid
|
* Super Metroid
|
||||||
* Secret of Evermore
|
* Secret of Evermore
|
||||||
* Final Fantasy
|
* Final Fantasy
|
||||||
|
* Rogue Legacy
|
||||||
* VVVVVV
|
* VVVVVV
|
||||||
* Raft
|
* Raft
|
||||||
* Super Mario 64
|
* Super Mario 64
|
||||||
* Meritous
|
* Meritous
|
||||||
* Super Metroid/Link to the Past combo randomizer (SMZ3)
|
* Super Metroid/Link to the Past combo randomizer (SMZ3)
|
||||||
* ChecksFinder
|
* ChecksFinder
|
||||||
|
* ArchipIDLE
|
||||||
* Hollow Knight
|
* Hollow Knight
|
||||||
* The Witness
|
* The Witness
|
||||||
* Sonic Adventure 2: Battle
|
* Sonic Adventure 2: Battle
|
||||||
@@ -39,6 +43,7 @@ Currently, the following games are supported:
|
|||||||
* The Messenger
|
* The Messenger
|
||||||
* Kingdom Hearts 2
|
* Kingdom Hearts 2
|
||||||
* The Legend of Zelda: Link's Awakening DX
|
* The Legend of Zelda: Link's Awakening DX
|
||||||
|
* Clique
|
||||||
* Adventure
|
* Adventure
|
||||||
* DLC Quest
|
* DLC Quest
|
||||||
* Noita
|
* Noita
|
||||||
@@ -58,6 +63,7 @@ Currently, the following games are supported:
|
|||||||
* TUNIC
|
* TUNIC
|
||||||
* Kirby's Dream Land 3
|
* Kirby's Dream Land 3
|
||||||
* Celeste 64
|
* Celeste 64
|
||||||
|
* Zork Grand Inquisitor
|
||||||
* Castlevania 64
|
* Castlevania 64
|
||||||
* A Short Hike
|
* A Short Hike
|
||||||
* Yoshi's Island
|
* Yoshi's Island
|
||||||
@@ -74,18 +80,6 @@ Currently, the following games are supported:
|
|||||||
* Saving Princess
|
* Saving Princess
|
||||||
* Castlevania: Circle of the Moon
|
* Castlevania: Circle of the Moon
|
||||||
* Inscryption
|
* 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
|
|
||||||
* Mega Man 3
|
|
||||||
|
|
||||||
For setup and instructions check out our [tutorials page](https://archipelago.gg/tutorial/).
|
For setup and instructions check out our [tutorials page](https://archipelago.gg/tutorial/).
|
||||||
Downloads can be found at [Releases](https://github.com/ArchipelagoMW/Archipelago/releases), including compiled
|
Downloads can be found at [Releases](https://github.com/ArchipelagoMW/Archipelago/releases), including compiled
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ from json import loads, dumps
|
|||||||
from CommonClient import CommonContext, server_loop, ClientCommandProcessor, gui_enabled, get_base_parser
|
from CommonClient import CommonContext, server_loop, ClientCommandProcessor, gui_enabled, get_base_parser
|
||||||
|
|
||||||
import Utils
|
import Utils
|
||||||
import settings
|
|
||||||
from Utils import async_start
|
from Utils import async_start
|
||||||
from MultiServer import mark_raw
|
from MultiServer import mark_raw
|
||||||
if typing.TYPE_CHECKING:
|
if typing.TYPE_CHECKING:
|
||||||
@@ -286,7 +285,7 @@ class SNESState(enum.IntEnum):
|
|||||||
|
|
||||||
|
|
||||||
def launch_sni() -> None:
|
def launch_sni() -> None:
|
||||||
sni_path = settings.get_settings().sni_options.sni_path
|
sni_path = Utils.get_settings()["sni_options"]["sni_path"]
|
||||||
|
|
||||||
if not os.path.isdir(sni_path):
|
if not os.path.isdir(sni_path):
|
||||||
sni_path = Utils.local_path(sni_path)
|
sni_path = Utils.local_path(sni_path)
|
||||||
@@ -669,7 +668,8 @@ async def game_watcher(ctx: SNIContext) -> None:
|
|||||||
|
|
||||||
|
|
||||||
async def run_game(romfile: str) -> None:
|
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_settings()["sni_options"].get("snes_rom_start", True))
|
||||||
if auto_start is True:
|
if auto_start is True:
|
||||||
import webbrowser
|
import webbrowser
|
||||||
webbrowser.open(romfile)
|
webbrowser.open(romfile)
|
||||||
@@ -735,6 +735,6 @@ async def main() -> None:
|
|||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
colorama.just_fix_windows_console()
|
colorama.init()
|
||||||
asyncio.run(main())
|
asyncio.run(main())
|
||||||
colorama.deinit()
|
colorama.deinit()
|
||||||
|
|||||||
11
Starcraft2Client.py
Normal file
11
Starcraft2Client.py
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import ModuleUpdate
|
||||||
|
ModuleUpdate.update()
|
||||||
|
|
||||||
|
from worlds.sc2.Client import launch
|
||||||
|
import Utils
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
Utils.init_logging("Starcraft2Client", exception_logger="Client")
|
||||||
|
launch()
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
import time
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import typing
|
import typing
|
||||||
import bsdiff4
|
import bsdiff4
|
||||||
@@ -16,9 +15,6 @@ from CommonClient import CommonContext, server_loop, \
|
|||||||
gui_enabled, ClientCommandProcessor, logger, get_base_parser
|
gui_enabled, ClientCommandProcessor, logger, get_base_parser
|
||||||
from Utils import async_start
|
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):
|
class UndertaleCommandProcessor(ClientCommandProcessor):
|
||||||
def __init__(self, ctx):
|
def __init__(self, ctx):
|
||||||
@@ -113,11 +109,6 @@ class UndertaleContext(CommonContext):
|
|||||||
self.completed_routes = {"pacifist": 0, "genocide": 0, "neutral": 0}
|
self.completed_routes = {"pacifist": 0, "genocide": 0, "neutral": 0}
|
||||||
# self.save_game_folder: files go in this path to pass data between us and the actual game
|
# self.save_game_folder: 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.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):
|
def patch_game(self):
|
||||||
with open(Utils.user_path("Undertale", "data.win"), "rb") as f:
|
with open(Utils.user_path("Undertale", "data.win"), "rb") as f:
|
||||||
@@ -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",
|
await ctx.send_msgs([{"cmd": "SetNotify", "keys": [str(ctx.slot)+" RoutesDone neutral",
|
||||||
str(ctx.slot)+" RoutesDone pacifist",
|
str(ctx.slot)+" RoutesDone pacifist",
|
||||||
str(ctx.slot)+" RoutesDone genocide"]}])
|
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"]:
|
if args["slot_data"]["only_flakes"]:
|
||||||
with open(os.path.join(ctx.save_game_folder, "GenoNoChest.flag"), "w") as f:
|
with open(os.path.join(ctx.save_game_folder, "GenoNoChest.flag"), "w") as f:
|
||||||
f.close()
|
f.close()
|
||||||
@@ -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 str(ctx.slot)+" RoutesDone pacifist" in args["keys"]:
|
||||||
if args["keys"][str(ctx.slot) + " RoutesDone pacifist"] is not None:
|
if args["keys"][str(ctx.slot) + " RoutesDone pacifist"] is not None:
|
||||||
ctx.completed_routes["pacifist"] = args["keys"][str(ctx.slot)+" RoutesDone pacifist"]
|
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":
|
elif cmd == "SetReply":
|
||||||
if args["value"] is not None:
|
if args["value"] is not None:
|
||||||
if str(ctx.slot)+" RoutesDone pacifist" == args["key"]:
|
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"]
|
ctx.completed_routes["genocide"] = args["value"]
|
||||||
elif str(ctx.slot)+" RoutesDone neutral" == args["key"]:
|
elif str(ctx.slot)+" RoutesDone neutral" == args["key"]:
|
||||||
ctx.completed_routes["neutral"] = args["value"]
|
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":
|
elif cmd == "ReceivedItems":
|
||||||
start_index = args["index"]
|
start_index = args["index"]
|
||||||
|
|
||||||
if start_index == 0:
|
if start_index == 0:
|
||||||
ctx.items_received = []
|
ctx.items_received = []
|
||||||
elif start_index != len(ctx.items_received):
|
elif start_index != len(ctx.items_received):
|
||||||
await ctx.check_locations(ctx.locations_checked)
|
sync_msg = [{"cmd": "Sync"}]
|
||||||
await ctx.send_msgs([{"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):
|
if start_index == len(ctx.items_received):
|
||||||
counter = -1
|
counter = -1
|
||||||
placedWeapon = 0
|
placedWeapon = 0
|
||||||
@@ -388,8 +368,9 @@ async def process_undertale_cmd(ctx: UndertaleContext, cmd: str, args: dict):
|
|||||||
f.close()
|
f.close()
|
||||||
|
|
||||||
elif cmd == "Bounced":
|
elif cmd == "Bounced":
|
||||||
data = args.get("data", {})
|
tags = args.get("tags", [])
|
||||||
if "x" in data and "room" in data:
|
if "Online" in tags:
|
||||||
|
data = args.get("data", {})
|
||||||
if data["player"] != ctx.slot and data["player"] is not None:
|
if data["player"] != ctx.slot and data["player"] is not None:
|
||||||
filename = f"FRISK" + str(data["player"]) + ".playerspot"
|
filename = f"FRISK" + str(data["player"]) + ".playerspot"
|
||||||
with open(os.path.join(ctx.save_game_folder, filename), "w") as f:
|
with open(os.path.join(ctx.save_game_folder, filename), "w") as f:
|
||||||
@@ -400,63 +381,21 @@ async def process_undertale_cmd(ctx: UndertaleContext, cmd: str, args: dict):
|
|||||||
|
|
||||||
async def multi_watcher(ctx: UndertaleContext):
|
async def multi_watcher(ctx: UndertaleContext):
|
||||||
while not ctx.exit_event.is_set():
|
while not ctx.exit_event.is_set():
|
||||||
if "Online" in ctx.tags and any(
|
path = ctx.save_game_folder
|
||||||
info.game == "Undertale" and slot != ctx.slot
|
for root, dirs, files in os.walk(path):
|
||||||
for slot, info in ctx.slot_info.items()):
|
for file in files:
|
||||||
now = time.time()
|
if "spots.mine" in file and "Online" in ctx.tags:
|
||||||
path = ctx.save_game_folder
|
with open(os.path.join(root, file), "r") as mine:
|
||||||
for root, dirs, files in os.walk(path):
|
this_x = mine.readline()
|
||||||
for file in files:
|
this_y = mine.readline()
|
||||||
if "spots.mine" in file:
|
this_room = mine.readline()
|
||||||
with open(os.path.join(root, file), "r") as mine:
|
this_sprite = mine.readline()
|
||||||
this_x = mine.readline()
|
this_frame = mine.readline()
|
||||||
this_y = mine.readline()
|
mine.close()
|
||||||
this_room = mine.readline()
|
message = [{"cmd": "Bounce", "tags": ["Online"],
|
||||||
this_sprite = mine.readline()
|
"data": {"player": ctx.slot, "x": this_x, "y": this_y, "room": this_room,
|
||||||
this_frame = mine.readline()
|
"spr": this_sprite, "frm": this_frame}}]
|
||||||
|
await ctx.send_msgs(message)
|
||||||
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
|
|
||||||
|
|
||||||
await asyncio.sleep(0.1)
|
await asyncio.sleep(0.1)
|
||||||
|
|
||||||
@@ -470,9 +409,10 @@ async def game_watcher(ctx: UndertaleContext):
|
|||||||
for file in files:
|
for file in files:
|
||||||
if ".item" in file:
|
if ".item" in file:
|
||||||
os.remove(os.path.join(root, file))
|
os.remove(os.path.join(root, file))
|
||||||
await ctx.check_locations(ctx.locations_checked)
|
sync_msg = [{"cmd": "Sync"}]
|
||||||
await ctx.send_msgs([{"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
|
ctx.syncing = False
|
||||||
if ctx.got_deathlink:
|
if ctx.got_deathlink:
|
||||||
ctx.got_deathlink = False
|
ctx.got_deathlink = False
|
||||||
@@ -507,7 +447,7 @@ async def game_watcher(ctx: UndertaleContext):
|
|||||||
for l in lines:
|
for l in lines:
|
||||||
sending = sending+[(int(l.rstrip('\n')))+12000]
|
sending = sending+[(int(l.rstrip('\n')))+12000]
|
||||||
finally:
|
finally:
|
||||||
await ctx.check_locations(sending)
|
await ctx.send_msgs([{"cmd": "LocationChecks", "locations": sending}])
|
||||||
if "victory" in file and str(ctx.route) in file:
|
if "victory" in file and str(ctx.route) in file:
|
||||||
victory = True
|
victory = True
|
||||||
if ".playerspot" in file and "Online" not in ctx.tags:
|
if ".playerspot" in file and "Online" not in ctx.tags:
|
||||||
@@ -560,7 +500,7 @@ def main():
|
|||||||
|
|
||||||
import colorama
|
import colorama
|
||||||
|
|
||||||
colorama.just_fix_windows_console()
|
colorama.init()
|
||||||
|
|
||||||
asyncio.run(_main())
|
asyncio.run(_main())
|
||||||
colorama.deinit()
|
colorama.deinit()
|
||||||
|
|||||||
407
Utils.py
407
Utils.py
@@ -1,7 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import concurrent.futures
|
|
||||||
import json
|
import json
|
||||||
import typing
|
import typing
|
||||||
import builtins
|
import builtins
|
||||||
@@ -18,14 +17,10 @@ import logging
|
|||||||
import warnings
|
import warnings
|
||||||
|
|
||||||
from argparse import Namespace
|
from argparse import Namespace
|
||||||
from datetime import datetime, timezone
|
|
||||||
|
|
||||||
from settings import Settings, get_settings
|
from settings import Settings, get_settings
|
||||||
from time import sleep
|
from time import sleep
|
||||||
from typing import BinaryIO, Coroutine, Optional, Set, Dict, Any, Union, TypeGuard
|
from typing import BinaryIO, Coroutine, Optional, Set, Dict, Any, Union, TypeGuard
|
||||||
from yaml import load, load_all, dump
|
from yaml import load, load_all, dump
|
||||||
from pathspec import PathSpec, GitIgnoreSpec
|
|
||||||
from typing_extensions import deprecated
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from yaml import CLoader as UnsafeLoader, CSafeLoader as SafeLoader, CDumper as Dumper
|
from yaml import CLoader as UnsafeLoader, CSafeLoader as SafeLoader, CDumper as Dumper
|
||||||
@@ -40,7 +35,7 @@ if typing.TYPE_CHECKING:
|
|||||||
|
|
||||||
|
|
||||||
def tuplize_version(version: str) -> Version:
|
def tuplize_version(version: str) -> Version:
|
||||||
return Version(*(int(piece) for piece in version.split(".")))
|
return Version(*(int(piece, 10) for piece in version.split(".")))
|
||||||
|
|
||||||
|
|
||||||
class Version(typing.NamedTuple):
|
class Version(typing.NamedTuple):
|
||||||
@@ -52,7 +47,7 @@ class Version(typing.NamedTuple):
|
|||||||
return ".".join(str(item) for item in self)
|
return ".".join(str(item) for item in self)
|
||||||
|
|
||||||
|
|
||||||
__version__ = "0.6.7"
|
__version__ = "0.6.0"
|
||||||
version_tuple = tuplize_version(__version__)
|
version_tuple = tuplize_version(__version__)
|
||||||
|
|
||||||
is_linux = sys.platform.startswith("linux")
|
is_linux = sys.platform.startswith("linux")
|
||||||
@@ -119,8 +114,6 @@ def cache_self1(function: typing.Callable[[S, T], RetType]) -> typing.Callable[[
|
|||||||
cache[arg] = res
|
cache[arg] = res
|
||||||
return res
|
return res
|
||||||
|
|
||||||
wrap.__defaults__ = function.__defaults__
|
|
||||||
|
|
||||||
return wrap
|
return wrap
|
||||||
|
|
||||||
|
|
||||||
@@ -144,11 +137,8 @@ def local_path(*path: str) -> str:
|
|||||||
local_path.cached_path = os.path.dirname(os.path.abspath(sys.argv[0]))
|
local_path.cached_path = os.path.dirname(os.path.abspath(sys.argv[0]))
|
||||||
else:
|
else:
|
||||||
import __main__
|
import __main__
|
||||||
if globals().get("__file__") and os.path.isfile(__file__):
|
if hasattr(__main__, "__file__") and os.path.isfile(__main__.__file__):
|
||||||
# we are running in a normal Python environment
|
# we are running in a normal Python environment
|
||||||
local_path.cached_path = os.path.dirname(os.path.abspath(__file__))
|
|
||||||
elif hasattr(__main__, "__file__") and os.path.isfile(__main__.__file__):
|
|
||||||
# we are running in a normal Python environment, but AP was imported weirdly
|
|
||||||
local_path.cached_path = os.path.dirname(os.path.abspath(__main__.__file__))
|
local_path.cached_path = os.path.dirname(os.path.abspath(__main__.__file__))
|
||||||
else:
|
else:
|
||||||
# pray
|
# pray
|
||||||
@@ -171,10 +161,6 @@ def home_path(*path: str) -> str:
|
|||||||
os.symlink(home_path.cached_path, legacy_home_path)
|
os.symlink(home_path.cached_path, legacy_home_path)
|
||||||
else:
|
else:
|
||||||
os.makedirs(home_path.cached_path, 0o700, exist_ok=True)
|
os.makedirs(home_path.cached_path, 0o700, exist_ok=True)
|
||||||
elif sys.platform == 'darwin':
|
|
||||||
import platformdirs
|
|
||||||
home_path.cached_path = platformdirs.user_data_dir("Archipelago", False)
|
|
||||||
os.makedirs(home_path.cached_path, 0o700, exist_ok=True)
|
|
||||||
else:
|
else:
|
||||||
# not implemented
|
# not implemented
|
||||||
home_path.cached_path = local_path() # this will generate the same exceptions we got previously
|
home_path.cached_path = local_path() # this will generate the same exceptions we got previously
|
||||||
@@ -186,7 +172,7 @@ def user_path(*path: str) -> str:
|
|||||||
"""Returns either local_path or home_path based on write permissions."""
|
"""Returns either local_path or home_path based on write permissions."""
|
||||||
if hasattr(user_path, "cached_path"):
|
if hasattr(user_path, "cached_path"):
|
||||||
pass
|
pass
|
||||||
elif os.access(local_path(), os.W_OK) and not (is_macos and is_frozen()):
|
elif os.access(local_path(), os.W_OK):
|
||||||
user_path.cached_path = local_path()
|
user_path.cached_path = local_path()
|
||||||
else:
|
else:
|
||||||
user_path.cached_path = home_path()
|
user_path.cached_path = home_path()
|
||||||
@@ -235,12 +221,7 @@ def open_file(filename: typing.Union[str, "pathlib.Path"]) -> None:
|
|||||||
from shutil import which
|
from shutil import which
|
||||||
open_command = which("open") if is_macos else (which("xdg-open") or which("gnome-open") or which("kde-open"))
|
open_command = which("open") if is_macos else (which("xdg-open") or which("gnome-open") or which("kde-open"))
|
||||||
assert open_command, "Didn't find program for open_file! Please report this together with system details."
|
assert open_command, "Didn't find program for open_file! Please report this together with system details."
|
||||||
|
subprocess.call([open_command, filename])
|
||||||
env = os.environ
|
|
||||||
if "LD_LIBRARY_PATH" in env:
|
|
||||||
env = env.copy()
|
|
||||||
del env["LD_LIBRARY_PATH"] # exe is a system binary, so reset LD_LIBRARY_PATH
|
|
||||||
subprocess.call([open_command, filename], env=env)
|
|
||||||
|
|
||||||
|
|
||||||
# from https://gist.github.com/pypt/94d747fe5180851196eb#gistcomment-4015118 with some changes
|
# from https://gist.github.com/pypt/94d747fe5180851196eb#gistcomment-4015118 with some changes
|
||||||
@@ -318,19 +299,20 @@ def get_public_ipv6() -> str:
|
|||||||
return ip
|
return ip
|
||||||
|
|
||||||
|
|
||||||
@deprecated("Utils.get_options() is deprecated. Use the settings API instead.")
|
OptionsType = Settings # TODO: remove when removing get_options
|
||||||
|
|
||||||
|
|
||||||
def get_options() -> Settings:
|
def get_options() -> Settings:
|
||||||
deprecate("Utils.get_options() is deprecated. Use the settings API instead.")
|
# TODO: switch to Utils.deprecate after 0.4.4
|
||||||
|
warnings.warn("Utils.get_options() is deprecated. Use the settings API instead.", DeprecationWarning)
|
||||||
return get_settings()
|
return get_settings()
|
||||||
|
|
||||||
|
|
||||||
def persistent_store(category: str, key: str, value: typing.Any, force_store: bool = False):
|
def persistent_store(category: str, key: str, value: typing.Any):
|
||||||
|
path = user_path("_persistent_storage.yaml")
|
||||||
storage = persistent_load()
|
storage = persistent_load()
|
||||||
if not force_store and category in storage and key in storage[category] and storage[category][key] == value:
|
|
||||||
return # no changes necessary
|
|
||||||
category_dict = storage.setdefault(category, {})
|
category_dict = storage.setdefault(category, {})
|
||||||
category_dict[key] = value
|
category_dict[key] = value
|
||||||
path = user_path("_persistent_storage.yaml")
|
|
||||||
with open(path, "wt") as f:
|
with open(path, "wt") as f:
|
||||||
f.write(dump(storage, Dumper=Dumper))
|
f.write(dump(storage, Dumper=Dumper))
|
||||||
|
|
||||||
@@ -392,14 +374,6 @@ def store_data_package_for_checksum(game: str, data: typing.Dict[str, Any]) -> N
|
|||||||
logging.debug(f"Could not store data package: {e}")
|
logging.debug(f"Could not store data package: {e}")
|
||||||
|
|
||||||
|
|
||||||
def read_apignore(filename: str | pathlib.Path) -> PathSpec | None:
|
|
||||||
try:
|
|
||||||
with open(filename) as ignore_file:
|
|
||||||
return GitIgnoreSpec.from_lines(ignore_file)
|
|
||||||
except FileNotFoundError:
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def get_default_adjuster_settings(game_name: str) -> Namespace:
|
def get_default_adjuster_settings(game_name: str) -> Namespace:
|
||||||
import LttPAdjuster
|
import LttPAdjuster
|
||||||
adjuster_settings = Namespace()
|
adjuster_settings = Namespace()
|
||||||
@@ -425,26 +399,13 @@ def get_adjuster_settings(game_name: str) -> Namespace:
|
|||||||
|
|
||||||
@cache_argsless
|
@cache_argsless
|
||||||
def get_unique_identifier():
|
def get_unique_identifier():
|
||||||
common_path = cache_path("common.json")
|
uuid = persistent_load().get("client", {}).get("uuid", None)
|
||||||
try:
|
|
||||||
with open(common_path) as f:
|
|
||||||
common_file = json.load(f)
|
|
||||||
uuid = common_file.get("uuid", None)
|
|
||||||
except FileNotFoundError:
|
|
||||||
common_file = {}
|
|
||||||
uuid = None
|
|
||||||
|
|
||||||
if uuid:
|
if uuid:
|
||||||
return uuid
|
return uuid
|
||||||
|
|
||||||
from uuid import uuid4
|
import uuid
|
||||||
uuid = str(uuid4())
|
uuid = uuid.getnode()
|
||||||
common_file["uuid"] = uuid
|
persistent_store("client", "uuid", uuid)
|
||||||
|
|
||||||
cache_folder = os.path.dirname(common_path)
|
|
||||||
os.makedirs(cache_folder, exist_ok=True)
|
|
||||||
with open(common_path, "w") as f:
|
|
||||||
json.dump(common_file, f, separators=(",", ":"))
|
|
||||||
return uuid
|
return uuid
|
||||||
|
|
||||||
|
|
||||||
@@ -466,10 +427,6 @@ class RestrictedUnpickler(pickle.Unpickler):
|
|||||||
def find_class(self, module: str, name: str) -> type:
|
def find_class(self, module: str, name: str) -> type:
|
||||||
if module == "builtins" and name in safe_builtins:
|
if module == "builtins" and name in safe_builtins:
|
||||||
return getattr(builtins, name)
|
return getattr(builtins, name)
|
||||||
# used by OptionCounter
|
|
||||||
# necessary because the actual Options class instances are pickled when transfered to WebHost generation pool
|
|
||||||
if module == "collections" and name == "Counter":
|
|
||||||
return collections.Counter
|
|
||||||
# used by MultiServer -> savegame/multidata
|
# used by MultiServer -> savegame/multidata
|
||||||
if module == "NetUtils" and name in {"NetworkItem", "ClientStatus", "Hint",
|
if module == "NetUtils" and name in {"NetworkItem", "ClientStatus", "Hint",
|
||||||
"SlotType", "NetworkSlot", "HintStatus"}:
|
"SlotType", "NetworkSlot", "HintStatus"}:
|
||||||
@@ -487,7 +444,7 @@ class RestrictedUnpickler(pickle.Unpickler):
|
|||||||
mod = importlib.import_module(module)
|
mod = importlib.import_module(module)
|
||||||
obj = getattr(mod, name)
|
obj = getattr(mod, name)
|
||||||
if issubclass(obj, (self.options_module.Option, self.options_module.PlandoConnection,
|
if issubclass(obj, (self.options_module.Option, self.options_module.PlandoConnection,
|
||||||
self.options_module.PlandoItem, self.options_module.PlandoText)):
|
self.options_module.PlandoText)):
|
||||||
return obj
|
return obj
|
||||||
# Forbid everything else.
|
# Forbid everything else.
|
||||||
raise pickle.UnpicklingError(f"global '{module}.{name}' is forbidden")
|
raise pickle.UnpicklingError(f"global '{module}.{name}' is forbidden")
|
||||||
@@ -498,18 +455,6 @@ def restricted_loads(s: bytes) -> Any:
|
|||||||
return RestrictedUnpickler(io.BytesIO(s)).load()
|
return RestrictedUnpickler(io.BytesIO(s)).load()
|
||||||
|
|
||||||
|
|
||||||
def restricted_dumps(obj: Any) -> bytes:
|
|
||||||
"""Helper function analogous to pickle.dumps()."""
|
|
||||||
s = pickle.dumps(obj)
|
|
||||||
# Assert that the string can be successfully loaded by restricted_loads
|
|
||||||
try:
|
|
||||||
restricted_loads(s)
|
|
||||||
except pickle.UnpicklingError as e:
|
|
||||||
raise pickle.PicklingError(e) from e
|
|
||||||
|
|
||||||
return s
|
|
||||||
|
|
||||||
|
|
||||||
class ByValue:
|
class ByValue:
|
||||||
"""
|
"""
|
||||||
Mixin for enums to pickle value instead of name (restores pre-3.11 behavior). Use as left-most parent.
|
Mixin for enums to pickle value instead of name (restores pre-3.11 behavior). Use as left-most parent.
|
||||||
@@ -587,8 +532,6 @@ def init_logging(name: str, loglevel: typing.Union[str, int] = logging.INFO,
|
|||||||
if add_timestamp:
|
if add_timestamp:
|
||||||
stream_handler.setFormatter(formatter)
|
stream_handler.setFormatter(formatter)
|
||||||
root_logger.addHandler(stream_handler)
|
root_logger.addHandler(stream_handler)
|
||||||
if hasattr(sys.stdout, "reconfigure"):
|
|
||||||
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
|
||||||
|
|
||||||
# Relay unhandled exceptions to logger.
|
# Relay unhandled exceptions to logger.
|
||||||
if not getattr(sys.excepthook, "_wrapped", False): # skip if already modified
|
if not getattr(sys.excepthook, "_wrapped", False): # skip if already modified
|
||||||
@@ -687,8 +630,6 @@ def get_fuzzy_results(input_word: str, word_list: typing.Collection[str], limit:
|
|||||||
import jellyfish
|
import jellyfish
|
||||||
|
|
||||||
def get_fuzzy_ratio(word1: str, word2: str) -> float:
|
def get_fuzzy_ratio(word1: str, word2: str) -> float:
|
||||||
if word1 == word2:
|
|
||||||
return 1.01
|
|
||||||
return (1 - jellyfish.damerau_levenshtein_distance(word1.lower(), word2.lower())
|
return (1 - jellyfish.damerau_levenshtein_distance(word1.lower(), word2.lower())
|
||||||
/ max(len(word1), len(word2)))
|
/ max(len(word1), len(word2)))
|
||||||
|
|
||||||
@@ -709,10 +650,8 @@ def get_intended_text(input_text: str, possible_answers) -> typing.Tuple[str, bo
|
|||||||
picks = get_fuzzy_results(input_text, possible_answers, limit=2)
|
picks = get_fuzzy_results(input_text, possible_answers, limit=2)
|
||||||
if len(picks) > 1:
|
if len(picks) > 1:
|
||||||
dif = picks[0][1] - picks[1][1]
|
dif = picks[0][1] - picks[1][1]
|
||||||
if picks[0][1] == 101:
|
if picks[0][1] == 100:
|
||||||
return picks[0][0], True, "Perfect Match"
|
return picks[0][0], True, "Perfect Match"
|
||||||
elif picks[0][1] == 100:
|
|
||||||
return picks[0][0], True, "Case Insensitive Perfect Match"
|
|
||||||
elif picks[0][1] < 75:
|
elif picks[0][1] < 75:
|
||||||
return picks[0][0], False, f"Didn't find something that closely matches '{input_text}', " \
|
return picks[0][0], False, f"Didn't find something that closely matches '{input_text}', " \
|
||||||
f"did you mean '{picks[0][0]}'? ({picks[0][1]}% sure)"
|
f"did you mean '{picks[0][0]}'? ({picks[0][1]}% sure)"
|
||||||
@@ -730,22 +669,13 @@ def get_intended_text(input_text: str, possible_answers) -> typing.Tuple[str, bo
|
|||||||
|
|
||||||
|
|
||||||
def get_input_text_from_response(text: str, command: str) -> typing.Optional[str]:
|
def get_input_text_from_response(text: str, command: str) -> typing.Optional[str]:
|
||||||
"""
|
|
||||||
Parses the response text from `get_intended_text` to find the suggested input and autocomplete the command in
|
|
||||||
arguments with it.
|
|
||||||
|
|
||||||
:param text: The response text from `get_intended_text`.
|
|
||||||
:param command: The command to which the input text should be added. Must contain the prefix used by the command
|
|
||||||
(`!` or `/`).
|
|
||||||
:return: The command with the suggested input text appended, or None if no suggestion was found.
|
|
||||||
"""
|
|
||||||
if "did you mean " in text:
|
if "did you mean " in text:
|
||||||
for question in ("Didn't find something that closely matches",
|
for question in ("Didn't find something that closely matches",
|
||||||
"Too many close matches"):
|
"Too many close matches"):
|
||||||
if text.startswith(question):
|
if text.startswith(question):
|
||||||
name = get_text_between(text, "did you mean '",
|
name = get_text_between(text, "did you mean '",
|
||||||
"'? (")
|
"'? (")
|
||||||
return f"{command} {name}"
|
return f"!{command} {name}"
|
||||||
elif text.startswith("Missing: "):
|
elif text.startswith("Missing: "):
|
||||||
return text.replace("Missing: ", "!hint_location ")
|
return text.replace("Missing: ", "!hint_location ")
|
||||||
return None
|
return None
|
||||||
@@ -764,35 +694,25 @@ def _mp_open_filename(res: "multiprocessing.Queue[typing.Optional[str]]", *args:
|
|||||||
res.put(open_filename(*args))
|
res.put(open_filename(*args))
|
||||||
|
|
||||||
|
|
||||||
def _mp_save_filename(res: "multiprocessing.Queue[typing.Optional[str]]", *args: Any) -> None:
|
|
||||||
if is_kivy_running():
|
|
||||||
raise RuntimeError("kivy should not be running in multiprocess")
|
|
||||||
res.put(save_filename(*args))
|
|
||||||
|
|
||||||
def _run_for_stdout(*args: str):
|
|
||||||
env = os.environ
|
|
||||||
if "LD_LIBRARY_PATH" in env:
|
|
||||||
env = env.copy()
|
|
||||||
del env["LD_LIBRARY_PATH"] # exe is a system binary, so reset LD_LIBRARY_PATH
|
|
||||||
return subprocess.run(args, capture_output=True, text=True, env=env).stdout.split("\n", 1)[0] or None
|
|
||||||
|
|
||||||
|
|
||||||
def open_filename(title: str, filetypes: typing.Iterable[typing.Tuple[str, typing.Iterable[str]]], suggest: str = "") \
|
def open_filename(title: str, filetypes: typing.Iterable[typing.Tuple[str, typing.Iterable[str]]], suggest: str = "") \
|
||||||
-> typing.Optional[str]:
|
-> typing.Optional[str]:
|
||||||
logging.info(f"Opening file input dialog for {title}.")
|
logging.info(f"Opening file input dialog for {title}.")
|
||||||
|
|
||||||
|
def run(*args: str):
|
||||||
|
return subprocess.run(args, capture_output=True, text=True).stdout.split("\n", 1)[0] or None
|
||||||
|
|
||||||
if is_linux:
|
if is_linux:
|
||||||
# prefer native dialog
|
# prefer native dialog
|
||||||
from shutil import which
|
from shutil import which
|
||||||
kdialog = which("kdialog")
|
kdialog = which("kdialog")
|
||||||
if kdialog:
|
if kdialog:
|
||||||
k_filters = '|'.join((f'{text} (*{" *".join(ext)})' for (text, ext) in filetypes))
|
k_filters = '|'.join((f'{text} (*{" *".join(ext)})' for (text, ext) in filetypes))
|
||||||
return _run_for_stdout(kdialog, f"--title={title}", "--getopenfilename", suggest or ".", k_filters)
|
return run(kdialog, f"--title={title}", "--getopenfilename", suggest or ".", k_filters)
|
||||||
zenity = which("zenity")
|
zenity = which("zenity")
|
||||||
if zenity:
|
if zenity:
|
||||||
z_filters = (f'--file-filter={text} ({", ".join(ext)}) | *{" *".join(ext)}' for (text, ext) in filetypes)
|
z_filters = (f'--file-filter={text} ({", ".join(ext)}) | *{" *".join(ext)}' for (text, ext) in filetypes)
|
||||||
selection = (f"--filename={suggest}",) if suggest else ()
|
selection = (f"--filename={suggest}",) if suggest else ()
|
||||||
return _run_for_stdout(zenity, f"--title={title}", "--file-selection", *z_filters, *selection)
|
return run(zenity, f"--title={title}", "--file-selection", *z_filters, *selection)
|
||||||
|
|
||||||
# fall back to tk
|
# fall back to tk
|
||||||
try:
|
try:
|
||||||
@@ -815,62 +735,8 @@ def open_filename(title: str, filetypes: typing.Iterable[typing.Tuple[str, typin
|
|||||||
except tkinter.TclError:
|
except tkinter.TclError:
|
||||||
return None # GUI not available. None is the same as a user clicking "cancel"
|
return None # GUI not available. None is the same as a user clicking "cancel"
|
||||||
root.withdraw()
|
root.withdraw()
|
||||||
try:
|
return tkinter.filedialog.askopenfilename(title=title, filetypes=((t[0], ' '.join(t[1])) for t in filetypes),
|
||||||
return tkinter.filedialog.askopenfilename(
|
initialfile=suggest or None)
|
||||||
title=title,
|
|
||||||
filetypes=((t[0], ' '.join(t[1])) for t in filetypes),
|
|
||||||
initialfile=suggest or None,
|
|
||||||
)
|
|
||||||
finally:
|
|
||||||
root.destroy()
|
|
||||||
|
|
||||||
|
|
||||||
def save_filename(title: str, filetypes: typing.Iterable[typing.Tuple[str, typing.Iterable[str]]], suggest: str = "") \
|
|
||||||
-> typing.Optional[str]:
|
|
||||||
logging.info(f"Opening file save dialog for {title}.")
|
|
||||||
|
|
||||||
if is_linux:
|
|
||||||
# prefer native dialog
|
|
||||||
from shutil import which
|
|
||||||
kdialog = which("kdialog")
|
|
||||||
if kdialog:
|
|
||||||
k_filters = '|'.join((f'{text} (*{" *".join(ext)})' for (text, ext) in filetypes))
|
|
||||||
return _run_for_stdout(kdialog, f"--title={title}", "--getsavefilename", suggest or ".", k_filters)
|
|
||||||
zenity = which("zenity")
|
|
||||||
if zenity:
|
|
||||||
z_filters = (f'--file-filter={text} ({", ".join(ext)}) | *{" *".join(ext)}' for (text, ext) in filetypes)
|
|
||||||
selection = (f"--filename={suggest}",) if suggest else ()
|
|
||||||
return _run_for_stdout(zenity, f"--title={title}", "--file-selection", "--save", *z_filters, *selection)
|
|
||||||
|
|
||||||
# fall back to tk
|
|
||||||
try:
|
|
||||||
import tkinter
|
|
||||||
import tkinter.filedialog
|
|
||||||
except Exception as e:
|
|
||||||
logging.error('Could not load tkinter, which is likely not installed. '
|
|
||||||
f'This attempt was made because save_filename was used for "{title}".')
|
|
||||||
raise e
|
|
||||||
else:
|
|
||||||
if is_macos and is_kivy_running():
|
|
||||||
# on macOS, mixing kivy and tk does not work, so spawn a new process
|
|
||||||
# FIXME: performance of this is pretty bad, and we should (also) look into alternatives
|
|
||||||
from multiprocessing import Process, Queue
|
|
||||||
res: "Queue[typing.Optional[str]]" = Queue()
|
|
||||||
Process(target=_mp_save_filename, args=(res, title, filetypes, suggest)).start()
|
|
||||||
return res.get()
|
|
||||||
try:
|
|
||||||
root = tkinter.Tk()
|
|
||||||
except tkinter.TclError:
|
|
||||||
return None # GUI not available. None is the same as a user clicking "cancel"
|
|
||||||
root.withdraw()
|
|
||||||
try:
|
|
||||||
return tkinter.filedialog.asksaveasfilename(
|
|
||||||
title=title,
|
|
||||||
filetypes=((t[0], ' '.join(t[1])) for t in filetypes),
|
|
||||||
initialfile=suggest or None,
|
|
||||||
)
|
|
||||||
finally:
|
|
||||||
root.destroy()
|
|
||||||
|
|
||||||
|
|
||||||
def _mp_open_directory(res: "multiprocessing.Queue[typing.Optional[str]]", *args: Any) -> None:
|
def _mp_open_directory(res: "multiprocessing.Queue[typing.Optional[str]]", *args: Any) -> None:
|
||||||
@@ -880,18 +746,21 @@ def _mp_open_directory(res: "multiprocessing.Queue[typing.Optional[str]]", *args
|
|||||||
|
|
||||||
|
|
||||||
def open_directory(title: str, suggest: str = "") -> typing.Optional[str]:
|
def open_directory(title: str, suggest: str = "") -> typing.Optional[str]:
|
||||||
|
def run(*args: str):
|
||||||
|
return subprocess.run(args, capture_output=True, text=True).stdout.split("\n", 1)[0] or None
|
||||||
|
|
||||||
if is_linux:
|
if is_linux:
|
||||||
# prefer native dialog
|
# prefer native dialog
|
||||||
from shutil import which
|
from shutil import which
|
||||||
kdialog = which("kdialog")
|
kdialog = which("kdialog")
|
||||||
if kdialog:
|
if kdialog:
|
||||||
return _run_for_stdout(kdialog, f"--title={title}", "--getexistingdirectory",
|
return run(kdialog, f"--title={title}", "--getexistingdirectory",
|
||||||
os.path.abspath(suggest) if suggest else ".")
|
os.path.abspath(suggest) if suggest else ".")
|
||||||
zenity = which("zenity")
|
zenity = which("zenity")
|
||||||
if zenity:
|
if zenity:
|
||||||
z_filters = ("--directory",)
|
z_filters = ("--directory",)
|
||||||
selection = (f"--filename={os.path.abspath(suggest)}/",) if suggest else ()
|
selection = (f"--filename={os.path.abspath(suggest)}/",) if suggest else ()
|
||||||
return _run_for_stdout(zenity, f"--title={title}", "--file-selection", *z_filters, *selection)
|
return run(zenity, f"--title={title}", "--file-selection", *z_filters, *selection)
|
||||||
|
|
||||||
# fall back to tk
|
# fall back to tk
|
||||||
try:
|
try:
|
||||||
@@ -918,12 +787,8 @@ def open_directory(title: str, suggest: str = "") -> typing.Optional[str]:
|
|||||||
|
|
||||||
|
|
||||||
def messagebox(title: str, text: str, error: bool = False) -> None:
|
def messagebox(title: str, text: str, error: bool = False) -> None:
|
||||||
if not gui_enabled:
|
def run(*args: str):
|
||||||
if error:
|
return subprocess.run(args, capture_output=True, text=True).stdout.split("\n", 1)[0] or None
|
||||||
logging.error(f"{title}: {text}")
|
|
||||||
else:
|
|
||||||
logging.info(f"{title}: {text}")
|
|
||||||
return
|
|
||||||
|
|
||||||
if is_kivy_running():
|
if is_kivy_running():
|
||||||
from kvui import MessageBox
|
from kvui import MessageBox
|
||||||
@@ -935,10 +800,10 @@ def messagebox(title: str, text: str, error: bool = False) -> None:
|
|||||||
from shutil import which
|
from shutil import which
|
||||||
kdialog = which("kdialog")
|
kdialog = which("kdialog")
|
||||||
if kdialog:
|
if kdialog:
|
||||||
return _run_for_stdout(kdialog, f"--title={title}", "--error" if error else "--msgbox", text)
|
return run(kdialog, f"--title={title}", "--error" if error else "--msgbox", text)
|
||||||
zenity = which("zenity")
|
zenity = which("zenity")
|
||||||
if zenity:
|
if zenity:
|
||||||
return _run_for_stdout(zenity, f"--title={title}", f"--text={text}", "--error" if error else "--info")
|
return run(zenity, f"--title={title}", f"--text={text}", "--error" if error else "--info")
|
||||||
|
|
||||||
elif is_windows:
|
elif is_windows:
|
||||||
import ctypes
|
import ctypes
|
||||||
@@ -960,9 +825,6 @@ def messagebox(title: str, text: str, error: bool = False) -> None:
|
|||||||
root.update()
|
root.update()
|
||||||
|
|
||||||
|
|
||||||
gui_enabled = not sys.stdout or "--nogui" not in sys.argv
|
|
||||||
"""Checks if the user wanted no GUI mode and has a terminal to use it with."""
|
|
||||||
|
|
||||||
def title_sorted(data: typing.Iterable, key=None, ignore: typing.AbstractSet[str] = frozenset(("a", "the"))):
|
def title_sorted(data: typing.Iterable, key=None, ignore: typing.AbstractSet[str] = frozenset(("a", "the"))):
|
||||||
"""Sorts a sequence of text ignoring typical articles like "a" or "the" in the beginning."""
|
"""Sorts a sequence of text ignoring typical articles like "a" or "the" in the beginning."""
|
||||||
def sorter(element: Union[str, Dict[str, Any]]) -> str:
|
def sorter(element: Union[str, Dict[str, Any]]) -> str:
|
||||||
@@ -993,7 +855,7 @@ def async_start(co: Coroutine[None, None, typing.Any], name: Optional[str] = Non
|
|||||||
Use this to start a task when you don't keep a reference to it or immediately await it,
|
Use this to start a task when you don't keep a reference to it or immediately await it,
|
||||||
to prevent early garbage collection. "fire-and-forget"
|
to prevent early garbage collection. "fire-and-forget"
|
||||||
"""
|
"""
|
||||||
# https://docs.python.org/3.11/library/asyncio-task.html#asyncio.create_task
|
# https://docs.python.org/3.10/library/asyncio-task.html#asyncio.create_task
|
||||||
# Python docs:
|
# Python docs:
|
||||||
# ```
|
# ```
|
||||||
# Important: Save a reference to the result of [asyncio.create_task],
|
# Important: Save a reference to the result of [asyncio.create_task],
|
||||||
@@ -1007,7 +869,6 @@ def async_start(co: Coroutine[None, None, typing.Any], name: Optional[str] = Non
|
|||||||
|
|
||||||
|
|
||||||
def deprecate(message: str, add_stacklevels: int = 0):
|
def deprecate(message: str, add_stacklevels: int = 0):
|
||||||
"""also use typing_extensions.deprecated wherever you use this"""
|
|
||||||
if __debug__:
|
if __debug__:
|
||||||
raise Exception(message)
|
raise Exception(message)
|
||||||
warnings.warn(message, stacklevel=2 + add_stacklevels)
|
warnings.warn(message, stacklevel=2 + add_stacklevels)
|
||||||
@@ -1031,15 +892,15 @@ class DeprecateDict(dict):
|
|||||||
|
|
||||||
|
|
||||||
def _extend_freeze_support() -> None:
|
def _extend_freeze_support() -> None:
|
||||||
"""Extend multiprocessing.freeze_support() to also work on Non-Windows and without setting spawn method first."""
|
"""Extend multiprocessing.freeze_support() to also work on Non-Windows for spawn."""
|
||||||
# original upstream issue: https://github.com/python/cpython/issues/76327
|
# upstream issue: https://github.com/python/cpython/issues/76327
|
||||||
# code based on https://github.com/pyinstaller/pyinstaller/blob/develop/PyInstaller/hooks/rthooks/pyi_rth_multiprocessing.py#L26
|
# code based on https://github.com/pyinstaller/pyinstaller/blob/develop/PyInstaller/hooks/rthooks/pyi_rth_multiprocessing.py#L26
|
||||||
import multiprocessing
|
import multiprocessing
|
||||||
import multiprocessing.spawn
|
import multiprocessing.spawn
|
||||||
|
|
||||||
def _freeze_support() -> None:
|
def _freeze_support() -> None:
|
||||||
"""Minimal freeze_support. Only apply this if frozen."""
|
"""Minimal freeze_support. Only apply this if frozen."""
|
||||||
from subprocess import _args_from_interpreter_flags # noqa
|
from subprocess import _args_from_interpreter_flags
|
||||||
|
|
||||||
# Prevent `spawn` from trying to read `__main__` in from the main script
|
# Prevent `spawn` from trying to read `__main__` in from the main script
|
||||||
multiprocessing.process.ORIGINAL_DIR = None
|
multiprocessing.process.ORIGINAL_DIR = None
|
||||||
@@ -1047,7 +908,8 @@ def _extend_freeze_support() -> None:
|
|||||||
# Handle the first process that MP will create
|
# Handle the first process that MP will create
|
||||||
if (
|
if (
|
||||||
len(sys.argv) >= 2 and sys.argv[-2] == '-c' and sys.argv[-1].startswith((
|
len(sys.argv) >= 2 and sys.argv[-2] == '-c' and sys.argv[-1].startswith((
|
||||||
'from multiprocessing.resource_tracker import main',
|
'from multiprocessing.semaphore_tracker import main', # Py<3.8
|
||||||
|
'from multiprocessing.resource_tracker import main', # Py>=3.8
|
||||||
'from multiprocessing.forkserver import main'
|
'from multiprocessing.forkserver import main'
|
||||||
)) and set(sys.argv[1:-2]) == set(_args_from_interpreter_flags())
|
)) and set(sys.argv[1:-2]) == set(_args_from_interpreter_flags())
|
||||||
):
|
):
|
||||||
@@ -1066,36 +928,20 @@ def _extend_freeze_support() -> None:
|
|||||||
multiprocessing.spawn.spawn_main(**kwargs)
|
multiprocessing.spawn.spawn_main(**kwargs)
|
||||||
sys.exit()
|
sys.exit()
|
||||||
|
|
||||||
def _noop() -> None:
|
if not is_windows and is_frozen():
|
||||||
pass
|
multiprocessing.freeze_support = multiprocessing.spawn.freeze_support = _freeze_support
|
||||||
|
|
||||||
multiprocessing.freeze_support = multiprocessing.spawn.freeze_support = _freeze_support if is_frozen() else _noop
|
|
||||||
|
|
||||||
|
|
||||||
@deprecated("Use multiprocessing.freeze_support() instead")
|
|
||||||
def freeze_support() -> None:
|
def freeze_support() -> None:
|
||||||
"""This now only calls multiprocessing.freeze_support since we are patching freeze_support on module load."""
|
"""This behaves like multiprocessing.freeze_support but also works on Non-Windows."""
|
||||||
import multiprocessing
|
import multiprocessing
|
||||||
|
_extend_freeze_support()
|
||||||
deprecate("Use multiprocessing.freeze_support() instead")
|
|
||||||
multiprocessing.freeze_support()
|
multiprocessing.freeze_support()
|
||||||
|
|
||||||
|
|
||||||
_extend_freeze_support()
|
def visualize_regions(root_region: Region, file_name: str, *,
|
||||||
|
show_entrance_names: bool = False, show_locations: bool = True, show_other_regions: bool = True,
|
||||||
|
linetype_ortho: bool = True, regions_to_highlight: set[Region] | None = None) -> None:
|
||||||
def visualize_regions(
|
|
||||||
root_region: Region,
|
|
||||||
file_name: str,
|
|
||||||
*,
|
|
||||||
show_entrance_names: bool = False,
|
|
||||||
show_locations: bool = True,
|
|
||||||
show_other_regions: bool = True,
|
|
||||||
linetype_ortho: bool = True,
|
|
||||||
regions_to_highlight: set[Region] | None = None,
|
|
||||||
entrance_highlighting: dict[int, int] | None = None,
|
|
||||||
detail_other_regions: bool = False,
|
|
||||||
auto_assign_colors: bool = False) -> None:
|
|
||||||
"""Visualize the layout of a world as a PlantUML diagram.
|
"""Visualize the layout of a world as a PlantUML diagram.
|
||||||
|
|
||||||
:param root_region: The region from which to start the diagram from. (Usually the "Menu" region of your world.)
|
:param root_region: The region from which to start the diagram from. (Usually the "Menu" region of your world.)
|
||||||
@@ -1112,13 +958,6 @@ def visualize_regions(
|
|||||||
:param show_other_regions: (default True) If enabled, regions that can't be reached by traversing exits are shown.
|
:param show_other_regions: (default True) If enabled, regions that can't be reached by traversing exits are shown.
|
||||||
:param linetype_ortho: (default True) If enabled, orthogonal straight line parts will be used; otherwise polylines.
|
:param linetype_ortho: (default True) If enabled, orthogonal straight line parts will be used; otherwise polylines.
|
||||||
:param regions_to_highlight: Regions that will be highlighted in green if they are reachable.
|
:param regions_to_highlight: Regions that will be highlighted in green if they are reachable.
|
||||||
:param entrance_highlighting: a mapping from your world's entrance randomization groups to RGB values, used to color
|
|
||||||
your entrances
|
|
||||||
:param detail_other_regions: (default False) If enabled, will fully visualize regions that aren't reachable
|
|
||||||
from root_region.
|
|
||||||
:param auto_assign_colors: (default False) If enabled, will automatically assign random colors to entrances of the
|
|
||||||
same randomization group. Uses entrance_highlighting first, and only picks random colors for entrance groups
|
|
||||||
not found in the passed-in map
|
|
||||||
|
|
||||||
Example usage in World code:
|
Example usage in World code:
|
||||||
from Utils import visualize_regions
|
from Utils import visualize_regions
|
||||||
@@ -1144,34 +983,6 @@ def visualize_regions(
|
|||||||
regions: typing.Deque[Region] = deque((root_region,))
|
regions: typing.Deque[Region] = deque((root_region,))
|
||||||
multiworld: MultiWorld = root_region.multiworld
|
multiworld: MultiWorld = root_region.multiworld
|
||||||
|
|
||||||
colors_used: set[int] = set()
|
|
||||||
if entrance_highlighting:
|
|
||||||
for color in entrance_highlighting.values():
|
|
||||||
# filter the colors to their most-significant bits to avoid too similar colors
|
|
||||||
colors_used.add(color & 0xF0F0F0)
|
|
||||||
else:
|
|
||||||
# assign an empty dict to not crash later
|
|
||||||
# the parameter is optional for ease of use when you don't care about colors
|
|
||||||
entrance_highlighting = {}
|
|
||||||
|
|
||||||
def select_color(group: int) -> int:
|
|
||||||
# specifically spacing color indexes by three different prime numbers (3, 5, 7) for the RGB components to avoid
|
|
||||||
# obvious cyclical color patterns
|
|
||||||
COLOR_INDEX_SPACING: int = 0x357
|
|
||||||
new_color_index: int = (group * COLOR_INDEX_SPACING) % 0x1000
|
|
||||||
new_color = ((new_color_index & 0xF00) << 12) + \
|
|
||||||
((new_color_index & 0xF0) << 8) + \
|
|
||||||
((new_color_index & 0xF) << 4)
|
|
||||||
while new_color in colors_used:
|
|
||||||
# while this is technically unbounded, expected collisions are low. There are 4095 possible colors
|
|
||||||
# and worlds are unlikely to get to anywhere close to that many entrance groups
|
|
||||||
# intentionally not using multiworld.random to not affect output when debugging with this tool
|
|
||||||
new_color_index += COLOR_INDEX_SPACING
|
|
||||||
new_color = ((new_color_index & 0xF00) << 12) + \
|
|
||||||
((new_color_index & 0xF0) << 8) + \
|
|
||||||
((new_color_index & 0xF) << 4)
|
|
||||||
return new_color
|
|
||||||
|
|
||||||
def fmt(obj: Union[Entrance, Item, Location, Region]) -> str:
|
def fmt(obj: Union[Entrance, Item, Location, Region]) -> str:
|
||||||
name = obj.name
|
name = obj.name
|
||||||
if isinstance(obj, Item):
|
if isinstance(obj, Item):
|
||||||
@@ -1191,28 +1002,18 @@ def visualize_regions(
|
|||||||
|
|
||||||
def visualize_exits(region: Region) -> None:
|
def visualize_exits(region: Region) -> None:
|
||||||
for exit_ in region.exits:
|
for exit_ in region.exits:
|
||||||
color_code: str = ""
|
|
||||||
if exit_.randomization_group in entrance_highlighting:
|
|
||||||
color_code = f" #{entrance_highlighting[exit_.randomization_group]:0>6X}"
|
|
||||||
if exit_.connected_region:
|
if exit_.connected_region:
|
||||||
if show_entrance_names:
|
if show_entrance_names:
|
||||||
uml.append(f"\"{fmt(region)}\" --> \"{fmt(exit_.connected_region)}\" : \"{fmt(exit_)}\"{color_code}")
|
uml.append(f"\"{fmt(region)}\" --> \"{fmt(exit_.connected_region)}\" : \"{fmt(exit_)}\"")
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
uml.remove(f"\"{fmt(exit_.connected_region)}\" --> \"{fmt(region)}\"{color_code}")
|
uml.remove(f"\"{fmt(exit_.connected_region)}\" --> \"{fmt(region)}\"")
|
||||||
uml.append(f"\"{fmt(exit_.connected_region)}\" <--> \"{fmt(region)}\"{color_code}")
|
uml.append(f"\"{fmt(exit_.connected_region)}\" <--> \"{fmt(region)}\"")
|
||||||
except ValueError:
|
except ValueError:
|
||||||
uml.append(f"\"{fmt(region)}\" --> \"{fmt(exit_.connected_region)}\"{color_code}")
|
uml.append(f"\"{fmt(region)}\" --> \"{fmt(exit_.connected_region)}\"")
|
||||||
else:
|
else:
|
||||||
uml.append(f"circle \"unconnected exit:\\n{fmt(exit_)}\" {color_code}")
|
uml.append(f"circle \"unconnected exit:\\n{fmt(exit_)}\"")
|
||||||
uml.append(f"\"{fmt(region)}\" --> \"unconnected exit:\\n{fmt(exit_)}\"{color_code}")
|
uml.append(f"\"{fmt(region)}\" --> \"unconnected exit:\\n{fmt(exit_)}\"")
|
||||||
for entrance in region.entrances:
|
|
||||||
color_code: str = ""
|
|
||||||
if entrance.randomization_group in entrance_highlighting:
|
|
||||||
color_code = f" #{entrance_highlighting[entrance.randomization_group]:0>6X}"
|
|
||||||
if not entrance.parent_region:
|
|
||||||
uml.append(f"circle \"unconnected entrance:\\n{fmt(entrance)}\"{color_code}")
|
|
||||||
uml.append(f"\"unconnected entrance:\\n{fmt(entrance)}\" --> \"{fmt(region)}\"{color_code}")
|
|
||||||
|
|
||||||
def visualize_locations(region: Region) -> None:
|
def visualize_locations(region: Region) -> None:
|
||||||
any_lock = any(location.locked for location in region.locations)
|
any_lock = any(location.locked for location in region.locations)
|
||||||
@@ -1233,27 +1034,9 @@ def visualize_regions(
|
|||||||
if other_regions := [region for region in multiworld.get_regions(root_region.player) if region not in seen]:
|
if other_regions := [region for region in multiworld.get_regions(root_region.player) if region not in seen]:
|
||||||
uml.append("package \"other regions\" <<Cloud>> {")
|
uml.append("package \"other regions\" <<Cloud>> {")
|
||||||
for region in other_regions:
|
for region in other_regions:
|
||||||
if detail_other_regions:
|
uml.append(f"class \"{fmt(region)}\"")
|
||||||
visualize_region(region)
|
|
||||||
else:
|
|
||||||
uml.append(f"class \"{fmt(region)}\"")
|
|
||||||
uml.append("}")
|
uml.append("}")
|
||||||
|
|
||||||
if auto_assign_colors:
|
|
||||||
all_entrances: list[Entrance] = []
|
|
||||||
for region in multiworld.get_regions(root_region.player):
|
|
||||||
all_entrances.extend(region.entrances)
|
|
||||||
all_entrances.extend(region.exits)
|
|
||||||
all_groups: list[int] = sorted(set([entrance.randomization_group for entrance in all_entrances]))
|
|
||||||
for group in all_groups:
|
|
||||||
if group not in entrance_highlighting:
|
|
||||||
if len(colors_used) >= 0x1000:
|
|
||||||
# on the off chance someone makes 4096 different entrance groups, don't cycle forever
|
|
||||||
break
|
|
||||||
new_color: int = select_color(group)
|
|
||||||
entrance_highlighting[group] = new_color
|
|
||||||
colors_used.add(new_color)
|
|
||||||
|
|
||||||
uml.append("@startuml")
|
uml.append("@startuml")
|
||||||
uml.append("hide circle")
|
uml.append("hide circle")
|
||||||
uml.append("hide empty members")
|
uml.append("hide empty members")
|
||||||
@@ -1264,7 +1047,7 @@ def visualize_regions(
|
|||||||
seen.add(current_region)
|
seen.add(current_region)
|
||||||
visualize_region(current_region)
|
visualize_region(current_region)
|
||||||
regions.extend(exit_.connected_region for exit_ in current_region.exits if exit_.connected_region)
|
regions.extend(exit_.connected_region for exit_ in current_region.exits if exit_.connected_region)
|
||||||
if show_other_regions or detail_other_regions:
|
if show_other_regions:
|
||||||
visualize_other_regions()
|
visualize_other_regions()
|
||||||
uml.append("@enduml")
|
uml.append("@enduml")
|
||||||
|
|
||||||
@@ -1291,81 +1074,3 @@ def is_iterable_except_str(obj: object) -> TypeGuard[typing.Iterable[typing.Any]
|
|||||||
if isinstance(obj, str):
|
if isinstance(obj, str):
|
||||||
return False
|
return False
|
||||||
return isinstance(obj, typing.Iterable)
|
return isinstance(obj, typing.Iterable)
|
||||||
|
|
||||||
|
|
||||||
def utcnow() -> datetime:
|
|
||||||
"""
|
|
||||||
Implementation of Python's datetime.utcnow() function for use after deprecation.
|
|
||||||
Needed for timezone-naive UTC datetimes stored in databases with PonyORM (upstream).
|
|
||||||
https://ponyorm.org/ponyorm-list/2014-August/000113.html
|
|
||||||
"""
|
|
||||||
return datetime.now(timezone.utc).replace(tzinfo=None)
|
|
||||||
|
|
||||||
|
|
||||||
class DaemonThreadPoolExecutor(concurrent.futures.ThreadPoolExecutor):
|
|
||||||
"""
|
|
||||||
ThreadPoolExecutor that uses daemonic threads that do not keep the program alive.
|
|
||||||
NOTE: use this with caution because killed threads will not properly clean up.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def _adjust_thread_count(self):
|
|
||||||
# see upstream ThreadPoolExecutor for details
|
|
||||||
import threading
|
|
||||||
import weakref
|
|
||||||
from concurrent.futures.thread import _worker
|
|
||||||
|
|
||||||
if self._idle_semaphore.acquire(timeout=0):
|
|
||||||
return
|
|
||||||
|
|
||||||
def weakref_cb(_, q=self._work_queue):
|
|
||||||
q.put(None)
|
|
||||||
|
|
||||||
num_threads = len(self._threads)
|
|
||||||
if num_threads < self._max_workers:
|
|
||||||
thread_name = f"{self._thread_name_prefix or self}_{num_threads}"
|
|
||||||
t = threading.Thread(
|
|
||||||
name=thread_name,
|
|
||||||
target=_worker,
|
|
||||||
args=(
|
|
||||||
weakref.ref(self, weakref_cb),
|
|
||||||
self._work_queue,
|
|
||||||
self._initializer,
|
|
||||||
self._initargs,
|
|
||||||
),
|
|
||||||
daemon=True,
|
|
||||||
)
|
|
||||||
t.start()
|
|
||||||
self._threads.add(t)
|
|
||||||
# NOTE: don't add to _threads_queues so we don't block on shutdown
|
|
||||||
|
|
||||||
|
|
||||||
def get_full_typename(t: type) -> str:
|
|
||||||
"""Returns the full qualified name of a type, including its module (if not builtins)."""
|
|
||||||
module = t.__module__
|
|
||||||
if module and module != "builtins":
|
|
||||||
return f"{module}.{t.__qualname__}"
|
|
||||||
return t.__qualname__
|
|
||||||
|
|
||||||
|
|
||||||
def get_all_causes(ex: Exception) -> str:
|
|
||||||
"""Return a string describing the recursive causes of this exception.
|
|
||||||
|
|
||||||
:param ex: The exception to be described.
|
|
||||||
:return A multiline string starting with the initial exception on the first line and each resulting exception
|
|
||||||
on subsequent lines with progressive indentation.
|
|
||||||
|
|
||||||
For example:
|
|
||||||
|
|
||||||
```
|
|
||||||
Exception: Invalid value 'bad'.
|
|
||||||
Which caused: Options.OptionError: Error generating option
|
|
||||||
Which caused: ValueError: File bad.yaml is invalid.
|
|
||||||
```
|
|
||||||
"""
|
|
||||||
cause = ex
|
|
||||||
causes = [f"{get_full_typename(type(ex))}: {ex}"]
|
|
||||||
while cause := cause.__cause__:
|
|
||||||
causes.append(f"{get_full_typename(type(cause))}: {cause}")
|
|
||||||
top = causes[-1]
|
|
||||||
others = "".join(f"\n{' ' * (i + 1)}Which caused: {c}" for i, c in enumerate(reversed(causes[:-1])))
|
|
||||||
return f"{top}{others}"
|
|
||||||
|
|||||||
@@ -2,15 +2,14 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import atexit
|
import atexit
|
||||||
import os
|
import os
|
||||||
import pkgutil
|
|
||||||
import sys
|
import sys
|
||||||
import asyncio
|
import asyncio
|
||||||
import random
|
import random
|
||||||
import typing
|
import shutil
|
||||||
from typing import Tuple, List, Iterable, Dict
|
from typing import Tuple, List, Iterable, Dict
|
||||||
|
|
||||||
from . import WargrooveWorld
|
from worlds.wargroove import WargrooveWorld
|
||||||
from .Items import item_table, faction_table, CommanderData, ItemData
|
from worlds.wargroove.Items import item_table, faction_table, CommanderData, ItemData
|
||||||
|
|
||||||
import ModuleUpdate
|
import ModuleUpdate
|
||||||
ModuleUpdate.update()
|
ModuleUpdate.update()
|
||||||
@@ -22,7 +21,7 @@ import logging
|
|||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
Utils.init_logging("WargrooveClient", exception_logger="Client")
|
Utils.init_logging("WargrooveClient", exception_logger="Client")
|
||||||
|
|
||||||
from NetUtils import ClientStatus
|
from NetUtils import NetworkItem, ClientStatus
|
||||||
from CommonClient import gui_enabled, logger, get_base_parser, ClientCommandProcessor, \
|
from CommonClient import gui_enabled, logger, get_base_parser, ClientCommandProcessor, \
|
||||||
CommonContext, server_loop
|
CommonContext, server_loop
|
||||||
|
|
||||||
@@ -30,34 +29,6 @@ wg_logger = logging.getLogger("WG")
|
|||||||
|
|
||||||
|
|
||||||
class WargrooveClientCommandProcessor(ClientCommandProcessor):
|
class WargrooveClientCommandProcessor(ClientCommandProcessor):
|
||||||
def _cmd_sacrifice_summon(self):
|
|
||||||
"""Toggles sacrifices and summons On/Off"""
|
|
||||||
if isinstance(self.ctx, WargrooveContext):
|
|
||||||
self.ctx.has_sacrifice_summon = not self.ctx.has_sacrifice_summon
|
|
||||||
if self.ctx.has_sacrifice_summon:
|
|
||||||
self.output(f"Sacrifices and summons are enabled.")
|
|
||||||
else:
|
|
||||||
unit_summon_response_file = os.path.join(self.ctx.game_communication_path, "unitSummonResponse")
|
|
||||||
if os.path.exists(unit_summon_response_file):
|
|
||||||
os.remove(unit_summon_response_file)
|
|
||||||
self.output(f"Sacrifices and summons are disabled.")
|
|
||||||
|
|
||||||
def _cmd_deathlink(self):
|
|
||||||
"""Toggles deathlink On/Off"""
|
|
||||||
if isinstance(self.ctx, WargrooveContext):
|
|
||||||
self.ctx.has_death_link = not self.ctx.has_death_link
|
|
||||||
Utils.async_start(self.ctx.update_death_link(self.ctx.has_death_link), name="Update Deathlink")
|
|
||||||
if self.ctx.has_death_link:
|
|
||||||
death_link_send_file = os.path.join(self.ctx.game_communication_path, "deathLinkSend")
|
|
||||||
if os.path.exists(death_link_send_file):
|
|
||||||
os.remove(death_link_send_file)
|
|
||||||
self.output(f"Deathlink enabled.")
|
|
||||||
else:
|
|
||||||
death_link_receive_file = os.path.join(self.ctx.game_communication_path, "deathLinkReceive")
|
|
||||||
if os.path.exists(death_link_receive_file):
|
|
||||||
os.remove(death_link_receive_file)
|
|
||||||
self.output(f"Deathlink disabled.")
|
|
||||||
|
|
||||||
def _cmd_resync(self):
|
def _cmd_resync(self):
|
||||||
"""Manually trigger a resync."""
|
"""Manually trigger a resync."""
|
||||||
self.output(f"Syncing items.")
|
self.output(f"Syncing items.")
|
||||||
@@ -87,11 +58,6 @@ class WargrooveContext(CommonContext):
|
|||||||
commander_defense_boost_multiplier: int = 0
|
commander_defense_boost_multiplier: int = 0
|
||||||
income_boost_multiplier: int = 0
|
income_boost_multiplier: int = 0
|
||||||
starting_groove_multiplier: float
|
starting_groove_multiplier: float
|
||||||
has_death_link: bool = False
|
|
||||||
has_sacrifice_summon: bool = True
|
|
||||||
player_stored_units_key: str = ""
|
|
||||||
ai_stored_units_key: str = ""
|
|
||||||
max_stored_units: int = 1000
|
|
||||||
faction_item_ids = {
|
faction_item_ids = {
|
||||||
'Starter': 0,
|
'Starter': 0,
|
||||||
'Cherrystone': 52025,
|
'Cherrystone': 52025,
|
||||||
@@ -105,31 +71,6 @@ class WargrooveContext(CommonContext):
|
|||||||
'Income Boost': 52023,
|
'Income Boost': 52023,
|
||||||
'Commander Defense Boost': 52024,
|
'Commander Defense Boost': 52024,
|
||||||
}
|
}
|
||||||
unit_classes = {
|
|
||||||
"archer",
|
|
||||||
"ballista",
|
|
||||||
"balloon",
|
|
||||||
"dog",
|
|
||||||
"dragon",
|
|
||||||
"giant",
|
|
||||||
"harpoonship",
|
|
||||||
"harpy",
|
|
||||||
"knight",
|
|
||||||
"mage",
|
|
||||||
"merman",
|
|
||||||
"rifleman",
|
|
||||||
"soldier",
|
|
||||||
"spearman",
|
|
||||||
"thief",
|
|
||||||
"thief_with_gold",
|
|
||||||
"travelboat",
|
|
||||||
"trebuchet",
|
|
||||||
"turtle",
|
|
||||||
"villager",
|
|
||||||
"wagon",
|
|
||||||
"warship",
|
|
||||||
"witch",
|
|
||||||
}
|
|
||||||
|
|
||||||
def __init__(self, server_address, password):
|
def __init__(self, server_address, password):
|
||||||
super(WargrooveContext, self).__init__(server_address, password)
|
super(WargrooveContext, self).__init__(server_address, password)
|
||||||
@@ -137,80 +78,31 @@ class WargrooveContext(CommonContext):
|
|||||||
self.syncing = False
|
self.syncing = False
|
||||||
self.awaiting_bridge = False
|
self.awaiting_bridge = False
|
||||||
# self.game_communication_path: files go in this path to pass data between us and the actual game
|
# self.game_communication_path: files go in this path to pass data between us and the actual game
|
||||||
game_options = WargrooveWorld.settings
|
|
||||||
|
|
||||||
# Validate the AppData directory with Wargroove save data.
|
|
||||||
# By default, Windows sets an environment variable we can leverage.
|
|
||||||
# However, other OSes don't usually have this value set, so we need to rely on a settings value instead.
|
|
||||||
appdata_wargroove = None
|
|
||||||
if "appdata" in os.environ:
|
if "appdata" in os.environ:
|
||||||
appdata_wargroove = os.environ['appdata']
|
options = Utils.get_options()
|
||||||
else:
|
root_directory = os.path.join(options["wargroove_options"]["root_directory"])
|
||||||
try:
|
data_directory = os.path.join("lib", "worlds", "wargroove", "data")
|
||||||
appdata_wargroove = game_options.save_directory
|
dev_data_directory = os.path.join("worlds", "wargroove", "data")
|
||||||
except FileNotFoundError:
|
appdata_wargroove = os.path.expandvars(os.path.join("%APPDATA%", "Chucklefish", "Wargroove"))
|
||||||
print_error_and_close("WargrooveClient couldn't detect a path to the AppData folder.\n"
|
if not os.path.isfile(os.path.join(root_directory, "win64_bin", "wargroove64.exe")):
|
||||||
"Unable to infer required game_communication_path.\n"
|
print_error_and_close("WargrooveClient couldn't find wargroove64.exe. "
|
||||||
"Try setting the \"save_directory\" value in your local options file "
|
"Unable to infer required game_communication_path")
|
||||||
"to the AppData folder containing your Wargroove saves.")
|
self.game_communication_path = os.path.join(root_directory, "AP")
|
||||||
appdata_wargroove = os.path.expandvars(os.path.join(appdata_wargroove, "Chucklefish", "Wargroove"))
|
if not os.path.exists(self.game_communication_path):
|
||||||
if not os.path.isdir(appdata_wargroove):
|
os.makedirs(self.game_communication_path)
|
||||||
print_error_and_close(f"WargrooveClient couldn't find Wargroove data in your AppData folder.\n"
|
self.remove_communication_files()
|
||||||
f"Looked in \"{appdata_wargroove}\".\n"
|
atexit.register(self.remove_communication_files)
|
||||||
f"If you haven't yet booted the game at least once, boot Wargroove "
|
if not os.path.isdir(appdata_wargroove):
|
||||||
f"and then close it to attempt to fix this error.\n"
|
print_error_and_close("WargrooveClient couldn't find Wargoove in appdata!"
|
||||||
f"If the AppData folder above seems wrong, try setting the "
|
"Boot Wargroove and then close it to attempt to fix this error")
|
||||||
f"\"save_directory\" value in your local options file "
|
if not os.path.isdir(data_directory):
|
||||||
f"to the AppData folder containing your Wargroove saves.")
|
data_directory = dev_data_directory
|
||||||
|
if not os.path.isdir(data_directory):
|
||||||
# Check for the Wargroove game executable path.
|
|
||||||
# This should always be set regardless of the OS.
|
|
||||||
root_directory = game_options["root_directory"]
|
|
||||||
if not os.path.isfile(os.path.join(root_directory, "win64_bin", "wargroove64.exe")):
|
|
||||||
print_error_and_close(f"WargrooveClient couldn't find wargroove64.exe in "
|
|
||||||
f"\"{root_directory}/win64_bin/\".\n"
|
|
||||||
f"Unable to infer required game_communication_path.\n"
|
|
||||||
f"Please verify the \"root_directory\" value in your local "
|
|
||||||
f"options file is set correctly.")
|
|
||||||
self.game_communication_path = os.path.join(root_directory, "AP")
|
|
||||||
if not os.path.exists(self.game_communication_path):
|
|
||||||
os.makedirs(self.game_communication_path)
|
|
||||||
self.remove_communication_files()
|
|
||||||
atexit.register(self.remove_communication_files)
|
|
||||||
if not os.path.isdir(appdata_wargroove):
|
|
||||||
print_error_and_close("WargrooveClient couldn't find Wargoove in appdata! "
|
|
||||||
"Boot Wargroove and then close it to attempt to fix this error")
|
|
||||||
mods_directory = os.path.join(appdata_wargroove, "mods", "ArchipelagoMod")
|
|
||||||
save_directory = os.path.join(appdata_wargroove, "save")
|
|
||||||
|
|
||||||
# Wargroove doesn't always create the mods directory, so we have to do it
|
|
||||||
if not os.path.isdir(mods_directory):
|
|
||||||
os.makedirs(mods_directory)
|
|
||||||
resources = ["data/mods/ArchipelagoMod/maps.dat",
|
|
||||||
"data/mods/ArchipelagoMod/mod.dat",
|
|
||||||
"data/mods/ArchipelagoMod/modAssets.dat",
|
|
||||||
"data/save/campaign-c40a6e5b0cdf86ddac03b276691c483d.cmp",
|
|
||||||
"data/save/campaign-c40a6e5b0cdf86ddac03b276691c483d.cmp.bak"]
|
|
||||||
file_paths = [os.path.join(mods_directory, "maps.dat"),
|
|
||||||
os.path.join(mods_directory, "mod.dat"),
|
|
||||||
os.path.join(mods_directory, "modAssets.dat"),
|
|
||||||
os.path.join(save_directory, "campaign-c40a6e5b0cdf86ddac03b276691c483d.cmp"),
|
|
||||||
os.path.join(save_directory, "campaign-c40a6e5b0cdf86ddac03b276691c483d.cmp.bak")]
|
|
||||||
for resource, destination in zip(resources, file_paths):
|
|
||||||
file_data = pkgutil.get_data("worlds.wargroove", resource)
|
|
||||||
if file_data is None:
|
|
||||||
print_error_and_close("WargrooveClient couldn't find Wargoove mod and save files in install!")
|
print_error_and_close("WargrooveClient couldn't find Wargoove mod and save files in install!")
|
||||||
with open(destination, 'wb') as f:
|
shutil.copytree(data_directory, appdata_wargroove, dirs_exist_ok=True)
|
||||||
f.write(file_data)
|
else:
|
||||||
|
print_error_and_close("WargrooveClient couldn't detect system type. "
|
||||||
def on_deathlink(self, data: typing.Dict[str, typing.Any]) -> None:
|
"Unable to infer required game_communication_path")
|
||||||
with open(os.path.join(self.game_communication_path, "deathLinkReceive"), 'w+') as f:
|
|
||||||
text = data.get("cause", "")
|
|
||||||
if text:
|
|
||||||
f.write(f"DeathLink: {text}")
|
|
||||||
else:
|
|
||||||
f.write(f"DeathLink: Received from {data['source']}")
|
|
||||||
super(WargrooveContext, self).on_deathlink(data)
|
|
||||||
|
|
||||||
async def server_auth(self, password_requested: bool = False):
|
async def server_auth(self, password_requested: bool = False):
|
||||||
if password_requested and not self.password:
|
if password_requested and not self.password:
|
||||||
@@ -246,25 +138,20 @@ class WargrooveContext(CommonContext):
|
|||||||
|
|
||||||
def on_package(self, cmd: str, args: dict):
|
def on_package(self, cmd: str, args: dict):
|
||||||
if cmd in {"Connected"}:
|
if cmd in {"Connected"}:
|
||||||
slot_data = args["slot_data"]
|
|
||||||
self.has_death_link = slot_data.get("death_link", False)
|
|
||||||
filename = f"AP_settings.json"
|
filename = f"AP_settings.json"
|
||||||
with open(os.path.join(self.game_communication_path, filename), 'w') as f:
|
with open(os.path.join(self.game_communication_path, filename), 'w') as f:
|
||||||
json.dump(slot_data, f)
|
slot_data = args["slot_data"]
|
||||||
|
json.dump(args["slot_data"], f)
|
||||||
self.can_choose_commander = slot_data["can_choose_commander"]
|
self.can_choose_commander = slot_data["can_choose_commander"]
|
||||||
print('can choose commander:', self.can_choose_commander)
|
print('can choose commander:', self.can_choose_commander)
|
||||||
self.starting_groove_multiplier = slot_data["starting_groove_multiplier"]
|
self.starting_groove_multiplier = slot_data["starting_groove_multiplier"]
|
||||||
self.income_boost_multiplier = slot_data["income_boost"]
|
self.income_boost_multiplier = slot_data["income_boost"]
|
||||||
self.commander_defense_boost_multiplier = slot_data["commander_defense_boost"]
|
self.commander_defense_boost_multiplier = slot_data["commander_defense_boost"]
|
||||||
|
f.close()
|
||||||
for ss in self.checked_locations:
|
for ss in self.checked_locations:
|
||||||
filename = f"send{ss}"
|
filename = f"send{ss}"
|
||||||
with open(os.path.join(self.game_communication_path, filename), 'w') as f:
|
with open(os.path.join(self.game_communication_path, filename), 'w') as f:
|
||||||
pass
|
f.close()
|
||||||
|
|
||||||
self.player_stored_units_key = f"wargroove_player_units_{self.team}"
|
|
||||||
self.ai_stored_units_key = f"wargroove_ai_units_{self.team}"
|
|
||||||
self.set_notify(self.player_stored_units_key, self.ai_stored_units_key)
|
|
||||||
|
|
||||||
self.update_commander_data()
|
self.update_commander_data()
|
||||||
self.ui.update_tracker()
|
self.ui.update_tracker()
|
||||||
|
|
||||||
@@ -274,6 +161,7 @@ class WargrooveContext(CommonContext):
|
|||||||
filename = f"seed{i}"
|
filename = f"seed{i}"
|
||||||
with open(os.path.join(self.game_communication_path, filename), 'w') as f:
|
with open(os.path.join(self.game_communication_path, filename), 'w') as f:
|
||||||
f.write(str(random.randint(0, 4294967295)))
|
f.write(str(random.randint(0, 4294967295)))
|
||||||
|
f.close()
|
||||||
|
|
||||||
if cmd in {"RoomInfo"}:
|
if cmd in {"RoomInfo"}:
|
||||||
self.seed_name = args["seed_name"]
|
self.seed_name = args["seed_name"]
|
||||||
@@ -301,6 +189,7 @@ class WargrooveContext(CommonContext):
|
|||||||
f.write(f"{item_count * self.commander_defense_boost_multiplier}")
|
f.write(f"{item_count * self.commander_defense_boost_multiplier}")
|
||||||
else:
|
else:
|
||||||
f.write(f"{item_count}")
|
f.write(f"{item_count}")
|
||||||
|
f.close()
|
||||||
|
|
||||||
print_filename = f"AP_{str(network_item.item)}.item.print"
|
print_filename = f"AP_{str(network_item.item)}.item.print"
|
||||||
print_path = os.path.join(self.game_communication_path, print_filename)
|
print_path = os.path.join(self.game_communication_path, print_filename)
|
||||||
@@ -311,6 +200,7 @@ class WargrooveContext(CommonContext):
|
|||||||
self.item_names.lookup_in_game(network_item.item) +
|
self.item_names.lookup_in_game(network_item.item) +
|
||||||
" from " +
|
" from " +
|
||||||
self.player_names[network_item.player])
|
self.player_names[network_item.player])
|
||||||
|
f.close()
|
||||||
self.update_commander_data()
|
self.update_commander_data()
|
||||||
self.ui.update_tracker()
|
self.ui.update_tracker()
|
||||||
|
|
||||||
@@ -319,16 +209,22 @@ class WargrooveContext(CommonContext):
|
|||||||
for ss in self.checked_locations:
|
for ss in self.checked_locations:
|
||||||
filename = f"send{ss}"
|
filename = f"send{ss}"
|
||||||
with open(os.path.join(self.game_communication_path, filename), 'w') as f:
|
with open(os.path.join(self.game_communication_path, filename), 'w') as f:
|
||||||
pass
|
f.close()
|
||||||
|
|
||||||
def run_gui(self):
|
def run_gui(self):
|
||||||
"""Import kivy UI system and start running it as self.ui_task."""
|
"""Import kivy UI system and start running it as self.ui_task."""
|
||||||
from kvui import GameManager, HoverBehavior, ServerToolTip
|
from kvui import GameManager, HoverBehavior, ServerToolTip
|
||||||
from kivymd.uix.tab import MDTabsItem, MDTabsItemText
|
from kivy.uix.tabbedpanel import TabbedPanelItem
|
||||||
from kivy.lang import Builder
|
from kivy.lang import Builder
|
||||||
|
from kivy.uix.button import Button
|
||||||
from kivy.uix.togglebutton import ToggleButton
|
from kivy.uix.togglebutton import ToggleButton
|
||||||
from kivy.uix.boxlayout import BoxLayout
|
from kivy.uix.boxlayout import BoxLayout
|
||||||
|
from kivy.uix.gridlayout import GridLayout
|
||||||
|
from kivy.uix.image import AsyncImage, Image
|
||||||
|
from kivy.uix.stacklayout import StackLayout
|
||||||
from kivy.uix.label import Label
|
from kivy.uix.label import Label
|
||||||
|
from kivy.properties import ColorProperty
|
||||||
|
from kivy.uix.image import Image
|
||||||
import pkgutil
|
import pkgutil
|
||||||
|
|
||||||
class TrackerLayout(BoxLayout):
|
class TrackerLayout(BoxLayout):
|
||||||
@@ -495,75 +391,32 @@ class WargrooveContext(CommonContext):
|
|||||||
|
|
||||||
|
|
||||||
async def game_watcher(ctx: WargrooveContext):
|
async def game_watcher(ctx: WargrooveContext):
|
||||||
|
from worlds.wargroove.Locations import location_table
|
||||||
while not ctx.exit_event.is_set():
|
while not ctx.exit_event.is_set():
|
||||||
try:
|
if ctx.syncing == True:
|
||||||
if ctx.syncing == True:
|
sync_msg = [{'cmd': 'Sync'}]
|
||||||
sync_msg = [{'cmd': 'Sync'}]
|
if ctx.locations_checked:
|
||||||
if ctx.locations_checked:
|
sync_msg.append({"cmd": "LocationChecks", "locations": list(ctx.locations_checked)})
|
||||||
sync_msg.append({"cmd": "LocationChecks", "locations": list(ctx.locations_checked)})
|
await ctx.send_msgs(sync_msg)
|
||||||
await ctx.send_msgs(sync_msg)
|
ctx.syncing = False
|
||||||
ctx.syncing = False
|
sending = []
|
||||||
sending = []
|
victory = False
|
||||||
victory = False
|
for root, dirs, files in os.walk(ctx.game_communication_path):
|
||||||
for root, dirs, files in os.walk(ctx.game_communication_path):
|
for file in files:
|
||||||
for file in files:
|
if file.find("send") > -1:
|
||||||
if file == "deathLinkSend" and ctx.has_death_link:
|
st = file.split("send", -1)[1]
|
||||||
with open(os.path.join(ctx.game_communication_path, file), 'r') as f:
|
sending = sending+[(int(st))]
|
||||||
failed_mission = f.read()
|
os.remove(os.path.join(ctx.game_communication_path, file))
|
||||||
if ctx.slot is not None:
|
if file.find("victory") > -1:
|
||||||
await ctx.send_death(f"{ctx.player_names[ctx.slot]} failed {failed_mission}")
|
victory = True
|
||||||
os.remove(os.path.join(ctx.game_communication_path, file))
|
os.remove(os.path.join(ctx.game_communication_path, file))
|
||||||
if file.find("send") > -1:
|
ctx.locations_checked = sending
|
||||||
st = file.split("send", -1)[1]
|
message = [{"cmd": 'LocationChecks', "locations": sending}]
|
||||||
sending = sending+[(int(st))]
|
await ctx.send_msgs(message)
|
||||||
os.remove(os.path.join(ctx.game_communication_path, file))
|
if not ctx.finished_game and victory:
|
||||||
if file.find("victory") > -1:
|
await ctx.send_msgs([{"cmd": "StatusUpdate", "status": ClientStatus.CLIENT_GOAL}])
|
||||||
victory = True
|
ctx.finished_game = True
|
||||||
os.remove(os.path.join(ctx.game_communication_path, file))
|
await asyncio.sleep(0.1)
|
||||||
if file == "unitSacrifice" or file == "unitSacrificeAI":
|
|
||||||
if ctx.has_sacrifice_summon:
|
|
||||||
stored_units_key = ctx.player_stored_units_key
|
|
||||||
if file == "unitSacrificeAI":
|
|
||||||
stored_units_key = ctx.ai_stored_units_key
|
|
||||||
with open(os.path.join(ctx.game_communication_path, file), 'r') as f:
|
|
||||||
unit_class = f.read()
|
|
||||||
message = [{"cmd": 'Set', "key": stored_units_key,
|
|
||||||
"default": [],
|
|
||||||
"want_reply": True,
|
|
||||||
"operations": [{"operation": "add", "value": [unit_class[:64]]}]}]
|
|
||||||
await ctx.send_msgs(message)
|
|
||||||
os.remove(os.path.join(ctx.game_communication_path, file))
|
|
||||||
if file == "unitSummonRequestAI" or file == "unitSummonRequest":
|
|
||||||
if ctx.has_sacrifice_summon:
|
|
||||||
stored_units_key = ctx.player_stored_units_key
|
|
||||||
if file == "unitSummonRequestAI":
|
|
||||||
stored_units_key = ctx.ai_stored_units_key
|
|
||||||
with open(os.path.join(ctx.game_communication_path, "unitSummonResponse"), 'w') as f:
|
|
||||||
if stored_units_key in ctx.stored_data:
|
|
||||||
stored_units = ctx.stored_data[stored_units_key]
|
|
||||||
if stored_units is None:
|
|
||||||
stored_units = []
|
|
||||||
wg1_stored_units = [unit for unit in stored_units if unit in ctx.unit_classes]
|
|
||||||
if len(wg1_stored_units) != 0:
|
|
||||||
summoned_unit = random.choice(wg1_stored_units)
|
|
||||||
message = [{"cmd": 'Set', "key": stored_units_key,
|
|
||||||
"default": [],
|
|
||||||
"want_reply": True,
|
|
||||||
"operations": [{"operation": "remove", "value": summoned_unit[:64]}]}]
|
|
||||||
await ctx.send_msgs(message)
|
|
||||||
f.write(summoned_unit)
|
|
||||||
os.remove(os.path.join(ctx.game_communication_path, file))
|
|
||||||
|
|
||||||
ctx.locations_checked = sending
|
|
||||||
message = [{"cmd": 'LocationChecks', "locations": sending}]
|
|
||||||
await ctx.send_msgs(message)
|
|
||||||
if not ctx.finished_game and victory:
|
|
||||||
await ctx.send_msgs([{"cmd": "StatusUpdate", "status": ClientStatus.CLIENT_GOAL}])
|
|
||||||
ctx.finished_game = True
|
|
||||||
await asyncio.sleep(0.1)
|
|
||||||
|
|
||||||
except Exception as err:
|
|
||||||
logger.warn("Exception in communication thread, a check may not have been sent: " + str(err))
|
|
||||||
|
|
||||||
|
|
||||||
def print_error_and_close(msg):
|
def print_error_and_close(msg):
|
||||||
@@ -571,9 +424,8 @@ def print_error_and_close(msg):
|
|||||||
Utils.messagebox("Error", msg, error=True)
|
Utils.messagebox("Error", msg, error=True)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
def launch(*launch_args: str):
|
if __name__ == '__main__':
|
||||||
async def main():
|
async def main(args):
|
||||||
args = parser.parse_args(launch_args)
|
|
||||||
ctx = WargrooveContext(args.connect, args.password)
|
ctx = WargrooveContext(args.connect, args.password)
|
||||||
ctx.server_task = asyncio.create_task(server_loop(ctx), name="server loop")
|
ctx.server_task = asyncio.create_task(server_loop(ctx), name="server loop")
|
||||||
if gui_enabled:
|
if gui_enabled:
|
||||||
@@ -593,6 +445,7 @@ def launch(*launch_args: str):
|
|||||||
|
|
||||||
parser = get_base_parser(description="Wargroove Client, for text interfacing.")
|
parser = get_base_parser(description="Wargroove Client, for text interfacing.")
|
||||||
|
|
||||||
colorama.just_fix_windows_console()
|
args, rest = parser.parse_known_args()
|
||||||
asyncio.run(main())
|
colorama.init()
|
||||||
|
asyncio.run(main(args))
|
||||||
colorama.deinit()
|
colorama.deinit()
|
||||||
60
WebHost.py
60
WebHost.py
@@ -20,8 +20,7 @@ if typing.TYPE_CHECKING:
|
|||||||
Utils.local_path.cached_path = os.path.dirname(__file__)
|
Utils.local_path.cached_path = os.path.dirname(__file__)
|
||||||
settings.no_gui = True
|
settings.no_gui = True
|
||||||
configpath = os.path.abspath("config.yaml")
|
configpath = os.path.abspath("config.yaml")
|
||||||
if not os.path.exists(configpath):
|
if not os.path.exists(configpath): # fall back to config.yaml in home
|
||||||
# fall back to config.yaml in user_path if config does not exist in cwd to match settings.py
|
|
||||||
configpath = os.path.abspath(Utils.user_path('config.yaml'))
|
configpath = os.path.abspath(Utils.user_path('config.yaml'))
|
||||||
|
|
||||||
|
|
||||||
@@ -55,15 +54,16 @@ def get_app() -> "Flask":
|
|||||||
return app
|
return app
|
||||||
|
|
||||||
|
|
||||||
def copy_tutorials_files_to_static() -> None:
|
def create_ordered_tutorials_file() -> typing.List[typing.Dict[str, typing.Any]]:
|
||||||
|
import json
|
||||||
import shutil
|
import shutil
|
||||||
import zipfile
|
import zipfile
|
||||||
from werkzeug.utils import secure_filename
|
|
||||||
|
|
||||||
zfile: zipfile.ZipInfo
|
zfile: zipfile.ZipInfo
|
||||||
|
|
||||||
from worlds.AutoWorld import AutoWorldRegister
|
from worlds.AutoWorld import AutoWorldRegister
|
||||||
worlds = {}
|
worlds = {}
|
||||||
|
data = []
|
||||||
for game, world in AutoWorldRegister.world_types.items():
|
for game, world in AutoWorldRegister.world_types.items():
|
||||||
if hasattr(world.web, 'tutorials') and (not world.hidden or game == 'Archipelago'):
|
if hasattr(world.web, 'tutorials') and (not world.hidden or game == 'Archipelago'):
|
||||||
worlds[game] = world
|
worlds[game] = world
|
||||||
@@ -72,7 +72,7 @@ def copy_tutorials_files_to_static() -> None:
|
|||||||
shutil.rmtree(base_target_path, ignore_errors=True)
|
shutil.rmtree(base_target_path, ignore_errors=True)
|
||||||
for game, world in worlds.items():
|
for game, world in worlds.items():
|
||||||
# copy files from world's docs folder to the generated folder
|
# copy files from world's docs folder to the generated folder
|
||||||
target_path = os.path.join(base_target_path, secure_filename(game))
|
target_path = os.path.join(base_target_path, get_file_safe_name(game))
|
||||||
os.makedirs(target_path, exist_ok=True)
|
os.makedirs(target_path, exist_ok=True)
|
||||||
|
|
||||||
if world.zip_path:
|
if world.zip_path:
|
||||||
@@ -85,14 +85,45 @@ def copy_tutorials_files_to_static() -> None:
|
|||||||
for zfile in zf.infolist():
|
for zfile in zf.infolist():
|
||||||
if not zfile.is_dir() and "/docs/" in zfile.filename:
|
if not zfile.is_dir() and "/docs/" in zfile.filename:
|
||||||
zfile.filename = os.path.basename(zfile.filename)
|
zfile.filename = os.path.basename(zfile.filename)
|
||||||
with open(os.path.join(target_path, secure_filename(zfile.filename)), "wb") as f:
|
zf.extract(zfile, target_path)
|
||||||
f.write(zf.read(zfile))
|
|
||||||
else:
|
else:
|
||||||
source_path = Utils.local_path(os.path.dirname(world.__file__), "docs")
|
source_path = Utils.local_path(os.path.dirname(world.__file__), "docs")
|
||||||
files = os.listdir(source_path)
|
files = os.listdir(source_path)
|
||||||
for file in files:
|
for file in files:
|
||||||
shutil.copyfile(Utils.local_path(source_path, file),
|
shutil.copyfile(Utils.local_path(source_path, file), Utils.local_path(target_path, file))
|
||||||
Utils.local_path(target_path, secure_filename(file)))
|
|
||||||
|
# build a json tutorial dict per game
|
||||||
|
game_data = {'gameTitle': game, 'tutorials': []}
|
||||||
|
for tutorial in world.web.tutorials:
|
||||||
|
# build dict for the json file
|
||||||
|
current_tutorial = {
|
||||||
|
'name': tutorial.tutorial_name,
|
||||||
|
'description': tutorial.description,
|
||||||
|
'files': [{
|
||||||
|
'language': tutorial.language,
|
||||||
|
'filename': game + '/' + tutorial.file_name,
|
||||||
|
'link': f'{game}/{tutorial.link}',
|
||||||
|
'authors': tutorial.authors
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
|
||||||
|
# check if the name of the current guide exists already
|
||||||
|
for guide in game_data['tutorials']:
|
||||||
|
if guide and tutorial.tutorial_name == guide['name']:
|
||||||
|
guide['files'].append(current_tutorial['files'][0])
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
game_data['tutorials'].append(current_tutorial)
|
||||||
|
|
||||||
|
data.append(game_data)
|
||||||
|
with open(Utils.local_path("WebHostLib", "static", "generated", "tutorials.json"), 'w', encoding='utf-8-sig') as json_target:
|
||||||
|
generic_data = {}
|
||||||
|
for games in data:
|
||||||
|
if 'Archipelago' in games['gameTitle']:
|
||||||
|
generic_data = data.pop(data.index(games))
|
||||||
|
sorted_data = [generic_data] + Utils.title_sorted(data, key=lambda entry: entry["gameTitle"])
|
||||||
|
json.dump(sorted_data, json_target, indent=2, ensure_ascii=False)
|
||||||
|
return sorted_data
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
@@ -100,25 +131,18 @@ if __name__ == "__main__":
|
|||||||
multiprocessing.set_start_method('spawn')
|
multiprocessing.set_start_method('spawn')
|
||||||
logging.basicConfig(format='[%(asctime)s] %(message)s', level=logging.INFO)
|
logging.basicConfig(format='[%(asctime)s] %(message)s', level=logging.INFO)
|
||||||
|
|
||||||
|
from WebHostLib.lttpsprites import update_sprites_lttp
|
||||||
from WebHostLib.autolauncher import autohost, autogen, stop
|
from WebHostLib.autolauncher import autohost, autogen, stop
|
||||||
from WebHostLib.options import create as create_options_files
|
from WebHostLib.options import create as create_options_files
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from WebHostLib.lttpsprites import update_sprites_lttp
|
|
||||||
update_sprites_lttp()
|
update_sprites_lttp()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.exception(e)
|
logging.exception(e)
|
||||||
logging.warning("Could not update LttP sprites.")
|
logging.warning("Could not update LttP sprites.")
|
||||||
app = get_app()
|
app = get_app()
|
||||||
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()
|
create_options_files()
|
||||||
copy_tutorials_files_to_static()
|
create_ordered_tutorials_file()
|
||||||
if app.config["SELFLAUNCH"]:
|
if app.config["SELFLAUNCH"]:
|
||||||
autohost(app.config)
|
autohost(app.config)
|
||||||
if app.config["SELFGEN"]:
|
if app.config["SELFGEN"]:
|
||||||
|
|||||||
@@ -1,20 +1,46 @@
|
|||||||
# WebHost
|
# 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
|
## 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.
|
### Small Changes
|
||||||
Design changes have to fit the overall design.
|
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 base64
|
||||||
import os
|
import os
|
||||||
import socket
|
import socket
|
||||||
import typing
|
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from flask import Flask
|
from flask import Flask
|
||||||
@@ -11,7 +10,6 @@ from pony.flask import Pony
|
|||||||
from werkzeug.routing import BaseConverter
|
from werkzeug.routing import BaseConverter
|
||||||
|
|
||||||
from Utils import title_sorted, get_file_safe_name
|
from Utils import title_sorted, get_file_safe_name
|
||||||
from .cli import CLI
|
|
||||||
|
|
||||||
UPLOAD_FOLDER = os.path.relpath('uploads')
|
UPLOAD_FOLDER = os.path.relpath('uploads')
|
||||||
LOGS_FOLDER = os.path.relpath('logs')
|
LOGS_FOLDER = os.path.relpath('logs')
|
||||||
@@ -24,17 +22,6 @@ app.jinja_env.filters['any'] = any
|
|||||||
app.jinja_env.filters['all'] = all
|
app.jinja_env.filters['all'] = all
|
||||||
app.jinja_env.filters['get_file_safe_name'] = get_file_safe_name
|
app.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["SELFHOST"] = True # application process is in charge of running the websites
|
||||||
app.config["GENERATORS"] = 8 # maximum concurrent world gens
|
app.config["GENERATORS"] = 8 # maximum concurrent world gens
|
||||||
app.config["HOSTERS"] = 8 # maximum concurrent room hosters
|
app.config["HOSTERS"] = 8 # maximum concurrent room hosters
|
||||||
@@ -42,13 +29,19 @@ app.config["SELFLAUNCH"] = True # application process is in charge of launching
|
|||||||
app.config["SELFLAUNCHCERT"] = None # can point to a SSL Certificate to encrypt Room websocket connections
|
app.config["SELFLAUNCHCERT"] = None # can point to a SSL Certificate to encrypt Room websocket connections
|
||||||
app.config["SELFLAUNCHKEY"] = None # can point to a SSL Certificate Key to encrypt Room websocket connections
|
app.config["SELFLAUNCHKEY"] = None # can point to a SSL Certificate Key to encrypt Room websocket connections
|
||||||
app.config["SELFGEN"] = True # application process is in charge of scheduling Generations.
|
app.config["SELFGEN"] = True # application process is in charge of scheduling Generations.
|
||||||
app.config["GAME_PORTS"] = ["49152-65535", 0]
|
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
|
# at what amount of worlds should scheduling be used, instead of rolling in the web-thread
|
||||||
app.config["JOB_THRESHOLD"] = 1
|
app.config["JOB_THRESHOLD"] = 1
|
||||||
# after what time in seconds should generation be aborted, freeing the queue slot. Can be set to None to disable.
|
# after what time in seconds should generation be aborted, freeing the queue slot. Can be set to None to disable.
|
||||||
app.config["JOB_TIME"] = 600
|
app.config["JOB_TIME"] = 600
|
||||||
# memory limit for generator processes in bytes
|
# memory limit for generator processes in bytes
|
||||||
app.config["GENERATOR_MEMORY_LIMIT"] = 4294967296
|
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
|
# 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
|
# archipelago.gg uses gunicorn + nginx; ignoring this option
|
||||||
@@ -66,47 +59,34 @@ app.config["ASSET_RIGHTS"] = False
|
|||||||
|
|
||||||
cache = Cache()
|
cache = Cache()
|
||||||
Compress(app)
|
Compress(app)
|
||||||
CLI(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):
|
class B64UUIDConverter(BaseConverter):
|
||||||
|
|
||||||
def to_python(self, value: str) -> uuid.UUID:
|
def to_python(self, value):
|
||||||
return to_python(value)
|
return uuid.UUID(bytes=base64.urlsafe_b64decode(value + '=='))
|
||||||
|
|
||||||
def to_url(self, value: typing.Any) -> str:
|
def to_url(self, value):
|
||||||
assert isinstance(value, uuid.UUID)
|
return base64.urlsafe_b64encode(value.bytes).rstrip(b'=').decode('ascii')
|
||||||
return to_url(value)
|
|
||||||
|
|
||||||
|
|
||||||
# short UUID
|
# short UUID
|
||||||
app.url_map.converters["suuid"] = B64UUIDConverter
|
app.url_map.converters["suuid"] = B64UUIDConverter
|
||||||
app.jinja_env.filters["suuid"] = to_url
|
app.jinja_env.filters['suuid'] = lambda value: base64.urlsafe_b64encode(value.bytes).rstrip(b'=').decode('ascii')
|
||||||
app.jinja_env.filters["title_sorted"] = title_sorted
|
app.jinja_env.filters["title_sorted"] = title_sorted
|
||||||
|
|
||||||
|
|
||||||
def register() -> None:
|
def register():
|
||||||
"""Import submodules, triggering their registering on flask routing.
|
"""Import submodules, triggering their registering on flask routing.
|
||||||
Note: initializes worlds subsystem."""
|
Note: initializes worlds subsystem."""
|
||||||
import importlib
|
|
||||||
|
|
||||||
from werkzeug.utils import find_modules
|
|
||||||
# has automatic patch integration
|
# has automatic patch integration
|
||||||
|
import worlds.AutoWorld
|
||||||
import worlds.Files
|
import worlds.Files
|
||||||
app.jinja_env.filters['is_applayercontainer'] = worlds.Files.is_ap_player_container
|
app.jinja_env.filters['supports_apdeltapatch'] = lambda game_name: \
|
||||||
|
game_name in worlds.Files.AutoPatchRegister.patch_types
|
||||||
|
|
||||||
from WebHostLib.customserver import run_server_process
|
from WebHostLib.customserver import run_server_process
|
||||||
|
# to trigger app routing picking up on it
|
||||||
|
from . import tracker, upload, landing, check, generate, downloads, api, stats, misc, robots, options, session
|
||||||
|
|
||||||
for module in find_modules("WebHostLib", include_packages=True):
|
|
||||||
importlib.import_module(module)
|
|
||||||
|
|
||||||
from . import api
|
|
||||||
app.register_blueprint(api.api_endpoints)
|
app.register_blueprint(api.api_endpoints)
|
||||||
|
|||||||
@@ -2,24 +2,14 @@
|
|||||||
from typing import List, Tuple
|
from typing import List, Tuple
|
||||||
|
|
||||||
from flask import Blueprint
|
from flask import Blueprint
|
||||||
from flask_cors import CORS
|
|
||||||
|
|
||||||
from ..models import Seed, Slot
|
from ..models import Seed, Slot
|
||||||
|
|
||||||
api_endpoints = Blueprint('api', __name__, url_prefix="/api")
|
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": "*"}
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
def get_players(seed: Seed) -> List[Tuple[str, str]]:
|
def get_players(seed: Seed) -> List[Tuple[str, str]]:
|
||||||
return [(slot.player_name, slot.game) for slot in seed.slots.order_by(Slot.player_id)]
|
return [(slot.player_name, slot.game) for slot in seed.slots.order_by(Slot.player_id)]
|
||||||
|
|
||||||
# trigger endpoint registration
|
|
||||||
from . import datapackage, generate, room, tracker, user
|
from . import datapackage, generate, room, user # trigger registration
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import json
|
import json
|
||||||
|
import pickle
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from flask import request, session, url_for
|
from flask import request, session, url_for
|
||||||
from markupsafe import Markup
|
from markupsafe import Markup
|
||||||
from pony.orm import commit
|
from pony.orm import commit
|
||||||
|
|
||||||
from Utils import restricted_dumps
|
|
||||||
from WebHostLib import app
|
from WebHostLib import app
|
||||||
from WebHostLib.check import get_yaml_data, roll_options
|
from WebHostLib.check import get_yaml_data, roll_options
|
||||||
from WebHostLib.generate import get_meta
|
from WebHostLib.generate import get_meta
|
||||||
@@ -56,7 +56,7 @@ def generate_api():
|
|||||||
"detail": results}, 400
|
"detail": results}, 400
|
||||||
else:
|
else:
|
||||||
gen = Generation(
|
gen = Generation(
|
||||||
options=restricted_dumps({name: vars(options) for name, options in gen_options.items()}),
|
options=pickle.dumps({name: vars(options) for name, options in gen_options.items()}),
|
||||||
# convert to json compatible
|
# convert to json compatible
|
||||||
meta=json.dumps(meta), state=STATE_QUEUED,
|
meta=json.dumps(meta), state=STATE_QUEUED,
|
||||||
owner=session["_id"])
|
owner=session["_id"])
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ from uuid import UUID
|
|||||||
|
|
||||||
from flask import abort, url_for
|
from flask import abort, url_for
|
||||||
|
|
||||||
from WebHostLib import to_url
|
|
||||||
import worlds.Files
|
import worlds.Files
|
||||||
from . import api_endpoints, get_players
|
from . import api_endpoints, get_players
|
||||||
from ..models import Room
|
from ..models import Room
|
||||||
@@ -34,7 +33,7 @@ def room_info(room_id: UUID) -> Dict[str, Any]:
|
|||||||
downloads.append(slot_download)
|
downloads.append(slot_download)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"tracker": to_url(room.tracker),
|
"tracker": room.tracker,
|
||||||
"players": get_players(room.seed),
|
"players": get_players(room.seed),
|
||||||
"last_port": room.last_port,
|
"last_port": room.last_port,
|
||||||
"last_activity": room.last_activity,
|
"last_activity": room.last_activity,
|
||||||
|
|||||||
@@ -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 flask import session, jsonify
|
||||||
from pony.orm import select
|
from pony.orm import select
|
||||||
|
|
||||||
from WebHostLib import to_url
|
|
||||||
from WebHostLib.models import Room, Seed
|
from WebHostLib.models import Room, Seed
|
||||||
from . import api_endpoints, get_players
|
from . import api_endpoints, get_players
|
||||||
|
|
||||||
@@ -11,13 +10,13 @@ def get_rooms():
|
|||||||
response = []
|
response = []
|
||||||
for room in select(room for room in Room if room.owner == session["_id"]):
|
for room in select(room for room in Room if room.owner == session["_id"]):
|
||||||
response.append({
|
response.append({
|
||||||
"room_id": to_url(room.id),
|
"room_id": room.id,
|
||||||
"seed_id": to_url(room.seed.id),
|
"seed_id": room.seed.id,
|
||||||
"creation_time": room.creation_time,
|
"creation_time": room.creation_time,
|
||||||
"last_activity": room.last_activity,
|
"last_activity": room.last_activity,
|
||||||
"last_port": room.last_port,
|
"last_port": room.last_port,
|
||||||
"timeout": room.timeout,
|
"timeout": room.timeout,
|
||||||
"tracker": to_url(room.tracker),
|
"tracker": room.tracker,
|
||||||
})
|
})
|
||||||
return jsonify(response)
|
return jsonify(response)
|
||||||
|
|
||||||
@@ -27,8 +26,8 @@ def get_seeds():
|
|||||||
response = []
|
response = []
|
||||||
for seed in select(seed for seed in Seed if seed.owner == session["_id"]):
|
for seed in select(seed for seed in Seed if seed.owner == session["_id"]):
|
||||||
response.append({
|
response.append({
|
||||||
"seed_id": to_url(seed.id),
|
"seed_id": seed.id,
|
||||||
"creation_time": seed.creation_time,
|
"creation_time": seed.creation_time,
|
||||||
"players": get_players(seed),
|
"players": get_players(seed.slots),
|
||||||
})
|
})
|
||||||
return jsonify(response)
|
return jsonify(response)
|
||||||
|
|||||||
@@ -4,20 +4,20 @@ import json
|
|||||||
import logging
|
import logging
|
||||||
import multiprocessing
|
import multiprocessing
|
||||||
import typing
|
import typing
|
||||||
from datetime import timedelta
|
from datetime import timedelta, datetime
|
||||||
from threading import Event, Thread
|
from threading import Event, Thread
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from pony.orm import db_session, select, commit, PrimaryKey
|
from pony.orm import db_session, select, commit
|
||||||
|
|
||||||
from Utils import restricted_loads, utcnow
|
from Utils import restricted_loads
|
||||||
from .locker import Locker, AlreadyRunningException
|
from .locker import Locker, AlreadyRunningException
|
||||||
|
|
||||||
_stop_event = Event()
|
_stop_event = Event()
|
||||||
|
|
||||||
|
|
||||||
def stop() -> None:
|
def stop():
|
||||||
"""Stops previously launched threads"""
|
"""Stops previously launched threads"""
|
||||||
global _stop_event
|
global _stop_event
|
||||||
stop_event = _stop_event
|
stop_event = _stop_event
|
||||||
@@ -36,39 +36,16 @@ def handle_generation_failure(result: BaseException):
|
|||||||
logging.exception(e)
|
logging.exception(e)
|
||||||
|
|
||||||
|
|
||||||
def _mp_gen_game(
|
def launch_generator(pool: multiprocessing.pool.Pool, generation: Generation):
|
||||||
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:
|
|
||||||
try:
|
try:
|
||||||
meta = json.loads(generation.meta)
|
meta = json.loads(generation.meta)
|
||||||
options = restricted_loads(generation.options)
|
options = restricted_loads(generation.options)
|
||||||
logging.info(f"Generating {generation.id} for {len(options)} players")
|
logging.info(f"Generating {generation.id} for {len(options)} players")
|
||||||
pool.apply_async(
|
pool.apply_async(gen_game, (options,),
|
||||||
_mp_gen_game,
|
{"meta": meta,
|
||||||
(options,),
|
"sid": generation.id,
|
||||||
{
|
"owner": generation.owner},
|
||||||
"meta": meta,
|
handle_generation_success, handle_generation_failure)
|
||||||
"sid": generation.id,
|
|
||||||
"owner": generation.owner,
|
|
||||||
"timeout": timeout,
|
|
||||||
},
|
|
||||||
handle_generation_success,
|
|
||||||
handle_generation_failure,
|
|
||||||
)
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
generation.state = STATE_ERROR
|
generation.state = STATE_ERROR
|
||||||
commit()
|
commit()
|
||||||
@@ -78,10 +55,6 @@ def launch_generator(pool: multiprocessing.pool.Pool, generation: Generation, ti
|
|||||||
|
|
||||||
|
|
||||||
def init_generator(config: dict[str, Any]) -> None:
|
def init_generator(config: dict[str, Any]) -> None:
|
||||||
from setproctitle import setproctitle
|
|
||||||
|
|
||||||
setproctitle("Generator (idle)")
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import resource
|
import resource
|
||||||
except ModuleNotFoundError:
|
except ModuleNotFoundError:
|
||||||
@@ -129,10 +102,10 @@ def autohost(config: dict):
|
|||||||
with db_session:
|
with db_session:
|
||||||
rooms = select(
|
rooms = select(
|
||||||
room for room in Room if
|
room for room in Room if
|
||||||
room.last_activity >= utcnow() - timedelta(days=3))
|
room.last_activity >= datetime.utcnow() - timedelta(days=3))
|
||||||
for room in rooms:
|
for room in rooms:
|
||||||
# we have to filter twice, as the per-room timeout can't currently be PonyORM transpiled.
|
# we have to filter twice, as the per-room timeout can't currently be PonyORM transpiled.
|
||||||
if room.last_activity >= utcnow() - timedelta(seconds=room.timeout + 5):
|
if room.last_activity >= datetime.utcnow() - timedelta(seconds=room.timeout + 5):
|
||||||
hosters[room.id.int % len(hosters)].start_room(room.id)
|
hosters[room.id.int % len(hosters)].start_room(room.id)
|
||||||
|
|
||||||
except AlreadyRunningException:
|
except AlreadyRunningException:
|
||||||
@@ -149,7 +122,6 @@ def autogen(config: dict):
|
|||||||
|
|
||||||
with multiprocessing.Pool(config["GENERATORS"], initializer=init_generator,
|
with multiprocessing.Pool(config["GENERATORS"], initializer=init_generator,
|
||||||
initargs=(config,), maxtasksperchild=10) as generator_pool:
|
initargs=(config,), maxtasksperchild=10) as generator_pool:
|
||||||
job_time = config["JOB_TIME"]
|
|
||||||
with db_session:
|
with db_session:
|
||||||
to_start = select(generation for generation in Generation if generation.state == STATE_STARTED)
|
to_start = select(generation for generation in Generation if generation.state == STATE_STARTED)
|
||||||
|
|
||||||
@@ -160,7 +132,7 @@ def autogen(config: dict):
|
|||||||
if sid:
|
if sid:
|
||||||
generation.delete()
|
generation.delete()
|
||||||
else:
|
else:
|
||||||
launch_generator(generator_pool, generation, timeout=job_time)
|
launch_generator(generator_pool, generation)
|
||||||
|
|
||||||
commit()
|
commit()
|
||||||
select(generation for generation in Generation if generation.state == STATE_ERROR).delete()
|
select(generation for generation in Generation if generation.state == STATE_ERROR).delete()
|
||||||
@@ -172,13 +144,16 @@ def autogen(config: dict):
|
|||||||
generation for generation in Generation
|
generation for generation in Generation
|
||||||
if generation.state == STATE_QUEUED).for_update()
|
if generation.state == STATE_QUEUED).for_update()
|
||||||
for generation in to_start:
|
for generation in to_start:
|
||||||
launch_generator(generator_pool, generation, timeout=job_time)
|
launch_generator(generator_pool, generation)
|
||||||
except AlreadyRunningException:
|
except AlreadyRunningException:
|
||||||
logging.info("Autogen reports as already running, not starting another.")
|
logging.info("Autogen reports as already running, not starting another.")
|
||||||
|
|
||||||
Thread(target=keep_running, name="AP_Autogen").start()
|
Thread(target=keep_running, name="AP_Autogen").start()
|
||||||
|
|
||||||
|
|
||||||
|
multiworlds: typing.Dict[type(Room.id), MultiworldInstance] = {}
|
||||||
|
|
||||||
|
|
||||||
class MultiworldInstance():
|
class MultiworldInstance():
|
||||||
def __init__(self, config: dict, id: int):
|
def __init__(self, config: dict, id: int):
|
||||||
self.room_ids = set()
|
self.room_ids = set()
|
||||||
@@ -187,7 +162,6 @@ class MultiworldInstance():
|
|||||||
self.cert = config["SELFLAUNCHCERT"]
|
self.cert = config["SELFLAUNCHCERT"]
|
||||||
self.key = config["SELFLAUNCHKEY"]
|
self.key = config["SELFLAUNCHKEY"]
|
||||||
self.host = config["HOST_ADDRESS"]
|
self.host = config["HOST_ADDRESS"]
|
||||||
self.game_ports = config["GAME_PORTS"]
|
|
||||||
self.rooms_to_start = multiprocessing.Queue()
|
self.rooms_to_start = multiprocessing.Queue()
|
||||||
self.rooms_shutting_down = multiprocessing.Queue()
|
self.rooms_shutting_down = multiprocessing.Queue()
|
||||||
self.name = f"MultiHoster{id}"
|
self.name = f"MultiHoster{id}"
|
||||||
@@ -198,7 +172,7 @@ class MultiworldInstance():
|
|||||||
|
|
||||||
process = multiprocessing.Process(group=None, target=run_server_process,
|
process = multiprocessing.Process(group=None, target=run_server_process,
|
||||||
args=(self.name, self.ponyconfig, get_static_server_data(),
|
args=(self.name, self.ponyconfig, get_static_server_data(),
|
||||||
self.cert, self.key, self.host, self.game_ports,
|
self.cert, self.key, self.host,
|
||||||
self.rooms_to_start, self.rooms_shutting_down),
|
self.rooms_to_start, self.rooms_shutting_down),
|
||||||
name=self.name)
|
name=self.name)
|
||||||
process.start()
|
process.start()
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import os
|
import os
|
||||||
import zipfile
|
import zipfile
|
||||||
import base64
|
import base64
|
||||||
from collections.abc import Set
|
from typing import Union, Dict, Set, Tuple
|
||||||
|
|
||||||
from flask import request, flash, redirect, url_for, render_template
|
from flask import request, flash, redirect, url_for, render_template
|
||||||
from markupsafe import Markup
|
from markupsafe import Markup
|
||||||
@@ -43,7 +43,7 @@ def mysterycheck():
|
|||||||
return redirect(url_for("check"), 301)
|
return redirect(url_for("check"), 301)
|
||||||
|
|
||||||
|
|
||||||
def get_yaml_data(files) -> dict[str, str] | str | Markup:
|
def get_yaml_data(files) -> Union[Dict[str, str], str, Markup]:
|
||||||
options = {}
|
options = {}
|
||||||
for uploaded_file in files:
|
for uploaded_file in files:
|
||||||
if banned_file(uploaded_file.filename):
|
if banned_file(uploaded_file.filename):
|
||||||
@@ -84,12 +84,12 @@ def get_yaml_data(files) -> dict[str, str] | str | Markup:
|
|||||||
return options
|
return options
|
||||||
|
|
||||||
|
|
||||||
def roll_options(options: dict[str, dict | str],
|
def roll_options(options: Dict[str, Union[dict, str]],
|
||||||
plando_options: Set[str] = frozenset({"bosses", "items", "connections", "texts"})) -> \
|
plando_options: Set[str] = frozenset({"bosses", "items", "connections", "texts"})) -> \
|
||||||
tuple[dict[str, str | bool], dict[str, dict]]:
|
Tuple[Dict[str, Union[str, bool]], Dict[str, dict]]:
|
||||||
plando_options = PlandoOptions.from_set(set(plando_options))
|
plando_options = PlandoOptions.from_set(set(plando_options))
|
||||||
results: dict[str, str | bool] = {}
|
results = {}
|
||||||
rolled_results: dict[str, dict] = {}
|
rolled_results = {}
|
||||||
for filename, text in options.items():
|
for filename, text in options.items():
|
||||||
try:
|
try:
|
||||||
if type(text) is dict:
|
if type(text) is dict:
|
||||||
|
|||||||
@@ -1,8 +0,0 @@
|
|||||||
from flask import Flask
|
|
||||||
|
|
||||||
|
|
||||||
class CLI:
|
|
||||||
def __init__(self, app: Flask) -> None:
|
|
||||||
from .stats import stats_cli
|
|
||||||
|
|
||||||
app.cli.add_command(stats_cli)
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
import click
|
|
||||||
from flask.cli import AppGroup
|
|
||||||
from pony.orm import raw_sql
|
|
||||||
|
|
||||||
from Utils import format_SI_prefix
|
|
||||||
|
|
||||||
stats_cli = AppGroup("stats")
|
|
||||||
|
|
||||||
|
|
||||||
@stats_cli.command("show")
|
|
||||||
def show() -> None:
|
|
||||||
from pony.orm import db_session, select
|
|
||||||
|
|
||||||
from WebHostLib.models import GameDataPackage
|
|
||||||
|
|
||||||
total_games_package_count: int = 0
|
|
||||||
total_games_package_size: int
|
|
||||||
top_10_package_sizes: list[tuple[int, str]] = []
|
|
||||||
|
|
||||||
with db_session:
|
|
||||||
data_length = raw_sql("LENGTH(data)")
|
|
||||||
data_length_desc = raw_sql("LENGTH(data) DESC")
|
|
||||||
data_length_sum = raw_sql("SUM(LENGTH(data))")
|
|
||||||
total_games_package_count = GameDataPackage.select().count()
|
|
||||||
total_games_package_size = select(data_length_sum for _ in GameDataPackage).first() # type: ignore
|
|
||||||
top_10_package_sizes = list(
|
|
||||||
select((data_length, dp.checksum) for dp in GameDataPackage) # type: ignore
|
|
||||||
.order_by(lambda _, _2: data_length_desc)
|
|
||||||
.limit(10)
|
|
||||||
)
|
|
||||||
|
|
||||||
click.echo(f"Total number of games packages: {total_games_package_count}")
|
|
||||||
click.echo(f"Total size of games packages: {format_SI_prefix(total_games_package_size, power=1024)}B")
|
|
||||||
click.echo(f"Top {len(top_10_package_sizes)} biggest games packages:")
|
|
||||||
for size, checksum in top_10_package_sizes:
|
|
||||||
click.echo(f" {checksum}: {size:>8d}")
|
|
||||||
@@ -4,7 +4,6 @@ import asyncio
|
|||||||
import collections
|
import collections
|
||||||
import datetime
|
import datetime
|
||||||
import functools
|
import functools
|
||||||
import itertools
|
|
||||||
import logging
|
import logging
|
||||||
import multiprocessing
|
import multiprocessing
|
||||||
import pickle
|
import pickle
|
||||||
@@ -14,23 +13,16 @@ import threading
|
|||||||
import time
|
import time
|
||||||
import typing
|
import typing
|
||||||
import sys
|
import sys
|
||||||
from asyncio import AbstractEventLoop
|
|
||||||
|
|
||||||
import psutil
|
|
||||||
import websockets
|
import websockets
|
||||||
from pony.orm import commit, db_session, select
|
from pony.orm import commit, db_session, select
|
||||||
|
|
||||||
import Utils
|
import Utils
|
||||||
|
|
||||||
from MultiServer import (
|
from MultiServer import Context, server, auto_shutdown, ServerCommandProcessor, ClientMessageProcessor, load_server_cert
|
||||||
Context, server, auto_shutdown, ServerCommandProcessor, ClientMessageProcessor, load_server_cert,
|
|
||||||
server_per_message_deflate_factory,
|
|
||||||
)
|
|
||||||
from Utils import restricted_loads, cache_argsless
|
from Utils import restricted_loads, cache_argsless
|
||||||
from NetUtils import GamesPackage
|
|
||||||
from apmw.webhost.customserver.gamespackagecache import DBGamesPackageCache
|
|
||||||
from .locker import Locker
|
from .locker import Locker
|
||||||
from .models import Command, Room, db
|
from .models import Command, GameDataPackage, Room, db
|
||||||
|
|
||||||
|
|
||||||
class CustomClientMessageProcessor(ClientMessageProcessor):
|
class CustomClientMessageProcessor(ClientMessageProcessor):
|
||||||
@@ -67,39 +59,18 @@ class DBCommandProcessor(ServerCommandProcessor):
|
|||||||
|
|
||||||
class WebHostContext(Context):
|
class WebHostContext(Context):
|
||||||
room_id: int
|
room_id: int
|
||||||
video: dict[tuple[int, int], tuple[str, str]]
|
|
||||||
main_loop: AbstractEventLoop
|
|
||||||
static_server_data: StaticServerData
|
|
||||||
|
|
||||||
def __init__(
|
def __init__(self, static_server_data: dict, logger: logging.Logger):
|
||||||
self,
|
|
||||||
static_server_data: StaticServerData,
|
|
||||||
games_package_cache: DBGamesPackageCache,
|
|
||||||
logger: logging.Logger,
|
|
||||||
) -> None:
|
|
||||||
# static server data is used during _load_game_data to load required data,
|
# static server data is used during _load_game_data to load required data,
|
||||||
# without needing to import worlds system, which takes quite a bit of memory
|
# without needing to import worlds system, which takes quite a bit of memory
|
||||||
super(WebHostContext, self).__init__(
|
|
||||||
"",
|
|
||||||
0,
|
|
||||||
"",
|
|
||||||
"",
|
|
||||||
1,
|
|
||||||
40,
|
|
||||||
True,
|
|
||||||
"enabled",
|
|
||||||
"enabled",
|
|
||||||
"enabled",
|
|
||||||
0,
|
|
||||||
2,
|
|
||||||
games_package_cache=games_package_cache,
|
|
||||||
logger=logger,
|
|
||||||
)
|
|
||||||
self.tags = ["AP", "WebHost"]
|
|
||||||
self.video = {}
|
|
||||||
self.main_loop = asyncio.get_running_loop()
|
|
||||||
self.static_server_data = static_server_data
|
self.static_server_data = static_server_data
|
||||||
self.games_package_cache = games_package_cache
|
super(WebHostContext, self).__init__("", 0, "", "", 1,
|
||||||
|
40, True, "enabled", "enabled",
|
||||||
|
"enabled", 0, 2, logger=logger)
|
||||||
|
del self.static_server_data
|
||||||
|
self.main_loop = asyncio.get_running_loop()
|
||||||
|
self.video = {}
|
||||||
|
self.tags = ["AP", "WebHost"]
|
||||||
|
|
||||||
def __del__(self):
|
def __del__(self):
|
||||||
try:
|
try:
|
||||||
@@ -109,24 +80,24 @@ class WebHostContext(Context):
|
|||||||
except ImportError:
|
except ImportError:
|
||||||
self.logger.debug("Context destroyed")
|
self.logger.debug("Context destroyed")
|
||||||
|
|
||||||
async def listen_to_db_commands(self):
|
def _load_game_data(self):
|
||||||
|
for key, value in self.static_server_data.items():
|
||||||
|
# NOTE: attributes are mutable and shared, so they will have to be copied before being modified
|
||||||
|
setattr(self, key, value)
|
||||||
|
self.non_hintable_names = collections.defaultdict(frozenset, self.non_hintable_names)
|
||||||
|
|
||||||
|
def listen_to_db_commands(self):
|
||||||
cmdprocessor = DBCommandProcessor(self)
|
cmdprocessor = DBCommandProcessor(self)
|
||||||
|
|
||||||
while not self.exit_event.is_set():
|
while not self.exit_event.is_set():
|
||||||
await self.main_loop.run_in_executor(None, self._process_db_commands, cmdprocessor)
|
with db_session:
|
||||||
try:
|
commands = select(command for command in Command if command.room.id == self.room_id)
|
||||||
await asyncio.wait_for(self.exit_event.wait(), 5)
|
if commands:
|
||||||
except asyncio.TimeoutError:
|
for command in commands:
|
||||||
pass
|
self.main_loop.call_soon_threadsafe(cmdprocessor, command.commandtext)
|
||||||
|
command.delete()
|
||||||
def _process_db_commands(self, cmdprocessor):
|
commit()
|
||||||
with db_session:
|
time.sleep(5)
|
||||||
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()
|
|
||||||
|
|
||||||
@db_session
|
@db_session
|
||||||
def load(self, room_id: int):
|
def load(self, room_id: int):
|
||||||
@@ -135,36 +106,63 @@ class WebHostContext(Context):
|
|||||||
if room.last_port:
|
if room.last_port:
|
||||||
self.port = room.last_port
|
self.port = room.last_port
|
||||||
else:
|
else:
|
||||||
self.port = 0
|
self.port = get_random_port()
|
||||||
|
|
||||||
multidata = self.decompress(room.seed.multidata)
|
multidata = self.decompress(room.seed.multidata)
|
||||||
return self._load(multidata, True)
|
game_data_packages = {}
|
||||||
|
|
||||||
def _load_world_data(self):
|
static_gamespackage = self.gamespackage # this is shared across all rooms
|
||||||
# Use static_server_data, but skip static data package since that is in cache anyway.
|
static_item_name_groups = self.item_name_groups
|
||||||
# Also NOT importing worlds here!
|
static_location_name_groups = self.location_name_groups
|
||||||
# FIXME: does this copy the non_hintable_names (also for games not part of the room)?
|
self.gamespackage = {"Archipelago": static_gamespackage.get("Archipelago", {})} # this may be modified by _load
|
||||||
self.non_hintable_names = collections.defaultdict(frozenset, self.static_server_data["non_hintable_names"])
|
self.item_name_groups = {"Archipelago": static_item_name_groups.get("Archipelago", {})}
|
||||||
del self.static_server_data # Not used past this point. Free memory.
|
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
|
||||||
|
# games package could be dropped from static data once all rooms embed data package
|
||||||
|
del multidata["datapackage"][game]
|
||||||
|
else:
|
||||||
|
row = GameDataPackage.get(checksum=game_data["checksum"])
|
||||||
|
if row: # None if rolled on >= 0.3.9 but uploaded to <= 0.3.8. multidata should be complete
|
||||||
|
game_data_packages[game] = Utils.restricted_loads(row.data)
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
self.logger.warning(f"Did not find game_data_package for {game}: {game_data['checksum']}")
|
||||||
|
else:
|
||||||
|
missing_checksum = True # Game rolled on old AP and will load data package from multidata
|
||||||
|
self.gamespackage[game] = static_gamespackage.get(game, {})
|
||||||
|
self.item_name_groups[game] = static_item_name_groups.get(game, {})
|
||||||
|
self.location_name_groups[game] = static_location_name_groups.get(game, {})
|
||||||
|
|
||||||
|
if not game_data_packages and not missing_checksum:
|
||||||
|
# all static -> use the static dicts directly
|
||||||
|
self.gamespackage = static_gamespackage
|
||||||
|
self.item_name_groups = static_item_name_groups
|
||||||
|
self.location_name_groups = static_location_name_groups
|
||||||
|
return self._load(multidata, game_data_packages, True)
|
||||||
|
|
||||||
|
@db_session
|
||||||
def init_save(self, enabled: bool = True):
|
def init_save(self, enabled: bool = True):
|
||||||
self.saving = enabled
|
self.saving = enabled
|
||||||
if self.saving:
|
if self.saving:
|
||||||
with db_session:
|
savegame_data = Room.get(id=self.room_id).multisave
|
||||||
savegame_data = Room.get(id=self.room_id).multisave
|
if savegame_data:
|
||||||
if savegame_data:
|
self.set_save(restricted_loads(Room.get(id=self.room_id).multisave))
|
||||||
self.set_save(restricted_loads(savegame_data))
|
|
||||||
self._start_async_saving(atexit_save=False)
|
self._start_async_saving(atexit_save=False)
|
||||||
asyncio.create_task(self.listen_to_db_commands())
|
threading.Thread(target=self.listen_to_db_commands, daemon=True).start()
|
||||||
|
|
||||||
@db_session
|
@db_session
|
||||||
def _save(self, exit_save: bool = False) -> bool:
|
def _save(self, exit_save: bool = False) -> bool:
|
||||||
room = Room.get(id=self.room_id)
|
room = Room.get(id=self.room_id)
|
||||||
# Does not use Utils.restricted_dumps because we'd rather make a save than not make one
|
|
||||||
room.multisave = pickle.dumps(self.get_save())
|
room.multisave = pickle.dumps(self.get_save())
|
||||||
# saving only occurs on activity, so we can "abuse" this information to mark this as last_activity
|
# saving only occurs on activity, so we can "abuse" this information to mark this as last_activity
|
||||||
if not exit_save: # we don't want to count a shutdown as activity, which would restart the server again
|
if not exit_save: # we don't want to count a shutdown as activity, which would restart the server again
|
||||||
room.last_activity = Utils.utcnow()
|
room.last_activity = datetime.datetime.utcnow()
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def get_save(self) -> dict:
|
def get_save(self) -> dict:
|
||||||
@@ -173,117 +171,38 @@ class WebHostContext(Context):
|
|||||||
return d
|
return d
|
||||||
|
|
||||||
|
|
||||||
class GameRangePorts(typing.NamedTuple):
|
def get_random_port():
|
||||||
parsed_ports: list[range]
|
return random.randint(49152, 65535)
|
||||||
weights: list[int]
|
|
||||||
ephemeral_allowed: bool
|
|
||||||
|
|
||||||
|
|
||||||
@functools.cache
|
|
||||||
def parse_game_ports(game_ports: tuple[str | int, ...]) -> GameRangePorts:
|
|
||||||
parsed_ports: list[range] = []
|
|
||||||
weights: list[int] = []
|
|
||||||
ephemeral_allowed = False
|
|
||||||
total_length = 0
|
|
||||||
|
|
||||||
for item in game_ports:
|
|
||||||
if isinstance(item, str) and "-" in item:
|
|
||||||
start, end = map(int, item.split("-"))
|
|
||||||
x = range(start, end + 1)
|
|
||||||
total_length += len(x)
|
|
||||||
weights.append(total_length)
|
|
||||||
parsed_ports.append(x)
|
|
||||||
elif int(item) == 0:
|
|
||||||
ephemeral_allowed = True
|
|
||||||
else:
|
|
||||||
total_length += 1
|
|
||||||
weights.append(total_length)
|
|
||||||
num = int(item)
|
|
||||||
parsed_ports.append(range(num, num + 1))
|
|
||||||
|
|
||||||
return GameRangePorts(parsed_ports, weights, ephemeral_allowed)
|
|
||||||
|
|
||||||
|
|
||||||
def weighted_random(ranges: list[range], cum_weights: list[int]) -> int:
|
|
||||||
[picked] = random.choices(ranges, cum_weights=cum_weights)
|
|
||||||
return random.randrange(picked.start, picked.stop, picked.step)
|
|
||||||
|
|
||||||
|
|
||||||
def create_random_port_socket(game_ports: tuple[str | int, ...], host: str) -> socket.socket:
|
|
||||||
parsed_ports, weights, ephemeral_allowed = parse_game_ports(game_ports)
|
|
||||||
used_ports = get_used_ports()
|
|
||||||
i = 1024 if len(parsed_ports) > 0 else 0
|
|
||||||
while i > 0:
|
|
||||||
port_num = weighted_random(parsed_ports, weights)
|
|
||||||
if port_num in used_ports:
|
|
||||||
used_ports = get_used_ports()
|
|
||||||
continue
|
|
||||||
|
|
||||||
i -= 0
|
|
||||||
|
|
||||||
try:
|
|
||||||
return socket.create_server((host, port_num))
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
if ephemeral_allowed:
|
|
||||||
return socket.create_server((host, 0))
|
|
||||||
|
|
||||||
raise OSError(98, "No available ports")
|
|
||||||
|
|
||||||
|
|
||||||
def try_conns_per_process(p: psutil.Process) -> typing.Iterable[int]:
|
|
||||||
try:
|
|
||||||
return (c.laddr.port for c in p.net_connections("tcp4"))
|
|
||||||
except psutil.AccessDenied:
|
|
||||||
return ()
|
|
||||||
|
|
||||||
|
|
||||||
def get_active_net_connections() -> typing.Iterable[int]:
|
|
||||||
# Don't even try to check if system using AIX
|
|
||||||
if psutil.AIX:
|
|
||||||
return ()
|
|
||||||
|
|
||||||
try:
|
|
||||||
return (c.laddr.port for c in psutil.net_connections("tcp4"))
|
|
||||||
# raises AccessDenied when done on macOS
|
|
||||||
except psutil.AccessDenied:
|
|
||||||
# flatten the list of iterables
|
|
||||||
return itertools.chain.from_iterable(map(
|
|
||||||
# get the net connections of the process and then map its ports
|
|
||||||
try_conns_per_process,
|
|
||||||
# this method has caching handled by psutil
|
|
||||||
psutil.process_iter(["net_connections"])
|
|
||||||
))
|
|
||||||
|
|
||||||
|
|
||||||
def get_used_ports():
|
|
||||||
last_used_ports: tuple[frozenset[int], float] | None = getattr(get_used_ports, "last", None)
|
|
||||||
t_hash = round(time.time() / 90) # cache for 90 seconds
|
|
||||||
if last_used_ports is None or last_used_ports[1] != t_hash:
|
|
||||||
last_used_ports = (frozenset(get_active_net_connections()), t_hash)
|
|
||||||
setattr(get_used_ports, "last", last_used_ports)
|
|
||||||
|
|
||||||
return last_used_ports[0]
|
|
||||||
|
|
||||||
|
|
||||||
class StaticServerData(typing.TypedDict, total=True):
|
|
||||||
non_hintable_names: dict[str, typing.AbstractSet[str]]
|
|
||||||
games_package: dict[str, GamesPackage]
|
|
||||||
|
|
||||||
|
|
||||||
@cache_argsless
|
@cache_argsless
|
||||||
def get_static_server_data() -> StaticServerData:
|
def get_static_server_data() -> dict:
|
||||||
import worlds
|
import worlds
|
||||||
|
data = {
|
||||||
return {
|
|
||||||
"non_hintable_names": {
|
"non_hintable_names": {
|
||||||
world_name: world.hint_blacklist
|
world_name: world.hint_blacklist
|
||||||
for world_name, world in worlds.AutoWorldRegister.world_types.items()
|
for world_name, world in worlds.AutoWorldRegister.world_types.items()
|
||||||
},
|
},
|
||||||
"games_package": worlds.network_data_package["games"]
|
"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()
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
def set_up_logging(room_id) -> logging.Logger:
|
def set_up_logging(room_id) -> logging.Logger:
|
||||||
import os
|
import os
|
||||||
@@ -305,33 +224,9 @@ def set_up_logging(room_id) -> logging.Logger:
|
|||||||
return logger
|
return logger
|
||||||
|
|
||||||
|
|
||||||
def tear_down_logging(room_id):
|
def run_server_process(name: str, ponyconfig: dict, static_server_data: dict,
|
||||||
"""Close logging handling for a room."""
|
cert_file: typing.Optional[str], cert_key_file: typing.Optional[str],
|
||||||
logger_name = f"RoomLogger {room_id}"
|
host: str, rooms_to_run: multiprocessing.Queue, rooms_shutting_down: multiprocessing.Queue):
|
||||||
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[str, typing.Any],
|
|
||||||
static_server_data: StaticServerData,
|
|
||||||
cert_file: typing.Optional[str],
|
|
||||||
cert_key_file: typing.Optional[str],
|
|
||||||
host: str,
|
|
||||||
game_ports: typing.Iterable[str | int],
|
|
||||||
rooms_to_run: multiprocessing.Queue,
|
|
||||||
rooms_shutting_down: multiprocessing.Queue,
|
|
||||||
) -> None:
|
|
||||||
import gc
|
|
||||||
|
|
||||||
from setproctitle import setproctitle
|
|
||||||
|
|
||||||
setproctitle(name)
|
|
||||||
Utils.init_logging(name)
|
Utils.init_logging(name)
|
||||||
try:
|
try:
|
||||||
import resource
|
import resource
|
||||||
@@ -344,11 +239,6 @@ def run_server_process(
|
|||||||
resource.setrlimit(resource.RLIMIT_NOFILE, (file_limit, file_limit))
|
resource.setrlimit(resource.RLIMIT_NOFILE, (file_limit, file_limit))
|
||||||
del resource, file_limit
|
del resource, file_limit
|
||||||
|
|
||||||
# prime the data package cache with static data
|
|
||||||
games_package_cache = DBGamesPackageCache(static_server_data["games_package"])
|
|
||||||
# convert to tuple because its hashable
|
|
||||||
game_ports = tuple(game_ports)
|
|
||||||
|
|
||||||
# establish DB connection for multidata and multisave
|
# establish DB connection for multidata and multisave
|
||||||
db.bind(**ponyconfig)
|
db.bind(**ponyconfig)
|
||||||
db.generate_mapping(check_tables=False)
|
db.generate_mapping(check_tables=False)
|
||||||
@@ -356,22 +246,9 @@ def run_server_process(
|
|||||||
if "worlds" in sys.modules:
|
if "worlds" in sys.modules:
|
||||||
raise Exception("Worlds system should not be loaded in the custom server.")
|
raise Exception("Worlds system should not be loaded in the custom server.")
|
||||||
|
|
||||||
if not cert_file:
|
import gc
|
||||||
def get_ssl_context():
|
ssl_context = load_server_cert(cert_file, cert_key_file) if cert_file else None
|
||||||
return None
|
del cert_file, cert_key_file, ponyconfig
|
||||||
else:
|
|
||||||
load_date = None
|
|
||||||
ssl_context = load_server_cert(cert_file, cert_key_file)
|
|
||||||
|
|
||||||
def get_ssl_context():
|
|
||||||
nonlocal load_date, ssl_context
|
|
||||||
today = datetime.date.today()
|
|
||||||
if load_date != today:
|
|
||||||
ssl_context = load_server_cert(cert_file, cert_key_file)
|
|
||||||
load_date = today
|
|
||||||
return ssl_context
|
|
||||||
|
|
||||||
del ponyconfig
|
|
||||||
gc.collect() # free intermediate objects used during setup
|
gc.collect() # free intermediate objects used during setup
|
||||||
|
|
||||||
loop = asyncio.get_event_loop()
|
loop = asyncio.get_event_loop()
|
||||||
@@ -380,29 +257,19 @@ def run_server_process(
|
|||||||
with Locker(f"RoomLocker {room_id}"):
|
with Locker(f"RoomLocker {room_id}"):
|
||||||
try:
|
try:
|
||||||
logger = set_up_logging(room_id)
|
logger = set_up_logging(room_id)
|
||||||
ctx = WebHostContext(static_server_data, games_package_cache, logger)
|
ctx = WebHostContext(static_server_data, logger)
|
||||||
ctx.load(room_id)
|
ctx.load(room_id)
|
||||||
ctx.init_save()
|
ctx.init_save()
|
||||||
assert ctx.server is None
|
assert ctx.server is None
|
||||||
if ctx.port != 0:
|
try:
|
||||||
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:
|
|
||||||
ctx.port = 0
|
|
||||||
if ctx.port == 0:
|
|
||||||
ctx.server = websockets.serve(
|
ctx.server = websockets.serve(
|
||||||
functools.partial(server, ctx=ctx),
|
functools.partial(server, ctx=ctx), ctx.host, ctx.port, ssl=ssl_context)
|
||||||
sock=create_random_port_socket(game_ports, ctx.host),
|
|
||||||
ssl=get_ssl_context(),
|
await ctx.server
|
||||||
extensions=[server_per_message_deflate_factory],
|
except OSError: # likely port in use
|
||||||
)
|
ctx.server = websockets.serve(
|
||||||
|
functools.partial(server, ctx=ctx), ctx.host, 0, ssl=ssl_context)
|
||||||
|
|
||||||
await ctx.server
|
await ctx.server
|
||||||
port = 0
|
port = 0
|
||||||
for wssocket in ctx.server.ws_server.sockets:
|
for wssocket in ctx.server.ws_server.sockets:
|
||||||
@@ -418,7 +285,6 @@ def run_server_process(
|
|||||||
with db_session:
|
with db_session:
|
||||||
room = Room.get(id=ctx.room_id)
|
room = Room.get(id=ctx.room_id)
|
||||||
room.last_port = port
|
room.last_port = port
|
||||||
del room
|
|
||||||
else:
|
else:
|
||||||
ctx.logger.exception("Could not determine port. Likely hosting failure.")
|
ctx.logger.exception("Could not determine port. Likely hosting failure.")
|
||||||
with db_session:
|
with db_session:
|
||||||
@@ -431,35 +297,28 @@ def run_server_process(
|
|||||||
|
|
||||||
except (KeyboardInterrupt, SystemExit):
|
except (KeyboardInterrupt, SystemExit):
|
||||||
if ctx.saving:
|
if ctx.saving:
|
||||||
ctx._save(True)
|
ctx._save()
|
||||||
setattr(asyncio.current_task(), "save", None)
|
setattr(asyncio.current_task(), "save", None)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
with db_session:
|
with db_session:
|
||||||
room = Room.get(id=room_id)
|
room = Room.get(id=room_id)
|
||||||
room.last_port = -1
|
room.last_port = -1
|
||||||
del room
|
|
||||||
logger.exception(e)
|
logger.exception(e)
|
||||||
raise
|
raise
|
||||||
else:
|
else:
|
||||||
if ctx.saving:
|
if ctx.saving:
|
||||||
ctx._save(True)
|
ctx._save()
|
||||||
setattr(asyncio.current_task(), "save", None)
|
setattr(asyncio.current_task(), "save", None)
|
||||||
finally:
|
finally:
|
||||||
try:
|
try:
|
||||||
ctx.save_dirty = False # make sure the saving thread does not write to DB after final wakeup
|
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
|
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
|
# NOTE: async saving should probably be an async task and could be merged with shutdown_task
|
||||||
|
with (db_session):
|
||||||
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
|
# ensure the Room does not spin up again on its own, minute of safety buffer
|
||||||
room = Room.get(id=room_id)
|
room = Room.get(id=room_id)
|
||||||
room.last_activity = Utils.utcnow() - datetime.timedelta(minutes=1, seconds=room.timeout)
|
room.last_activity = datetime.datetime.utcnow() - \
|
||||||
del room
|
datetime.timedelta(minutes=1, seconds=room.timeout)
|
||||||
tear_down_logging(room_id)
|
|
||||||
logging.info(f"Shutting down room {room_id} on {name}.")
|
logging.info(f"Shutting down room {room_id} on {name}.")
|
||||||
finally:
|
finally:
|
||||||
await asyncio.sleep(5)
|
await asyncio.sleep(5)
|
||||||
@@ -478,7 +337,7 @@ def run_server_process(
|
|||||||
|
|
||||||
def run(self):
|
def run(self):
|
||||||
while 1:
|
while 1:
|
||||||
next_room = rooms_to_run.get(block=True, timeout=None)
|
next_room = rooms_to_run.get(block=True, timeout=None)
|
||||||
gc.collect()
|
gc.collect()
|
||||||
task = asyncio.run_coroutine_threadsafe(start_room(next_room), loop)
|
task = asyncio.run_coroutine_threadsafe(start_room(next_room), loop)
|
||||||
self._tasks.append(task)
|
self._tasks.append(task)
|
||||||
|
|||||||
@@ -61,7 +61,12 @@ def download_slot_file(room_id, player_id: int):
|
|||||||
else:
|
else:
|
||||||
import io
|
import io
|
||||||
|
|
||||||
if slot_data.game == "Factorio":
|
if slot_data.game == "Minecraft":
|
||||||
|
from worlds.minecraft import mc_update_output
|
||||||
|
fname = f"AP_{app.jinja_env.filters['suuid'](room_id)}_P{slot_data.player_id}_{slot_data.player_name}.apmc"
|
||||||
|
data = mc_update_output(slot_data.data, server=app.config['HOST_ADDRESS'], port=room.last_port)
|
||||||
|
return send_file(io.BytesIO(data), as_attachment=True, download_name=fname)
|
||||||
|
elif slot_data.game == "Factorio":
|
||||||
with zipfile.ZipFile(io.BytesIO(slot_data.data)) as zf:
|
with zipfile.ZipFile(io.BytesIO(slot_data.data)) as zf:
|
||||||
for name in zf.namelist():
|
for name in zf.namelist():
|
||||||
if name.endswith("info.json"):
|
if name.endswith("info.json"):
|
||||||
|
|||||||
@@ -1,29 +1,30 @@
|
|||||||
import concurrent.futures
|
import concurrent.futures
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import pickle
|
||||||
import random
|
import random
|
||||||
import tempfile
|
import tempfile
|
||||||
import zipfile
|
import zipfile
|
||||||
from collections import Counter
|
from collections import Counter
|
||||||
from pickle import PicklingError
|
from typing import Any, Dict, List, Optional, Union, Set
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from flask import flash, redirect, render_template, request, session, url_for
|
from flask import flash, redirect, render_template, request, session, url_for
|
||||||
from pony.orm import commit, db_session
|
from pony.orm import commit, db_session
|
||||||
|
|
||||||
from BaseClasses import get_seed, seeddigits
|
from BaseClasses import get_seed, seeddigits
|
||||||
from Generate import PlandoOptions, handle_name, mystery_argparse
|
from Generate import PlandoOptions, handle_name
|
||||||
from Main import main as ERmain
|
from Main import main as ERmain
|
||||||
from Utils import __version__, restricted_dumps, DaemonThreadPoolExecutor
|
from Utils import __version__
|
||||||
from WebHostLib import app
|
from WebHostLib import app
|
||||||
from settings import ServerOptions, GeneratorOptions
|
from settings import ServerOptions, GeneratorOptions
|
||||||
|
from worlds.alttp.EntranceRandomizer import parse_arguments
|
||||||
from .check import get_yaml_data, roll_options
|
from .check import get_yaml_data, roll_options
|
||||||
from .models import Generation, STATE_ERROR, STATE_QUEUED, Seed, UUID
|
from .models import Generation, STATE_ERROR, STATE_QUEUED, Seed, UUID
|
||||||
from .upload import upload_zip_to_db
|
from .upload import upload_zip_to_db
|
||||||
|
|
||||||
|
|
||||||
def get_meta(options_source: dict, race: bool = False) -> dict[str, list[str] | dict[str, Any]]:
|
def get_meta(options_source: dict, race: bool = False) -> Dict[str, Union[List[str], Dict[str, Any]]]:
|
||||||
plando_options: set[str] = set()
|
plando_options: Set[str] = set()
|
||||||
for substr in ("bosses", "items", "connections", "texts"):
|
for substr in ("bosses", "items", "connections", "texts"):
|
||||||
if options_source.get(f"plando_{substr}", substr in GeneratorOptions.plando_options):
|
if options_source.get(f"plando_{substr}", substr in GeneratorOptions.plando_options):
|
||||||
plando_options.add(substr)
|
plando_options.add(substr)
|
||||||
@@ -33,7 +34,6 @@ def get_meta(options_source: dict, race: bool = False) -> dict[str, list[str] |
|
|||||||
"release_mode": str(options_source.get("release_mode", ServerOptions.release_mode)),
|
"release_mode": str(options_source.get("release_mode", ServerOptions.release_mode)),
|
||||||
"remaining_mode": str(options_source.get("remaining_mode", ServerOptions.remaining_mode)),
|
"remaining_mode": str(options_source.get("remaining_mode", ServerOptions.remaining_mode)),
|
||||||
"collect_mode": str(options_source.get("collect_mode", ServerOptions.collect_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))),
|
"item_cheat": bool(int(options_source.get("item_cheat", not ServerOptions.disable_item_cheat))),
|
||||||
"server_password": str(options_source.get("server_password", None)),
|
"server_password": str(options_source.get("server_password", None)),
|
||||||
}
|
}
|
||||||
@@ -73,11 +73,7 @@ def generate(race=False):
|
|||||||
return render_template("generate.html", race=race, version=__version__)
|
return render_template("generate.html", race=race, version=__version__)
|
||||||
|
|
||||||
|
|
||||||
def format_exception(e: BaseException) -> str:
|
def start_generation(options: Dict[str, Union[dict, str]], meta: Dict[str, Any]):
|
||||||
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"]))
|
results, gen_options = roll_options(options, set(meta["plando_options"]))
|
||||||
|
|
||||||
if any(type(result) == str for result in results.values()):
|
if any(type(result) == str for result in results.values()):
|
||||||
@@ -87,40 +83,30 @@ def start_generation(options: dict[str, dict | str], meta: dict[str, Any]):
|
|||||||
f"If you have a larger group, please generate it yourself and upload it.")
|
f"If you have a larger group, please generate it yourself and upload it.")
|
||||||
return redirect(url_for(request.endpoint, **(request.view_args or {})))
|
return redirect(url_for(request.endpoint, **(request.view_args or {})))
|
||||||
elif len(gen_options) >= app.config["JOB_THRESHOLD"]:
|
elif len(gen_options) >= app.config["JOB_THRESHOLD"]:
|
||||||
try:
|
gen = Generation(
|
||||||
gen = Generation(
|
options=pickle.dumps({name: vars(options) for name, options in gen_options.items()}),
|
||||||
options=restricted_dumps({name: vars(options) for name, options in gen_options.items()}),
|
# convert to json compatible
|
||||||
# convert to json compatible
|
meta=json.dumps(meta),
|
||||||
meta=json.dumps(meta),
|
state=STATE_QUEUED,
|
||||||
state=STATE_QUEUED,
|
owner=session["_id"])
|
||||||
owner=session["_id"])
|
|
||||||
except PicklingError as e:
|
|
||||||
from .autolauncher import handle_generation_failure
|
|
||||||
handle_generation_failure(e)
|
|
||||||
meta["error"] = format_exception(e)
|
|
||||||
details = json.dumps(meta, indent=4).strip()
|
|
||||||
return render_template("seedError.html", seed_error=meta["error"], details=details)
|
|
||||||
|
|
||||||
commit()
|
commit()
|
||||||
|
|
||||||
return redirect(url_for("wait_seed", seed=gen.id))
|
return redirect(url_for("wait_seed", seed=gen.id))
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
seed_id = gen_game({name: vars(options) for name, options in gen_options.items()},
|
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"])
|
meta=meta, owner=session["_id"].int)
|
||||||
except BaseException as e:
|
except BaseException as e:
|
||||||
from .autolauncher import handle_generation_failure
|
from .autolauncher import handle_generation_failure
|
||||||
handle_generation_failure(e)
|
handle_generation_failure(e)
|
||||||
meta["error"] = format_exception(e)
|
return render_template("seedError.html", seed_error=(e.__class__.__name__ + ": " + str(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))
|
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):
|
def gen_game(gen_options: dict, meta: Optional[Dict[str, Any]] = None, owner=None, sid=None):
|
||||||
if meta is None:
|
if not meta:
|
||||||
meta = {}
|
meta: Dict[str, Any] = {}
|
||||||
|
|
||||||
meta.setdefault("server_options", {}).setdefault("hint_cost", 10)
|
meta.setdefault("server_options", {}).setdefault("hint_cost", 10)
|
||||||
race = meta.setdefault("generator_options", {}).setdefault("race", False)
|
race = meta.setdefault("generator_options", {}).setdefault("race", False)
|
||||||
@@ -137,47 +123,42 @@ def gen_game(gen_options: dict, meta: dict[str, Any] | None = None, owner=None,
|
|||||||
|
|
||||||
seedname = "W" + (f"{random.randint(0, pow(10, seeddigits) - 1)}".zfill(seeddigits))
|
seedname = "W" + (f"{random.randint(0, pow(10, seeddigits) - 1)}".zfill(seeddigits))
|
||||||
|
|
||||||
args = mystery_argparse([]) # Just to set up the Namespace with defaults
|
erargs = parse_arguments(['--multi', str(playercount)])
|
||||||
args.multi = playercount
|
erargs.seed = seed
|
||||||
args.seed = seed
|
erargs.name = {x: "" for x in range(1, playercount + 1)} # only so it can be overwritten in mystery
|
||||||
args.name = {x: "" for x in range(1, playercount + 1)} # only so it can be overwritten in mystery
|
erargs.spoiler = meta["generator_options"].get("spoiler", 0)
|
||||||
args.spoiler = meta["generator_options"].get("spoiler", 0)
|
erargs.race = race
|
||||||
args.race = race
|
erargs.outputname = seedname
|
||||||
args.outputname = seedname
|
erargs.outputpath = target.name
|
||||||
args.outputpath = target.name
|
erargs.teams = 1
|
||||||
args.teams = 1
|
erargs.plando_options = PlandoOptions.from_set(meta.setdefault("plando_options",
|
||||||
args.plando_options = PlandoOptions.from_set(meta.setdefault("plando_options",
|
{"bosses", "items", "connections", "texts"}))
|
||||||
{"bosses", "items", "connections", "texts"}))
|
erargs.skip_prog_balancing = False
|
||||||
args.skip_prog_balancing = False
|
erargs.skip_output = False
|
||||||
args.skip_output = False
|
erargs.csv_output = False
|
||||||
args.spoiler_only = False
|
|
||||||
args.csv_output = False
|
|
||||||
args.sprite = dict.fromkeys(range(1, args.multi+1), None)
|
|
||||||
args.sprite_pool = dict.fromkeys(range(1, args.multi+1), None)
|
|
||||||
|
|
||||||
name_counter = Counter()
|
name_counter = Counter()
|
||||||
for player, (playerfile, settings) in enumerate(gen_options.items(), 1):
|
for player, (playerfile, settings) in enumerate(gen_options.items(), 1):
|
||||||
for k, v in settings.items():
|
for k, v in settings.items():
|
||||||
if v is not None:
|
if v is not None:
|
||||||
if hasattr(args, k):
|
if hasattr(erargs, k):
|
||||||
getattr(args, k)[player] = v
|
getattr(erargs, k)[player] = v
|
||||||
else:
|
else:
|
||||||
setattr(args, k, {player: v})
|
setattr(erargs, k, {player: v})
|
||||||
|
|
||||||
if not args.name[player]:
|
if not erargs.name[player]:
|
||||||
args.name[player] = os.path.splitext(os.path.split(playerfile)[-1])[0]
|
erargs.name[player] = os.path.splitext(os.path.split(playerfile)[-1])[0]
|
||||||
args.name[player] = handle_name(args.name[player], player, name_counter)
|
erargs.name[player] = handle_name(erargs.name[player], player, name_counter)
|
||||||
if len(set(args.name.values())) != len(args.name):
|
if len(set(erargs.name.values())) != len(erargs.name):
|
||||||
raise Exception(f"Names have to be unique. Names: {Counter(args.name.values())}")
|
raise Exception(f"Names have to be unique. Names: {Counter(erargs.name.values())}")
|
||||||
ERmain(args, seed, baked_server_options=meta["server_options"])
|
ERmain(erargs, seed, baked_server_options=meta["server_options"])
|
||||||
|
|
||||||
return upload_to_db(target.name, sid, owner, race)
|
return upload_to_db(target.name, sid, owner, race)
|
||||||
|
thread_pool = concurrent.futures.ThreadPoolExecutor(max_workers=1)
|
||||||
thread_pool = DaemonThreadPoolExecutor(max_workers=1)
|
|
||||||
thread = thread_pool.submit(task)
|
thread = thread_pool.submit(task)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
return thread.result(timeout)
|
return thread.result(app.config["JOB_TIME"])
|
||||||
except concurrent.futures.TimeoutError as e:
|
except concurrent.futures.TimeoutError as e:
|
||||||
if sid:
|
if sid:
|
||||||
with db_session:
|
with db_session:
|
||||||
@@ -185,14 +166,11 @@ def gen_game(gen_options: dict, meta: dict[str, Any] | None = None, owner=None,
|
|||||||
if gen is not None:
|
if gen is not None:
|
||||||
gen.state = STATE_ERROR
|
gen.state = STATE_ERROR
|
||||||
meta = json.loads(gen.meta)
|
meta = json.loads(gen.meta)
|
||||||
meta["error"] = ("Allowed time for Generation exceeded, " +
|
meta["error"] = (
|
||||||
"please consider generating locally instead. " +
|
"Allowed time for Generation exceeded, please consider generating locally instead. " +
|
||||||
format_exception(e))
|
e.__class__.__name__ + ": " + str(e))
|
||||||
gen.meta = json.dumps(meta)
|
gen.meta = json.dumps(meta)
|
||||||
commit()
|
commit()
|
||||||
except (KeyboardInterrupt, SystemExit):
|
|
||||||
# don't update db, retry next time
|
|
||||||
raise
|
|
||||||
except BaseException as e:
|
except BaseException as e:
|
||||||
if sid:
|
if sid:
|
||||||
with db_session:
|
with db_session:
|
||||||
@@ -200,15 +178,10 @@ def gen_game(gen_options: dict, meta: dict[str, Any] | None = None, owner=None,
|
|||||||
if gen is not None:
|
if gen is not None:
|
||||||
gen.state = STATE_ERROR
|
gen.state = STATE_ERROR
|
||||||
meta = json.loads(gen.meta)
|
meta = json.loads(gen.meta)
|
||||||
meta["error"] = format_exception(e)
|
meta["error"] = (e.__class__.__name__ + ": " + str(e))
|
||||||
gen.meta = json.dumps(meta)
|
gen.meta = json.dumps(meta)
|
||||||
commit()
|
commit()
|
||||||
raise
|
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>')
|
@app.route('/wait/<suuid:seed>')
|
||||||
@@ -222,9 +195,7 @@ def wait_seed(seed: UUID):
|
|||||||
if not generation:
|
if not generation:
|
||||||
return "Generation not found."
|
return "Generation not found."
|
||||||
elif generation.state == STATE_ERROR:
|
elif generation.state == STATE_ERROR:
|
||||||
meta = json.loads(generation.meta)
|
return render_template("seedError.html", seed_error=generation.meta)
|
||||||
details = json.dumps(meta, indent=4).strip()
|
|
||||||
return render_template("seedError.html", seed_error=meta["error"], details=details)
|
|
||||||
return render_template("waitSeed.html", seed_id=seed_id)
|
return render_template("waitSeed.html", seed_id=seed_id)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
from datetime import timedelta
|
from datetime import timedelta, datetime
|
||||||
|
|
||||||
from flask import render_template
|
from flask import render_template
|
||||||
from pony.orm import count
|
from pony.orm import count
|
||||||
|
|
||||||
from Utils import utcnow
|
|
||||||
from WebHostLib import app, cache
|
from WebHostLib import app, cache
|
||||||
from .models import Room, Seed
|
from .models import Room, Seed
|
||||||
|
|
||||||
@@ -11,6 +10,6 @@ from .models import Room, Seed
|
|||||||
@app.route('/', methods=['GET', 'POST'])
|
@app.route('/', methods=['GET', 'POST'])
|
||||||
@cache.cached(timeout=300) # cache has to appear under app route for caching to work
|
@cache.cached(timeout=300) # cache has to appear under app route for caching to work
|
||||||
def landing():
|
def landing():
|
||||||
rooms = count(room for room in Room if room.creation_time >= utcnow() - timedelta(days=7))
|
rooms = count(room for room in Room if room.creation_time >= datetime.utcnow() - timedelta(days=7))
|
||||||
seeds = count(seed for seed in Seed if seed.creation_time >= utcnow() - timedelta(days=7))
|
seeds = count(seed for seed in Seed if seed.creation_time >= datetime.utcnow() - timedelta(days=7))
|
||||||
return render_template("landing.html", rooms=rooms, seeds=seeds)
|
return render_template("landing.html", rooms=rooms, seeds=seeds)
|
||||||
|
|||||||
@@ -3,10 +3,10 @@ import threading
|
|||||||
import json
|
import json
|
||||||
|
|
||||||
from Utils import local_path, user_path
|
from Utils import local_path, user_path
|
||||||
|
from worlds.alttp.Rom import Sprite
|
||||||
|
|
||||||
|
|
||||||
def update_sprites_lttp():
|
def update_sprites_lttp():
|
||||||
from worlds.alttp.Rom import Sprite
|
|
||||||
from tkinter import Tk
|
from tkinter import Tk
|
||||||
from LttPAdjuster import get_image_for_sprite
|
from LttPAdjuster import get_image_for_sprite
|
||||||
from LttPAdjuster import BackgroundTaskProgress
|
from LttPAdjuster import BackgroundTaskProgress
|
||||||
@@ -14,7 +14,7 @@ def update_sprites_lttp():
|
|||||||
from LttPAdjuster import update_sprites
|
from LttPAdjuster import update_sprites
|
||||||
|
|
||||||
# Target directories
|
# Target directories
|
||||||
input_dir = user_path("data", "sprites", "alttp", "remote")
|
input_dir = user_path("data", "sprites", "alttpr")
|
||||||
output_dir = local_path("WebHostLib", "static", "generated") # TODO: move to user_path
|
output_dir = local_path("WebHostLib", "static", "generated") # TODO: move to user_path
|
||||||
|
|
||||||
os.makedirs(os.path.join(output_dir, "sprites"), exist_ok=True)
|
os.makedirs(os.path.join(output_dir, "sprites"), exist_ok=True)
|
||||||
|
|||||||
@@ -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,7 +1,5 @@
|
|||||||
import datetime
|
import datetime
|
||||||
import os
|
import os
|
||||||
import warnings
|
|
||||||
from enum import StrEnum
|
|
||||||
from typing import Any, IO, Dict, Iterator, List, Tuple, Union
|
from typing import Any, IO, Dict, Iterator, List, Tuple, Union
|
||||||
|
|
||||||
import jinja2.exceptions
|
import jinja2.exceptions
|
||||||
@@ -9,40 +7,15 @@ from flask import request, redirect, url_for, render_template, Response, session
|
|||||||
from pony.orm import count, commit, db_session
|
from pony.orm import count, commit, db_session
|
||||||
from werkzeug.utils import secure_filename
|
from werkzeug.utils import secure_filename
|
||||||
|
|
||||||
|
from worlds.AutoWorld import AutoWorldRegister
|
||||||
from worlds.AutoWorld import AutoWorldRegister, World
|
|
||||||
from . import app, cache
|
from . import app, cache
|
||||||
from .markdown import render_markdown
|
|
||||||
from .models import Seed, Room, Command, UUID, uuid4
|
from .models import Seed, Room, Command, UUID, uuid4
|
||||||
from Utils import title_sorted, utcnow
|
|
||||||
|
|
||||||
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)]:
|
def get_world_theme(game_name: str):
|
||||||
worlds = {}
|
if game_name in AutoWorldRegister.world_types:
|
||||||
for game, world in AutoWorldRegister.world_types.items():
|
return AutoWorldRegister.world_types[game_name].web.theme
|
||||||
if not world.hidden:
|
return 'grass'
|
||||||
worlds[game] = world
|
|
||||||
return worlds
|
|
||||||
|
|
||||||
|
|
||||||
@app.errorhandler(404)
|
@app.errorhandler(404)
|
||||||
@@ -58,106 +31,71 @@ def start_playing():
|
|||||||
return render_template(f"startPlaying.html")
|
return render_template(f"startPlaying.html")
|
||||||
|
|
||||||
|
|
||||||
|
# Game Info Pages
|
||||||
@app.route('/games/<string:game>/info/<string:lang>')
|
@app.route('/games/<string:game>/info/<string:lang>')
|
||||||
@cache.cached()
|
@cache.cached()
|
||||||
def game_info(game, lang):
|
def game_info(game, lang):
|
||||||
"""Game Info Pages"""
|
return render_template('gameInfo.html', game=game, lang=lang, theme=get_world_theme(game))
|
||||||
try:
|
|
||||||
theme = get_world_theme(game)
|
|
||||||
secure_game_name = secure_filename(game)
|
|
||||||
lang = secure_filename(lang)
|
|
||||||
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)
|
|
||||||
|
|
||||||
|
|
||||||
|
# List of supported games
|
||||||
@app.route('/games')
|
@app.route('/games')
|
||||||
@cache.cached()
|
@cache.cached()
|
||||||
def games():
|
def games():
|
||||||
"""List of supported games"""
|
worlds = {}
|
||||||
return render_template("supportedGames.html", worlds=get_visible_worlds())
|
for game, world in AutoWorldRegister.world_types.items():
|
||||||
|
if not world.hidden:
|
||||||
|
worlds[game] = world
|
||||||
@app.route('/tutorial/<string:game>/<string:file>')
|
return render_template("supportedGames.html", worlds=worlds)
|
||||||
@cache.cached()
|
|
||||||
def tutorial(game: str, file: str):
|
|
||||||
try:
|
|
||||||
theme = get_world_theme(game)
|
|
||||||
secure_game_name = secure_filename(game)
|
|
||||||
file = secure_filename(file)
|
|
||||||
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)
|
|
||||||
|
|
||||||
|
|
||||||
@app.route('/tutorial/<string:game>/<string:file>/<string:lang>')
|
@app.route('/tutorial/<string:game>/<string:file>/<string:lang>')
|
||||||
def tutorial_redirect(game: str, file: str, lang: str):
|
@cache.cached()
|
||||||
"""
|
def tutorial(game, file, lang):
|
||||||
Permanent redirect old tutorial URLs to new ones to keep search engines happy.
|
return render_template("tutorial.html", game=game, file=file, lang=lang, theme=get_world_theme(game))
|
||||||
e.g. /tutorial/Archipelago/setup/en -> /tutorial/Archipelago/setup_en
|
|
||||||
"""
|
|
||||||
return redirect(url_for("tutorial", game=game, file=f"{file}_{lang}"), code=301)
|
|
||||||
|
|
||||||
|
|
||||||
@app.route('/tutorial/')
|
@app.route('/tutorial/')
|
||||||
@cache.cached()
|
@cache.cached()
|
||||||
def tutorial_landing():
|
def tutorial_landing():
|
||||||
tutorials = {}
|
return render_template("tutorialLanding.html")
|
||||||
worlds = AutoWorldRegister.world_types
|
|
||||||
for world_name, world_type in worlds.items():
|
|
||||||
current_world = tutorials[world_name] = {}
|
|
||||||
for tutorial in world_type.web.tutorials:
|
|
||||||
current_tutorial = current_world.setdefault(tutorial.tutorial_name, {
|
|
||||||
"description": tutorial.description, "files": {}})
|
|
||||||
current_tutorial["files"][secure_filename(tutorial.file_name).rsplit(".", 1)[0]] = {
|
|
||||||
"authors": tutorial.authors,
|
|
||||||
"language": tutorial.language
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
|
|
||||||
@app.route('/faq/<string:lang>/')
|
@app.route('/faq/<string:lang>/')
|
||||||
@cache.cached()
|
@cache.cached()
|
||||||
def faq(lang: str):
|
def faq(lang: str):
|
||||||
document = render_markdown(os.path.join(app.static_folder, "assets", "faq", secure_filename(lang)+".md"))
|
import markdown
|
||||||
|
with open(os.path.join(app.static_folder, "assets", "faq", secure_filename(lang)+".md")) as f:
|
||||||
|
document = f.read()
|
||||||
return render_template(
|
return render_template(
|
||||||
"markdown_document.html",
|
"markdown_document.html",
|
||||||
title="Frequently Asked Questions",
|
title="Frequently Asked Questions",
|
||||||
html_from_markdown=document,
|
html_from_markdown=markdown.markdown(
|
||||||
|
document,
|
||||||
|
extensions=["toc", "mdx_breakless_lists"],
|
||||||
|
extension_configs={
|
||||||
|
"toc": {"anchorlink": True}
|
||||||
|
}
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.route('/glossary/<string:lang>/')
|
@app.route('/glossary/<string:lang>/')
|
||||||
@cache.cached()
|
@cache.cached()
|
||||||
def glossary(lang: str):
|
def glossary(lang: str):
|
||||||
document = render_markdown(os.path.join(app.static_folder, "assets", "glossary", secure_filename(lang)+".md"))
|
import markdown
|
||||||
|
with open(os.path.join(app.static_folder, "assets", "glossary", secure_filename(lang)+".md")) as f:
|
||||||
|
document = f.read()
|
||||||
return render_template(
|
return render_template(
|
||||||
"markdown_document.html",
|
"markdown_document.html",
|
||||||
title="Glossary",
|
title="Glossary",
|
||||||
html_from_markdown=document,
|
html_from_markdown=markdown.markdown(
|
||||||
|
document,
|
||||||
|
extensions=["toc", "mdx_breakless_lists"],
|
||||||
|
extension_configs={
|
||||||
|
"toc": {"anchorlink": True}
|
||||||
|
}
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -234,15 +172,11 @@ def host_room(room: UUID):
|
|||||||
if room is None:
|
if room is None:
|
||||||
return abort(404)
|
return abort(404)
|
||||||
|
|
||||||
now = utcnow()
|
now = datetime.datetime.utcnow()
|
||||||
# indicate that the page should reload to get the assigned port
|
# indicate that the page should reload to get the assigned port
|
||||||
should_refresh = (
|
should_refresh = ((not room.last_port and now - room.creation_time < datetime.timedelta(seconds=3))
|
||||||
(not room.last_port and now - room.creation_time < datetime.timedelta(seconds=3))
|
or room.last_activity < now - datetime.timedelta(seconds=room.timeout))
|
||||||
or room.last_activity < now - datetime.timedelta(seconds=room.timeout)
|
with db_session:
|
||||||
)
|
|
||||||
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"
|
|
||||||
room.last_activity = now # will trigger a spinup, if it's not already running
|
room.last_activity = now # will trigger a spinup, if it's not already running
|
||||||
|
|
||||||
browser_tokens = "Mozilla", "Chrome", "Safari"
|
browser_tokens = "Mozilla", "Chrome", "Safari"
|
||||||
@@ -250,9 +184,9 @@ def host_room(room: UUID):
|
|||||||
or "Discordbot" in request.user_agent.string
|
or "Discordbot" in request.user_agent.string
|
||||||
or not any(browser_token in request.user_agent.string for browser_token in browser_tokens))
|
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]:
|
def get_log(max_size: int = 0 if automated else 1024000) -> str:
|
||||||
if max_size == 0:
|
if max_size == 0:
|
||||||
return "…", 0
|
return "…"
|
||||||
try:
|
try:
|
||||||
with open(os.path.join("logs", str(room.id) + ".txt"), "rb") as log:
|
with open(os.path.join("logs", str(room.id) + ".txt"), "rb") as log:
|
||||||
raw_size = 0
|
raw_size = 0
|
||||||
@@ -263,9 +197,9 @@ def host_room(room: UUID):
|
|||||||
break
|
break
|
||||||
raw_size += len(block)
|
raw_size += len(block)
|
||||||
fragments.append(block.decode("utf-8"))
|
fragments.append(block.decode("utf-8"))
|
||||||
return "".join(fragments), raw_size
|
return "".join(fragments)
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
return "", 0
|
return ""
|
||||||
|
|
||||||
return render_template("hostRoom.html", room=room, should_refresh=should_refresh, get_log=get_log)
|
return render_template("hostRoom.html", room=room, should_refresh=should_refresh, get_log=get_log)
|
||||||
|
|
||||||
|
|||||||
@@ -2,8 +2,6 @@ from datetime import datetime
|
|||||||
from uuid import UUID, uuid4
|
from uuid import UUID, uuid4
|
||||||
from pony.orm import Database, PrimaryKey, Required, Set, Optional, buffer, LongStr
|
from pony.orm import Database, PrimaryKey, Required, Set, Optional, buffer, LongStr
|
||||||
|
|
||||||
from Utils import utcnow
|
|
||||||
|
|
||||||
db = Database()
|
db = Database()
|
||||||
|
|
||||||
STATE_QUEUED = 0
|
STATE_QUEUED = 0
|
||||||
@@ -22,8 +20,8 @@ class Slot(db.Entity):
|
|||||||
|
|
||||||
class Room(db.Entity):
|
class Room(db.Entity):
|
||||||
id = PrimaryKey(UUID, default=uuid4)
|
id = PrimaryKey(UUID, default=uuid4)
|
||||||
last_activity: datetime = Required(datetime, default=lambda: utcnow(), index=True)
|
last_activity = Required(datetime, default=lambda: datetime.utcnow(), index=True)
|
||||||
creation_time: datetime = Required(datetime, default=lambda: utcnow(), index=True) # index used by landing page
|
creation_time = Required(datetime, default=lambda: datetime.utcnow(), index=True) # index used by landing page
|
||||||
owner = Required(UUID, index=True)
|
owner = Required(UUID, index=True)
|
||||||
commands = Set('Command')
|
commands = Set('Command')
|
||||||
seed = Required('Seed', index=True)
|
seed = Required('Seed', index=True)
|
||||||
@@ -40,7 +38,7 @@ class Seed(db.Entity):
|
|||||||
rooms = Set(Room)
|
rooms = Set(Room)
|
||||||
multidata = Required(bytes, lazy=True)
|
multidata = Required(bytes, lazy=True)
|
||||||
owner = Required(UUID, index=True)
|
owner = Required(UUID, index=True)
|
||||||
creation_time: datetime = Required(datetime, default=lambda: utcnow(), index=True) # index used by landing page
|
creation_time = Required(datetime, default=lambda: datetime.utcnow(), index=True) # index used by landing page
|
||||||
slots = Set(Slot)
|
slots = Set(Slot)
|
||||||
spoiler = Optional(LongStr, lazy=True)
|
spoiler = Optional(LongStr, lazy=True)
|
||||||
meta = Required(LongStr, default=lambda: "{\"race\": false}") # additional meta information/tags
|
meta = Required(LongStr, default=lambda: "{\"race\": false}") # additional meta information/tags
|
||||||
|
|||||||
@@ -6,14 +6,13 @@ from typing import Dict, Union
|
|||||||
from docutils.core import publish_parts
|
from docutils.core import publish_parts
|
||||||
|
|
||||||
import yaml
|
import yaml
|
||||||
from flask import redirect, render_template, request, Response, abort
|
from flask import redirect, render_template, request, Response
|
||||||
|
|
||||||
import Options
|
import Options
|
||||||
from Utils import local_path
|
from Utils import local_path
|
||||||
from worlds.AutoWorld import AutoWorldRegister
|
from worlds.AutoWorld import AutoWorldRegister
|
||||||
from . import app, cache
|
from . import app, cache
|
||||||
from .generate import get_meta
|
from .generate import get_meta
|
||||||
from .misc import get_world_theme
|
|
||||||
|
|
||||||
|
|
||||||
def create() -> None:
|
def create() -> None:
|
||||||
@@ -23,6 +22,12 @@ def create() -> None:
|
|||||||
Options.generate_yaml_templates(yaml_folder)
|
Options.generate_yaml_templates(yaml_folder)
|
||||||
|
|
||||||
|
|
||||||
|
def get_world_theme(game_name: str) -> str:
|
||||||
|
if game_name in AutoWorldRegister.world_types:
|
||||||
|
return AutoWorldRegister.world_types[game_name].web.theme
|
||||||
|
return 'grass'
|
||||||
|
|
||||||
|
|
||||||
def render_options_page(template: str, world_name: str, is_complex: bool = False) -> Union[Response, str]:
|
def render_options_page(template: str, world_name: str, is_complex: bool = False) -> Union[Response, str]:
|
||||||
world = AutoWorldRegister.world_types[world_name]
|
world = AutoWorldRegister.world_types[world_name]
|
||||||
if world.hidden or world.web.options_page is False:
|
if world.hidden or world.web.options_page is False:
|
||||||
@@ -71,7 +76,7 @@ def filter_rst_to_html(text: str) -> str:
|
|||||||
lines = text.splitlines()
|
lines = text.splitlines()
|
||||||
text = lines[0] + "\n" + dedent("\n".join(lines[1:]))
|
text = lines[0] + "\n" + dedent("\n".join(lines[1:]))
|
||||||
|
|
||||||
return publish_parts(text, writer='html', settings=None, settings_overrides={
|
return publish_parts(text, writer_name='html', settings=None, settings_overrides={
|
||||||
'raw_enable': False,
|
'raw_enable': False,
|
||||||
'file_insertion_enabled': False,
|
'file_insertion_enabled': False,
|
||||||
'output_encoding': 'unicode'
|
'output_encoding': 'unicode'
|
||||||
@@ -103,7 +108,7 @@ def option_presets(game: str) -> Response:
|
|||||||
f"Expected {option.special_range_names.keys()} or {option.range_start}-{option.range_end}."
|
f"Expected {option.special_range_names.keys()} or {option.range_start}-{option.range_end}."
|
||||||
|
|
||||||
presets[preset_name][preset_option_name] = option.value
|
presets[preset_name][preset_option_name] = option.value
|
||||||
elif isinstance(option, (Options.Range, Options.OptionSet, Options.OptionList, Options.OptionCounter)):
|
elif isinstance(option, (Options.Range, Options.OptionSet, Options.OptionList, Options.ItemDict)):
|
||||||
presets[preset_name][preset_option_name] = option.value
|
presets[preset_name][preset_option_name] = option.value
|
||||||
elif isinstance(preset_option, str):
|
elif isinstance(preset_option, str):
|
||||||
# Ensure the option value is valid for Choice and Toggle options
|
# Ensure the option value is valid for Choice and Toggle options
|
||||||
@@ -137,10 +142,7 @@ def weighted_options_old():
|
|||||||
@app.route("/games/<string:game>/weighted-options")
|
@app.route("/games/<string:game>/weighted-options")
|
||||||
@cache.cached()
|
@cache.cached()
|
||||||
def weighted_options(game: str):
|
def weighted_options(game: str):
|
||||||
try:
|
return render_options_page("weightedOptions/weightedOptions.html", game, is_complex=True)
|
||||||
return render_options_page("weightedOptions/weightedOptions.html", game, is_complex=True)
|
|
||||||
except KeyError:
|
|
||||||
return abort(404)
|
|
||||||
|
|
||||||
|
|
||||||
@app.route("/games/<string:game>/generate-weighted-yaml", methods=["POST"])
|
@app.route("/games/<string:game>/generate-weighted-yaml", methods=["POST"])
|
||||||
@@ -150,9 +152,7 @@ def generate_weighted_yaml(game: str):
|
|||||||
options = {}
|
options = {}
|
||||||
|
|
||||||
for key, val in request.form.items():
|
for key, val in request.form.items():
|
||||||
if val == "_ensure-empty-list":
|
if "||" not in key:
|
||||||
options[key] = {}
|
|
||||||
elif "||" not in key:
|
|
||||||
if len(str(val)) == 0:
|
if len(str(val)) == 0:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@@ -197,10 +197,7 @@ def generate_weighted_yaml(game: str):
|
|||||||
@app.route("/games/<string:game>/player-options")
|
@app.route("/games/<string:game>/player-options")
|
||||||
@cache.cached()
|
@cache.cached()
|
||||||
def player_options(game: str):
|
def player_options(game: str):
|
||||||
try:
|
return render_options_page("playerOptions/playerOptions.html", game, is_complex=False)
|
||||||
return render_options_page("playerOptions/playerOptions.html", game, is_complex=False)
|
|
||||||
except KeyError:
|
|
||||||
return abort(404)
|
|
||||||
|
|
||||||
|
|
||||||
# YAML generator for player-options
|
# YAML generator for player-options
|
||||||
@@ -209,11 +206,8 @@ def generate_yaml(game: str):
|
|||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
options = {}
|
options = {}
|
||||||
intent_generate = False
|
intent_generate = False
|
||||||
|
|
||||||
for key, val in request.form.items(multi=True):
|
for key, val in request.form.items(multi=True):
|
||||||
if val == "_ensure-empty-list":
|
if key in options:
|
||||||
options[key] = []
|
|
||||||
elif options.get(key):
|
|
||||||
if not isinstance(options[key], list):
|
if not isinstance(options[key], list):
|
||||||
options[key] = [options[key]]
|
options[key] = [options[key]]
|
||||||
options[key].append(val)
|
options[key].append(val)
|
||||||
@@ -222,11 +216,11 @@ def generate_yaml(game: str):
|
|||||||
|
|
||||||
for key, val in options.copy().items():
|
for key, val in options.copy().items():
|
||||||
key_parts = key.rsplit("||", 2)
|
key_parts = key.rsplit("||", 2)
|
||||||
# Detect and build OptionCounter options from their name pattern
|
# Detect and build ItemDict options from their name pattern
|
||||||
if key_parts[-1] == "qty":
|
if key_parts[-1] == "qty":
|
||||||
if key_parts[0] not in options:
|
if key_parts[0] not in options:
|
||||||
options[key_parts[0]] = {}
|
options[key_parts[0]] = {}
|
||||||
if val and val != "0":
|
if val != "0":
|
||||||
options[key_parts[0]][key_parts[1]] = int(val)
|
options[key_parts[0]][key_parts[1]] = int(val)
|
||||||
del options[key]
|
del options[key]
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,11 @@
|
|||||||
flask>=3.1.1
|
flask>=3.0.3
|
||||||
werkzeug>=3.1.3
|
werkzeug>=3.0.6
|
||||||
pony>=0.7.19; python_version <= '3.12'
|
pony>=0.7.19
|
||||||
pony @ git+https://github.com/black-sliver/pony@7feb1221953b7fa4a6735466bf21a8b4d35e33ba#0.7.19; python_version >= '3.13'
|
waitress>=3.0.0
|
||||||
waitress>=3.0.2
|
|
||||||
Flask-Caching>=2.3.0
|
Flask-Caching>=2.3.0
|
||||||
Flask-Compress==1.18 # pkg_resources can't resolve the "backports.zstd" dependency of >1.18, breaking ModuleUpdate.py
|
Flask-Compress>=1.15
|
||||||
Flask-Limiter>=3.12
|
Flask-Limiter>=3.8.0
|
||||||
Flask-Cors>=6.0.2
|
bokeh>=3.5.2
|
||||||
bokeh>=3.6.3
|
markupsafe>=2.1.5
|
||||||
markupsafe>=3.0.2
|
Markdown>=3.7
|
||||||
setproctitle>=1.3.5
|
mdx-breakless-lists>=1.0.1
|
||||||
mistune>=3.1.3
|
|
||||||
docutils>=0.22.2
|
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ players to rely upon each other to complete their game.
|
|||||||
While a multiworld game traditionally requires all players to be playing the same game, a multi-game multiworld allows
|
While a multiworld game traditionally requires all players to be playing the same game, a multi-game multiworld allows
|
||||||
players to randomize any of the supported games, and send items between them. This allows players of different
|
players to randomize any of the supported games, and send items between them. This allows players of different
|
||||||
games to interact with one another in a single multiplayer environment. Archipelago supports multi-game multiworlds.
|
games to interact with one another in a single multiplayer environment. Archipelago supports multi-game multiworlds.
|
||||||
Here is a list of our [Supported Games](/games).
|
Here is a list of our [Supported Games](https://archipelago.gg/games).
|
||||||
|
|
||||||
## Can I generate a single-player game with Archipelago?
|
## Can I generate a single-player game with Archipelago?
|
||||||
|
|
||||||
@@ -33,7 +33,7 @@ play, open the Settings Page, pick your settings, and click Generate Game.
|
|||||||
|
|
||||||
## How do I get started?
|
## 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
|
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.
|
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
|
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
|
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?
|
## 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
|
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.
|
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:
|
The best way to get started is to take a look at our code on GitHub:
|
||||||
[Archipelago GitHub Page](https://github.com/ArchipelagoMW/Archipelago).
|
[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:
|
You may also find developer documentation in the `docs` folder:
|
||||||
[/docs Folder in Archipelago Code](https://github.com/ArchipelagoMW/Archipelago/tree/main/docs).
|
[/docs Folder in Archipelago Code](https://github.com/ArchipelagoMW/Archipelago/tree/main/docs).
|
||||||
|
|
||||||
If you have more questions regarding development of a game implementation, feel free to ask in the **#ap-world-dev**
|
If you have more questions, feel free to ask in the **#ap-world-dev** channel on our Discord.
|
||||||
channel on our Discord.
|
|
||||||
|
|||||||
51
WebHostLib/static/assets/gameInfo.js
Normal file
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>`;
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -6,4 +6,6 @@ window.addEventListener('load', () => {
|
|||||||
document.getElementById('file-input').addEventListener('change', () => {
|
document.getElementById('file-input').addEventListener('change', () => {
|
||||||
document.getElementById('host-game-form').submit();
|
document.getElementById('host-game-form').submit();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
adjustFooterHeight();
|
||||||
});
|
});
|
||||||
|
|||||||
49
WebHostLib/static/assets/minecraftTracker.js
Normal file
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;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -1,43 +1,49 @@
|
|||||||
let updateSection = (sectionName, fakeDOM) => {
|
|
||||||
document.getElementById(sectionName).innerHTML = fakeDOM.getElementById(sectionName).innerHTML;
|
|
||||||
}
|
|
||||||
|
|
||||||
window.addEventListener('load', () => {
|
window.addEventListener('load', () => {
|
||||||
// Reload tracker every 60 seconds (sync'd)
|
// Reload tracker every 15 seconds
|
||||||
const url = window.location;
|
const url = window.location;
|
||||||
// Note: This synchronization code is adapted from code in trackerCommon.js
|
setInterval(() => {
|
||||||
const targetSecond = parseInt(document.getElementById('player-tracker').getAttribute('data-second')) + 3;
|
const ajax = new XMLHttpRequest();
|
||||||
console.log("Target second of refresh: " + targetSecond);
|
ajax.onreadystatechange = () => {
|
||||||
|
if (ajax.readyState !== 4) { return; }
|
||||||
|
|
||||||
let getSleepTimeSeconds = () => {
|
// Create a fake DOM using the returned HTML
|
||||||
// -40 % 60 is -40, which is absolutely wrong and should burn
|
const domParser = new DOMParser();
|
||||||
var sleepSeconds = (((targetSecond - new Date().getSeconds()) % 60) + 60) % 60;
|
const fakeDOM = domParser.parseFromString(ajax.responseText, 'text/html');
|
||||||
return sleepSeconds || 60;
|
|
||||||
};
|
|
||||||
|
|
||||||
let updateTracker = () => {
|
// Update item tracker
|
||||||
const ajax = new XMLHttpRequest();
|
document.getElementById('inventory-table').innerHTML = fakeDOM.getElementById('inventory-table').innerHTML;
|
||||||
ajax.onreadystatechange = () => {
|
// Update only counters in the location-table
|
||||||
if (ajax.readyState !== 4) { return; }
|
let counters = document.getElementsByClassName('counter');
|
||||||
|
const fakeCounters = fakeDOM.getElementsByClassName('counter');
|
||||||
// Create a fake DOM using the returned HTML
|
for (let i = 0; i < counters.length; i++) {
|
||||||
const domParser = new DOMParser();
|
counters[i].innerHTML = fakeCounters[i].innerHTML;
|
||||||
const fakeDOM = domParser.parseFromString(ajax.responseText, 'text/html');
|
}
|
||||||
|
|
||||||
// Update dynamic sections
|
|
||||||
updateSection('player-info', fakeDOM);
|
|
||||||
updateSection('section-filler', fakeDOM);
|
|
||||||
updateSection('section-terran', fakeDOM);
|
|
||||||
updateSection('section-zerg', fakeDOM);
|
|
||||||
updateSection('section-protoss', fakeDOM);
|
|
||||||
updateSection('section-nova', fakeDOM);
|
|
||||||
updateSection('section-kerrigan', fakeDOM);
|
|
||||||
updateSection('section-keys', fakeDOM);
|
|
||||||
updateSection('section-locations', fakeDOM);
|
|
||||||
};
|
|
||||||
ajax.open('GET', url);
|
|
||||||
ajax.send();
|
|
||||||
updater = setTimeout(updateTracker, getSleepTimeSeconds() * 1000);
|
|
||||||
};
|
};
|
||||||
window.updater = setTimeout(updateTracker, getSleepTimeSeconds() * 1000);
|
ajax.open('GET', url);
|
||||||
|
ajax.send();
|
||||||
|
}, 15000)
|
||||||
|
|
||||||
|
// Collapsible advancement sections
|
||||||
|
const categories = document.getElementsByClassName("location-category");
|
||||||
|
for (let category of categories) {
|
||||||
|
let hide_id = category.id.split('_')[0];
|
||||||
|
if (hide_id === 'Total') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
category.addEventListener('click', function() {
|
||||||
|
// Toggle the advancement list
|
||||||
|
document.getElementById(hide_id).classList.toggle("hide");
|
||||||
|
// Change text of the header
|
||||||
|
const tab_header = document.getElementById(hide_id+'_header').children[0];
|
||||||
|
const orig_text = tab_header.innerHTML;
|
||||||
|
let new_text;
|
||||||
|
if (orig_text.includes("▼")) {
|
||||||
|
new_text = orig_text.replace("▼", "▲");
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
new_text = orig_text.replace("▲", "▼");
|
||||||
|
}
|
||||||
|
tab_header.innerHTML = new_text;
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
47
WebHostLib/static/assets/styleController.js
Normal file
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();
|
||||||
|
});
|
||||||
58
WebHostLib/static/assets/tutorial.js
Normal file
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
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();
|
||||||
|
});
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 12 KiB |
@@ -36,13 +36,6 @@ html{
|
|||||||
|
|
||||||
body{
|
body{
|
||||||
margin: 0;
|
margin: 0;
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
min-height: calc(100vh - 110px);
|
|
||||||
}
|
|
||||||
|
|
||||||
main {
|
|
||||||
flex-grow: 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
a{
|
a{
|
||||||
|
|||||||
@@ -33,17 +33,6 @@ html{
|
|||||||
z-index: 10;
|
z-index: 10;
|
||||||
}
|
}
|
||||||
|
|
||||||
#landing-header h5 {
|
|
||||||
color: #ffffff;
|
|
||||||
font-style: italic;
|
|
||||||
font-size: 28px;
|
|
||||||
margin-top: 15px;
|
|
||||||
margin-bottom: -43px;
|
|
||||||
text-shadow: 1px 1px 7px #000000;
|
|
||||||
font-kerning: none;
|
|
||||||
z-index: 10;
|
|
||||||
}
|
|
||||||
|
|
||||||
#landing-links{
|
#landing-links{
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
margin-right: auto;
|
margin-right: auto;
|
||||||
|
|||||||
@@ -28,6 +28,7 @@
|
|||||||
font-weight: normal;
|
font-weight: normal;
|
||||||
font-family: LondrinaSolid-Regular, sans-serif;
|
font-family: LondrinaSolid-Regular, sans-serif;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
|
cursor: pointer; /* TODO: remove once we drop showdown.js */
|
||||||
width: 100%;
|
width: 100%;
|
||||||
text-shadow: 1px 1px 4px #000000;
|
text-shadow: 1px 1px 4px #000000;
|
||||||
}
|
}
|
||||||
@@ -36,6 +37,7 @@
|
|||||||
font-size: 38px;
|
font-size: 38px;
|
||||||
font-weight: normal;
|
font-weight: normal;
|
||||||
font-family: LondrinaSolid-Light, sans-serif;
|
font-family: LondrinaSolid-Light, sans-serif;
|
||||||
|
cursor: pointer; /* TODO: remove once we drop showdown.js */
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin-top: 20px;
|
margin-top: 20px;
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
@@ -48,6 +50,7 @@
|
|||||||
font-family: LexendDeca-Regular, sans-serif;
|
font-family: LexendDeca-Regular, sans-serif;
|
||||||
text-transform: none;
|
text-transform: none;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
|
cursor: pointer; /* TODO: remove once we drop showdown.js */
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
}
|
}
|
||||||
@@ -56,6 +59,7 @@
|
|||||||
font-family: LexendDeca-Regular, sans-serif;
|
font-family: LexendDeca-Regular, sans-serif;
|
||||||
text-transform: none;
|
text-transform: none;
|
||||||
font-size: 24px;
|
font-size: 24px;
|
||||||
|
cursor: pointer; /* TODO: remove once we drop showdown.js */
|
||||||
margin-bottom: 24px;
|
margin-bottom: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,12 +67,14 @@
|
|||||||
font-family: LexendDeca-Regular, sans-serif;
|
font-family: LexendDeca-Regular, sans-serif;
|
||||||
text-transform: none;
|
text-transform: none;
|
||||||
font-size: 22px;
|
font-size: 22px;
|
||||||
|
cursor: pointer; /* TODO: remove once we drop showdown.js */
|
||||||
}
|
}
|
||||||
|
|
||||||
.markdown h6, .markdown details summary.h6{
|
.markdown h6, .markdown details summary.h6{
|
||||||
font-family: LexendDeca-Regular, sans-serif;
|
font-family: LexendDeca-Regular, sans-serif;
|
||||||
text-transform: none;
|
text-transform: none;
|
||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
|
cursor: pointer; /* TODO: remove once we drop showdown.js */
|
||||||
}
|
}
|
||||||
|
|
||||||
.markdown h4, .markdown h5, .markdown h6{
|
.markdown h4, .markdown h5, .markdown h6{
|
||||||
|
|||||||
102
WebHostLib/static/styles/minecraftTracker.css
Normal file
102
WebHostLib/static/styles/minecraftTracker.css
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
#player-tracker-wrapper{
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#inventory-table{
|
||||||
|
border-top: 2px solid #000000;
|
||||||
|
border-left: 2px solid #000000;
|
||||||
|
border-right: 2px solid #000000;
|
||||||
|
border-top-left-radius: 4px;
|
||||||
|
border-top-right-radius: 4px;
|
||||||
|
padding: 3px 3px 10px;
|
||||||
|
width: 384px;
|
||||||
|
background-color: #42b149;
|
||||||
|
}
|
||||||
|
|
||||||
|
#inventory-table td{
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
text-align: center;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
#inventory-table img{
|
||||||
|
height: 100%;
|
||||||
|
max-width: 40px;
|
||||||
|
max-height: 40px;
|
||||||
|
filter: grayscale(100%) contrast(75%) brightness(30%);
|
||||||
|
}
|
||||||
|
|
||||||
|
#inventory-table img.acquired{
|
||||||
|
filter: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#inventory-table div.counted-item {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
#inventory-table div.item-count {
|
||||||
|
position: absolute;
|
||||||
|
color: white;
|
||||||
|
font-family: "Minecraftia", monospace;
|
||||||
|
font-weight: bold;
|
||||||
|
bottom: 0;
|
||||||
|
right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#location-table{
|
||||||
|
width: 384px;
|
||||||
|
border-left: 2px solid #000000;
|
||||||
|
border-right: 2px solid #000000;
|
||||||
|
border-bottom: 2px solid #000000;
|
||||||
|
border-bottom-left-radius: 4px;
|
||||||
|
border-bottom-right-radius: 4px;
|
||||||
|
background-color: #42b149;
|
||||||
|
padding: 0 3px 3px;
|
||||||
|
font-family: "Minecraftia", monospace;
|
||||||
|
font-size: 14px;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
#location-table th{
|
||||||
|
vertical-align: middle;
|
||||||
|
text-align: left;
|
||||||
|
padding-right: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#location-table td{
|
||||||
|
padding-top: 2px;
|
||||||
|
padding-bottom: 2px;
|
||||||
|
line-height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#location-table td.counter {
|
||||||
|
text-align: right;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#location-table td.toggle-arrow {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
#location-table tr#Total-header {
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
#location-table img{
|
||||||
|
height: 100%;
|
||||||
|
max-width: 30px;
|
||||||
|
max-height: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#location-table tbody.locations {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#location-table td.location-name {
|
||||||
|
padding-left: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hide {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
@@ -1,276 +1,160 @@
|
|||||||
*{
|
#player-tracker-wrapper{
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-family: "JuraBook", monospace;
|
|
||||||
}
|
|
||||||
body{
|
|
||||||
--icon-size: 36px;
|
|
||||||
--item-class-padding: 4px;
|
|
||||||
}
|
|
||||||
a{
|
|
||||||
color: #1ae;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Section colours */
|
#tracker-table td {
|
||||||
#player-info{
|
vertical-align: top;
|
||||||
background-color: #37a;
|
|
||||||
}
|
|
||||||
.player-tracker{
|
|
||||||
max-width: 100%;
|
|
||||||
}
|
|
||||||
.tracker-section{
|
|
||||||
background-color: grey;
|
|
||||||
}
|
|
||||||
#terran-items{
|
|
||||||
background-color: #3a7;
|
|
||||||
}
|
|
||||||
#zerg-items{
|
|
||||||
background-color: #d94;
|
|
||||||
}
|
|
||||||
#protoss-items{
|
|
||||||
background-color: #37a;
|
|
||||||
}
|
|
||||||
#nova-items{
|
|
||||||
background-color: #777;
|
|
||||||
}
|
|
||||||
#kerrigan-items{
|
|
||||||
background-color: #a37;
|
|
||||||
}
|
|
||||||
#keys{
|
|
||||||
background-color: #aa2;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Sections */
|
.inventory-table-area{
|
||||||
.section-body{
|
border: 2px solid #000000;
|
||||||
display: flex;
|
border-radius: 4px;
|
||||||
flex-flow: row wrap;
|
padding: 3px 10px 3px 10px;
|
||||||
justify-content: flex-start;
|
|
||||||
align-items: flex-start;
|
|
||||||
padding-bottom: 3px;
|
|
||||||
}
|
|
||||||
.section-body-2{
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
.tracker-section:has(input.collapse-section[type=checkbox]:checked) .section-body,
|
|
||||||
.tracker-section:has(input.collapse-section[type=checkbox]:checked) .section-body-2{
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
.section-title{
|
|
||||||
position: relative;
|
|
||||||
border-bottom: 3px solid black;
|
|
||||||
/* Prevent text selection */
|
|
||||||
user-select: none;
|
|
||||||
-webkit-user-select: none;
|
|
||||||
-ms-user-select: none;
|
|
||||||
}
|
|
||||||
input[type="checkbox"]{
|
|
||||||
position: absolute;
|
|
||||||
cursor: pointer;
|
|
||||||
opacity: 0;
|
|
||||||
z-index: 1;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
.section-title:hover h2{
|
|
||||||
text-shadow: 0 0 4px #ddd;
|
|
||||||
}
|
|
||||||
.f {
|
|
||||||
display: flex;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Acquire item filters */
|
.inventory-table-area:has(.inventory-table-terran) {
|
||||||
.tracker-section img{
|
width: 690px;
|
||||||
height: 100%;
|
background-color: #525494;
|
||||||
width: var(--icon-size);
|
|
||||||
height: var(--icon-size);
|
|
||||||
background-color: black;
|
|
||||||
}
|
|
||||||
.unacquired, .lvl-0 .f{
|
|
||||||
filter: grayscale(100%) contrast(80%) brightness(42%) blur(0.5px);
|
|
||||||
}
|
|
||||||
.spacer{
|
|
||||||
width: var(--icon-size);
|
|
||||||
height: var(--icon-size);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Item groups */
|
.inventory-table-area:has(.inventory-table-zerg) {
|
||||||
.item-class{
|
width: 360px;
|
||||||
display: flex;
|
background-color: #9d60d2;
|
||||||
flex-flow: column;
|
|
||||||
justify-content: center;
|
|
||||||
padding: var(--item-class-padding);
|
|
||||||
}
|
|
||||||
.item-class-header{
|
|
||||||
display: flex;
|
|
||||||
flex-flow: row;
|
|
||||||
}
|
|
||||||
.item-class-upgrades{
|
|
||||||
/* Note: {display: flex; flex-flow: column wrap} */
|
|
||||||
/* just breaks on Firefox (width does not scale to content) */
|
|
||||||
display: grid;
|
|
||||||
grid-template-rows: repeat(4, auto);
|
|
||||||
grid-auto-flow: column;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Subsections */
|
.inventory-table-area:has(.inventory-table-protoss) {
|
||||||
.section-toc{
|
width: 400px;
|
||||||
display: flex;
|
background-color: #d2b260;
|
||||||
flex-direction: row;
|
|
||||||
}
|
|
||||||
.toc-box{
|
|
||||||
position: relative;
|
|
||||||
padding-left: 15px;
|
|
||||||
padding-right: 15px;
|
|
||||||
}
|
|
||||||
.toc-box:hover{
|
|
||||||
text-shadow: 0 0 7px white;
|
|
||||||
}
|
|
||||||
.ss-header{
|
|
||||||
position: relative;
|
|
||||||
text-align: center;
|
|
||||||
writing-mode: sideways-lr;
|
|
||||||
user-select: none;
|
|
||||||
padding-top: 5px;
|
|
||||||
font-size: 115%;
|
|
||||||
}
|
|
||||||
.tracker-section:has(input.ss-1-toggle:checked) .ss-1{
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
.tracker-section:has(input.ss-2-toggle:checked) .ss-2{
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
.tracker-section:has(input.ss-3-toggle:checked) .ss-3{
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
.tracker-section:has(input.ss-4-toggle:checked) .ss-4{
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
.tracker-section:has(input.ss-5-toggle:checked) .ss-5{
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
.tracker-section:has(input.ss-6-toggle:checked) .ss-6{
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
.tracker-section:has(input.ss-7-toggle:checked) .ss-7{
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
.tracker-section:has(input.ss-1-toggle:hover) .ss-1{
|
|
||||||
background-color: #fff5;
|
|
||||||
box-shadow: 0 0 1px 1px white;
|
|
||||||
}
|
|
||||||
.tracker-section:has(input.ss-2-toggle:hover) .ss-2{
|
|
||||||
background-color: #fff5;
|
|
||||||
box-shadow: 0 0 1px 1px white;
|
|
||||||
}
|
|
||||||
.tracker-section:has(input.ss-3-toggle:hover) .ss-3{
|
|
||||||
background-color: #fff5;
|
|
||||||
box-shadow: 0 0 1px 1px white;
|
|
||||||
}
|
|
||||||
.tracker-section:has(input.ss-4-toggle:hover) .ss-4{
|
|
||||||
background-color: #fff5;
|
|
||||||
box-shadow: 0 0 1px 1px white;
|
|
||||||
}
|
|
||||||
.tracker-section:has(input.ss-5-toggle:hover) .ss-5{
|
|
||||||
background-color: #fff5;
|
|
||||||
box-shadow: 0 0 1px 1px white;
|
|
||||||
}
|
|
||||||
.tracker-section:has(input.ss-6-toggle:hover) .ss-6{
|
|
||||||
background-color: #fff5;
|
|
||||||
box-shadow: 0 0 1px 1px white;
|
|
||||||
}
|
|
||||||
.tracker-section:has(input.ss-7-toggle:hover) .ss-7{
|
|
||||||
background-color: #fff5;
|
|
||||||
box-shadow: 0 0 1px 1px white;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Progressive items */
|
#tracker-table .inventory-table td{
|
||||||
.progressive{
|
width: 40px;
|
||||||
max-height: var(--icon-size);
|
height: 40px;
|
||||||
display: contents;
|
text-align: center;
|
||||||
|
vertical-align: middle;
|
||||||
}
|
}
|
||||||
|
|
||||||
.lvl-0 > :nth-child(2),
|
.inventory-table td.title{
|
||||||
.lvl-0 > :nth-child(3),
|
padding-top: 10px;
|
||||||
.lvl-0 > :nth-child(4),
|
height: 20px;
|
||||||
.lvl-0 > :nth-child(5){
|
font-family: "JuraBook", monospace;
|
||||||
display: none;
|
font-size: 16px;
|
||||||
}
|
font-weight: bold;
|
||||||
.lvl-1 > :nth-child(2),
|
|
||||||
.lvl-1 > :nth-child(3),
|
|
||||||
.lvl-1 > :nth-child(4),
|
|
||||||
.lvl-1 > :nth-child(5){
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
.lvl-2 > :nth-child(1),
|
|
||||||
.lvl-2 > :nth-child(3),
|
|
||||||
.lvl-2 > :nth-child(4),
|
|
||||||
.lvl-2 > :nth-child(5){
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
.lvl-3 > :nth-child(1),
|
|
||||||
.lvl-3 > :nth-child(2),
|
|
||||||
.lvl-3 > :nth-child(4),
|
|
||||||
.lvl-3 > :nth-child(5){
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
.lvl-4 > :nth-child(1),
|
|
||||||
.lvl-4 > :nth-child(2),
|
|
||||||
.lvl-4 > :nth-child(3),
|
|
||||||
.lvl-4 > :nth-child(5){
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
.lvl-5 > :nth-child(1),
|
|
||||||
.lvl-5 > :nth-child(2),
|
|
||||||
.lvl-5 > :nth-child(3),
|
|
||||||
.lvl-5 > :nth-child(4){
|
|
||||||
display: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Filler item counters */
|
.inventory-table img{
|
||||||
.item-counter{
|
height: 100%;
|
||||||
display: table;
|
max-width: 40px;
|
||||||
text-align: center;
|
max-height: 40px;
|
||||||
padding: var(--item-class-padding);
|
border: 1px solid #000000;
|
||||||
}
|
filter: grayscale(100%) contrast(75%) brightness(20%);
|
||||||
.item-count{
|
background-color: black;
|
||||||
display: table-cell;
|
|
||||||
vertical-align: middle;
|
|
||||||
padding-left: 3px;
|
|
||||||
padding-right: 15px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Hidden items */
|
.inventory-table img.acquired{
|
||||||
.hidden-class:not(:has(.f:not(.unacquired))), .hidden-item{
|
filter: none;
|
||||||
display: none;
|
background-color: black;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Keys */
|
.inventory-table .tint-terran img.acquired {
|
||||||
#keys ol, #keys ul{
|
filter: sepia(100%) saturate(300%) brightness(130%) hue-rotate(120deg)
|
||||||
columns: 3;
|
|
||||||
-webkit-columns: 3;
|
|
||||||
-moz-columns: 3;
|
|
||||||
}
|
|
||||||
#keys li{
|
|
||||||
padding-right: 15pt;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Locations */
|
.inventory-table .tint-protoss img.acquired {
|
||||||
#section-locations{
|
filter: sepia(100%) saturate(1000%) brightness(110%) hue-rotate(180deg)
|
||||||
padding-left: 5px;
|
|
||||||
}
|
|
||||||
@media only screen and (min-width: 120ch){
|
|
||||||
#section-locations ul{
|
|
||||||
columns: 2;
|
|
||||||
-webkit-columns: 2;
|
|
||||||
-moz-columns: 2;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#locations li.checked{
|
|
||||||
list-style-type: "✔ ";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Allowing scrolling down a little further */
|
.inventory-table .tint-level-1 img.acquired {
|
||||||
.bottom-padding{
|
filter: sepia(100%) saturate(1000%) brightness(110%) hue-rotate(60deg)
|
||||||
min-height: 33vh;
|
}
|
||||||
}
|
|
||||||
|
.inventory-table .tint-level-2 img.acquired {
|
||||||
|
filter: sepia(100%) saturate(1000%) brightness(110%) hue-rotate(60deg) hue-rotate(120deg)
|
||||||
|
}
|
||||||
|
|
||||||
|
.inventory-table .tint-level-3 img.acquired {
|
||||||
|
filter: sepia(100%) saturate(1000%) brightness(110%) hue-rotate(60deg) hue-rotate(240deg)
|
||||||
|
}
|
||||||
|
|
||||||
|
.inventory-table div.counted-item {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inventory-table div.item-count {
|
||||||
|
width: 160px;
|
||||||
|
text-align: left;
|
||||||
|
color: black;
|
||||||
|
font-family: "JuraBook", monospace;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
#location-table{
|
||||||
|
border: 2px solid #000000;
|
||||||
|
border-radius: 4px;
|
||||||
|
background-color: #87b678;
|
||||||
|
padding: 10px 3px 3px;
|
||||||
|
font-family: "JuraBook", monospace;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: bold;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
#location-table table{
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
#location-table th{
|
||||||
|
vertical-align: middle;
|
||||||
|
text-align: left;
|
||||||
|
padding-right: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#location-table td{
|
||||||
|
padding-top: 2px;
|
||||||
|
padding-bottom: 2px;
|
||||||
|
line-height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#location-table td.counter {
|
||||||
|
text-align: right;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#location-table td.toggle-arrow {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
#location-table tr#Total-header {
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
#location-table img{
|
||||||
|
height: 100%;
|
||||||
|
max-width: 30px;
|
||||||
|
max-height: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#location-table tbody.locations {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#location-table td.location-name {
|
||||||
|
padding-left: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#location-table td:has(.location-column) {
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
#location-table .location-column {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
#location-table .location-column .spacer {
|
||||||
|
min-height: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hide {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -72,13 +72,3 @@ code{
|
|||||||
padding-right: 0.25rem;
|
padding-right: 0.25rem;
|
||||||
color: #000000;
|
color: #000000;
|
||||||
}
|
}
|
||||||
|
|
||||||
code.grassy {
|
|
||||||
background-color: #b5e9a4;
|
|
||||||
border: 1px solid #2a6c2f;
|
|
||||||
white-space: preserve;
|
|
||||||
text-align: left;
|
|
||||||
display: block;
|
|
||||||
font-size: 14px;
|
|
||||||
line-height: 20px;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -13,7 +13,3 @@
|
|||||||
min-height: 360px;
|
min-height: 360px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
h2, h4 {
|
|
||||||
color: #ffffff;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import typing
|
||||||
from collections import Counter, defaultdict
|
from collections import Counter, defaultdict
|
||||||
from colorsys import hsv_to_rgb
|
from colorsys import hsv_to_rgb
|
||||||
from datetime import datetime, timedelta, date
|
from datetime import datetime, timedelta, date
|
||||||
@@ -17,23 +18,21 @@ from .models import Room
|
|||||||
PLOT_WIDTH = 600
|
PLOT_WIDTH = 600
|
||||||
|
|
||||||
|
|
||||||
def get_db_data(known_games: set[str]) -> tuple[Counter[str], defaultdict[date, dict[str, int]]]:
|
def get_db_data(known_games: typing.Set[str]) -> typing.Tuple[typing.Counter[str],
|
||||||
games_played: defaultdict[date, dict[str, int]] = defaultdict(Counter)
|
typing.DefaultDict[datetime.date, typing.Dict[str, int]]]:
|
||||||
total_games: Counter[str] = Counter()
|
games_played = defaultdict(Counter)
|
||||||
|
total_games = Counter()
|
||||||
cutoff = date.today() - timedelta(days=30)
|
cutoff = date.today() - timedelta(days=30)
|
||||||
room: Room
|
room: Room
|
||||||
for room in select(room for room in Room if room.creation_time >= cutoff):
|
for room in select(room for room in Room if room.creation_time >= cutoff):
|
||||||
for slot in room.seed.slots:
|
for slot in room.seed.slots:
|
||||||
if slot.game in known_games:
|
if slot.game in known_games:
|
||||||
current_game = slot.game
|
total_games[slot.game] += 1
|
||||||
else:
|
games_played[room.creation_time.date()][slot.game] += 1
|
||||||
current_game = "Other"
|
|
||||||
total_games[current_game] += 1
|
|
||||||
games_played[room.creation_time.date()][current_game] += 1
|
|
||||||
return total_games, games_played
|
return total_games, games_played
|
||||||
|
|
||||||
|
|
||||||
def get_color_palette(colors_needed: int) -> list[RGB]:
|
def get_color_palette(colors_needed: int) -> typing.List[RGB]:
|
||||||
colors = []
|
colors = []
|
||||||
# colors_needed +1 to prevent first and last color being too close to each other
|
# colors_needed +1 to prevent first and last color being too close to each other
|
||||||
colors_needed += 1
|
colors_needed += 1
|
||||||
@@ -48,7 +47,8 @@ def get_color_palette(colors_needed: int) -> list[RGB]:
|
|||||||
return colors
|
return colors
|
||||||
|
|
||||||
|
|
||||||
def create_game_played_figure(all_games_data: dict[date, dict[str, int]], game: str, color: RGB) -> figure:
|
def create_game_played_figure(all_games_data: typing.Dict[datetime.date, typing.Dict[str, int]],
|
||||||
|
game: str, color: RGB) -> figure:
|
||||||
occurences = []
|
occurences = []
|
||||||
days = [day for day, game_data in all_games_data.items() if game_data[game]]
|
days = [day for day, game_data in all_games_data.items() if game_data[game]]
|
||||||
for day in days:
|
for day in days:
|
||||||
@@ -84,7 +84,7 @@ def stats():
|
|||||||
days = sorted(games_played)
|
days = sorted(games_played)
|
||||||
|
|
||||||
color_palette = get_color_palette(len(total_games))
|
color_palette = get_color_palette(len(total_games))
|
||||||
game_to_color: dict[str, RGB] = {game: color for game, color in zip(total_games, color_palette)}
|
game_to_color: typing.Dict[str, RGB] = {game: color for game, color in zip(total_games, color_palette)}
|
||||||
|
|
||||||
for game in sorted(total_games):
|
for game in sorted(total_games):
|
||||||
occurences = []
|
occurences = []
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
{% extends 'pageWrapper.html' %}
|
{% extends 'pageWrapper.html' %}
|
||||||
{% import "macros.html" as macros %}
|
{% import "macros.html" as macros %}
|
||||||
{% set show_footer = True %}
|
|
||||||
|
|
||||||
{% block head %}
|
{% block head %}
|
||||||
<title>Page Not Found (404)</title>
|
<title>Page Not Found (404)</title>
|
||||||
@@ -14,4 +13,5 @@
|
|||||||
The page you're looking for doesn't exist.<br />
|
The page you're looking for doesn't exist.<br />
|
||||||
<a href="/">Click here to return to safety.</a>
|
<a href="/">Click here to return to safety.</a>
|
||||||
</div>
|
</div>
|
||||||
|
{% include 'islandFooter.html' %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
17
WebHostLib/templates/gameInfo.html
Normal file
17
WebHostLib/templates/gameInfo.html
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
{% extends 'pageWrapper.html' %}
|
||||||
|
|
||||||
|
{% block head %}
|
||||||
|
<title>{{ game }} Info</title>
|
||||||
|
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/markdown.css") }}" />
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/showdown/1.9.1/showdown.min.js"
|
||||||
|
integrity="sha512-L03kznCrNOfVxOUovR6ESfCz9Gfny7gihUX/huVbQB9zjODtYpxaVtIaAkpetoiyV2eqWbvxMH9fiSv5enX7bw=="
|
||||||
|
crossorigin="anonymous"></script>
|
||||||
|
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/gameInfo.js") }}"></script>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block body %}
|
||||||
|
{% include 'header/'+theme+'Header.html' %}
|
||||||
|
<div id="game-info" class="markdown" data-lang="{{ lang }}" data-game="{{ game | get_file_safe_name }}">
|
||||||
|
<!-- Populated my JS / MD -->
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -98,7 +98,7 @@
|
|||||||
<td>
|
<td>
|
||||||
{% if hint.finding_player == player %}
|
{% if hint.finding_player == player %}
|
||||||
<b>{{ player_names_with_alias[(team, hint.finding_player)] }}</b>
|
<b>{{ player_names_with_alias[(team, hint.finding_player)] }}</b>
|
||||||
{% elif get_slot_info(hint.finding_player).type == 2 %}
|
{% elif get_slot_info(team, hint.finding_player).type == 2 %}
|
||||||
<i>{{ player_names_with_alias[(team, hint.finding_player)] }}</i>
|
<i>{{ player_names_with_alias[(team, hint.finding_player)] }}</i>
|
||||||
{% else %}
|
{% else %}
|
||||||
<a href="{{ url_for("get_player_tracker", tracker=room.tracker, tracked_team=team, tracked_player=hint.finding_player) }}">
|
<a href="{{ url_for("get_player_tracker", tracker=room.tracker, tracked_team=team, tracked_player=hint.finding_player) }}">
|
||||||
@@ -109,7 +109,7 @@
|
|||||||
<td>
|
<td>
|
||||||
{% if hint.receiving_player == player %}
|
{% if hint.receiving_player == player %}
|
||||||
<b>{{ player_names_with_alias[(team, hint.receiving_player)] }}</b>
|
<b>{{ player_names_with_alias[(team, hint.receiving_player)] }}</b>
|
||||||
{% elif get_slot_info(hint.receiving_player).type == 2 %}
|
{% elif get_slot_info(team, hint.receiving_player).type == 2 %}
|
||||||
<i>{{ player_names_with_alias[(team, hint.receiving_player)] }}</i>
|
<i>{{ player_names_with_alias[(team, hint.receiving_player)] }}</i>
|
||||||
{% else %}
|
{% else %}
|
||||||
<a href="{{ url_for("get_player_tracker", tracker=room.tracker, tracked_team=team, tracked_player=hint.receiving_player) }}">
|
<a href="{{ url_for("get_player_tracker", tracker=room.tracker, tracked_team=team, tracked_player=hint.receiving_player) }}">
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
{% extends 'pageWrapper.html' %}
|
{% extends 'pageWrapper.html' %}
|
||||||
{% set show_footer = True %}
|
|
||||||
|
|
||||||
{% block head %}
|
{% block head %}
|
||||||
<title>Upload Multidata</title>
|
<title>Upload Multidata</title>
|
||||||
@@ -28,4 +27,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{% include 'islandFooter.html' %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -58,7 +58,8 @@
|
|||||||
Open Log File...
|
Open Log File...
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
{% set log, log_len = get_log() -%}
|
{% set log = get_log() -%}
|
||||||
|
{%- set log_len = log | length - 1 if log.endswith("…") else log | length -%}
|
||||||
<div id="logger" style="white-space: pre">{{ log }}</div>
|
<div id="logger" style="white-space: pre">{{ log }}</div>
|
||||||
<script>
|
<script>
|
||||||
let url = '{{ url_for('display_log', room = room.id) }}';
|
let url = '{{ url_for('display_log', room = room.id) }}';
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
{% extends 'pageWrapper.html' %}
|
{% extends 'pageWrapper.html' %}
|
||||||
{% set show_footer = True %}
|
|
||||||
|
|
||||||
{% block head %}
|
{% block head %}
|
||||||
<title>Archipelago</title>
|
<title>Archipelago</title>
|
||||||
@@ -11,7 +10,7 @@
|
|||||||
<div id="landing-wrapper">
|
<div id="landing-wrapper">
|
||||||
<div id="landing-header">
|
<div id="landing-header">
|
||||||
<img id="landing-logo" src="static/static/branding/landing-logo.png" alt="Archipelago Logo" />
|
<img id="landing-logo" src="static/static/branding/landing-logo.png" alt="Archipelago Logo" />
|
||||||
<h4>multiworld multi-game randomizer</h4><h5>beta</h5>
|
<h4>multiworld multi-game randomizer</h4>
|
||||||
</div>
|
</div>
|
||||||
<div id="landing-links">
|
<div id="landing-links">
|
||||||
<a href="/games" id="far-left-button">Supported<br />Games</a>
|
<a href="/games" id="far-left-button">Supported<br />Games</a>
|
||||||
@@ -35,8 +34,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div id="landing" class="grass-island">
|
<div id="landing" class="grass-island">
|
||||||
<div id="landing-body">
|
<div id="landing-body">
|
||||||
<p id="first-line">Welcome to Archipelago Beta!</p>
|
<p id="first-line">Welcome to Archipelago!</p>
|
||||||
<p>For the stable version, visit <a href="//archipelago.gg">Archipelago.gg</a>!</p>
|
|
||||||
<p>
|
<p>
|
||||||
This is a cross-game modification system which randomizes different games, then uses the result to
|
This is a cross-game modification system which randomizes different games, then uses the result to
|
||||||
build a single unified multi-player game. Items from one game may be present in another, and
|
build a single unified multi-player game. Items from one game may be present in another, and
|
||||||
@@ -59,4 +57,5 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{% include 'islandFooter.html' %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -26,18 +26,30 @@
|
|||||||
<td>{{ patch.game }}</td>
|
<td>{{ patch.game }}</td>
|
||||||
<td>
|
<td>
|
||||||
{% if patch.data %}
|
{% if patch.data %}
|
||||||
{% if patch.game == "VVVVVV" and room.seed.slots|length == 1 %}
|
{% if patch.game == "Minecraft" %}
|
||||||
|
<a href="{{ url_for("download_slot_file", room_id=room.id, player_id=patch.player_id) }}" download>
|
||||||
|
Download APMC File...</a>
|
||||||
|
{% elif patch.game == "Factorio" %}
|
||||||
|
<a href="{{ url_for("download_slot_file", room_id=room.id, player_id=patch.player_id) }}" download>
|
||||||
|
Download Factorio Mod...</a>
|
||||||
|
{% elif patch.game == "Kingdom Hearts 2" %}
|
||||||
|
<a href="{{ url_for("download_slot_file", room_id=room.id, player_id=patch.player_id) }}" download>
|
||||||
|
Download Kingdom Hearts 2 Mod...</a>
|
||||||
|
{% elif patch.game == "Ocarina of Time" %}
|
||||||
|
<a href="{{ url_for("download_slot_file", room_id=room.id, player_id=patch.player_id) }}" download>
|
||||||
|
Download APZ5 File...</a>
|
||||||
|
{% elif patch.game == "VVVVVV" and room.seed.slots|length == 1 %}
|
||||||
<a href="{{ url_for("download_slot_file", room_id=room.id, player_id=patch.player_id) }}" download>
|
<a href="{{ url_for("download_slot_file", room_id=room.id, player_id=patch.player_id) }}" download>
|
||||||
Download APV6 File...</a>
|
Download APV6 File...</a>
|
||||||
{% elif patch.game == "Super Mario 64" and room.seed.slots|length == 1 %}
|
{% elif patch.game == "Super Mario 64" and room.seed.slots|length == 1 %}
|
||||||
<a href="{{ url_for("download_slot_file", room_id=room.id, player_id=patch.player_id) }}" download>
|
<a href="{{ url_for("download_slot_file", room_id=room.id, player_id=patch.player_id) }}" download>
|
||||||
Download APSM64EX File...</a>
|
Download APSM64EX File...</a>
|
||||||
{% elif patch.game == "Factorio" %}
|
{% elif patch.game | supports_apdeltapatch %}
|
||||||
<a href="{{ url_for("download_slot_file", room_id=room.id, player_id=patch.player_id) }}" download>
|
|
||||||
Download Factorio Mod...</a>
|
|
||||||
{% elif patch.game | is_applayercontainer(patch.data, patch.player_id) %}
|
|
||||||
<a href="{{ url_for("download_patch", patch_id=patch.id, room_id=room.id) }}" download>
|
<a href="{{ url_for("download_patch", patch_id=patch.id, room_id=room.id) }}" download>
|
||||||
Download Patch File...</a>
|
Download Patch File...</a>
|
||||||
|
{% elif patch.game == "Final Fantasy Mystic Quest" %}
|
||||||
|
<a href="{{ url_for("download_slot_file", room_id=room.id, player_id=patch.player_id) }}" download>
|
||||||
|
Download APMQ File...</a>
|
||||||
{% else %}
|
{% else %}
|
||||||
No file to download for this game.
|
No file to download for this game.
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
{% extends 'pageWrapper.html' %}
|
{% extends 'pageWrapper.html' %}
|
||||||
|
|
||||||
{% block head %}
|
{% block head %}
|
||||||
{% set theme_name = theme|default("grass", true) %}
|
{% include 'header/grassHeader.html' %}
|
||||||
{% include "header/"+theme_name+"Header.html" %}
|
|
||||||
<title>{{ title }}</title>
|
<title>{{ title }}</title>
|
||||||
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/markdown.css") }}" />
|
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/markdown.css") }}" />
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -45,15 +45,15 @@
|
|||||||
{%- set current_sphere = loop.index %}
|
{%- set current_sphere = loop.index %}
|
||||||
{%- for player, sphere_location_ids in sphere.items() %}
|
{%- for player, sphere_location_ids in sphere.items() %}
|
||||||
{%- set checked_locations = tracker_data.get_player_checked_locations(team, player) %}
|
{%- set checked_locations = tracker_data.get_player_checked_locations(team, player) %}
|
||||||
{%- set finder_game = tracker_data.get_player_game(player) %}
|
{%- set finder_game = tracker_data.get_player_game(team, player) %}
|
||||||
{%- set player_location_data = tracker_data.get_player_locations(player) %}
|
{%- set player_location_data = tracker_data.get_player_locations(team, player) %}
|
||||||
{%- for location_id in sphere_location_ids.intersection(checked_locations) %}
|
{%- for location_id in sphere_location_ids.intersection(checked_locations) %}
|
||||||
<tr>
|
<tr>
|
||||||
{%- set item_id, receiver, item_flags = player_location_data[location_id] %}
|
{%- set item_id, receiver, item_flags = player_location_data[location_id] %}
|
||||||
{%- set receiver_game = tracker_data.get_player_game(receiver) %}
|
{%- set receiver_game = tracker_data.get_player_game(team, receiver) %}
|
||||||
<td>{{ current_sphere }}</td>
|
<td>{{ current_sphere }}</td>
|
||||||
<td>{{ tracker_data.get_player_name(player) }}</td>
|
<td>{{ tracker_data.get_player_name(team, player) }}</td>
|
||||||
<td>{{ tracker_data.get_player_name(receiver) }}</td>
|
<td>{{ tracker_data.get_player_name(team, receiver) }}</td>
|
||||||
<td>{{ tracker_data.item_id_to_name[receiver_game][item_id] }}</td>
|
<td>{{ tracker_data.item_id_to_name[receiver_game][item_id] }}</td>
|
||||||
<td>{{ tracker_data.location_id_to_name[finder_game][location_id] }}</td>
|
<td>{{ tracker_data.location_id_to_name[finder_game][location_id] }}</td>
|
||||||
<td>{{ finder_game }}</td>
|
<td>{{ finder_game }}</td>
|
||||||
|
|||||||
@@ -22,14 +22,14 @@
|
|||||||
-%}
|
-%}
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
{% if get_slot_info(hint.finding_player).type == 2 %}
|
{% if get_slot_info(team, hint.finding_player).type == 2 %}
|
||||||
<i>{{ player_names_with_alias[(team, hint.finding_player)] }}</i>
|
<i>{{ player_names_with_alias[(team, hint.finding_player)] }}</i>
|
||||||
{% else %}
|
{% else %}
|
||||||
{{ player_names_with_alias[(team, hint.finding_player)] }}
|
{{ player_names_with_alias[(team, hint.finding_player)] }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{% if get_slot_info(hint.receiving_player).type == 2 %}
|
{% if get_slot_info(team, hint.receiving_player).type == 2 %}
|
||||||
<i>{{ player_names_with_alias[(team, hint.receiving_player)] }}</i>
|
<i>{{ player_names_with_alias[(team, hint.receiving_player)] }}</i>
|
||||||
{% else %}
|
{% else %}
|
||||||
{{ player_names_with_alias[(team, hint.receiving_player)] }}
|
{{ player_names_with_alias[(team, hint.receiving_player)] }}
|
||||||
|
|||||||
@@ -5,30 +5,26 @@
|
|||||||
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/tooltip.css") }}" />
|
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/tooltip.css") }}" />
|
||||||
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/cookieNotice.css") }}" />
|
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/cookieNotice.css") }}" />
|
||||||
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/globalStyles.css") }}" />
|
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/globalStyles.css") }}" />
|
||||||
|
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/styleController.js") }}"></script>
|
||||||
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/cookieNotice.js") }}"></script>
|
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/cookieNotice.js") }}"></script>
|
||||||
{% block head %}
|
{% block head %}
|
||||||
<title>Archipelago</title>
|
<title>Archipelago</title>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<main>
|
|
||||||
{% with messages = get_flashed_messages() %}
|
|
||||||
{% if messages %}
|
|
||||||
<div>
|
|
||||||
{% for message in messages | unique %}
|
|
||||||
<div class="user-message">{{ message }}</div>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
{% endwith %}
|
|
||||||
<div class="user-message">This is the beta site! For the stable version, visit <a href="https://archipelago.gg">Archipelago.gg</a>!</div>
|
|
||||||
|
|
||||||
{% block body %}
|
{% with messages = get_flashed_messages() %}
|
||||||
{% endblock %}
|
{% if messages %}
|
||||||
</main>
|
<div>
|
||||||
|
{% for message in messages | unique %}
|
||||||
{% if show_footer %}
|
<div class="user-message">{{ message }}</div>
|
||||||
{% include "islandFooter.html" %}
|
{% endfor %}
|
||||||
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% endwith %}
|
||||||
|
|
||||||
|
{% block body %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -55,9 +55,6 @@
|
|||||||
{{ OptionTitle(option_name, option) }}
|
{{ OptionTitle(option_name, option) }}
|
||||||
<div class="named-range-container">
|
<div class="named-range-container">
|
||||||
<select id="{{ option_name }}-select" name="{{ option_name }}" data-option-name="{{ option_name }}" {{ "disabled" if option.default == "random" }}>
|
<select id="{{ option_name }}-select" name="{{ option_name }}" data-option-name="{{ option_name }}" {{ "disabled" if option.default == "random" }}>
|
||||||
{% if option.default not in option.special_range_names.values() %}
|
|
||||||
<option value="{{ option.default }}" selected>Default ({{ option.default }})</option>
|
|
||||||
{% endif %}
|
|
||||||
{% for key, val in option.special_range_names.items() %}
|
{% for key, val in option.special_range_names.items() %}
|
||||||
{% if option.default == val %}
|
{% if option.default == val %}
|
||||||
<option value="{{ val }}" selected>{{ key|replace("_", " ")|title }} ({{ val }})</option>
|
<option value="{{ val }}" selected>{{ key|replace("_", " ")|title }} ({{ val }})</option>
|
||||||
@@ -97,9 +94,6 @@
|
|||||||
<div class="text-choice-container">
|
<div class="text-choice-container">
|
||||||
<div class="text-choice-wrapper">
|
<div class="text-choice-wrapper">
|
||||||
<select id="{{ option_name }}" name="{{ option_name }}" {{ "disabled" if option.default == "random" }}>
|
<select id="{{ option_name }}" name="{{ option_name }}" {{ "disabled" if option.default == "random" }}>
|
||||||
{% if option.default not in option.options.values() %}
|
|
||||||
<option value="{{ option.default }}" selected>Default ({{ option.default }})</option>
|
|
||||||
{% endif %}
|
|
||||||
{% for id, name in option.name_lookup.items()|sort %}
|
{% for id, name in option.name_lookup.items()|sort %}
|
||||||
{% if name != "random" %}
|
{% if name != "random" %}
|
||||||
{% if option.default == id %}
|
{% if option.default == id %}
|
||||||
@@ -117,19 +111,10 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
{% macro OptionCounter(option_name, option) %}
|
{% macro ItemDict(option_name, option) %}
|
||||||
{% set relevant_keys = option.valid_keys %}
|
|
||||||
{% if not relevant_keys %}
|
|
||||||
{% if option.verify_item_name %}
|
|
||||||
{% set relevant_keys = world.item_names %}
|
|
||||||
{% elif option.verify_location_name %}
|
|
||||||
{% set relevant_keys = world.location_names %}
|
|
||||||
{% endif %}
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{{ OptionTitle(option_name, option) }}
|
{{ OptionTitle(option_name, option) }}
|
||||||
<div class="option-container">
|
<div class="option-container">
|
||||||
{% for item_name in (relevant_keys if relevant_keys is ordered else relevant_keys|sort) %}
|
{% for item_name in (option.valid_keys|sort if (option.valid_keys|length > 0) else world.item_names|sort) %}
|
||||||
<div class="option-entry">
|
<div class="option-entry">
|
||||||
<label for="{{ option_name }}-{{ item_name }}-qty">{{ item_name }}</label>
|
<label for="{{ option_name }}-{{ item_name }}-qty">{{ item_name }}</label>
|
||||||
<input type="number" id="{{ option_name }}-{{ item_name }}-qty" name="{{ option_name }}||{{ item_name }}||qty" value="{{ option.default[item_name]|default("0") }}" data-option-name="{{ option_name }}" data-item-name="{{ item_name }}" />
|
<input type="number" id="{{ option_name }}-{{ item_name }}-qty" name="{{ option_name }}||{{ item_name }}||qty" value="{{ option.default[item_name]|default("0") }}" data-option-name="{{ option_name }}" data-item-name="{{ item_name }}" />
|
||||||
@@ -140,7 +125,6 @@
|
|||||||
|
|
||||||
{% macro OptionList(option_name, option) %}
|
{% macro OptionList(option_name, option) %}
|
||||||
{{ OptionTitle(option_name, option) }}
|
{{ OptionTitle(option_name, option) }}
|
||||||
<input type="hidden" id="{{ option_name }}-{{ key }}-hidden" name="{{ option_name }}" value="_ensure-empty-list"/>
|
|
||||||
<div class="option-container">
|
<div class="option-container">
|
||||||
{% for key in (option.valid_keys if option.valid_keys is ordered else option.valid_keys|sort) %}
|
{% for key in (option.valid_keys if option.valid_keys is ordered else option.valid_keys|sort) %}
|
||||||
<div class="option-entry">
|
<div class="option-entry">
|
||||||
@@ -153,7 +137,6 @@
|
|||||||
|
|
||||||
{% macro LocationSet(option_name, option) %}
|
{% macro LocationSet(option_name, option) %}
|
||||||
{{ OptionTitle(option_name, option) }}
|
{{ OptionTitle(option_name, option) }}
|
||||||
<input type="hidden" id="{{ option_name }}-{{ key }}-hidden" name="{{ option_name }}" value="_ensure-empty-list"/>
|
|
||||||
<div class="option-container">
|
<div class="option-container">
|
||||||
{% for group_name in world.location_name_groups.keys()|sort %}
|
{% for group_name in world.location_name_groups.keys()|sort %}
|
||||||
{% if group_name != "Everywhere" %}
|
{% if group_name != "Everywhere" %}
|
||||||
@@ -177,7 +160,6 @@
|
|||||||
|
|
||||||
{% macro ItemSet(option_name, option) %}
|
{% macro ItemSet(option_name, option) %}
|
||||||
{{ OptionTitle(option_name, option) }}
|
{{ OptionTitle(option_name, option) }}
|
||||||
<input type="hidden" id="{{ option_name }}-{{ key }}-hidden" name="{{ option_name }}" value="_ensure-empty-list"/>
|
|
||||||
<div class="option-container">
|
<div class="option-container">
|
||||||
{% for group_name in world.item_name_groups.keys()|sort %}
|
{% for group_name in world.item_name_groups.keys()|sort %}
|
||||||
{% if group_name != "Everything" %}
|
{% if group_name != "Everything" %}
|
||||||
@@ -201,7 +183,6 @@
|
|||||||
|
|
||||||
{% macro OptionSet(option_name, option) %}
|
{% macro OptionSet(option_name, option) %}
|
||||||
{{ OptionTitle(option_name, option) }}
|
{{ OptionTitle(option_name, option) }}
|
||||||
<input type="hidden" id="{{ option_name }}-{{ key }}-hidden" name="{{ option_name }}" value="_ensure-empty-list"/>
|
|
||||||
<div class="option-container">
|
<div class="option-container">
|
||||||
{% for key in (option.valid_keys if option.valid_keys is ordered else option.valid_keys|sort) %}
|
{% for key in (option.valid_keys if option.valid_keys is ordered else option.valid_keys|sort) %}
|
||||||
<div class="option-entry">
|
<div class="option-entry">
|
||||||
@@ -232,7 +213,7 @@
|
|||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
{% macro RandomizeButton(option_name, option) %}
|
{% macro RandomizeButton(option_name, option) %}
|
||||||
<div class="randomize-button" data-tooltip="Pick a random value for this option.">
|
<div class="randomize-button" data-tooltip="Toggle randomization for this option!">
|
||||||
<label for="random-{{ option_name }}">
|
<label for="random-{{ option_name }}">
|
||||||
<input type="checkbox" id="random-{{ option_name }}" name="random-{{ option_name }}" class="randomize-checkbox" data-option-name="{{ option_name }}" {{ "checked" if option.default == "random" }} />
|
<input type="checkbox" id="random-{{ option_name }}" name="random-{{ option_name }}" class="randomize-checkbox" data-option-name="{{ option_name }}" {{ "checked" if option.default == "random" }} />
|
||||||
🎲
|
🎲
|
||||||
|
|||||||
@@ -93,10 +93,8 @@
|
|||||||
{% elif issubclass(option, Options.FreeText) %}
|
{% elif issubclass(option, Options.FreeText) %}
|
||||||
{{ inputs.FreeText(option_name, option) }}
|
{{ inputs.FreeText(option_name, option) }}
|
||||||
|
|
||||||
{% elif issubclass(option, Options.OptionCounter) and (
|
{% elif issubclass(option, Options.ItemDict) and option.verify_item_name %}
|
||||||
option.valid_keys or option.verify_item_name or option.verify_location_name
|
{{ inputs.ItemDict(option_name, option) }}
|
||||||
) %}
|
|
||||||
{{ inputs.OptionCounter(option_name, option) }}
|
|
||||||
|
|
||||||
{% elif issubclass(option, Options.OptionList) and option.valid_keys %}
|
{% elif issubclass(option, Options.OptionList) and option.valid_keys %}
|
||||||
{{ inputs.OptionList(option_name, option) }}
|
{{ inputs.OptionList(option_name, option) }}
|
||||||
@@ -135,10 +133,8 @@
|
|||||||
{% elif issubclass(option, Options.FreeText) %}
|
{% elif issubclass(option, Options.FreeText) %}
|
||||||
{{ inputs.FreeText(option_name, option) }}
|
{{ inputs.FreeText(option_name, option) }}
|
||||||
|
|
||||||
{% elif issubclass(option, Options.OptionCounter) and (
|
{% elif issubclass(option, Options.ItemDict) and option.verify_item_name %}
|
||||||
option.valid_keys or option.verify_item_name or option.verify_location_name
|
{{ inputs.ItemDict(option_name, option) }}
|
||||||
) %}
|
|
||||||
{{ inputs.OptionCounter(option_name, option) }}
|
|
||||||
|
|
||||||
{% elif issubclass(option, Options.OptionList) and option.valid_keys %}
|
{% elif issubclass(option, Options.OptionList) and option.valid_keys %}
|
||||||
{{ inputs.OptionList(option_name, option) }}
|
{{ inputs.OptionList(option_name, option) }}
|
||||||
|
|||||||
@@ -1,23 +1,19 @@
|
|||||||
{% extends 'pageWrapper.html' %}
|
{% extends 'pageWrapper.html' %}
|
||||||
{% import "macros.html" as macros %}
|
{% import "macros.html" as macros %}
|
||||||
{% set show_footer = True %}
|
|
||||||
|
|
||||||
{% block head %}
|
{% block head %}
|
||||||
<title>Generation failed, please retry.</title>
|
<title>Generation failed, please retry.</title>
|
||||||
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename='styles/waitSeed.css') }}"/>
|
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/waitSeed.css") }}"/>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block body %}
|
{% block body %}
|
||||||
{% include 'header/oceanIslandHeader.html' %}
|
{% include 'header/oceanIslandHeader.html' %}
|
||||||
<div id="wait-seed-wrapper" class="grass-island">
|
<div id="wait-seed-wrapper" class="grass-island">
|
||||||
<div id="wait-seed">
|
<div id="wait-seed">
|
||||||
<h1>Generation Failed</h1>
|
<h1>Generation failed</h1>
|
||||||
<h2>Please try again!</h2>
|
<h2>please retry</h2>
|
||||||
<p>{{ seed_error }}</p>
|
{{ seed_error }}
|
||||||
<h4>More details:</h4>
|
|
||||||
<p>
|
|
||||||
<code class="grassy">{{ details }}</code>
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{% include 'islandFooter.html' %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -11,32 +11,32 @@
|
|||||||
<h1>Site Map</h1>
|
<h1>Site Map</h1>
|
||||||
<h2>Base Pages</h2>
|
<h2>Base Pages</h2>
|
||||||
<ul>
|
<ul>
|
||||||
<li><a href="{{ url_for('discord') }}">Discord Link</a></li>
|
<li><a href="/discord">Discord Link</a></li>
|
||||||
<li><a href="{{ url_for('faq', lang='en') }}">F.A.Q. Page</a></li>
|
<li><a href="/faq/en">F.A.Q. Page</a></li>
|
||||||
<li><a href="{{ url_for('favicon') }}">Favicon</a></li>
|
<li><a href="/favicon.ico">Favicon</a></li>
|
||||||
<li><a href="{{ url_for('generate') }}">Generate Game Page</a></li>
|
<li><a href="/generate">Generate Game Page</a></li>
|
||||||
<li><a href="{{ url_for('landing') }}">Homepage</a></li>
|
<li><a href="/">Homepage</a></li>
|
||||||
<li><a href="{{ url_for('uploads') }}">Host Game Page</a></li>
|
<li><a href="/uploads">Host Game Page</a></li>
|
||||||
<li><a href="{{ url_for('get_datapackage') }}">Raw Data Package</a></li>
|
<li><a href="/datapackage">Raw Data Package</a></li>
|
||||||
<li><a href="{{ url_for('check') }}">Settings Validator</a></li>
|
<li><a href="{{ url_for('check')}}">Settings Validator</a></li>
|
||||||
<li><a href="{{ url_for('get_sitemap') }}">Site Map</a></li>
|
<li><a href="/sitemap">Site Map</a></li>
|
||||||
<li><a href="{{ url_for('start_playing') }}">Start Playing</a></li>
|
<li><a href="/start-playing">Start Playing</a></li>
|
||||||
<li><a href="{{ url_for('games') }}">Supported Games Page</a></li>
|
<li><a href="/games">Supported Games Page</a></li>
|
||||||
<li><a href="{{ url_for('tutorial_landing') }}">Tutorials Page</a></li>
|
<li><a href="/tutorial">Tutorials Page</a></li>
|
||||||
<li><a href="{{ url_for('user_content') }}">User Content</a></li>
|
<li><a href="/user-content">User Content</a></li>
|
||||||
<li><a href="{{ url_for('stats') }}">Game Statistics</a></li>
|
<li><a href="{{url_for('stats')}}">Game Statistics</a></li>
|
||||||
<li><a href="{{ url_for('glossary', lang='en') }}">Glossary</a></li>
|
<li><a href="/glossary/en">Glossary</a></li>
|
||||||
<li><a href="{{ url_for('show_session') }}">Session / Login</a></li>
|
<li><a href="{{url_for("show_session")}}">Session / Login</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<h2>Tutorials</h2>
|
<h2>Tutorials</h2>
|
||||||
<ul>
|
<ul>
|
||||||
<li><a href="{{ url_for('tutorial', game='Archipelago', file='setup_en') }}">Multiworld Setup Tutorial</a></li>
|
<li><a href="/tutorial/Archipelago/setup/en">Multiworld Setup Tutorial</a></li>
|
||||||
<li><a href="{{ url_for('tutorial', game='Archipelago', file='mac_en') }}">Setup Guide for Mac</a></li>
|
<li><a href="/tutorial/Archipelago/mac/en">Setup Guide for Mac</a></li>
|
||||||
<li><a href="{{ url_for('tutorial', game='Archipelago', file='commands_en') }}">Server and Client Commands</a></li>
|
<li><a href="/tutorial/Archipelago/commands/en">Server and Client Commands</a></li>
|
||||||
<li><a href="{{ url_for('tutorial', game='Archipelago', file='advanced_settings_en') }}">Advanced YAML Guide</a></li>
|
<li><a href="/tutorial/Archipelago/advanced_settings/en">Advanced YAML Guide</a></li>
|
||||||
<li><a href="{{ url_for('tutorial', game='Archipelago', file='triggers_en') }}">Triggers Guide</a></li>
|
<li><a href="/tutorial/Archipelago/triggers/en">Triggers Guide</a></li>
|
||||||
<li><a href="{{ url_for('tutorial', game='Archipelago', file='plando_en') }}">Plando Guide</a></li>
|
<li><a href="/tutorial/Archipelago/plando/en">Plando Guide</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<h2>Game Info Pages</h2>
|
<h2>Game Info Pages</h2>
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
{% extends 'pageWrapper.html' %}
|
{% extends 'pageWrapper.html' %}
|
||||||
{% set show_footer = True %}
|
|
||||||
|
|
||||||
{% block head %}
|
{% block head %}
|
||||||
<title>Start Playing</title>
|
<title>Start Playing</title>
|
||||||
@@ -27,4 +26,6 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{% include 'islandFooter.html' %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -31,9 +31,6 @@
|
|||||||
{% include 'header/oceanHeader.html' %}
|
{% include 'header/oceanHeader.html' %}
|
||||||
<div id="games" class="markdown">
|
<div id="games" class="markdown">
|
||||||
<h1>Currently Supported Games</h1>
|
<h1>Currently Supported Games</h1>
|
||||||
<p>Below are the games that are currently included with the Archipelago software. To play a game that is not on
|
|
||||||
this page, please refer to the <a href="/tutorial/Archipelago/setup/en#playing-with-custom-worlds">playing with
|
|
||||||
custom worlds</a> section of the setup guide.</p>
|
|
||||||
<div class="js-only">
|
<div class="js-only">
|
||||||
<label for="game-search">Search for your game below!</label><br />
|
<label for="game-search">Search for your game below!</label><br />
|
||||||
<div class="page-controls">
|
<div class="page-controls">
|
||||||
|
|||||||
84
WebHostLib/templates/tracker__Minecraft.html
Normal file
84
WebHostLib/templates/tracker__Minecraft.html
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<title>{{ player_name }}'s Tracker</title>
|
||||||
|
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename='styles/minecraftTracker.css') }}"/>
|
||||||
|
<script type="application/ecmascript" src="{{ url_for('static', filename='assets/minecraftTracker.js') }}"></script>
|
||||||
|
<link rel="stylesheet" media="screen" href="https://fontlibrary.org//face/minecraftia" type="text/css"/>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
{# TODO: Replace this with a proper wrapper for each tracker when developing TrackerAPI. #}
|
||||||
|
<div style="margin-bottom: 0.5rem">
|
||||||
|
<a href="{{ url_for("get_generic_game_tracker", tracker=room.tracker, tracked_team=team, tracked_player=player) }}">Switch To Generic Tracker</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="player-tracker-wrapper" data-tracker="{{ room.tracker|suuid }}">
|
||||||
|
<table id="inventory-table">
|
||||||
|
<tr>
|
||||||
|
<td><img src="{{ tools_url }}" class="{{ 'acquired' }}" title="Progressive Tools" /></td>
|
||||||
|
<td><img src="{{ weapons_url }}" class="{{ 'acquired' }}" title="Progressive Weapons" /></td>
|
||||||
|
<td><img src="{{ armor_url }}" class="{{ 'acquired' }}" title="Progressive Armor" /></td>
|
||||||
|
<td><img src="{{ resource_crafting_url }}" class="{{ 'acquired' if 'Progressive Resource Crafting' in acquired_items }}"
|
||||||
|
title="Progressive Resource Crafting" /></td>
|
||||||
|
<td><img src="{{ icons['Brewing Stand'] }}" class="{{ 'acquired' if 'Brewing' in acquired_items }}" title="Brewing" /></td>
|
||||||
|
<td>
|
||||||
|
<div class="counted-item">
|
||||||
|
<img src="{{ icons['Ender Pearl'] }}" class="{{ 'acquired' if '3 Ender Pearls' in acquired_items }}" title="Ender Pearls" />
|
||||||
|
<div class="item-count">{{ pearls_count }}</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><img src="{{ icons['Bucket'] }}" class="{{ 'acquired' if 'Bucket' in acquired_items }}" title="Bucket" /></td>
|
||||||
|
<td><img src="{{ icons['Bow'] }}" class="{{ 'acquired' if 'Archery' in acquired_items }}" title="Archery" /></td>
|
||||||
|
<td><img src="{{ icons['Shield'] }}" class="{{ 'acquired' if 'Shield' in acquired_items }}" title="Shield" /></td>
|
||||||
|
<td><img src="{{ icons['Red Bed'] }}" class="{{ 'acquired' if 'Bed' in acquired_items }}" title="Bed" /></td>
|
||||||
|
<td><img src="{{ icons['Water Bottle'] }}" class="{{ 'acquired' if 'Bottles' in acquired_items }}" title="Bottles" /></td>
|
||||||
|
<td>
|
||||||
|
<div class="counted-item">
|
||||||
|
<img src="{{ icons['Netherite Scrap'] }}" class="{{ 'acquired' if '8 Netherite Scrap' in acquired_items }}" title="Netherite Scrap" />
|
||||||
|
<div class="item-count">{{ scrap_count }}</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><img src="{{ icons['Flint and Steel'] }}" class="{{ 'acquired' if 'Flint and Steel' in acquired_items }}" title="Flint and Steel" /></td>
|
||||||
|
<td><img src="{{ icons['Enchanting Table'] }}" class="{{ 'acquired' if 'Enchanting' in acquired_items }}" title="Enchanting" /></td>
|
||||||
|
<td><img src="{{ icons['Fishing Rod'] }}" class="{{ 'acquired' if 'Fishing Rod' in acquired_items }}" title="Fishing Rod" /></td>
|
||||||
|
<td><img src="{{ icons['Campfire'] }}" class="{{ 'acquired' if 'Campfire' in acquired_items }}" title="Campfire" /></td>
|
||||||
|
<td><img src="{{ icons['Spyglass'] }}" class="{{ 'acquired' if 'Spyglass' in acquired_items }}" title="Spyglass" /></td>
|
||||||
|
<td>
|
||||||
|
<div class="counted-item">
|
||||||
|
<img src="{{ icons['Dragon Egg Shard'] }}" class="{{ 'acquired' if 'Dragon Egg Shard' in acquired_items }}" title="Dragon Egg Shard" />
|
||||||
|
<div class="item-count">{{ shard_count }}</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><img src="{{ icons['Lead'] }}" class="{{ 'acquired' if 'Lead' in acquired_items }}" title="Lead" /></td>
|
||||||
|
<td><img src="{{ icons['Saddle'] }}" class="{{ 'acquired' if 'Saddle' in acquired_items }}" title="Saddle" /></td>
|
||||||
|
<td><img src="{{ icons['Channeling Book'] }}" class="{{ 'acquired' if 'Channeling Book' in acquired_items }}" title="Channeling Book" /></td>
|
||||||
|
<td><img src="{{ icons['Silk Touch Book'] }}" class="{{ 'acquired' if 'Silk Touch Book' in acquired_items }}" title="Silk Touch Book" /></td>
|
||||||
|
<td><img src="{{ icons['Piercing IV Book'] }}" class="{{ 'acquired' if 'Piercing IV Book' in acquired_items }}" title="Piercing IV Book" /></td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
<table id="location-table">
|
||||||
|
{% for area in checks_done %}
|
||||||
|
<tr class="location-category" id="{{area}}-header">
|
||||||
|
<td>{{ area }} {{'▼' if area != 'Total'}}</td>
|
||||||
|
<td class="counter">{{ checks_done[area] }} / {{ checks_in_area[area] }}</td>
|
||||||
|
</tr>
|
||||||
|
<tbody class="locations hide" id="{{area}}">
|
||||||
|
{% for location in location_info[area] %}
|
||||||
|
<tr>
|
||||||
|
<td class="location-name">{{ location }}</td>
|
||||||
|
<td class="counter">{{ '✔' if location_info[area][location] else '' }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
{% endfor %}
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user