mirror of
https://github.com/ArchipelagoMW/Archipelago.git
synced 2026-03-27 06:53:21 -07:00
Compare commits
5 Commits
options-pr
...
custom_web
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
62b3fd4d37 | ||
|
|
e2f7153312 | ||
|
|
96d4143030 | ||
|
|
a1dcaf52e3 | ||
|
|
aab8f31345 |
@@ -1,5 +0,0 @@
|
|||||||
[report]
|
|
||||||
exclude_lines =
|
|
||||||
pragma: no cover
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
if typing.TYPE_CHECKING:
|
|
||||||
31
.github/labeler.yml
vendored
31
.github/labeler.yml
vendored
@@ -1,31 +0,0 @@
|
|||||||
'is: documentation':
|
|
||||||
- changed-files:
|
|
||||||
- all-globs-to-all-files: '{**/docs/**,**/README.md}'
|
|
||||||
|
|
||||||
'affects: webhost':
|
|
||||||
- changed-files:
|
|
||||||
- all-globs-to-any-file: 'WebHost.py'
|
|
||||||
- all-globs-to-any-file: 'WebHostLib/**/*'
|
|
||||||
|
|
||||||
'affects: core':
|
|
||||||
- changed-files:
|
|
||||||
- all-globs-to-any-file:
|
|
||||||
- '!*Client.py'
|
|
||||||
- '!README.md'
|
|
||||||
- '!LICENSE'
|
|
||||||
- '!*.yml'
|
|
||||||
- '!.gitignore'
|
|
||||||
- '!**/docs/**'
|
|
||||||
- '!typings/kivy/**'
|
|
||||||
- '!test/**'
|
|
||||||
- '!data/**'
|
|
||||||
- '!.run/**'
|
|
||||||
- '!.github/**'
|
|
||||||
- '!worlds_disabled/**'
|
|
||||||
- '!worlds/**'
|
|
||||||
- '!WebHost.py'
|
|
||||||
- '!WebHostLib/**'
|
|
||||||
- any-glob-to-any-file: # exceptions to the above rules of "stuff that isn't core"
|
|
||||||
- 'worlds/generic/**/*.py'
|
|
||||||
- 'worlds/*.py'
|
|
||||||
- 'CommonClient.py'
|
|
||||||
27
.github/pyright-config.json
vendored
27
.github/pyright-config.json
vendored
@@ -1,27 +0,0 @@
|
|||||||
{
|
|
||||||
"include": [
|
|
||||||
"type_check.py",
|
|
||||||
"../worlds/AutoSNIClient.py",
|
|
||||||
"../Patch.py"
|
|
||||||
],
|
|
||||||
|
|
||||||
"exclude": [
|
|
||||||
"**/__pycache__"
|
|
||||||
],
|
|
||||||
|
|
||||||
"stubPath": "../typings",
|
|
||||||
|
|
||||||
"typeCheckingMode": "strict",
|
|
||||||
"reportImplicitOverride": "error",
|
|
||||||
"reportMissingImports": true,
|
|
||||||
"reportMissingTypeStubs": true,
|
|
||||||
|
|
||||||
"pythonVersion": "3.8",
|
|
||||||
"pythonPlatform": "Windows",
|
|
||||||
|
|
||||||
"executionEnvironments": [
|
|
||||||
{
|
|
||||||
"root": ".."
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
15
.github/type_check.py
vendored
15
.github/type_check.py
vendored
@@ -1,15 +0,0 @@
|
|||||||
from pathlib import Path
|
|
||||||
import subprocess
|
|
||||||
|
|
||||||
config = Path(__file__).parent / "pyright-config.json"
|
|
||||||
|
|
||||||
command = ("pyright", "-p", str(config))
|
|
||||||
print(" ".join(command))
|
|
||||||
|
|
||||||
try:
|
|
||||||
result = subprocess.run(command)
|
|
||||||
except FileNotFoundError as e:
|
|
||||||
print(f"{e} - Is pyright installed?")
|
|
||||||
exit(1)
|
|
||||||
|
|
||||||
exit(result.returncode)
|
|
||||||
6
.github/workflows/analyze-modified-files.yml
vendored
6
.github/workflows/analyze-modified-files.yml
vendored
@@ -25,7 +25,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v3
|
||||||
|
|
||||||
- name: "Determine modified files (pull_request)"
|
- name: "Determine modified files (pull_request)"
|
||||||
if: github.event_name == 'pull_request'
|
if: github.event_name == 'pull_request'
|
||||||
@@ -50,7 +50,7 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
echo "diff=." >> $GITHUB_ENV
|
echo "diff=." >> $GITHUB_ENV
|
||||||
|
|
||||||
- uses: actions/setup-python@v5
|
- uses: actions/setup-python@v4
|
||||||
if: env.diff != ''
|
if: env.diff != ''
|
||||||
with:
|
with:
|
||||||
python-version: 3.8
|
python-version: 3.8
|
||||||
@@ -71,7 +71,7 @@ jobs:
|
|||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
if: env.diff != '' && matrix.task == 'flake8'
|
if: env.diff != '' && matrix.task == 'flake8'
|
||||||
run: |
|
run: |
|
||||||
flake8 --count --max-complexity=14 --max-doc-length=120 --max-line-length=120 --statistics ${{ env.diff }}
|
flake8 --count --max-complexity=10 --max-doc-length=120 --max-line-length=120 --statistics ${{ env.diff }}
|
||||||
|
|
||||||
- name: "mypy: Type check modified files"
|
- name: "mypy: Type check modified files"
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
|
|||||||
33
.github/workflows/build.yml
vendored
33
.github/workflows/build.yml
vendored
@@ -8,13 +8,11 @@ on:
|
|||||||
- '.github/workflows/build.yml'
|
- '.github/workflows/build.yml'
|
||||||
- 'setup.py'
|
- 'setup.py'
|
||||||
- 'requirements.txt'
|
- 'requirements.txt'
|
||||||
- '*.iss'
|
|
||||||
pull_request:
|
pull_request:
|
||||||
paths:
|
paths:
|
||||||
- '.github/workflows/build.yml'
|
- '.github/workflows/build.yml'
|
||||||
- 'setup.py'
|
- 'setup.py'
|
||||||
- 'requirements.txt'
|
- 'requirements.txt'
|
||||||
- '*.iss'
|
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
env:
|
env:
|
||||||
@@ -27,9 +25,9 @@ jobs:
|
|||||||
build-win-py38: # RCs will still be built and signed by hand
|
build-win-py38: # RCs will still be built and signed by hand
|
||||||
runs-on: windows-latest
|
runs-on: windows-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v3
|
||||||
- name: Install python
|
- name: Install python
|
||||||
uses: actions/setup-python@v5
|
uses: actions/setup-python@v4
|
||||||
with:
|
with:
|
||||||
python-version: '3.8'
|
python-version: '3.8'
|
||||||
- name: Download run-time dependencies
|
- name: Download run-time dependencies
|
||||||
@@ -48,42 +46,25 @@ jobs:
|
|||||||
cd build
|
cd build
|
||||||
Rename-Item "exe.$NAME" Archipelago
|
Rename-Item "exe.$NAME" Archipelago
|
||||||
7z a -mx=9 -mhe=on -ms "../dist/$ZIP_NAME" Archipelago
|
7z a -mx=9 -mhe=on -ms "../dist/$ZIP_NAME" Archipelago
|
||||||
Rename-Item Archipelago "exe.$NAME" # inno_setup.iss expects the original name
|
|
||||||
- name: Store 7z
|
- name: Store 7z
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v3
|
||||||
with:
|
with:
|
||||||
name: ${{ env.ZIP_NAME }}
|
name: ${{ env.ZIP_NAME }}
|
||||||
path: dist/${{ env.ZIP_NAME }}
|
path: dist/${{ env.ZIP_NAME }}
|
||||||
retention-days: 7 # keep for 7 days, should be enough
|
retention-days: 7 # keep for 7 days, should be enough
|
||||||
- name: Build Setup
|
|
||||||
run: |
|
|
||||||
& "${env:ProgramFiles(x86)}\Inno Setup 6\iscc.exe" inno_setup.iss /DNO_SIGNTOOL
|
|
||||||
if ( $? -eq $false ) {
|
|
||||||
Write-Error "Building setup failed!"
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
$contents = Get-ChildItem -Path setups/*.exe -Force -Recurse
|
|
||||||
$SETUP_NAME=$contents[0].Name
|
|
||||||
echo "SETUP_NAME=$SETUP_NAME" >> $Env:GITHUB_ENV
|
|
||||||
- name: Store Setup
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: ${{ env.SETUP_NAME }}
|
|
||||||
path: setups/${{ env.SETUP_NAME }}
|
|
||||||
retention-days: 7 # keep for 7 days, should be enough
|
|
||||||
|
|
||||||
build-ubuntu2004:
|
build-ubuntu2004:
|
||||||
runs-on: ubuntu-20.04
|
runs-on: ubuntu-20.04
|
||||||
steps:
|
steps:
|
||||||
# - copy code below to release.yml -
|
# - copy code below to release.yml -
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v3
|
||||||
- name: Install base dependencies
|
- name: Install base dependencies
|
||||||
run: |
|
run: |
|
||||||
sudo apt update
|
sudo apt update
|
||||||
sudo apt -y install build-essential p7zip xz-utils wget libglib2.0-0
|
sudo apt -y install build-essential p7zip xz-utils wget libglib2.0-0
|
||||||
sudo apt -y install python3-gi libgirepository1.0-dev # should pull dependencies for gi installation below
|
sudo apt -y install python3-gi libgirepository1.0-dev # should pull dependencies for gi installation below
|
||||||
- name: Get a recent python
|
- name: Get a recent python
|
||||||
uses: actions/setup-python@v5
|
uses: actions/setup-python@v4
|
||||||
with:
|
with:
|
||||||
python-version: '3.11'
|
python-version: '3.11'
|
||||||
- name: Install build-time dependencies
|
- name: Install build-time dependencies
|
||||||
@@ -119,13 +100,13 @@ jobs:
|
|||||||
source venv/bin/activate
|
source venv/bin/activate
|
||||||
python setup.py build_exe --yes
|
python setup.py build_exe --yes
|
||||||
- name: Store AppImage
|
- name: Store AppImage
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v3
|
||||||
with:
|
with:
|
||||||
name: ${{ env.APPIMAGE_NAME }}
|
name: ${{ env.APPIMAGE_NAME }}
|
||||||
path: dist/${{ env.APPIMAGE_NAME }}
|
path: dist/${{ env.APPIMAGE_NAME }}
|
||||||
retention-days: 7
|
retention-days: 7
|
||||||
- name: Store .tar.gz
|
- name: Store .tar.gz
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v3
|
||||||
with:
|
with:
|
||||||
name: ${{ env.TAR_NAME }}
|
name: ${{ env.TAR_NAME }}
|
||||||
path: dist/${{ env.TAR_NAME }}
|
path: dist/${{ env.TAR_NAME }}
|
||||||
|
|||||||
2
.github/workflows/codeql-analysis.yml
vendored
2
.github/workflows/codeql-analysis.yml
vendored
@@ -43,7 +43,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
# Initializes the CodeQL tools for scanning.
|
# Initializes the CodeQL tools for scanning.
|
||||||
- name: Initialize CodeQL
|
- name: Initialize CodeQL
|
||||||
|
|||||||
46
.github/workflows/label-pull-requests.yml
vendored
46
.github/workflows/label-pull-requests.yml
vendored
@@ -1,46 +0,0 @@
|
|||||||
name: Label Pull Request
|
|
||||||
on:
|
|
||||||
pull_request_target:
|
|
||||||
types: ['opened', 'reopened', 'synchronize', 'ready_for_review', 'converted_to_draft', 'closed']
|
|
||||||
branches: ['main']
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
pull-requests: write
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
labeler:
|
|
||||||
name: 'Apply content-based labels'
|
|
||||||
if: github.event.action == 'opened' || github.event.action == 'reopened' || github.event.action == 'synchronize'
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/labeler@v5
|
|
||||||
with:
|
|
||||||
sync-labels: false
|
|
||||||
peer_review:
|
|
||||||
name: 'Apply peer review label'
|
|
||||||
needs: labeler
|
|
||||||
if: >-
|
|
||||||
(github.event.action == 'opened' || github.event.action == 'reopened' ||
|
|
||||||
github.event.action == 'ready_for_review') && !github.event.pull_request.draft
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: 'Add label'
|
|
||||||
run: "gh pr edit \"$PR_URL\" --add-label 'waiting-on: peer-review'"
|
|
||||||
env:
|
|
||||||
PR_URL: ${{ github.event.pull_request.html_url }}
|
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
unblock_draft_prs:
|
|
||||||
name: 'Remove waiting-on labels'
|
|
||||||
needs: labeler
|
|
||||||
if: github.event.action == 'converted_to_draft' || github.event.action == 'closed'
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: 'Remove labels'
|
|
||||||
run: |-
|
|
||||||
gh pr edit "$PR_URL" --remove-label 'waiting-on: peer-review' \
|
|
||||||
--remove-label 'waiting-on: core-review' \
|
|
||||||
--remove-label 'waiting-on: world-maintainer' \
|
|
||||||
--remove-label 'waiting-on: author'
|
|
||||||
env:
|
|
||||||
PR_URL: ${{ github.event.pull_request.html_url }}
|
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
8
.github/workflows/release.yml
vendored
8
.github/workflows/release.yml
vendored
@@ -18,7 +18,7 @@ jobs:
|
|||||||
- name: Set env
|
- name: Set env
|
||||||
run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV # tag x.y.z will become "Archipelago x.y.z"
|
run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV # tag x.y.z will become "Archipelago x.y.z"
|
||||||
- name: Create Release
|
- name: Create Release
|
||||||
uses: softprops/action-gh-release@975c1b265e11dd76618af1c374e7981f9a6ff44a
|
uses: softprops/action-gh-release@b7e450da2a4b4cb4bfbae528f788167786cfcedf
|
||||||
with:
|
with:
|
||||||
draft: true # don't publish right away, especially since windows build is added by hand
|
draft: true # don't publish right away, especially since windows build is added by hand
|
||||||
prerelease: false
|
prerelease: false
|
||||||
@@ -35,14 +35,14 @@ jobs:
|
|||||||
- name: Set env
|
- name: Set env
|
||||||
run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV
|
run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV
|
||||||
# - code below copied from build.yml -
|
# - code below copied from build.yml -
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v3
|
||||||
- name: Install base dependencies
|
- name: Install base dependencies
|
||||||
run: |
|
run: |
|
||||||
sudo apt update
|
sudo apt update
|
||||||
sudo apt -y install build-essential p7zip xz-utils wget libglib2.0-0
|
sudo apt -y install build-essential p7zip xz-utils wget libglib2.0-0
|
||||||
sudo apt -y install python3-gi libgirepository1.0-dev # should pull dependencies for gi installation below
|
sudo apt -y install python3-gi libgirepository1.0-dev # should pull dependencies for gi installation below
|
||||||
- name: Get a recent python
|
- name: Get a recent python
|
||||||
uses: actions/setup-python@v5
|
uses: actions/setup-python@v4
|
||||||
with:
|
with:
|
||||||
python-version: '3.11'
|
python-version: '3.11'
|
||||||
- name: Install build-time dependencies
|
- name: Install build-time dependencies
|
||||||
@@ -74,7 +74,7 @@ jobs:
|
|||||||
echo "TAR_NAME=$TAR_NAME" >> $GITHUB_ENV
|
echo "TAR_NAME=$TAR_NAME" >> $GITHUB_ENV
|
||||||
# - code above copied from build.yml -
|
# - code above copied from build.yml -
|
||||||
- name: Add to Release
|
- name: Add to Release
|
||||||
uses: softprops/action-gh-release@975c1b265e11dd76618af1c374e7981f9a6ff44a
|
uses: softprops/action-gh-release@b7e450da2a4b4cb4bfbae528f788167786cfcedf
|
||||||
with:
|
with:
|
||||||
draft: true # see above
|
draft: true # see above
|
||||||
prerelease: false
|
prerelease: false
|
||||||
|
|||||||
65
.github/workflows/scan-build.yml
vendored
65
.github/workflows/scan-build.yml
vendored
@@ -1,65 +0,0 @@
|
|||||||
name: Native Code Static Analysis
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
paths:
|
|
||||||
- '**.c'
|
|
||||||
- '**.cc'
|
|
||||||
- '**.cpp'
|
|
||||||
- '**.cxx'
|
|
||||||
- '**.h'
|
|
||||||
- '**.hh'
|
|
||||||
- '**.hpp'
|
|
||||||
- '**.pyx'
|
|
||||||
- 'setup.py'
|
|
||||||
- 'requirements.txt'
|
|
||||||
- '.github/workflows/scan-build.yml'
|
|
||||||
pull_request:
|
|
||||||
paths:
|
|
||||||
- '**.c'
|
|
||||||
- '**.cc'
|
|
||||||
- '**.cpp'
|
|
||||||
- '**.cxx'
|
|
||||||
- '**.h'
|
|
||||||
- '**.hh'
|
|
||||||
- '**.hpp'
|
|
||||||
- '**.pyx'
|
|
||||||
- 'setup.py'
|
|
||||||
- 'requirements.txt'
|
|
||||||
- '.github/workflows/scan-build.yml'
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
scan-build:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
submodules: recursive
|
|
||||||
- name: Install newer Clang
|
|
||||||
run: |
|
|
||||||
wget https://apt.llvm.org/llvm.sh
|
|
||||||
chmod +x ./llvm.sh
|
|
||||||
sudo ./llvm.sh 17
|
|
||||||
- name: Install scan-build command
|
|
||||||
run: |
|
|
||||||
sudo apt install clang-tools-17
|
|
||||||
- name: Get a recent python
|
|
||||||
uses: actions/setup-python@v5
|
|
||||||
with:
|
|
||||||
python-version: '3.11'
|
|
||||||
- name: Install dependencies
|
|
||||||
run: |
|
|
||||||
python -m venv venv
|
|
||||||
source venv/bin/activate
|
|
||||||
python -m pip install --upgrade pip -r requirements.txt
|
|
||||||
- name: scan-build
|
|
||||||
run: |
|
|
||||||
source venv/bin/activate
|
|
||||||
scan-build-17 --status-bugs -o scan-build-reports -disable-checker deadcode.DeadStores python setup.py build -y
|
|
||||||
- name: Store report
|
|
||||||
if: failure()
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: scan-build-reports
|
|
||||||
path: scan-build-reports
|
|
||||||
33
.github/workflows/strict-type-check.yml
vendored
33
.github/workflows/strict-type-check.yml
vendored
@@ -1,33 +0,0 @@
|
|||||||
name: type check
|
|
||||||
|
|
||||||
on:
|
|
||||||
pull_request:
|
|
||||||
paths:
|
|
||||||
- "**.py"
|
|
||||||
- ".github/pyright-config.json"
|
|
||||||
- ".github/workflows/strict-type-check.yml"
|
|
||||||
- "**.pyi"
|
|
||||||
push:
|
|
||||||
paths:
|
|
||||||
- "**.py"
|
|
||||||
- ".github/pyright-config.json"
|
|
||||||
- ".github/workflows/strict-type-check.yml"
|
|
||||||
- "**.pyi"
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
pyright:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- uses: actions/setup-python@v5
|
|
||||||
with:
|
|
||||||
python-version: "3.11"
|
|
||||||
|
|
||||||
- name: "Install dependencies"
|
|
||||||
run: |
|
|
||||||
python -m pip install --upgrade pip pyright==1.1.358
|
|
||||||
python ModuleUpdate.py --append "WebHostLib/requirements.txt" --force --yes
|
|
||||||
|
|
||||||
- name: "pyright: strict check on specific files"
|
|
||||||
run: python .github/type_check.py
|
|
||||||
8
.github/workflows/unittests.yml
vendored
8
.github/workflows/unittests.yml
vendored
@@ -46,17 +46,17 @@ jobs:
|
|||||||
os: macos-latest
|
os: macos-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v3
|
||||||
- name: Set up Python ${{ matrix.python.version }}
|
- name: Set up Python ${{ matrix.python.version }}
|
||||||
uses: actions/setup-python@v5
|
uses: actions/setup-python@v4
|
||||||
with:
|
with:
|
||||||
python-version: ${{ matrix.python.version }}
|
python-version: ${{ matrix.python.version }}
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: |
|
run: |
|
||||||
python -m pip install --upgrade pip
|
python -m pip install --upgrade pip
|
||||||
pip install pytest pytest-subtests pytest-xdist
|
pip install pytest pytest-subtests
|
||||||
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
|
||||||
run: |
|
run: |
|
||||||
pytest -n auto
|
pytest
|
||||||
|
|||||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -9,14 +9,12 @@
|
|||||||
*.apmc
|
*.apmc
|
||||||
*.apz5
|
*.apz5
|
||||||
*.aptloz
|
*.aptloz
|
||||||
*.apemerald
|
|
||||||
*.pyc
|
*.pyc
|
||||||
*.pyd
|
*.pyd
|
||||||
*.sfc
|
*.sfc
|
||||||
*.z64
|
*.z64
|
||||||
*.n64
|
*.n64
|
||||||
*.nes
|
*.nes
|
||||||
*.smc
|
|
||||||
*.sms
|
*.sms
|
||||||
*.gb
|
*.gb
|
||||||
*.gbc
|
*.gbc
|
||||||
@@ -29,20 +27,16 @@
|
|||||||
*.archipelago
|
*.archipelago
|
||||||
*.apsave
|
*.apsave
|
||||||
*.BIN
|
*.BIN
|
||||||
*.puml
|
|
||||||
|
|
||||||
setups
|
setups
|
||||||
build
|
build
|
||||||
bundle/components.wxs
|
bundle/components.wxs
|
||||||
dist
|
dist
|
||||||
/prof/
|
|
||||||
README.html
|
README.html
|
||||||
.vs/
|
.vs/
|
||||||
EnemizerCLI/
|
EnemizerCLI/
|
||||||
/Players/
|
/Players/
|
||||||
/SNI/
|
/SNI/
|
||||||
/sni-*/
|
|
||||||
/appimagetool*
|
|
||||||
/host.yaml
|
/host.yaml
|
||||||
/options.yaml
|
/options.yaml
|
||||||
/config.yaml
|
/config.yaml
|
||||||
@@ -145,7 +139,6 @@ ipython_config.py
|
|||||||
.venv*
|
.venv*
|
||||||
env/
|
env/
|
||||||
venv/
|
venv/
|
||||||
/venv*/
|
|
||||||
ENV/
|
ENV/
|
||||||
env.bak/
|
env.bak/
|
||||||
venv.bak/
|
venv.bak/
|
||||||
|
|||||||
@@ -1,18 +0,0 @@
|
|||||||
<component name="ProjectRunConfigurationManager">
|
|
||||||
<configuration default="false" name="Archipelago Unittests" type="tests" factoryName="Unittests">
|
|
||||||
<module name="Archipelago" />
|
|
||||||
<option name="INTERPRETER_OPTIONS" value="" />
|
|
||||||
<option name="PARENT_ENVS" value="true" />
|
|
||||||
<option name="SDK_HOME" value="" />
|
|
||||||
<option name="WORKING_DIRECTORY" value="$PROJECT_DIR$" />
|
|
||||||
<option name="IS_MODULE_SDK" value="true" />
|
|
||||||
<option name="ADD_CONTENT_ROOTS" value="true" />
|
|
||||||
<option name="ADD_SOURCE_ROOTS" value="true" />
|
|
||||||
<EXTENSION ID="PythonCoverageRunConfigurationExtension" runner="coverage.py" />
|
|
||||||
<option name="_new_pattern" value="""" />
|
|
||||||
<option name="_new_additionalArguments" value="""" />
|
|
||||||
<option name="_new_target" value=""$PROJECT_DIR$/test"" />
|
|
||||||
<option name="_new_targetType" value=""PATH"" />
|
|
||||||
<method v="2" />
|
|
||||||
</configuration>
|
|
||||||
</component>
|
|
||||||
@@ -115,12 +115,11 @@ class AdventureContext(CommonContext):
|
|||||||
msg = f"Received {', '.join([self.item_names[item.item] for item in args['items']])}"
|
msg = f"Received {', '.join([self.item_names[item.item] for item in args['items']])}"
|
||||||
self._set_message(msg, SYSTEM_MESSAGE_ID)
|
self._set_message(msg, SYSTEM_MESSAGE_ID)
|
||||||
elif cmd == "Retrieved":
|
elif cmd == "Retrieved":
|
||||||
if f"adventure_{self.auth}_freeincarnates_used" in args["keys"]:
|
self.freeincarnates_used = args["keys"][f"adventure_{self.auth}_freeincarnates_used"]
|
||||||
self.freeincarnates_used = args["keys"][f"adventure_{self.auth}_freeincarnates_used"]
|
if self.freeincarnates_used is None:
|
||||||
if self.freeincarnates_used is None:
|
self.freeincarnates_used = 0
|
||||||
self.freeincarnates_used = 0
|
self.freeincarnates_used += self.freeincarnate_pending
|
||||||
self.freeincarnates_used += self.freeincarnate_pending
|
self.send_pending_freeincarnates()
|
||||||
self.send_pending_freeincarnates()
|
|
||||||
elif cmd == "SetReply":
|
elif cmd == "SetReply":
|
||||||
if args["key"] == f"adventure_{self.auth}_freeincarnates_used":
|
if args["key"] == f"adventure_{self.auth}_freeincarnates_used":
|
||||||
self.freeincarnates_used = args["value"]
|
self.freeincarnates_used = args["value"]
|
||||||
|
|||||||
579
BaseClasses.py
579
BaseClasses.py
@@ -1,31 +1,26 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import copy
|
import copy
|
||||||
import itertools
|
|
||||||
import functools
|
import functools
|
||||||
import logging
|
import logging
|
||||||
import random
|
import random
|
||||||
import secrets
|
import secrets
|
||||||
import typing # this can go away when Python 3.8 support is dropped
|
import typing # this can go away when Python 3.8 support is dropped
|
||||||
from argparse import Namespace
|
from argparse import Namespace
|
||||||
from collections import Counter, deque
|
from collections import ChainMap, Counter, deque
|
||||||
from collections.abc import Collection, MutableSequence
|
|
||||||
from enum import IntEnum, IntFlag
|
from enum import IntEnum, IntFlag
|
||||||
from typing import Any, Callable, Dict, Iterable, Iterator, List, Mapping, NamedTuple, Optional, Set, Tuple, \
|
from typing import Any, Callable, Dict, Iterable, Iterator, List, NamedTuple, Optional, Set, Tuple, TypedDict, Union, \
|
||||||
TypedDict, Union, Type, ClassVar
|
Type, ClassVar
|
||||||
|
|
||||||
import NetUtils
|
import NetUtils
|
||||||
import Options
|
import Options
|
||||||
import Utils
|
import Utils
|
||||||
|
|
||||||
if typing.TYPE_CHECKING:
|
|
||||||
from worlds import AutoWorld
|
|
||||||
|
|
||||||
|
|
||||||
class Group(TypedDict, total=False):
|
class Group(TypedDict, total=False):
|
||||||
name: str
|
name: str
|
||||||
game: str
|
game: str
|
||||||
world: "AutoWorld.World"
|
world: auto_world
|
||||||
players: Set[int]
|
players: Set[int]
|
||||||
item_pool: Set[str]
|
item_pool: Set[str]
|
||||||
replacement_items: Dict[int, Optional[str]]
|
replacement_items: Dict[int, Optional[str]]
|
||||||
@@ -51,12 +46,17 @@ class ThreadBarrierProxy:
|
|||||||
class MultiWorld():
|
class MultiWorld():
|
||||||
debug_types = False
|
debug_types = False
|
||||||
player_name: Dict[int, str]
|
player_name: Dict[int, str]
|
||||||
|
_region_cache: Dict[int, Dict[str, Region]]
|
||||||
|
difficulty_requirements: dict
|
||||||
|
required_medallions: dict
|
||||||
|
dark_room_logic: Dict[int, str]
|
||||||
|
restrict_dungeon_item_on_boss: Dict[int, bool]
|
||||||
plando_texts: List[Dict[str, str]]
|
plando_texts: List[Dict[str, str]]
|
||||||
plando_items: List[List[Dict[str, Any]]]
|
plando_items: List[List[Dict[str, Any]]]
|
||||||
plando_connections: List
|
plando_connections: List
|
||||||
worlds: Dict[int, "AutoWorld.World"]
|
worlds: Dict[int, auto_world]
|
||||||
groups: Dict[int, Group]
|
groups: Dict[int, Group]
|
||||||
regions: RegionManager
|
regions: List[Region]
|
||||||
itempool: List[Item]
|
itempool: List[Item]
|
||||||
is_race: bool = False
|
is_race: bool = False
|
||||||
precollected_items: Dict[int, List[Item]]
|
precollected_items: Dict[int, List[Item]]
|
||||||
@@ -81,7 +81,7 @@ class MultiWorld():
|
|||||||
game: Dict[int, str]
|
game: Dict[int, str]
|
||||||
|
|
||||||
random: random.Random
|
random: random.Random
|
||||||
per_slot_randoms: Utils.DeprecateDict[int, random.Random]
|
per_slot_randoms: Dict[int, random.Random]
|
||||||
"""Deprecated. Please use `self.random` instead."""
|
"""Deprecated. Please use `self.random` instead."""
|
||||||
|
|
||||||
class AttributeProxy():
|
class AttributeProxy():
|
||||||
@@ -91,56 +91,24 @@ class MultiWorld():
|
|||||||
def __getitem__(self, player) -> bool:
|
def __getitem__(self, player) -> bool:
|
||||||
return self.rule(player)
|
return self.rule(player)
|
||||||
|
|
||||||
class RegionManager:
|
|
||||||
region_cache: Dict[int, Dict[str, Region]]
|
|
||||||
entrance_cache: Dict[int, Dict[str, Entrance]]
|
|
||||||
location_cache: Dict[int, Dict[str, Location]]
|
|
||||||
|
|
||||||
def __init__(self, players: int):
|
|
||||||
self.region_cache = {player: {} for player in range(1, players+1)}
|
|
||||||
self.entrance_cache = {player: {} for player in range(1, players+1)}
|
|
||||||
self.location_cache = {player: {} for player in range(1, players+1)}
|
|
||||||
|
|
||||||
def __iadd__(self, other: Iterable[Region]):
|
|
||||||
self.extend(other)
|
|
||||||
return self
|
|
||||||
|
|
||||||
def append(self, region: Region):
|
|
||||||
assert region.name not in self.region_cache[region.player], \
|
|
||||||
f"{region.name} already exists in region cache."
|
|
||||||
self.region_cache[region.player][region.name] = region
|
|
||||||
|
|
||||||
def extend(self, regions: Iterable[Region]):
|
|
||||||
for region in regions:
|
|
||||||
assert region.name not in self.region_cache[region.player], \
|
|
||||||
f"{region.name} already exists in region cache."
|
|
||||||
self.region_cache[region.player][region.name] = region
|
|
||||||
|
|
||||||
def add_group(self, new_id: int):
|
|
||||||
self.region_cache[new_id] = {}
|
|
||||||
self.entrance_cache[new_id] = {}
|
|
||||||
self.location_cache[new_id] = {}
|
|
||||||
|
|
||||||
def __iter__(self) -> Iterator[Region]:
|
|
||||||
for regions in self.region_cache.values():
|
|
||||||
yield from regions.values()
|
|
||||||
|
|
||||||
def __len__(self):
|
|
||||||
return sum(len(regions) for regions in self.region_cache.values())
|
|
||||||
|
|
||||||
def __init__(self, players: int):
|
def __init__(self, players: int):
|
||||||
# world-local random state is saved for multiple generations running concurrently
|
# world-local random state is saved for multiple generations running concurrently
|
||||||
self.random = ThreadBarrierProxy(random.Random())
|
self.random = ThreadBarrierProxy(random.Random())
|
||||||
self.players = players
|
self.players = players
|
||||||
self.player_types = {player: NetUtils.SlotType.player for player in self.player_ids}
|
self.player_types = {player: NetUtils.SlotType.player for player in self.player_ids}
|
||||||
|
self.glitch_triforce = False
|
||||||
self.algorithm = 'balanced'
|
self.algorithm = 'balanced'
|
||||||
self.groups = {}
|
self.groups = {}
|
||||||
self.regions = self.RegionManager(players)
|
self.regions = []
|
||||||
self.shops = []
|
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._cached_entrances = None
|
||||||
|
self._cached_locations = None
|
||||||
|
self._entrance_cache = {}
|
||||||
|
self._location_cache: Dict[Tuple[str, int], Location] = {}
|
||||||
self.required_locations = []
|
self.required_locations = []
|
||||||
self.light_world_light_cone = False
|
self.light_world_light_cone = False
|
||||||
self.dark_world_light_cone = False
|
self.dark_world_light_cone = False
|
||||||
@@ -155,18 +123,66 @@ 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.fix_trock_doors = self.AttributeProxy(
|
||||||
|
lambda player: self.shuffle[player] != 'vanilla' or self.mode[player] == 'inverted')
|
||||||
|
self.fix_skullwoods_exit = self.AttributeProxy(
|
||||||
|
lambda player: self.shuffle[player] not in ['vanilla', 'simple', 'restricted', 'dungeonssimple'])
|
||||||
|
self.fix_palaceofdarkness_exit = self.AttributeProxy(
|
||||||
|
lambda player: self.shuffle[player] not in ['vanilla', 'simple', 'restricted', 'dungeonssimple'])
|
||||||
|
self.fix_trock_exit = self.AttributeProxy(
|
||||||
|
lambda player: self.shuffle[player] not in ['vanilla', 'simple', 'restricted', 'dungeonssimple'])
|
||||||
|
|
||||||
for player in range(1, players + 1):
|
for player in range(1, players + 1):
|
||||||
def set_player_attr(attr, val):
|
def set_player_attr(attr, val):
|
||||||
self.__dict__.setdefault(attr, {})[player] = val
|
self.__dict__.setdefault(attr, {})[player] = val
|
||||||
|
|
||||||
|
set_player_attr('_region_cache', {})
|
||||||
|
set_player_attr('shuffle', "vanilla")
|
||||||
|
set_player_attr('logic', "noglitches")
|
||||||
|
set_player_attr('mode', 'open')
|
||||||
|
set_player_attr('difficulty', 'normal')
|
||||||
|
set_player_attr('item_functionality', 'normal')
|
||||||
|
set_player_attr('timer', False)
|
||||||
|
set_player_attr('goal', 'ganon')
|
||||||
|
set_player_attr('required_medallions', ['Ether', 'Quake'])
|
||||||
|
set_player_attr('swamp_patch_required', False)
|
||||||
|
set_player_attr('powder_patch_required', False)
|
||||||
|
set_player_attr('ganon_at_pyramid', True)
|
||||||
|
set_player_attr('ganonstower_vanilla', True)
|
||||||
|
set_player_attr('can_access_trock_eyebridge', None)
|
||||||
|
set_player_attr('can_access_trock_front', None)
|
||||||
|
set_player_attr('can_access_trock_big_chest', None)
|
||||||
|
set_player_attr('can_access_trock_middle', None)
|
||||||
|
set_player_attr('fix_fake_world', True)
|
||||||
|
set_player_attr('difficulty_requirements', None)
|
||||||
|
set_player_attr('boss_shuffle', 'none')
|
||||||
|
set_player_attr('enemy_health', 'default')
|
||||||
|
set_player_attr('enemy_damage', 'default')
|
||||||
|
set_player_attr('beemizer_total_chance', 0)
|
||||||
|
set_player_attr('beemizer_trap_chance', 0)
|
||||||
|
set_player_attr('escape_assist', [])
|
||||||
|
set_player_attr('treasure_hunt_icon', 'Triforce Piece')
|
||||||
|
set_player_attr('treasure_hunt_count', 0)
|
||||||
|
set_player_attr('clock_mode', False)
|
||||||
|
set_player_attr('countdown_start_time', 10)
|
||||||
|
set_player_attr('red_clock_time', -2)
|
||||||
|
set_player_attr('blue_clock_time', 2)
|
||||||
|
set_player_attr('green_clock_time', 4)
|
||||||
|
set_player_attr('can_take_damage', True)
|
||||||
|
set_player_attr('triforce_pieces_available', 30)
|
||||||
|
set_player_attr('triforce_pieces_required', 20)
|
||||||
|
set_player_attr('shop_shuffle', 'off')
|
||||||
|
set_player_attr('shuffle_prizes', "g")
|
||||||
|
set_player_attr('sprite_pool', [])
|
||||||
|
set_player_attr('dark_room_logic', "lamp")
|
||||||
set_player_attr('plando_items', [])
|
set_player_attr('plando_items', [])
|
||||||
set_player_attr('plando_texts', {})
|
set_player_attr('plando_texts', {})
|
||||||
set_player_attr('plando_connections', [])
|
set_player_attr('plando_connections', [])
|
||||||
set_player_attr('game', "Archipelago")
|
set_player_attr('game', "A Link to the Past")
|
||||||
set_player_attr('completion_condition', lambda state: True)
|
set_player_attr('completion_condition', lambda state: True)
|
||||||
|
self.custom_data = {}
|
||||||
self.worlds = {}
|
self.worlds = {}
|
||||||
self.per_slot_randoms = Utils.DeprecateDict("Using per_slot_randoms is now deprecated. Please use the "
|
self.per_slot_randoms = {}
|
||||||
"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, ...]:
|
||||||
@@ -175,19 +191,25 @@ class MultiWorld():
|
|||||||
def add_group(self, name: str, game: str, players: Set[int] = frozenset()) -> Tuple[int, Group]:
|
def add_group(self, name: str, game: str, players: Set[int] = frozenset()) -> Tuple[int, Group]:
|
||||||
"""Create a group with name and return the assigned player ID and group.
|
"""Create a group with name and return the assigned player ID and group.
|
||||||
If a group of this name already exists, the set of players is extended instead of creating a new one."""
|
If a group of this name already exists, the set of players is extended instead of creating a new one."""
|
||||||
from worlds import AutoWorld
|
|
||||||
|
|
||||||
for group_id, group in self.groups.items():
|
for group_id, group in self.groups.items():
|
||||||
if group["name"] == name:
|
if group["name"] == name:
|
||||||
group["players"] |= players
|
group["players"] |= players
|
||||||
return group_id, group
|
return group_id, group
|
||||||
new_id: int = self.players + len(self.groups) + 1
|
new_id: int = self.players + len(self.groups) + 1
|
||||||
|
|
||||||
self.regions.add_group(new_id)
|
|
||||||
self.game[new_id] = game
|
self.game[new_id] = game
|
||||||
|
self.custom_data[new_id] = {}
|
||||||
self.player_types[new_id] = NetUtils.SlotType.group
|
self.player_types[new_id] = NetUtils.SlotType.group
|
||||||
|
self._region_cache[new_id] = {}
|
||||||
world_type = AutoWorld.AutoWorldRegister.world_types[game]
|
world_type = AutoWorld.AutoWorldRegister.world_types[game]
|
||||||
self.worlds[new_id] = world_type.create_group(self, new_id, players)
|
for option_key, option in world_type.option_definitions.items():
|
||||||
|
getattr(self, option_key)[new_id] = option(option.default)
|
||||||
|
for option_key, option in Options.common_options.items():
|
||||||
|
getattr(self, option_key)[new_id] = option(option.default)
|
||||||
|
for option_key, option in Options.per_game_common_options.items():
|
||||||
|
getattr(self, option_key)[new_id] = option(option.default)
|
||||||
|
|
||||||
|
self.worlds[new_id] = world_type(self, new_id)
|
||||||
self.worlds[new_id].collect_item = classmethod(AutoWorld.World.collect_item).__get__(self.worlds[new_id])
|
self.worlds[new_id].collect_item = classmethod(AutoWorld.World.collect_item).__get__(self.worlds[new_id])
|
||||||
self.player_name[new_id] = name
|
self.player_name[new_id] = name
|
||||||
|
|
||||||
@@ -200,40 +222,35 @@ class MultiWorld():
|
|||||||
return {group_id for group_id, group in self.groups.items() if player in group["players"]}
|
return {group_id for group_id, group in self.groups.items() if player in group["players"]}
|
||||||
|
|
||||||
def set_seed(self, seed: Optional[int] = None, secure: bool = False, name: Optional[str] = None):
|
def set_seed(self, seed: Optional[int] = None, secure: bool = False, name: Optional[str] = None):
|
||||||
assert not self.worlds, "seed needs to be initialized before Worlds"
|
|
||||||
self.seed = get_seed(seed)
|
self.seed = get_seed(seed)
|
||||||
if secure:
|
if secure:
|
||||||
self.secure()
|
self.secure()
|
||||||
else:
|
else:
|
||||||
self.random.seed(self.seed)
|
self.random.seed(self.seed)
|
||||||
self.seed_name = name if name else str(self.seed)
|
self.seed_name = name if name else str(self.seed)
|
||||||
|
self.per_slot_randoms = {player: random.Random(self.random.getrandbits(64)) for player in
|
||||||
|
range(1, self.players + 1)}
|
||||||
|
|
||||||
def set_options(self, args: Namespace) -> None:
|
def set_options(self, args: Namespace) -> None:
|
||||||
# TODO - remove this section once all worlds use options dataclasses
|
for option_key in Options.common_options:
|
||||||
from worlds import AutoWorld
|
setattr(self, option_key, getattr(args, option_key, {}))
|
||||||
|
for option_key in Options.per_game_common_options:
|
||||||
all_keys: Set[str] = {key for player in self.player_ids for key in
|
setattr(self, option_key, getattr(args, option_key, {}))
|
||||||
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:
|
||||||
|
self.custom_data[player] = {}
|
||||||
world_type = AutoWorld.AutoWorldRegister.world_types[self.game[player]]
|
world_type = AutoWorld.AutoWorldRegister.world_types[self.game[player]]
|
||||||
|
for option_key in world_type.option_definitions:
|
||||||
|
setattr(self, option_key, getattr(args, option_key, {}))
|
||||||
|
|
||||||
self.worlds[player] = world_type(self, player)
|
self.worlds[player] = world_type(self, player)
|
||||||
options_dataclass: typing.Type[Options.PerGameCommonOptions] = world_type.options_dataclass
|
self.worlds[player].random = self.per_slot_randoms[player]
|
||||||
self.worlds[player].options = options_dataclass(**{option_key: getattr(args, option_key)[player]
|
|
||||||
for option_key in options_dataclass.type_hints})
|
|
||||||
|
|
||||||
def set_item_links(self):
|
def set_item_links(self):
|
||||||
from worlds import AutoWorld
|
|
||||||
|
|
||||||
item_links = {}
|
item_links = {}
|
||||||
replacement_prio = [False, True, None]
|
replacement_prio = [False, True, None]
|
||||||
for player in self.player_ids:
|
for player in self.player_ids:
|
||||||
for item_link in self.worlds[player].options.item_links.value:
|
for item_link in self.item_links[player].value:
|
||||||
if item_link["name"] in item_links:
|
if item_link["name"] in item_links:
|
||||||
if item_links[item_link["name"]]["game"] != self.game[player]:
|
if item_links[item_link["name"]]["game"] != self.game[player]:
|
||||||
raise Exception(f"Cannot ItemLink across games. Link: {item_link['name']}")
|
raise Exception(f"Cannot ItemLink across games. Link: {item_link['name']}")
|
||||||
@@ -288,6 +305,14 @@ class MultiWorld():
|
|||||||
group["non_local_items"] = item_link["non_local_items"]
|
group["non_local_items"] = item_link["non_local_items"]
|
||||||
group["link_replacement"] = replacement_prio[item_link["link_replacement"]]
|
group["link_replacement"] = replacement_prio[item_link["link_replacement"]]
|
||||||
|
|
||||||
|
# intended for unittests
|
||||||
|
def set_default_common_options(self):
|
||||||
|
for option_key, option in Options.common_options.items():
|
||||||
|
setattr(self, option_key, {player_id: option(option.default) for player_id in self.player_ids})
|
||||||
|
for option_key, option in Options.per_game_common_options.items():
|
||||||
|
setattr(self, option_key, {player_id: option(option.default) for player_id in self.player_ids})
|
||||||
|
self.state = CollectionState(self)
|
||||||
|
|
||||||
def secure(self):
|
def secure(self):
|
||||||
self.random = ThreadBarrierProxy(secrets.SystemRandom())
|
self.random = ThreadBarrierProxy(secrets.SystemRandom())
|
||||||
self.is_race = True
|
self.is_race = True
|
||||||
@@ -296,15 +321,11 @@ class MultiWorld():
|
|||||||
def player_ids(self) -> Tuple[int, ...]:
|
def player_ids(self) -> Tuple[int, ...]:
|
||||||
return tuple(range(1, self.players + 1))
|
return tuple(range(1, self.players + 1))
|
||||||
|
|
||||||
@Utils.cache_self1
|
@functools.lru_cache()
|
||||||
def get_game_players(self, game_name: str) -> Tuple[int, ...]:
|
def get_game_players(self, game_name: str) -> Tuple[int, ...]:
|
||||||
return tuple(player for player in self.player_ids if self.game[player] == game_name)
|
return tuple(player for player in self.player_ids if self.game[player] == game_name)
|
||||||
|
|
||||||
@Utils.cache_self1
|
@functools.lru_cache()
|
||||||
def get_game_groups(self, game_name: str) -> Tuple[int, ...]:
|
|
||||||
return tuple(group_id for group_id in self.groups if self.game[group_id] == game_name)
|
|
||||||
|
|
||||||
@Utils.cache_self1
|
|
||||||
def get_game_worlds(self, game_name: str):
|
def get_game_worlds(self, game_name: str):
|
||||||
return tuple(world for player, world in self.worlds.items() if
|
return tuple(world for player, world in self.worlds.items() if
|
||||||
player not in self.groups and self.game[player] == game_name)
|
player not in self.groups and self.game[player] == game_name)
|
||||||
@@ -322,21 +343,50 @@ class MultiWorld():
|
|||||||
""" the base name (without file extension) for each player's output file for a seed """
|
""" the base name (without file extension) for each player's output file for a seed """
|
||||||
return f"AP_{self.seed_name}_P{player}_{self.get_file_safe_player_name(player).replace(' ', '_')}"
|
return f"AP_{self.seed_name}_P{player}_{self.get_file_safe_player_name(player).replace(' ', '_')}"
|
||||||
|
|
||||||
|
def initialize_regions(self, regions=None):
|
||||||
|
for region in regions if regions else self.regions:
|
||||||
|
region.multiworld = self
|
||||||
|
self._region_cache[region.player][region.name] = region
|
||||||
|
|
||||||
@functools.cached_property
|
@functools.cached_property
|
||||||
def world_name_lookup(self):
|
def world_name_lookup(self):
|
||||||
return {self.player_name[player_id]: player_id for player_id in self.player_ids}
|
return {self.player_name[player_id]: player_id for player_id in self.player_ids}
|
||||||
|
|
||||||
def get_regions(self, player: Optional[int] = None) -> Collection[Region]:
|
def _recache(self):
|
||||||
return self.regions if player is None else self.regions.region_cache[player].values()
|
"""Rebuild world cache"""
|
||||||
|
self._cached_locations = None
|
||||||
|
for region in self.regions:
|
||||||
|
player = region.player
|
||||||
|
self._region_cache[player][region.name] = region
|
||||||
|
for exit in region.exits:
|
||||||
|
self._entrance_cache[exit.name, player] = exit
|
||||||
|
|
||||||
def get_region(self, region_name: str, player: int) -> Region:
|
for r_location in region.locations:
|
||||||
return self.regions.region_cache[player][region_name]
|
self._location_cache[r_location.name, player] = r_location
|
||||||
|
|
||||||
def get_entrance(self, entrance_name: str, player: int) -> Entrance:
|
def get_regions(self, player=None):
|
||||||
return self.regions.entrance_cache[player][entrance_name]
|
return self.regions if player is None else self._region_cache[player].values()
|
||||||
|
|
||||||
def get_location(self, location_name: str, player: int) -> Location:
|
def get_region(self, regionname: str, player: int) -> Region:
|
||||||
return self.regions.location_cache[player][location_name]
|
try:
|
||||||
|
return self._region_cache[player][regionname]
|
||||||
|
except KeyError:
|
||||||
|
self._recache()
|
||||||
|
return self._region_cache[player][regionname]
|
||||||
|
|
||||||
|
def get_entrance(self, entrance: str, player: int) -> Entrance:
|
||||||
|
try:
|
||||||
|
return self._entrance_cache[entrance, player]
|
||||||
|
except KeyError:
|
||||||
|
self._recache()
|
||||||
|
return self._entrance_cache[entrance, player]
|
||||||
|
|
||||||
|
def get_location(self, location: str, player: int) -> Location:
|
||||||
|
try:
|
||||||
|
return self._location_cache[location, player]
|
||||||
|
except KeyError:
|
||||||
|
self._recache()
|
||||||
|
return self._location_cache[location, player]
|
||||||
|
|
||||||
def get_all_state(self, use_cache: bool) -> CollectionState:
|
def get_all_state(self, use_cache: bool) -> CollectionState:
|
||||||
cached = getattr(self, "_all_state", None)
|
cached = getattr(self, "_all_state", None)
|
||||||
@@ -393,26 +443,32 @@ class MultiWorld():
|
|||||||
location.item = item
|
location.item = item
|
||||||
item.location = location
|
item.location = location
|
||||||
if collect:
|
if collect:
|
||||||
self.state.collect(item, location.advancement, location)
|
self.state.collect(item, location.event, location)
|
||||||
|
|
||||||
logging.debug('Placed %s at %s', item, location)
|
logging.debug('Placed %s at %s', item, location)
|
||||||
|
|
||||||
def get_entrances(self, player: Optional[int] = None) -> Iterable[Entrance]:
|
def get_entrances(self) -> List[Entrance]:
|
||||||
if player is not None:
|
if self._cached_entrances is None:
|
||||||
return self.regions.entrance_cache[player].values()
|
self._cached_entrances = [entrance for region in self.regions for entrance in region.entrances]
|
||||||
return Utils.RepeatableChain(tuple(self.regions.entrance_cache[player].values()
|
return self._cached_entrances
|
||||||
for player in self.regions.entrance_cache))
|
|
||||||
|
def clear_entrance_cache(self):
|
||||||
|
self._cached_entrances = None
|
||||||
|
|
||||||
def register_indirect_condition(self, region: Region, entrance: Entrance):
|
def register_indirect_condition(self, region: Region, entrance: Entrance):
|
||||||
"""Report that access to this Region can result in unlocking this Entrance,
|
"""Report that access to this Region can result in unlocking this Entrance,
|
||||||
state.can_reach(Region) in the Entrance's traversal condition, as opposed to pure transition logic."""
|
state.can_reach(Region) in the Entrance's traversal condition, as opposed to pure transition logic."""
|
||||||
self.indirect_connections.setdefault(region, set()).add(entrance)
|
self.indirect_connections.setdefault(region, set()).add(entrance)
|
||||||
|
|
||||||
def get_locations(self, player: Optional[int] = None) -> Iterable[Location]:
|
def get_locations(self, player: Optional[int] = None) -> List[Location]:
|
||||||
|
if self._cached_locations is None:
|
||||||
|
self._cached_locations = [location for region in self.regions for location in region.locations]
|
||||||
if player is not None:
|
if player is not None:
|
||||||
return self.regions.location_cache[player].values()
|
return [location for location in self._cached_locations if location.player == player]
|
||||||
return Utils.RepeatableChain(tuple(self.regions.location_cache[player].values()
|
return self._cached_locations
|
||||||
for player in self.regions.location_cache))
|
|
||||||
|
def clear_location_cache(self):
|
||||||
|
self._cached_locations = None
|
||||||
|
|
||||||
def get_unfilled_locations(self, player: Optional[int] = None) -> List[Location]:
|
def get_unfilled_locations(self, player: Optional[int] = None) -> List[Location]:
|
||||||
return [location for location in self.get_locations(player) if location.item is None]
|
return [location for location in self.get_locations(player) if location.item is None]
|
||||||
@@ -434,17 +490,16 @@ class MultiWorld():
|
|||||||
valid_locations = [location.name for location in self.get_unfilled_locations(player)]
|
valid_locations = [location.name for location in self.get_unfilled_locations(player)]
|
||||||
else:
|
else:
|
||||||
valid_locations = location_names
|
valid_locations = location_names
|
||||||
relevant_cache = self.regions.location_cache[player]
|
|
||||||
for location_name in valid_locations:
|
for location_name in valid_locations:
|
||||||
location = relevant_cache.get(location_name, None)
|
location = self._location_cache.get((location_name, player), None)
|
||||||
if location and location.item is None:
|
if location is not None and location.item is None:
|
||||||
yield location
|
yield location
|
||||||
|
|
||||||
def unlocks_new_location(self, item: Item) -> bool:
|
def unlocks_new_location(self, item: Item) -> bool:
|
||||||
temp_state = self.state.copy()
|
temp_state = self.state.copy()
|
||||||
temp_state.collect(item, True)
|
temp_state.collect(item, True)
|
||||||
|
|
||||||
for location in self.get_unfilled_locations(item.player):
|
for location in self.get_unfilled_locations():
|
||||||
if temp_state.can_reach(location) and not self.state.can_reach(location):
|
if temp_state.can_reach(location) and not self.state.can_reach(location):
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -456,7 +511,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, starting_state: Optional[CollectionState] = None) -> bool:
|
def can_beat_game(self, starting_state: Optional[CollectionState] = None):
|
||||||
if starting_state:
|
if starting_state:
|
||||||
if self.has_beaten_game(starting_state):
|
if self.has_beaten_game(starting_state):
|
||||||
return True
|
return True
|
||||||
@@ -469,7 +524,7 @@ class MultiWorld():
|
|||||||
and location.item.advancement and location not in state.locations_checked}
|
and location.item.advancement and location not in state.locations_checked}
|
||||||
|
|
||||||
while prog_locations:
|
while prog_locations:
|
||||||
sphere: Set[Location] = set()
|
sphere = set()
|
||||||
# build up spheres of collection radius.
|
# build up spheres of collection radius.
|
||||||
# Everything in each sphere is independent from each other in dependencies and only depends on lower spheres
|
# Everything in each sphere is independent from each other in dependencies and only depends on lower spheres
|
||||||
for location in prog_locations:
|
for location in prog_locations:
|
||||||
@@ -489,19 +544,12 @@ class MultiWorld():
|
|||||||
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def get_spheres(self) -> Iterator[Set[Location]]:
|
def get_spheres(self):
|
||||||
"""
|
|
||||||
yields a set of locations for each logical sphere
|
|
||||||
|
|
||||||
If there are unreachable locations, the last sphere of reachable
|
|
||||||
locations is followed by an empty set, and then a set of all of the
|
|
||||||
unreachable locations.
|
|
||||||
"""
|
|
||||||
state = CollectionState(self)
|
state = CollectionState(self)
|
||||||
locations = set(self.get_filled_locations())
|
locations = set(self.get_filled_locations())
|
||||||
|
|
||||||
while locations:
|
while locations:
|
||||||
sphere: Set[Location] = set()
|
sphere = set()
|
||||||
|
|
||||||
for location in locations:
|
for location in locations:
|
||||||
if location.can_reach(state):
|
if location.can_reach(state):
|
||||||
@@ -532,15 +580,15 @@ class MultiWorld():
|
|||||||
|
|
||||||
def location_condition(location: Location):
|
def location_condition(location: Location):
|
||||||
"""Determine if this location has to be accessible, location is already filtered by location_relevant"""
|
"""Determine if this location has to be accessible, location is already filtered by location_relevant"""
|
||||||
if location.player in players["locations"] or (location.item and location.item.player not in
|
if location.player in players["minimal"]:
|
||||||
players["minimal"]):
|
return False
|
||||||
return True
|
return True
|
||||||
return False
|
|
||||||
|
|
||||||
def location_relevant(location: Location):
|
def location_relevant(location: Location):
|
||||||
"""Determine if this location is relevant to sweep."""
|
"""Determine if this location is relevant to sweep."""
|
||||||
if location.progress_type != LocationProgressType.EXCLUDED \
|
if location.progress_type != LocationProgressType.EXCLUDED \
|
||||||
and (location.player in players["locations"] or location.advancement):
|
and (location.player in players["locations"] or location.event
|
||||||
|
or (location.item and location.item.advancement)):
|
||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@@ -583,7 +631,7 @@ PathValue = Tuple[str, Optional["PathValue"]]
|
|||||||
|
|
||||||
|
|
||||||
class CollectionState():
|
class CollectionState():
|
||||||
prog_items: Dict[int, Counter[str]]
|
prog_items: typing.Counter[Tuple[str, int]]
|
||||||
multiworld: MultiWorld
|
multiworld: MultiWorld
|
||||||
reachable_regions: Dict[int, Set[Region]]
|
reachable_regions: Dict[int, Set[Region]]
|
||||||
blocked_connections: Dict[int, Set[Entrance]]
|
blocked_connections: Dict[int, Set[Entrance]]
|
||||||
@@ -595,7 +643,7 @@ class CollectionState():
|
|||||||
additional_copy_functions: List[Callable[[CollectionState, CollectionState], CollectionState]] = []
|
additional_copy_functions: List[Callable[[CollectionState, CollectionState], CollectionState]] = []
|
||||||
|
|
||||||
def __init__(self, parent: MultiWorld):
|
def __init__(self, parent: MultiWorld):
|
||||||
self.prog_items = {player: Counter() for player in parent.get_all_ids()}
|
self.prog_items = Counter()
|
||||||
self.multiworld = parent
|
self.multiworld = parent
|
||||||
self.reachable_regions = {player: set() for player in parent.get_all_ids()}
|
self.reachable_regions = {player: set() for player in parent.get_all_ids()}
|
||||||
self.blocked_connections = {player: set() for player in parent.get_all_ids()}
|
self.blocked_connections = {player: set() for player in parent.get_all_ids()}
|
||||||
@@ -611,39 +659,39 @@ class CollectionState():
|
|||||||
|
|
||||||
def update_reachable_regions(self, player: int):
|
def update_reachable_regions(self, player: int):
|
||||||
self.stale[player] = False
|
self.stale[player] = False
|
||||||
reachable_regions = self.reachable_regions[player]
|
rrp = self.reachable_regions[player]
|
||||||
blocked_connections = self.blocked_connections[player]
|
bc = self.blocked_connections[player]
|
||||||
queue = deque(self.blocked_connections[player])
|
queue = deque(self.blocked_connections[player])
|
||||||
start = self.multiworld.get_region("Menu", player)
|
start = self.multiworld.get_region('Menu', player)
|
||||||
|
|
||||||
# init on first call - this can't be done on construction since the regions don't exist yet
|
# init on first call - this can't be done on construction since the regions don't exist yet
|
||||||
if start not in reachable_regions:
|
if start not in rrp:
|
||||||
reachable_regions.add(start)
|
rrp.add(start)
|
||||||
blocked_connections.update(start.exits)
|
bc.update(start.exits)
|
||||||
queue.extend(start.exits)
|
queue.extend(start.exits)
|
||||||
|
|
||||||
# run BFS on all connections, and keep track of those blocked by missing items
|
# run BFS on all connections, and keep track of those blocked by missing items
|
||||||
while queue:
|
while queue:
|
||||||
connection = queue.popleft()
|
connection = queue.popleft()
|
||||||
new_region = connection.connected_region
|
new_region = connection.connected_region
|
||||||
if new_region in reachable_regions:
|
if new_region in rrp:
|
||||||
blocked_connections.remove(connection)
|
bc.remove(connection)
|
||||||
elif connection.can_reach(self):
|
elif connection.can_reach(self):
|
||||||
assert new_region, f"tried to search through an Entrance \"{connection}\" with no Region"
|
assert new_region, f"tried to search through an Entrance \"{connection}\" with no Region"
|
||||||
reachable_regions.add(new_region)
|
rrp.add(new_region)
|
||||||
blocked_connections.remove(connection)
|
bc.remove(connection)
|
||||||
blocked_connections.update(new_region.exits)
|
bc.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))
|
||||||
|
|
||||||
# Retry connections if the new region can unblock them
|
# Retry connections if the new region can unblock them
|
||||||
for new_entrance in self.multiworld.indirect_connections.get(new_region, set()):
|
for new_entrance in self.multiworld.indirect_connections.get(new_region, set()):
|
||||||
if new_entrance in blocked_connections and new_entrance not in queue:
|
if new_entrance in bc and new_entrance not in queue:
|
||||||
queue.append(new_entrance)
|
queue.append(new_entrance)
|
||||||
|
|
||||||
def copy(self) -> CollectionState:
|
def copy(self) -> CollectionState:
|
||||||
ret = CollectionState(self.multiworld)
|
ret = CollectionState(self.multiworld)
|
||||||
ret.prog_items = copy.deepcopy(self.prog_items)
|
ret.prog_items = self.prog_items.copy()
|
||||||
ret.reachable_regions = {player: copy.copy(self.reachable_regions[player]) for player in
|
ret.reachable_regions = {player: copy.copy(self.reachable_regions[player]) for player in
|
||||||
self.reachable_regions}
|
self.reachable_regions}
|
||||||
ret.blocked_connections = {player: copy.copy(self.blocked_connections[player]) for player in
|
ret.blocked_connections = {player: copy.copy(self.blocked_connections[player]) for player in
|
||||||
@@ -663,29 +711,20 @@ class CollectionState():
|
|||||||
assert isinstance(player, int), "can_reach: player is required if spot is str"
|
assert isinstance(player, int), "can_reach: player is required if spot is str"
|
||||||
# try to resolve a name
|
# try to resolve a name
|
||||||
if resolution_hint == 'Location':
|
if resolution_hint == 'Location':
|
||||||
return self.can_reach_location(spot, player)
|
spot = self.multiworld.get_location(spot, player)
|
||||||
elif resolution_hint == 'Entrance':
|
elif resolution_hint == 'Entrance':
|
||||||
return self.can_reach_entrance(spot, player)
|
spot = self.multiworld.get_entrance(spot, player)
|
||||||
else:
|
else:
|
||||||
# default to Region
|
# default to Region
|
||||||
return self.can_reach_region(spot, player)
|
spot = self.multiworld.get_region(spot, player)
|
||||||
return spot.can_reach(self)
|
return spot.can_reach(self)
|
||||||
|
|
||||||
def can_reach_location(self, spot: str, player: int) -> bool:
|
|
||||||
return self.multiworld.get_location(spot, player).can_reach(self)
|
|
||||||
|
|
||||||
def can_reach_entrance(self, spot: str, player: int) -> bool:
|
|
||||||
return self.multiworld.get_entrance(spot, player).can_reach(self)
|
|
||||||
|
|
||||||
def can_reach_region(self, spot: str, player: int) -> bool:
|
|
||||||
return self.multiworld.get_region(spot, player).can_reach(self)
|
|
||||||
|
|
||||||
def sweep_for_events(self, key_only: bool = False, locations: Optional[Iterable[Location]] = None) -> None:
|
def sweep_for_events(self, key_only: bool = False, locations: Optional[Iterable[Location]] = None) -> None:
|
||||||
if locations is None:
|
if locations is None:
|
||||||
locations = self.multiworld.get_filled_locations()
|
locations = self.multiworld.get_filled_locations()
|
||||||
reachable_events = True
|
reachable_events = True
|
||||||
# since the loop has a good chance to run more than once, only filter the events once
|
# since the loop has a good chance to run more than once, only filter the events once
|
||||||
locations = {location for location in locations if location.advancement and location not in self.events and
|
locations = {location for location in locations if location.event and location not in self.events and
|
||||||
not key_only or getattr(location.item, "locked_dungeon_item", False)}
|
not key_only or getattr(location.item, "locked_dungeon_item", False)}
|
||||||
while reachable_events:
|
while reachable_events:
|
||||||
reachable_events = {location for location in locations if location.can_reach(self)}
|
reachable_events = {location for location in locations if location.can_reach(self)}
|
||||||
@@ -695,99 +734,37 @@ class CollectionState():
|
|||||||
assert isinstance(event.item, Item), "tried to collect Event with no Item"
|
assert isinstance(event.item, Item), "tried to collect Event with no Item"
|
||||||
self.collect(event.item, True, event)
|
self.collect(event.item, True, event)
|
||||||
|
|
||||||
# item name related
|
|
||||||
def has(self, item: str, player: int, count: int = 1) -> bool:
|
def has(self, item: str, player: int, count: int = 1) -> bool:
|
||||||
return self.prog_items[player][item] >= count
|
return self.prog_items[item, player] >= count
|
||||||
|
|
||||||
def has_all(self, items: Iterable[str], player: int) -> bool:
|
def has_all(self, items: Set[str], player: int) -> bool:
|
||||||
"""Returns True if each item name of items is in state at least once."""
|
"""Returns True if each item name of items is in state at least once."""
|
||||||
return all(self.prog_items[player][item] for item in items)
|
return all(self.prog_items[item, player] for item in items)
|
||||||
|
|
||||||
def has_any(self, items: Iterable[str], player: int) -> bool:
|
def has_any(self, items: Set[str], player: int) -> bool:
|
||||||
"""Returns True if at least one item name of items is in state at least once."""
|
"""Returns True if at least one item name of items is in state at least once."""
|
||||||
return any(self.prog_items[player][item] for item in items)
|
return any(self.prog_items[item, player] for item in items)
|
||||||
|
|
||||||
def has_all_counts(self, item_counts: Mapping[str, int], player: int) -> bool:
|
|
||||||
"""Returns True if each item name is in the state at least as many times as specified."""
|
|
||||||
return all(self.prog_items[player][item] >= count for item, count in item_counts.items())
|
|
||||||
|
|
||||||
def has_any_count(self, item_counts: Mapping[str, int], player: int) -> bool:
|
|
||||||
"""Returns True if at least one item name is in the state at least as many times as specified."""
|
|
||||||
return any(self.prog_items[player][item] >= count for item, count in item_counts.items())
|
|
||||||
|
|
||||||
def count(self, item: str, player: int) -> int:
|
def count(self, item: str, player: int) -> int:
|
||||||
return self.prog_items[player][item]
|
return self.prog_items[item, player]
|
||||||
|
|
||||||
def has_from_list(self, items: Iterable[str], player: int, count: int) -> bool:
|
|
||||||
"""Returns True if the state contains at least `count` items matching any of the item names from a list."""
|
|
||||||
found: int = 0
|
|
||||||
player_prog_items = self.prog_items[player]
|
|
||||||
for item_name in items:
|
|
||||||
found += player_prog_items[item_name]
|
|
||||||
if found >= count:
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
def has_from_list_exclusive(self, items: Iterable[str], player: int, count: int) -> bool:
|
|
||||||
"""Returns True if the state contains at least `count` items matching any of the item names from a list.
|
|
||||||
Ignores duplicates of the same item."""
|
|
||||||
found: int = 0
|
|
||||||
player_prog_items = self.prog_items[player]
|
|
||||||
for item_name in items:
|
|
||||||
found += player_prog_items[item_name] > 0
|
|
||||||
if found >= count:
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
def count_from_list(self, items: Iterable[str], player: int) -> int:
|
|
||||||
"""Returns the cumulative count of items from a list present in state."""
|
|
||||||
return sum(self.prog_items[player][item_name] for item_name in items)
|
|
||||||
|
|
||||||
def count_from_list_exclusive(self, items: Iterable[str], player: int) -> int:
|
|
||||||
"""Returns the cumulative count of items from a list present in state. Ignores duplicates of the same item."""
|
|
||||||
return sum(self.prog_items[player][item_name] > 0 for item_name in items)
|
|
||||||
|
|
||||||
# item name group related
|
|
||||||
def has_group(self, item_name_group: str, player: int, count: int = 1) -> bool:
|
def has_group(self, item_name_group: str, player: int, count: int = 1) -> bool:
|
||||||
"""Returns True if the state contains at least `count` items present in a specified item group."""
|
|
||||||
found: int = 0
|
found: int = 0
|
||||||
player_prog_items = self.prog_items[player]
|
|
||||||
for item_name in self.multiworld.worlds[player].item_name_groups[item_name_group]:
|
for item_name in self.multiworld.worlds[player].item_name_groups[item_name_group]:
|
||||||
found += player_prog_items[item_name]
|
found += self.prog_items[item_name, player]
|
||||||
if found >= count:
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
def has_group_exclusive(self, item_name_group: str, player: int, count: int = 1) -> bool:
|
|
||||||
"""Returns True if the state contains at least `count` items present in a specified item group.
|
|
||||||
Ignores duplicates of the same item.
|
|
||||||
"""
|
|
||||||
found: int = 0
|
|
||||||
player_prog_items = self.prog_items[player]
|
|
||||||
for item_name in self.multiworld.worlds[player].item_name_groups[item_name_group]:
|
|
||||||
found += player_prog_items[item_name] > 0
|
|
||||||
if found >= count:
|
if found >= count:
|
||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def count_group(self, item_name_group: str, player: int) -> int:
|
def count_group(self, item_name_group: str, player: int) -> int:
|
||||||
"""Returns the cumulative count of items from an item group present in state."""
|
found: int = 0
|
||||||
player_prog_items = self.prog_items[player]
|
for item_name in self.multiworld.worlds[player].item_name_groups[item_name_group]:
|
||||||
return sum(
|
found += self.prog_items[item_name, player]
|
||||||
player_prog_items[item_name]
|
return found
|
||||||
for item_name in self.multiworld.worlds[player].item_name_groups[item_name_group]
|
|
||||||
)
|
|
||||||
|
|
||||||
def count_group_exclusive(self, item_name_group: str, player: int) -> int:
|
def item_count(self, item: str, player: int) -> int:
|
||||||
"""Returns the cumulative count of items from an item group present in state.
|
return self.prog_items[item, player]
|
||||||
Ignores duplicates of the same item."""
|
|
||||||
player_prog_items = self.prog_items[player]
|
|
||||||
return sum(
|
|
||||||
player_prog_items[item_name] > 0
|
|
||||||
for item_name in self.multiworld.worlds[player].item_name_groups[item_name_group]
|
|
||||||
)
|
|
||||||
|
|
||||||
# Item related
|
|
||||||
def collect(self, item: Item, event: bool = False, location: Optional[Location] = None) -> bool:
|
def collect(self, item: Item, event: bool = False, location: Optional[Location] = None) -> bool:
|
||||||
if location:
|
if location:
|
||||||
self.locations_checked.add(location)
|
self.locations_checked.add(location)
|
||||||
@@ -795,7 +772,7 @@ class CollectionState():
|
|||||||
changed = self.multiworld.worlds[item.player].collect(self, item)
|
changed = self.multiworld.worlds[item.player].collect(self, item)
|
||||||
|
|
||||||
if not changed and event:
|
if not changed and event:
|
||||||
self.prog_items[item.player][item.name] += 1
|
self.prog_items[item.name, item.player] += 1
|
||||||
changed = True
|
changed = True
|
||||||
|
|
||||||
self.stale[item.player] = True
|
self.stale[item.player] = True
|
||||||
@@ -848,8 +825,8 @@ class Entrance:
|
|||||||
return self.__str__()
|
return self.__str__()
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
multiworld = self.parent_region.multiworld if self.parent_region else None
|
world = self.parent_region.multiworld if self.parent_region else None
|
||||||
return multiworld.get_name_string_for_object(self) if multiworld else f'{self.name} (Player {self.player})'
|
return world.get_name_string_for_object(self) if world else f'{self.name} (Player {self.player})'
|
||||||
|
|
||||||
|
|
||||||
class Region:
|
class Region:
|
||||||
@@ -862,92 +839,28 @@ class Region:
|
|||||||
locations: List[Location]
|
locations: List[Location]
|
||||||
entrance_type: ClassVar[Type[Entrance]] = Entrance
|
entrance_type: ClassVar[Type[Entrance]] = Entrance
|
||||||
|
|
||||||
class Register(MutableSequence):
|
|
||||||
region_manager: MultiWorld.RegionManager
|
|
||||||
|
|
||||||
def __init__(self, region_manager: MultiWorld.RegionManager):
|
|
||||||
self._list = []
|
|
||||||
self.region_manager = region_manager
|
|
||||||
|
|
||||||
def __getitem__(self, index: int) -> Location:
|
|
||||||
return self._list.__getitem__(index)
|
|
||||||
|
|
||||||
def __setitem__(self, index: int, value: Location) -> None:
|
|
||||||
raise NotImplementedError()
|
|
||||||
|
|
||||||
def __len__(self) -> int:
|
|
||||||
return self._list.__len__()
|
|
||||||
|
|
||||||
# This seems to not be needed, but that's a bit suspicious.
|
|
||||||
# def __del__(self):
|
|
||||||
# self.clear()
|
|
||||||
|
|
||||||
def copy(self):
|
|
||||||
return self._list.copy()
|
|
||||||
|
|
||||||
class LocationRegister(Register):
|
|
||||||
def __delitem__(self, index: int) -> None:
|
|
||||||
location: Location = self._list.__getitem__(index)
|
|
||||||
self._list.__delitem__(index)
|
|
||||||
del(self.region_manager.location_cache[location.player][location.name])
|
|
||||||
|
|
||||||
def insert(self, index: int, value: Location) -> None:
|
|
||||||
assert value.name not in self.region_manager.location_cache[value.player], \
|
|
||||||
f"{value.name} already exists in the location cache."
|
|
||||||
self._list.insert(index, value)
|
|
||||||
self.region_manager.location_cache[value.player][value.name] = value
|
|
||||||
|
|
||||||
class EntranceRegister(Register):
|
|
||||||
def __delitem__(self, index: int) -> None:
|
|
||||||
entrance: Entrance = self._list.__getitem__(index)
|
|
||||||
self._list.__delitem__(index)
|
|
||||||
del(self.region_manager.entrance_cache[entrance.player][entrance.name])
|
|
||||||
|
|
||||||
def insert(self, index: int, value: Entrance) -> None:
|
|
||||||
assert value.name not in self.region_manager.entrance_cache[value.player], \
|
|
||||||
f"{value.name} already exists in the entrance cache."
|
|
||||||
self._list.insert(index, value)
|
|
||||||
self.region_manager.entrance_cache[value.player][value.name] = value
|
|
||||||
|
|
||||||
_locations: LocationRegister[Location]
|
|
||||||
_exits: EntranceRegister[Entrance]
|
|
||||||
|
|
||||||
def __init__(self, name: str, player: int, multiworld: MultiWorld, hint: Optional[str] = None):
|
def __init__(self, name: str, player: int, multiworld: MultiWorld, hint: Optional[str] = None):
|
||||||
self.name = name
|
self.name = name
|
||||||
self.entrances = []
|
self.entrances = []
|
||||||
self._exits = self.EntranceRegister(multiworld.regions)
|
self.exits = []
|
||||||
self._locations = self.LocationRegister(multiworld.regions)
|
self.locations = []
|
||||||
self.multiworld = multiworld
|
self.multiworld = multiworld
|
||||||
self._hint_text = hint
|
self._hint_text = hint
|
||||||
self.player = player
|
self.player = player
|
||||||
|
|
||||||
def get_locations(self):
|
|
||||||
return self._locations
|
|
||||||
|
|
||||||
def set_locations(self, new):
|
|
||||||
if new is self._locations:
|
|
||||||
return
|
|
||||||
self._locations.clear()
|
|
||||||
self._locations.extend(new)
|
|
||||||
|
|
||||||
locations = property(get_locations, set_locations)
|
|
||||||
|
|
||||||
def get_exits(self):
|
|
||||||
return self._exits
|
|
||||||
|
|
||||||
def set_exits(self, new):
|
|
||||||
if new is self._exits:
|
|
||||||
return
|
|
||||||
self._exits.clear()
|
|
||||||
self._exits.extend(new)
|
|
||||||
|
|
||||||
exits = property(get_exits, set_exits)
|
|
||||||
|
|
||||||
def can_reach(self, state: CollectionState) -> bool:
|
def can_reach(self, state: CollectionState) -> bool:
|
||||||
if state.stale[self.player]:
|
if state.stale[self.player]:
|
||||||
state.update_reachable_regions(self.player)
|
state.update_reachable_regions(self.player)
|
||||||
return self in state.reachable_regions[self.player]
|
return self in state.reachable_regions[self.player]
|
||||||
|
|
||||||
|
def can_reach_private(self, state: CollectionState) -> bool:
|
||||||
|
for entrance in self.entrances:
|
||||||
|
if entrance.can_reach(state):
|
||||||
|
if not self in state.path:
|
||||||
|
state.path[self] = (self.name, state.path.get(entrance, None))
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def hint_text(self) -> str:
|
def hint_text(self) -> str:
|
||||||
return self._hint_text if self._hint_text else self.name
|
return self._hint_text if self._hint_text else self.name
|
||||||
@@ -964,19 +877,19 @@ class Region:
|
|||||||
"""
|
"""
|
||||||
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.
|
||||||
|
|
||||||
:param locations: dictionary of locations to be created and added to this Region `{name: ID}`
|
:param locations: dictionary of locations to be created and added to this Region `{name: ID}`
|
||||||
:param location_type: Location class to be used to create the locations with"""
|
:param location_type: Location class to be used to create the locations with"""
|
||||||
if location_type is None:
|
if location_type is None:
|
||||||
location_type = Location
|
location_type = Location
|
||||||
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 connect(self, connecting_region: Region, name: Optional[str] = None,
|
def connect(self, connecting_region: Region, name: Optional[str] = None,
|
||||||
rule: Optional[Callable[[CollectionState], bool]] = None) -> entrance_type:
|
rule: Optional[Callable[[CollectionState], bool]] = None) -> None:
|
||||||
"""
|
"""
|
||||||
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.
|
||||||
|
|
||||||
:param connecting_region: Region object to connect to path is `self -> exiting_region`
|
:param connecting_region: Region object to connect to path is `self -> exiting_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"""
|
||||||
@@ -984,12 +897,11 @@ class Region:
|
|||||||
if rule:
|
if rule:
|
||||||
exit_.access_rule = rule
|
exit_.access_rule = rule
|
||||||
exit_.connect(connecting_region)
|
exit_.connect(connecting_region)
|
||||||
return exit_
|
|
||||||
|
|
||||||
def create_exit(self, name: str) -> Entrance:
|
def create_exit(self, name: str) -> Entrance:
|
||||||
"""
|
"""
|
||||||
Creates and returns an Entrance object as an exit of this region.
|
Creates and returns an Entrance object as an exit of this region.
|
||||||
|
|
||||||
:param name: name of the Entrance being created
|
:param name: name of the Entrance being created
|
||||||
"""
|
"""
|
||||||
exit_ = self.entrance_type(self.player, name, self)
|
exit_ = self.entrance_type(self.player, name, self)
|
||||||
@@ -1031,10 +943,11 @@ class Location:
|
|||||||
name: str
|
name: str
|
||||||
address: Optional[int]
|
address: Optional[int]
|
||||||
parent_region: Optional[Region]
|
parent_region: Optional[Region]
|
||||||
|
event: bool = False
|
||||||
locked: bool = False
|
locked: bool = False
|
||||||
show_in_spoiler: bool = True
|
show_in_spoiler: bool = True
|
||||||
progress_type: LocationProgressType = LocationProgressType.DEFAULT
|
progress_type: LocationProgressType = LocationProgressType.DEFAULT
|
||||||
always_allow = staticmethod(lambda state, item: False)
|
always_allow = staticmethod(lambda item, state: False)
|
||||||
access_rule: Callable[[CollectionState], bool] = staticmethod(lambda state: True)
|
access_rule: Callable[[CollectionState], bool] = staticmethod(lambda state: True)
|
||||||
item_rule = staticmethod(lambda item: True)
|
item_rule = staticmethod(lambda item: True)
|
||||||
item: Optional[Item] = None
|
item: Optional[Item] = None
|
||||||
@@ -1046,7 +959,7 @@ class Location:
|
|||||||
self.parent_region = parent
|
self.parent_region = parent
|
||||||
|
|
||||||
def can_fill(self, state: CollectionState, item: Item, check_access=True) -> bool:
|
def can_fill(self, state: CollectionState, item: Item, check_access=True) -> bool:
|
||||||
return ((self.always_allow(state, item) and item.name not in state.multiworld.worlds[item.player].options.non_local_items)
|
return ((self.always_allow(state, item) and item.name not in state.multiworld.non_local_items[item.player])
|
||||||
or ((self.progress_type != LocationProgressType.EXCLUDED or not (item.advancement or item.useful))
|
or ((self.progress_type != LocationProgressType.EXCLUDED or not (item.advancement or item.useful))
|
||||||
and self.item_rule(item)
|
and self.item_rule(item)
|
||||||
and (not check_access or self.can_reach(state))))
|
and (not check_access or self.can_reach(state))))
|
||||||
@@ -1061,14 +974,15 @@ class Location:
|
|||||||
raise Exception(f"Location {self} already filled.")
|
raise Exception(f"Location {self} already filled.")
|
||||||
self.item = item
|
self.item = item
|
||||||
item.location = self
|
item.location = self
|
||||||
|
self.event = item.advancement
|
||||||
self.locked = True
|
self.locked = True
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return self.__str__()
|
return self.__str__()
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
multiworld = self.parent_region.multiworld if self.parent_region and self.parent_region.multiworld else None
|
world = 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 world.get_name_string_for_object(self) if world else f'{self.name} (Player {self.player})'
|
||||||
|
|
||||||
def __hash__(self):
|
def __hash__(self):
|
||||||
return hash((self.name, self.player))
|
return hash((self.name, self.player))
|
||||||
@@ -1076,15 +990,6 @@ class Location:
|
|||||||
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)
|
||||||
|
|
||||||
@property
|
|
||||||
def advancement(self) -> bool:
|
|
||||||
return self.item is not None and self.item.advancement
|
|
||||||
|
|
||||||
@property
|
|
||||||
def is_event(self) -> bool:
|
|
||||||
"""Returns True if the address of this location is None, denoting it is an Event Location."""
|
|
||||||
return self.address is None
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def native_item(self) -> bool:
|
def native_item(self) -> bool:
|
||||||
"""Returns True if the item in this location matches game."""
|
"""Returns True if the item in this location matches game."""
|
||||||
@@ -1092,6 +997,9 @@ class Location:
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def hint_text(self) -> str:
|
def hint_text(self) -> str:
|
||||||
|
hint_text = getattr(self, "_hint_text", None)
|
||||||
|
if hint_text:
|
||||||
|
return hint_text
|
||||||
return "at " + self.name.replace("_", " ").replace("-", " ")
|
return "at " + self.name.replace("_", " ").replace("-", " ")
|
||||||
|
|
||||||
|
|
||||||
@@ -1211,7 +1119,7 @@ class Spoiler:
|
|||||||
{"player": player, "entrance": entrance, "exit": exit_, "direction": direction}
|
{"player": player, "entrance": entrance, "exit": exit_, "direction": direction}
|
||||||
|
|
||||||
def create_playthrough(self, create_paths: bool = True) -> None:
|
def create_playthrough(self, create_paths: bool = True) -> None:
|
||||||
"""Destructive to the multiworld while it is run, damage gets repaired afterwards."""
|
"""Destructive to the world while it is run, damage gets repaired afterwards."""
|
||||||
from itertools import chain
|
from itertools import chain
|
||||||
# get locations containing progress items
|
# get locations containing progress items
|
||||||
multiworld = self.multiworld
|
multiworld = self.multiworld
|
||||||
@@ -1242,7 +1150,7 @@ 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 any([multiworld.worlds[location.item.player].options.accessibility != 'minimal' for location in sphere_candidates]):
|
if any([multiworld.accessibility[location.item.player] != 'minimal' for location in sphere_candidates]):
|
||||||
raise RuntimeError(f'Not all progression items reachable ({sphere_candidates}). '
|
raise RuntimeError(f'Not all progression items reachable ({sphere_candidates}). '
|
||||||
f'Something went terribly wrong here.')
|
f'Something went terribly wrong here.')
|
||||||
else:
|
else:
|
||||||
@@ -1298,12 +1206,12 @@ class Spoiler:
|
|||||||
for location in sphere:
|
for location in sphere:
|
||||||
state.collect(location.item, True, location)
|
state.collect(location.item, True, location)
|
||||||
|
|
||||||
|
required_locations -= sphere
|
||||||
|
|
||||||
collection_spheres.append(sphere)
|
collection_spheres.append(sphere)
|
||||||
|
|
||||||
logging.debug('Calculated final sphere %i, containing %i of %i progress items.', len(collection_spheres),
|
logging.debug('Calculated final sphere %i, containing %i of %i progress items.', len(collection_spheres),
|
||||||
len(sphere), len(required_locations))
|
len(sphere), len(required_locations))
|
||||||
|
|
||||||
required_locations -= sphere
|
|
||||||
if not sphere:
|
if not sphere:
|
||||||
raise RuntimeError(f'Not all required items reachable. Unreachable locations: {required_locations}')
|
raise RuntimeError(f'Not all required items reachable. Unreachable locations: {required_locations}')
|
||||||
|
|
||||||
@@ -1362,15 +1270,10 @@ class Spoiler:
|
|||||||
get_path(state, multiworld.get_region('Inverted Big Bomb Shop', player))
|
get_path(state, multiworld.get_region('Inverted Big Bomb Shop', player))
|
||||||
|
|
||||||
def to_file(self, filename: str) -> None:
|
def to_file(self, filename: str) -> None:
|
||||||
from itertools import chain
|
|
||||||
from worlds import AutoWorld
|
|
||||||
from Options import Visibility
|
|
||||||
|
|
||||||
def write_option(option_key: str, option_obj: Options.AssembleOptions) -> None:
|
def write_option(option_key: str, option_obj: Options.AssembleOptions) -> None:
|
||||||
res = getattr(self.multiworld.worlds[player].options, option_key)
|
res = getattr(self.multiworld, option_key)[player]
|
||||||
if res.visibility & Visibility.spoiler:
|
display_name = getattr(option_obj, "display_name", option_key)
|
||||||
display_name = getattr(option_obj, "display_name", option_key)
|
outfile.write(f"{display_name + ':':33}{res.current_option_name}\n")
|
||||||
outfile.write(f"{display_name + ':':33}{res.current_option_name}\n")
|
|
||||||
|
|
||||||
with open(filename, 'w', encoding="utf-8-sig") as outfile:
|
with open(filename, 'w', encoding="utf-8-sig") as outfile:
|
||||||
outfile.write(
|
outfile.write(
|
||||||
@@ -1386,7 +1289,8 @@ 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])
|
||||||
|
|
||||||
for f_option, option in self.multiworld.worlds[player].options_dataclass.type_hints.items():
|
options = ChainMap(Options.per_game_common_options, self.multiworld.worlds[player].option_definitions)
|
||||||
|
for f_option, option in options.items():
|
||||||
write_option(f_option, option)
|
write_option(f_option, option)
|
||||||
|
|
||||||
AutoWorld.call_single(self.multiworld, "write_spoiler_header", player, outfile)
|
AutoWorld.call_single(self.multiworld, "write_spoiler_header", player, outfile)
|
||||||
@@ -1401,14 +1305,6 @@ class Spoiler:
|
|||||||
|
|
||||||
AutoWorld.call_all(self.multiworld, "write_spoiler", outfile)
|
AutoWorld.call_all(self.multiworld, "write_spoiler", outfile)
|
||||||
|
|
||||||
precollected_items = [f"{item.name} ({self.multiworld.get_player_name(item.player)})"
|
|
||||||
if self.multiworld.players > 1
|
|
||||||
else item.name
|
|
||||||
for item in chain.from_iterable(self.multiworld.precollected_items.values())]
|
|
||||||
if precollected_items:
|
|
||||||
outfile.write("\n\nStarting Items:\n\n")
|
|
||||||
outfile.write("\n".join([item for item in precollected_items]))
|
|
||||||
|
|
||||||
locations = [(str(location), str(location.item) if location.item is not None else "Nothing")
|
locations = [(str(location), str(location.item) if location.item is not None else "Nothing")
|
||||||
for location in self.multiworld.get_locations() if location.show_in_spoiler]
|
for location in self.multiworld.get_locations() if location.show_in_spoiler]
|
||||||
outfile.write('\n\nLocations:\n\n')
|
outfile.write('\n\nLocations:\n\n')
|
||||||
@@ -1498,3 +1394,8 @@ def get_seed(seed: Optional[int] = None) -> int:
|
|||||||
random.seed(None)
|
random.seed(None)
|
||||||
return random.randint(0, pow(10, seeddigits) - 1)
|
return random.randint(0, pow(10, seeddigits) - 1)
|
||||||
return seed
|
return seed
|
||||||
|
|
||||||
|
|
||||||
|
from worlds import AutoWorld
|
||||||
|
|
||||||
|
auto_world = AutoWorld.World
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import ModuleUpdate
|
|
||||||
ModuleUpdate.update()
|
|
||||||
|
|
||||||
from worlds._bizhawk.context import launch
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
launch()
|
|
||||||
@@ -1,6 +1,4 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import copy
|
|
||||||
import logging
|
import logging
|
||||||
import asyncio
|
import asyncio
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
@@ -20,8 +18,8 @@ if __name__ == "__main__":
|
|||||||
Utils.init_logging("TextClient", exception_logger="Client")
|
Utils.init_logging("TextClient", exception_logger="Client")
|
||||||
|
|
||||||
from MultiServer import CommandProcessor
|
from MultiServer import CommandProcessor
|
||||||
from NetUtils import (Endpoint, decode, NetworkItem, encode, JSONtoTextParser, ClientStatus, Permission, NetworkSlot,
|
from NetUtils import Endpoint, decode, NetworkItem, encode, JSONtoTextParser, \
|
||||||
RawJSONtoTextParser, add_json_text, add_json_location, add_json_item, JSONTypes)
|
ClientStatus, Permission, NetworkSlot, RawJSONtoTextParser
|
||||||
from Utils import Version, stream_input, async_start
|
from Utils import Version, stream_input, async_start
|
||||||
from worlds import network_data_package, AutoWorldRegister
|
from worlds import network_data_package, AutoWorldRegister
|
||||||
import os
|
import os
|
||||||
@@ -72,16 +70,9 @@ class ClientCommandProcessor(CommandProcessor):
|
|||||||
|
|
||||||
def _cmd_received(self) -> bool:
|
def _cmd_received(self) -> bool:
|
||||||
"""List all received items"""
|
"""List all received items"""
|
||||||
item: NetworkItem
|
self.output(f'{len(self.ctx.items_received)} received items:')
|
||||||
self.output(f'{len(self.ctx.items_received)} received items, sorted by time:')
|
|
||||||
for index, item in enumerate(self.ctx.items_received, 1):
|
for index, item in enumerate(self.ctx.items_received, 1):
|
||||||
parts = []
|
self.output(f"{self.ctx.item_names[item.item]} from {self.ctx.player_names[item.player]}")
|
||||||
add_json_item(parts, item.item, self.ctx.slot, item.flags)
|
|
||||||
add_json_text(parts, " from ")
|
|
||||||
add_json_location(parts, item.location, item.player)
|
|
||||||
add_json_text(parts, " by ")
|
|
||||||
add_json_text(parts, item.player, type=JSONTypes.player_id)
|
|
||||||
self.ctx.on_print_json({"data": parts, "cmd": "PrintJSON"})
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def _cmd_missing(self, filter_text = "") -> bool:
|
def _cmd_missing(self, filter_text = "") -> bool:
|
||||||
@@ -122,15 +113,6 @@ class ClientCommandProcessor(CommandProcessor):
|
|||||||
for item_name in AutoWorldRegister.world_types[self.ctx.game].item_name_to_id:
|
for item_name in AutoWorldRegister.world_types[self.ctx.game].item_name_to_id:
|
||||||
self.output(item_name)
|
self.output(item_name)
|
||||||
|
|
||||||
def _cmd_item_groups(self):
|
|
||||||
"""List all item group names for the currently running game."""
|
|
||||||
if not self.ctx.game:
|
|
||||||
self.output("No game set, cannot determine existing item groups.")
|
|
||||||
return False
|
|
||||||
self.output(f"Item Group Names for {self.ctx.game}")
|
|
||||||
for group_name in AutoWorldRegister.world_types[self.ctx.game].item_name_groups:
|
|
||||||
self.output(group_name)
|
|
||||||
|
|
||||||
def _cmd_locations(self):
|
def _cmd_locations(self):
|
||||||
"""List all location names for the currently running game."""
|
"""List all location names for the currently running game."""
|
||||||
if not self.ctx.game:
|
if not self.ctx.game:
|
||||||
@@ -140,15 +122,6 @@ class ClientCommandProcessor(CommandProcessor):
|
|||||||
for location_name in AutoWorldRegister.world_types[self.ctx.game].location_name_to_id:
|
for location_name in AutoWorldRegister.world_types[self.ctx.game].location_name_to_id:
|
||||||
self.output(location_name)
|
self.output(location_name)
|
||||||
|
|
||||||
def _cmd_location_groups(self):
|
|
||||||
"""List all location group names for the currently running game."""
|
|
||||||
if not self.ctx.game:
|
|
||||||
self.output("No game set, cannot determine existing location groups.")
|
|
||||||
return False
|
|
||||||
self.output(f"Location Group Names for {self.ctx.game}")
|
|
||||||
for group_name in AutoWorldRegister.world_types[self.ctx.game].location_name_groups:
|
|
||||||
self.output(group_name)
|
|
||||||
|
|
||||||
def _cmd_ready(self):
|
def _cmd_ready(self):
|
||||||
"""Send ready status to server."""
|
"""Send ready status to server."""
|
||||||
self.ctx.ready = not self.ctx.ready
|
self.ctx.ready = not self.ctx.ready
|
||||||
@@ -193,7 +166,6 @@ class CommonContext:
|
|||||||
server_version: Version = Version(0, 0, 0)
|
server_version: Version = Version(0, 0, 0)
|
||||||
generator_version: Version = Version(0, 0, 0)
|
generator_version: Version = Version(0, 0, 0)
|
||||||
current_energy_link_value: typing.Optional[int] = None # to display in UI, gets set by server
|
current_energy_link_value: typing.Optional[int] = None # to display in UI, gets set by server
|
||||||
max_size: int = 16*1024*1024 # 16 MB of max incoming packet size
|
|
||||||
|
|
||||||
last_death_link: float = time.time() # last send/received death link on AP layer
|
last_death_link: float = time.time() # last send/received death link on AP layer
|
||||||
|
|
||||||
@@ -207,8 +179,6 @@ class CommonContext:
|
|||||||
|
|
||||||
finished_game: bool
|
finished_game: bool
|
||||||
ready: bool
|
ready: bool
|
||||||
team: typing.Optional[int]
|
|
||||||
slot: typing.Optional[int]
|
|
||||||
auth: typing.Optional[str]
|
auth: typing.Optional[str]
|
||||||
seed_name: typing.Optional[str]
|
seed_name: typing.Optional[str]
|
||||||
|
|
||||||
@@ -272,7 +242,6 @@ class CommonContext:
|
|||||||
self.watcher_event = asyncio.Event()
|
self.watcher_event = asyncio.Event()
|
||||||
|
|
||||||
self.jsontotextparser = JSONtoTextParser(self)
|
self.jsontotextparser = JSONtoTextParser(self)
|
||||||
self.rawjsontotextparser = RawJSONtoTextParser(self)
|
|
||||||
self.update_data_package(network_data_package)
|
self.update_data_package(network_data_package)
|
||||||
|
|
||||||
# execution
|
# execution
|
||||||
@@ -408,13 +377,10 @@ class CommonContext:
|
|||||||
|
|
||||||
def on_print_json(self, args: dict):
|
def on_print_json(self, args: dict):
|
||||||
if self.ui:
|
if self.ui:
|
||||||
# send copy to UI
|
self.ui.print_json(args["data"])
|
||||||
self.ui.print_json(copy.deepcopy(args["data"]))
|
else:
|
||||||
|
text = self.jsontotextparser(args["data"])
|
||||||
logging.getLogger("FileLog").info(self.rawjsontotextparser(copy.deepcopy(args["data"])),
|
logger.info(text)
|
||||||
extra={"NoStream": True})
|
|
||||||
logging.getLogger("StreamLog").info(self.jsontotextparser(copy.deepcopy(args["data"])),
|
|
||||||
extra={"NoFile": True})
|
|
||||||
|
|
||||||
def on_package(self, cmd: str, args: dict):
|
def on_package(self, cmd: str, args: dict):
|
||||||
"""For custom package handling in subclasses."""
|
"""For custom package handling in subclasses."""
|
||||||
@@ -488,7 +454,7 @@ class CommonContext:
|
|||||||
else:
|
else:
|
||||||
self.update_game(cached_game)
|
self.update_game(cached_game)
|
||||||
if needed_updates:
|
if needed_updates:
|
||||||
await self.send_msgs([{"cmd": "GetDataPackage", "games": [game_name]} for game_name in needed_updates])
|
await self.send_msgs([{"cmd": "GetDataPackage", "games": list(needed_updates)}])
|
||||||
|
|
||||||
def update_game(self, game_package: dict):
|
def update_game(self, game_package: dict):
|
||||||
for item_name, item_id in game_package["item_name_to_id"].items():
|
for item_name, item_id in game_package["item_name_to_id"].items():
|
||||||
@@ -505,7 +471,6 @@ class CommonContext:
|
|||||||
current_cache = Utils.persistent_load().get("datapackage", {}).get("games", {})
|
current_cache = Utils.persistent_load().get("datapackage", {}).get("games", {})
|
||||||
current_cache.update(data_package["games"])
|
current_cache.update(data_package["games"])
|
||||||
Utils.persistent_store("datapackage", "games", current_cache)
|
Utils.persistent_store("datapackage", "games", current_cache)
|
||||||
logger.info(f"Got new ID/Name DataPackage for {', '.join(data_package['games'])}")
|
|
||||||
for game, game_data in data_package["games"].items():
|
for game, game_data in data_package["games"].items():
|
||||||
Utils.store_data_package_for_checksum(game, game_data)
|
Utils.store_data_package_for_checksum(game, game_data)
|
||||||
|
|
||||||
@@ -646,16 +611,15 @@ async def server_loop(ctx: CommonContext, address: typing.Optional[str] = None)
|
|||||||
ctx.username = server_url.username
|
ctx.username = server_url.username
|
||||||
if server_url.password:
|
if server_url.password:
|
||||||
ctx.password = server_url.password
|
ctx.password = server_url.password
|
||||||
|
port = server_url.port or 38281
|
||||||
|
|
||||||
def reconnect_hint() -> str:
|
def reconnect_hint() -> str:
|
||||||
return ", type /connect to reconnect" if ctx.server_address else ""
|
return ", type /connect to reconnect" if ctx.server_address else ""
|
||||||
|
|
||||||
logger.info(f'Connecting to Archipelago server at {address}')
|
logger.info(f'Connecting to Archipelago server at {address}')
|
||||||
try:
|
try:
|
||||||
port = server_url.port or 38281 # raises ValueError if invalid
|
|
||||||
socket = await websockets.connect(address, port=port, ping_timeout=None, ping_interval=None,
|
socket = await websockets.connect(address, port=port, ping_timeout=None, ping_interval=None,
|
||||||
ssl=get_ssl_context() if address.startswith("wss://") else None,
|
ssl=get_ssl_context() if address.startswith("wss://") else None)
|
||||||
max_size=ctx.max_size)
|
|
||||||
if ctx.ui is not None:
|
if ctx.ui is not None:
|
||||||
ctx.ui.update_address_bar(server_url.netloc)
|
ctx.ui.update_address_bar(server_url.netloc)
|
||||||
ctx.server = Endpoint(socket)
|
ctx.server = Endpoint(socket)
|
||||||
@@ -757,19 +721,17 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
|
|||||||
await ctx.server_auth(args['password'])
|
await ctx.server_auth(args['password'])
|
||||||
|
|
||||||
elif cmd == 'DataPackage':
|
elif cmd == 'DataPackage':
|
||||||
|
logger.info("Got new ID/Name DataPackage")
|
||||||
ctx.consume_network_data_package(args['data'])
|
ctx.consume_network_data_package(args['data'])
|
||||||
|
|
||||||
elif cmd == 'ConnectionRefused':
|
elif cmd == 'ConnectionRefused':
|
||||||
errors = args["errors"]
|
errors = args["errors"]
|
||||||
if 'InvalidSlot' in errors:
|
if 'InvalidSlot' in errors:
|
||||||
ctx.disconnected_intentionally = True
|
|
||||||
ctx.event_invalid_slot()
|
ctx.event_invalid_slot()
|
||||||
elif 'InvalidGame' in errors:
|
elif 'InvalidGame' in errors:
|
||||||
ctx.disconnected_intentionally = True
|
|
||||||
ctx.event_invalid_game()
|
ctx.event_invalid_game()
|
||||||
elif 'IncompatibleVersion' in errors:
|
elif 'IncompatibleVersion' in errors:
|
||||||
raise Exception('Server reported your client version as incompatible. '
|
raise Exception('Server reported your client version as incompatible')
|
||||||
'This probably means you have to update.')
|
|
||||||
elif 'InvalidItemsHandling' in errors:
|
elif 'InvalidItemsHandling' in errors:
|
||||||
raise Exception('The item handling flags requested by the client are not supported')
|
raise Exception('The item handling flags requested by the client are not supported')
|
||||||
# last to check, recoverable problem
|
# last to check, recoverable problem
|
||||||
@@ -790,7 +752,6 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
|
|||||||
ctx.slot_info = {int(pid): data for pid, data in args["slot_info"].items()}
|
ctx.slot_info = {int(pid): data for pid, data in args["slot_info"].items()}
|
||||||
ctx.hint_points = args.get("hint_points", 0)
|
ctx.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}")
|
|
||||||
msgs = []
|
msgs = []
|
||||||
if ctx.locations_checked:
|
if ctx.locations_checked:
|
||||||
msgs.append({"cmd": "LocationChecks",
|
msgs.append({"cmd": "LocationChecks",
|
||||||
@@ -869,14 +830,10 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
|
|||||||
|
|
||||||
elif cmd == "Retrieved":
|
elif cmd == "Retrieved":
|
||||||
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"]:
|
|
||||||
ctx.ui.update_hints()
|
|
||||||
|
|
||||||
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 args["key"].startswith("EnergyLink"):
|
||||||
ctx.ui.update_hints()
|
|
||||||
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:
|
||||||
ctx.ui.set_new_energy_link_value()
|
ctx.ui.set_new_energy_link_value()
|
||||||
@@ -919,7 +876,7 @@ def get_base_parser(description: typing.Optional[str] = None):
|
|||||||
def run_as_textclient():
|
def run_as_textclient():
|
||||||
class TextContext(CommonContext):
|
class TextContext(CommonContext):
|
||||||
# Text Mode to use !hint and such with games that have no text entry
|
# Text Mode to use !hint and such with games that have no text entry
|
||||||
tags = CommonContext.tags | {"TextOnly"}
|
tags = {"AP", "TextOnly"}
|
||||||
game = "" # empty matches any game since 0.3.2
|
game = "" # empty matches any game since 0.3.2
|
||||||
items_handling = 0b111 # receive all items for /received
|
items_handling = 0b111 # receive all items for /received
|
||||||
want_slot_data = False # Can't use game specific slot_data
|
want_slot_data = False # Can't use game specific slot_data
|
||||||
@@ -972,5 +929,4 @@ def run_as_textclient():
|
|||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
logging.getLogger().setLevel(logging.INFO) # force log-level to work around log level resetting to WARNING
|
|
||||||
run_as_textclient()
|
run_as_textclient()
|
||||||
|
|||||||
371
Fill.py
371
Fill.py
@@ -5,8 +5,6 @@ import typing
|
|||||||
from collections import Counter, deque
|
from collections import Counter, deque
|
||||||
|
|
||||||
from BaseClasses import CollectionState, Item, Location, LocationProgressType, MultiWorld
|
from BaseClasses import CollectionState, Item, Location, LocationProgressType, MultiWorld
|
||||||
from Options import Accessibility
|
|
||||||
|
|
||||||
from worlds.AutoWorld import call_all
|
from worlds.AutoWorld import call_all
|
||||||
from worlds.generic.Rules import add_item_rule
|
from worlds.generic.Rules import add_item_rule
|
||||||
|
|
||||||
@@ -15,25 +13,20 @@ class FillError(RuntimeError):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
def _log_fill_progress(name: str, placed: int, total_items: int) -> None:
|
def sweep_from_pool(base_state: CollectionState, itempool: typing.Sequence[Item] = tuple()) -> CollectionState:
|
||||||
logging.info(f"Current fill step ({name}) at {placed}/{total_items} items placed.")
|
|
||||||
|
|
||||||
|
|
||||||
def sweep_from_pool(base_state: CollectionState, itempool: typing.Sequence[Item] = tuple(),
|
|
||||||
locations: typing.Optional[typing.List[Location]] = None) -> CollectionState:
|
|
||||||
new_state = base_state.copy()
|
new_state = base_state.copy()
|
||||||
for item in itempool:
|
for item in itempool:
|
||||||
new_state.collect(item, True)
|
new_state.collect(item, True)
|
||||||
new_state.sweep_for_events(locations=locations)
|
new_state.sweep_for_events()
|
||||||
return new_state
|
return new_state
|
||||||
|
|
||||||
|
|
||||||
def fill_restrictive(multiworld: MultiWorld, base_state: CollectionState, locations: typing.List[Location],
|
def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations: typing.List[Location],
|
||||||
item_pool: typing.List[Item], single_player_placement: bool = False, lock: bool = False,
|
item_pool: typing.List[Item], single_player_placement: bool = False, lock: bool = False,
|
||||||
swap: bool = True, on_place: typing.Optional[typing.Callable[[Location], None]] = None,
|
swap: bool = True, on_place: typing.Optional[typing.Callable[[Location], None]] = None,
|
||||||
allow_partial: bool = False, allow_excluded: bool = False, name: str = "Unknown") -> None:
|
allow_partial: bool = False, allow_excluded: bool = False) -> None:
|
||||||
"""
|
"""
|
||||||
:param multiworld: Multiworld to be filled.
|
:param world: Multiworld to be filled.
|
||||||
:param base_state: State assumed before fill.
|
:param base_state: State assumed before fill.
|
||||||
:param locations: Locations to be filled with item_pool
|
:param locations: Locations to be filled with item_pool
|
||||||
:param item_pool: Items to fill into the locations
|
:param item_pool: Items to fill into the locations
|
||||||
@@ -43,20 +36,16 @@ def fill_restrictive(multiworld: MultiWorld, base_state: CollectionState, locati
|
|||||||
:param on_place: callback that is called when a placement happens
|
:param on_place: callback that is called when a placement happens
|
||||||
:param allow_partial: only place what is possible. Remaining items will be in the item_pool list.
|
:param allow_partial: only place what is possible. Remaining items will be in the item_pool list.
|
||||||
:param allow_excluded: if true and placement fails, it is re-attempted while ignoring excluded on Locations
|
:param allow_excluded: if true and placement fails, it is re-attempted while ignoring excluded on Locations
|
||||||
:param name: name of this fill step for progress logging purposes
|
|
||||||
"""
|
"""
|
||||||
unplaced_items: typing.List[Item] = []
|
unplaced_items: typing.List[Item] = []
|
||||||
placements: typing.List[Location] = []
|
placements: typing.List[Location] = []
|
||||||
cleanup_required = False
|
cleanup_required = False
|
||||||
|
|
||||||
swapped_items: typing.Counter[typing.Tuple[int, str, bool]] = Counter()
|
swapped_items: typing.Counter[typing.Tuple[int, str, bool]] = Counter()
|
||||||
reachable_items: typing.Dict[int, typing.Deque[Item]] = {}
|
reachable_items: typing.Dict[int, typing.Deque[Item]] = {}
|
||||||
for item in item_pool:
|
for item in item_pool:
|
||||||
reachable_items.setdefault(item.player, deque()).append(item)
|
reachable_items.setdefault(item.player, deque()).append(item)
|
||||||
|
|
||||||
# for progress logging
|
|
||||||
total = min(len(item_pool), len(locations))
|
|
||||||
placed = 0
|
|
||||||
|
|
||||||
while any(reachable_items.values()) and locations:
|
while any(reachable_items.values()) and locations:
|
||||||
# grab one item per player
|
# grab one item per player
|
||||||
items_to_place = [items.pop()
|
items_to_place = [items.pop()
|
||||||
@@ -67,10 +56,9 @@ def fill_restrictive(multiworld: MultiWorld, base_state: CollectionState, locati
|
|||||||
item_pool.pop(p)
|
item_pool.pop(p)
|
||||||
break
|
break
|
||||||
maximum_exploration_state = sweep_from_pool(
|
maximum_exploration_state = sweep_from_pool(
|
||||||
base_state, item_pool + unplaced_items, multiworld.get_filled_locations(item.player)
|
base_state, item_pool + unplaced_items)
|
||||||
if single_player_placement else None)
|
|
||||||
|
|
||||||
has_beaten_game = multiworld.has_beaten_game(maximum_exploration_state)
|
has_beaten_game = world.has_beaten_game(maximum_exploration_state)
|
||||||
|
|
||||||
while items_to_place:
|
while items_to_place:
|
||||||
# if we have run out of locations to fill,break out of this loop
|
# if we have run out of locations to fill,break out of this loop
|
||||||
@@ -82,8 +70,8 @@ def fill_restrictive(multiworld: MultiWorld, base_state: CollectionState, locati
|
|||||||
spot_to_fill: typing.Optional[Location] = None
|
spot_to_fill: typing.Optional[Location] = None
|
||||||
|
|
||||||
# if minimal accessibility, only check whether location is reachable if game not beatable
|
# if minimal accessibility, only check whether location is reachable if game not beatable
|
||||||
if multiworld.worlds[item_to_place.player].options.accessibility == Accessibility.option_minimal:
|
if world.accessibility[item_to_place.player] == 'minimal':
|
||||||
perform_access_check = not multiworld.has_beaten_game(maximum_exploration_state,
|
perform_access_check = not world.has_beaten_game(maximum_exploration_state,
|
||||||
item_to_place.player) \
|
item_to_place.player) \
|
||||||
if single_player_placement else not has_beaten_game
|
if single_player_placement else not has_beaten_game
|
||||||
else:
|
else:
|
||||||
@@ -114,9 +102,7 @@ def fill_restrictive(multiworld: MultiWorld, base_state: CollectionState, locati
|
|||||||
|
|
||||||
location.item = None
|
location.item = None
|
||||||
placed_item.location = None
|
placed_item.location = None
|
||||||
swap_state = sweep_from_pool(base_state, [placed_item, *item_pool] if unsafe else item_pool,
|
swap_state = sweep_from_pool(base_state, [placed_item] if unsafe else [])
|
||||||
multiworld.get_filled_locations(item.player)
|
|
||||||
if single_player_placement else None)
|
|
||||||
# unsafe means swap_state assumes we can somehow collect placed_item before item_to_place
|
# unsafe means swap_state assumes we can somehow collect placed_item before item_to_place
|
||||||
# by continuing to swap, which is not guaranteed. This is unsafe because there is no mechanic
|
# by continuing to swap, which is not guaranteed. This is unsafe because there is no mechanic
|
||||||
# to clean that up later, so there is a chance generation fails.
|
# to clean that up later, so there is a chance generation fails.
|
||||||
@@ -126,11 +112,11 @@ def fill_restrictive(multiworld: MultiWorld, base_state: CollectionState, locati
|
|||||||
# Verify placing this item won't reduce available locations, which would be a useless swap.
|
# Verify placing this item won't reduce available locations, which would be a useless swap.
|
||||||
prev_state = swap_state.copy()
|
prev_state = swap_state.copy()
|
||||||
prev_loc_count = len(
|
prev_loc_count = len(
|
||||||
multiworld.get_reachable_locations(prev_state))
|
world.get_reachable_locations(prev_state))
|
||||||
|
|
||||||
swap_state.collect(item_to_place, True)
|
swap_state.collect(item_to_place, True)
|
||||||
new_loc_count = len(
|
new_loc_count = len(
|
||||||
multiworld.get_reachable_locations(swap_state))
|
world.get_reachable_locations(swap_state))
|
||||||
|
|
||||||
if new_loc_count >= prev_loc_count:
|
if new_loc_count >= prev_loc_count:
|
||||||
# Add this item to the existing placement, and
|
# Add this item to the existing placement, and
|
||||||
@@ -160,25 +146,18 @@ def fill_restrictive(multiworld: MultiWorld, base_state: CollectionState, locati
|
|||||||
else:
|
else:
|
||||||
unplaced_items.append(item_to_place)
|
unplaced_items.append(item_to_place)
|
||||||
continue
|
continue
|
||||||
multiworld.push_item(spot_to_fill, item_to_place, False)
|
world.push_item(spot_to_fill, item_to_place, False)
|
||||||
spot_to_fill.locked = lock
|
spot_to_fill.locked = lock
|
||||||
placements.append(spot_to_fill)
|
placements.append(spot_to_fill)
|
||||||
placed += 1
|
spot_to_fill.event = item_to_place.advancement
|
||||||
if not placed % 1000:
|
|
||||||
_log_fill_progress(name, placed, total)
|
|
||||||
if on_place:
|
if on_place:
|
||||||
on_place(spot_to_fill)
|
on_place(spot_to_fill)
|
||||||
|
|
||||||
if total > 1000:
|
|
||||||
_log_fill_progress(name, placed, total)
|
|
||||||
|
|
||||||
if cleanup_required:
|
if cleanup_required:
|
||||||
# validate all placements and remove invalid ones
|
# validate all placements and remove invalid ones
|
||||||
state = sweep_from_pool(
|
state = sweep_from_pool(base_state, [])
|
||||||
base_state, [], multiworld.get_filled_locations(item.player)
|
|
||||||
if single_player_placement else None)
|
|
||||||
for placement in placements:
|
for placement in placements:
|
||||||
if multiworld.worlds[placement.item.player].options.accessibility != "minimal" and not placement.can_reach(state):
|
if world.accessibility[placement.item.player] != "minimal" and not placement.can_reach(state):
|
||||||
placement.item.location = None
|
placement.item.location = None
|
||||||
unplaced_items.append(placement.item)
|
unplaced_items.append(placement.item)
|
||||||
placement.item = None
|
placement.item = None
|
||||||
@@ -193,7 +172,7 @@ def fill_restrictive(multiworld: MultiWorld, base_state: CollectionState, locati
|
|||||||
if excluded_locations:
|
if excluded_locations:
|
||||||
for location in excluded_locations:
|
for location in excluded_locations:
|
||||||
location.progress_type = location.progress_type.DEFAULT
|
location.progress_type = location.progress_type.DEFAULT
|
||||||
fill_restrictive(multiworld, base_state, excluded_locations, unplaced_items, single_player_placement, lock,
|
fill_restrictive(world, base_state, excluded_locations, unplaced_items, single_player_placement, lock,
|
||||||
swap, on_place, allow_partial, False)
|
swap, on_place, allow_partial, False)
|
||||||
for location in excluded_locations:
|
for location in excluded_locations:
|
||||||
if not location.item:
|
if not location.item:
|
||||||
@@ -201,31 +180,22 @@ def fill_restrictive(multiworld: MultiWorld, base_state: CollectionState, locati
|
|||||||
|
|
||||||
if not allow_partial and len(unplaced_items) > 0 and len(locations) > 0:
|
if not allow_partial and len(unplaced_items) > 0 and len(locations) > 0:
|
||||||
# There are leftover unplaceable items and locations that won't accept them
|
# There are leftover unplaceable items and locations that won't accept them
|
||||||
if multiworld.can_beat_game():
|
if world.can_beat_game():
|
||||||
logging.warning(
|
logging.warning(
|
||||||
f"Not all items placed. Game beatable anyway.\nCould not place:\n"
|
f'Not all items placed. Game beatable anyway. (Could not place {unplaced_items})')
|
||||||
f"{', '.join(str(item) for item in unplaced_items)}")
|
|
||||||
else:
|
else:
|
||||||
raise FillError(f"No more spots to place {len(unplaced_items)} items. Remaining locations are invalid.\n"
|
raise FillError(f'No more spots to place {unplaced_items}, locations {locations} are invalid. '
|
||||||
f"Unplaced items:\n"
|
f'Already placed {len(placements)}: {", ".join(str(place) for place in placements)}')
|
||||||
f"{', '.join(str(item) for item in unplaced_items)}\n"
|
|
||||||
f"Unfilled locations:\n"
|
|
||||||
f"{', '.join(str(location) for location in locations)}\n"
|
|
||||||
f"Already placed {len(placements)}:\n"
|
|
||||||
f"{', '.join(str(place) for place in placements)}")
|
|
||||||
|
|
||||||
item_pool.extend(unplaced_items)
|
item_pool.extend(unplaced_items)
|
||||||
|
|
||||||
|
|
||||||
def remaining_fill(multiworld: MultiWorld,
|
def remaining_fill(world: MultiWorld,
|
||||||
locations: typing.List[Location],
|
locations: typing.List[Location],
|
||||||
itempool: typing.List[Item],
|
itempool: typing.List[Item]) -> None:
|
||||||
name: str = "Remaining") -> None:
|
|
||||||
unplaced_items: typing.List[Item] = []
|
unplaced_items: typing.List[Item] = []
|
||||||
placements: typing.List[Location] = []
|
placements: typing.List[Location] = []
|
||||||
swapped_items: typing.Counter[typing.Tuple[int, str]] = Counter()
|
swapped_items: typing.Counter[typing.Tuple[int, str]] = Counter()
|
||||||
total = min(len(itempool), len(locations))
|
|
||||||
placed = 0
|
|
||||||
while locations and itempool:
|
while locations and itempool:
|
||||||
item_to_place = itempool.pop()
|
item_to_place = itempool.pop()
|
||||||
spot_to_fill: typing.Optional[Location] = None
|
spot_to_fill: typing.Optional[Location] = None
|
||||||
@@ -273,41 +243,30 @@ def remaining_fill(multiworld: MultiWorld,
|
|||||||
unplaced_items.append(item_to_place)
|
unplaced_items.append(item_to_place)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
multiworld.push_item(spot_to_fill, item_to_place, False)
|
world.push_item(spot_to_fill, item_to_place, False)
|
||||||
placements.append(spot_to_fill)
|
placements.append(spot_to_fill)
|
||||||
placed += 1
|
|
||||||
if not placed % 1000:
|
|
||||||
_log_fill_progress(name, placed, total)
|
|
||||||
|
|
||||||
if total > 1000:
|
|
||||||
_log_fill_progress(name, placed, total)
|
|
||||||
|
|
||||||
if unplaced_items and locations:
|
if unplaced_items and locations:
|
||||||
# There are leftover unplaceable items and locations that won't accept them
|
# There are leftover unplaceable items and locations that won't accept them
|
||||||
raise FillError(f"No more spots to place {len(unplaced_items)} items. Remaining locations are invalid.\n"
|
raise FillError(f'No more spots to place {unplaced_items}, locations {locations} are invalid. '
|
||||||
f"Unplaced items:\n"
|
f'Already placed {len(placements)}: {", ".join(str(place) for place in placements)}')
|
||||||
f"{', '.join(str(item) for item in unplaced_items)}\n"
|
|
||||||
f"Unfilled locations:\n"
|
|
||||||
f"{', '.join(str(location) for location in locations)}\n"
|
|
||||||
f"Already placed {len(placements)}:\n"
|
|
||||||
f"{', '.join(str(place) for place in placements)}")
|
|
||||||
|
|
||||||
itempool.extend(unplaced_items)
|
itempool.extend(unplaced_items)
|
||||||
|
|
||||||
|
|
||||||
def fast_fill(multiworld: MultiWorld,
|
def fast_fill(world: MultiWorld,
|
||||||
item_pool: typing.List[Item],
|
item_pool: typing.List[Item],
|
||||||
fill_locations: typing.List[Location]) -> typing.Tuple[typing.List[Item], typing.List[Location]]:
|
fill_locations: typing.List[Location]) -> typing.Tuple[typing.List[Item], typing.List[Location]]:
|
||||||
placing = min(len(item_pool), len(fill_locations))
|
placing = min(len(item_pool), len(fill_locations))
|
||||||
for item, location in zip(item_pool, fill_locations):
|
for item, location in zip(item_pool, fill_locations):
|
||||||
multiworld.push_item(location, item, False)
|
world.push_item(location, item, False)
|
||||||
return item_pool[placing:], fill_locations[placing:]
|
return item_pool[placing:], fill_locations[placing:]
|
||||||
|
|
||||||
|
|
||||||
def accessibility_corrections(multiworld: MultiWorld, state: CollectionState, locations, pool=[]):
|
def accessibility_corrections(world: MultiWorld, state: CollectionState, locations, pool=[]):
|
||||||
maximum_exploration_state = sweep_from_pool(state, pool)
|
maximum_exploration_state = sweep_from_pool(state, pool)
|
||||||
minimal_players = {player for player in multiworld.player_ids if multiworld.worlds[player].options.accessibility == "minimal"}
|
minimal_players = {player for player in world.player_ids if world.accessibility[player] == "minimal"}
|
||||||
unreachable_locations = [location for location in multiworld.get_locations() if location.player in minimal_players and
|
unreachable_locations = [location for location in world.get_locations() if location.player in minimal_players and
|
||||||
not location.can_reach(maximum_exploration_state)]
|
not location.can_reach(maximum_exploration_state)]
|
||||||
for location in unreachable_locations:
|
for location in unreachable_locations:
|
||||||
if (location.item is not None and location.item.advancement and location.address is not None and not
|
if (location.item is not None and location.item.advancement and location.address is not None and not
|
||||||
@@ -315,41 +274,42 @@ def accessibility_corrections(multiworld: MultiWorld, state: CollectionState, lo
|
|||||||
pool.append(location.item)
|
pool.append(location.item)
|
||||||
state.remove(location.item)
|
state.remove(location.item)
|
||||||
location.item = None
|
location.item = None
|
||||||
|
location.event = False
|
||||||
if location in state.events:
|
if location in state.events:
|
||||||
state.events.remove(location)
|
state.events.remove(location)
|
||||||
locations.append(location)
|
locations.append(location)
|
||||||
if pool and locations:
|
if pool and locations:
|
||||||
locations.sort(key=lambda loc: loc.progress_type != LocationProgressType.PRIORITY)
|
locations.sort(key=lambda loc: loc.progress_type != LocationProgressType.PRIORITY)
|
||||||
fill_restrictive(multiworld, state, locations, pool, name="Accessibility Corrections")
|
fill_restrictive(world, state, locations, pool)
|
||||||
|
|
||||||
|
|
||||||
def inaccessible_location_rules(multiworld: MultiWorld, state: CollectionState, locations):
|
def inaccessible_location_rules(world: MultiWorld, state: CollectionState, locations):
|
||||||
maximum_exploration_state = sweep_from_pool(state)
|
maximum_exploration_state = sweep_from_pool(state)
|
||||||
unreachable_locations = [location for location in locations if not location.can_reach(maximum_exploration_state)]
|
unreachable_locations = [location for location in locations if not location.can_reach(maximum_exploration_state)]
|
||||||
if unreachable_locations:
|
if unreachable_locations:
|
||||||
def forbid_important_item_rule(item: Item):
|
def forbid_important_item_rule(item: Item):
|
||||||
return not ((item.classification & 0b0011) and multiworld.worlds[item.player].options.accessibility != 'minimal')
|
return not ((item.classification & 0b0011) and world.accessibility[item.player] != 'minimal')
|
||||||
|
|
||||||
for location in unreachable_locations:
|
for location in unreachable_locations:
|
||||||
add_item_rule(location, forbid_important_item_rule)
|
add_item_rule(location, forbid_important_item_rule)
|
||||||
|
|
||||||
|
|
||||||
def distribute_early_items(multiworld: MultiWorld,
|
def distribute_early_items(world: MultiWorld,
|
||||||
fill_locations: typing.List[Location],
|
fill_locations: typing.List[Location],
|
||||||
itempool: typing.List[Item]) -> typing.Tuple[typing.List[Location], typing.List[Item]]:
|
itempool: typing.List[Item]) -> typing.Tuple[typing.List[Location], typing.List[Item]]:
|
||||||
""" returns new fill_locations and itempool """
|
""" returns new fill_locations and itempool """
|
||||||
early_items_count: typing.Dict[typing.Tuple[str, int], typing.List[int]] = {}
|
early_items_count: typing.Dict[typing.Tuple[str, int], typing.List[int]] = {}
|
||||||
for player in multiworld.player_ids:
|
for player in world.player_ids:
|
||||||
items = itertools.chain(multiworld.early_items[player], multiworld.local_early_items[player])
|
items = itertools.chain(world.early_items[player], world.local_early_items[player])
|
||||||
for item in items:
|
for item in items:
|
||||||
early_items_count[item, player] = [multiworld.early_items[player].get(item, 0),
|
early_items_count[item, player] = [world.early_items[player].get(item, 0),
|
||||||
multiworld.local_early_items[player].get(item, 0)]
|
world.local_early_items[player].get(item, 0)]
|
||||||
if early_items_count:
|
if early_items_count:
|
||||||
early_locations: typing.List[Location] = []
|
early_locations: typing.List[Location] = []
|
||||||
early_priority_locations: typing.List[Location] = []
|
early_priority_locations: typing.List[Location] = []
|
||||||
loc_indexes_to_remove: typing.Set[int] = set()
|
loc_indexes_to_remove: typing.Set[int] = set()
|
||||||
base_state = multiworld.state.copy()
|
base_state = world.state.copy()
|
||||||
base_state.sweep_for_events(locations=(loc for loc in multiworld.get_filled_locations() if loc.address is None))
|
base_state.sweep_for_events(locations=(loc for loc in world.get_filled_locations() if loc.address is None))
|
||||||
for i, loc in enumerate(fill_locations):
|
for i, loc in enumerate(fill_locations):
|
||||||
if loc.can_reach(base_state):
|
if loc.can_reach(base_state):
|
||||||
if loc.progress_type == LocationProgressType.PRIORITY:
|
if loc.progress_type == LocationProgressType.PRIORITY:
|
||||||
@@ -361,8 +321,8 @@ def distribute_early_items(multiworld: MultiWorld,
|
|||||||
|
|
||||||
early_prog_items: typing.List[Item] = []
|
early_prog_items: typing.List[Item] = []
|
||||||
early_rest_items: typing.List[Item] = []
|
early_rest_items: typing.List[Item] = []
|
||||||
early_local_prog_items: typing.Dict[int, typing.List[Item]] = {player: [] for player in multiworld.player_ids}
|
early_local_prog_items: typing.Dict[int, typing.List[Item]] = {player: [] for player in world.player_ids}
|
||||||
early_local_rest_items: typing.Dict[int, typing.List[Item]] = {player: [] for player in multiworld.player_ids}
|
early_local_rest_items: typing.Dict[int, typing.List[Item]] = {player: [] for player in world.player_ids}
|
||||||
item_indexes_to_remove: typing.Set[int] = set()
|
item_indexes_to_remove: typing.Set[int] = set()
|
||||||
for i, item in enumerate(itempool):
|
for i, item in enumerate(itempool):
|
||||||
if (item.name, item.player) in early_items_count:
|
if (item.name, item.player) in early_items_count:
|
||||||
@@ -386,29 +346,27 @@ def distribute_early_items(multiworld: MultiWorld,
|
|||||||
if len(early_items_count) == 0:
|
if len(early_items_count) == 0:
|
||||||
break
|
break
|
||||||
itempool = [item for i, item in enumerate(itempool) if i not in item_indexes_to_remove]
|
itempool = [item for i, item in enumerate(itempool) if i not in item_indexes_to_remove]
|
||||||
for player in multiworld.player_ids:
|
for player in world.player_ids:
|
||||||
player_local = early_local_rest_items[player]
|
player_local = early_local_rest_items[player]
|
||||||
fill_restrictive(multiworld, base_state,
|
fill_restrictive(world, base_state,
|
||||||
[loc for loc in early_locations if loc.player == player],
|
[loc for loc in early_locations if loc.player == player],
|
||||||
player_local, lock=True, allow_partial=True, name=f"Local Early Items P{player}")
|
player_local, lock=True, allow_partial=True)
|
||||||
if player_local:
|
if player_local:
|
||||||
logging.warning(f"Could not fulfill rules of early items: {player_local}")
|
logging.warning(f"Could not fulfill rules of early items: {player_local}")
|
||||||
early_rest_items.extend(early_local_rest_items[player])
|
early_rest_items.extend(early_local_rest_items[player])
|
||||||
early_locations = [loc for loc in early_locations if not loc.item]
|
early_locations = [loc for loc in early_locations if not loc.item]
|
||||||
fill_restrictive(multiworld, base_state, early_locations, early_rest_items, lock=True, allow_partial=True,
|
fill_restrictive(world, base_state, early_locations, early_rest_items, lock=True, allow_partial=True)
|
||||||
name="Early Items")
|
|
||||||
early_locations += early_priority_locations
|
early_locations += early_priority_locations
|
||||||
for player in multiworld.player_ids:
|
for player in world.player_ids:
|
||||||
player_local = early_local_prog_items[player]
|
player_local = early_local_prog_items[player]
|
||||||
fill_restrictive(multiworld, base_state,
|
fill_restrictive(world, base_state,
|
||||||
[loc for loc in early_locations if loc.player == player],
|
[loc for loc in early_locations if loc.player == player],
|
||||||
player_local, lock=True, allow_partial=True, name=f"Local Early Progression P{player}")
|
player_local, lock=True, allow_partial=True)
|
||||||
if player_local:
|
if player_local:
|
||||||
logging.warning(f"Could not fulfill rules of early items: {player_local}")
|
logging.warning(f"Could not fulfill rules of early items: {player_local}")
|
||||||
early_prog_items.extend(player_local)
|
early_prog_items.extend(player_local)
|
||||||
early_locations = [loc for loc in early_locations if not loc.item]
|
early_locations = [loc for loc in early_locations if not loc.item]
|
||||||
fill_restrictive(multiworld, base_state, early_locations, early_prog_items, lock=True, allow_partial=True,
|
fill_restrictive(world, base_state, early_locations, early_prog_items, lock=True, allow_partial=True)
|
||||||
name="Early Progression")
|
|
||||||
unplaced_early_items = early_rest_items + early_prog_items
|
unplaced_early_items = early_rest_items + early_prog_items
|
||||||
if unplaced_early_items:
|
if unplaced_early_items:
|
||||||
logging.warning("Ran out of early locations for early items. Failed to place "
|
logging.warning("Ran out of early locations for early items. Failed to place "
|
||||||
@@ -416,18 +374,18 @@ def distribute_early_items(multiworld: MultiWorld,
|
|||||||
itempool += unplaced_early_items
|
itempool += unplaced_early_items
|
||||||
|
|
||||||
fill_locations.extend(early_locations)
|
fill_locations.extend(early_locations)
|
||||||
multiworld.random.shuffle(fill_locations)
|
world.random.shuffle(fill_locations)
|
||||||
return fill_locations, itempool
|
return fill_locations, itempool
|
||||||
|
|
||||||
|
|
||||||
def distribute_items_restrictive(multiworld: MultiWorld) -> None:
|
def distribute_items_restrictive(world: MultiWorld) -> None:
|
||||||
fill_locations = sorted(multiworld.get_unfilled_locations())
|
fill_locations = sorted(world.get_unfilled_locations())
|
||||||
multiworld.random.shuffle(fill_locations)
|
world.random.shuffle(fill_locations)
|
||||||
# get items to distribute
|
# get items to distribute
|
||||||
itempool = sorted(multiworld.itempool)
|
itempool = sorted(world.itempool)
|
||||||
multiworld.random.shuffle(itempool)
|
world.random.shuffle(itempool)
|
||||||
|
|
||||||
fill_locations, itempool = distribute_early_items(multiworld, fill_locations, itempool)
|
fill_locations, itempool = distribute_early_items(world, fill_locations, itempool)
|
||||||
|
|
||||||
progitempool: typing.List[Item] = []
|
progitempool: typing.List[Item] = []
|
||||||
usefulitempool: typing.List[Item] = []
|
usefulitempool: typing.List[Item] = []
|
||||||
@@ -441,7 +399,7 @@ def distribute_items_restrictive(multiworld: MultiWorld) -> None:
|
|||||||
else:
|
else:
|
||||||
filleritempool.append(item)
|
filleritempool.append(item)
|
||||||
|
|
||||||
call_all(multiworld, "fill_hook", progitempool, usefulitempool, filleritempool, fill_locations)
|
call_all(world, "fill_hook", progitempool, usefulitempool, filleritempool, fill_locations)
|
||||||
|
|
||||||
locations: typing.Dict[LocationProgressType, typing.List[Location]] = {
|
locations: typing.Dict[LocationProgressType, typing.List[Location]] = {
|
||||||
loc_type: [] for loc_type in LocationProgressType}
|
loc_type: [] for loc_type in LocationProgressType}
|
||||||
@@ -462,80 +420,74 @@ def distribute_items_restrictive(multiworld: MultiWorld) -> None:
|
|||||||
|
|
||||||
if prioritylocations:
|
if prioritylocations:
|
||||||
# "priority fill"
|
# "priority fill"
|
||||||
fill_restrictive(multiworld, multiworld.state, prioritylocations, progitempool,
|
fill_restrictive(world, world.state, prioritylocations, progitempool, swap=False, on_place=mark_for_locking)
|
||||||
single_player_placement=multiworld.players == 1, swap=False, on_place=mark_for_locking,
|
accessibility_corrections(world, world.state, prioritylocations, progitempool)
|
||||||
name="Priority")
|
|
||||||
accessibility_corrections(multiworld, multiworld.state, prioritylocations, progitempool)
|
|
||||||
defaultlocations = prioritylocations + defaultlocations
|
defaultlocations = prioritylocations + defaultlocations
|
||||||
|
|
||||||
if progitempool:
|
if progitempool:
|
||||||
# "advancement/progression fill"
|
# "progression fill"
|
||||||
fill_restrictive(multiworld, multiworld.state, defaultlocations, progitempool, single_player_placement=multiworld.players == 1,
|
fill_restrictive(world, world.state, defaultlocations, progitempool)
|
||||||
name="Progression")
|
|
||||||
if progitempool:
|
if progitempool:
|
||||||
raise FillError(
|
raise FillError(
|
||||||
f"Not enough locations for progression items. "
|
f'Not enough locations for progress items. There are {len(progitempool)} more items than locations')
|
||||||
f"There are {len(progitempool)} more progression items than there are available locations."
|
accessibility_corrections(world, world.state, defaultlocations)
|
||||||
)
|
|
||||||
accessibility_corrections(multiworld, multiworld.state, defaultlocations)
|
|
||||||
|
|
||||||
for location in lock_later:
|
for location in lock_later:
|
||||||
if location.item:
|
if location.item:
|
||||||
location.locked = True
|
location.locked = True
|
||||||
del mark_for_locking, lock_later
|
del mark_for_locking, lock_later
|
||||||
|
|
||||||
inaccessible_location_rules(multiworld, multiworld.state, defaultlocations)
|
inaccessible_location_rules(world, world.state, defaultlocations)
|
||||||
|
|
||||||
remaining_fill(multiworld, excludedlocations, filleritempool, "Remaining Excluded")
|
remaining_fill(world, excludedlocations, filleritempool)
|
||||||
if excludedlocations:
|
if excludedlocations:
|
||||||
raise FillError(
|
raise FillError(
|
||||||
f"Not enough filler items for excluded locations. "
|
f"Not enough filler items for excluded locations. There are {len(excludedlocations)} more locations than items")
|
||||||
f"There are {len(excludedlocations)} more excluded locations than filler or trap items."
|
|
||||||
)
|
|
||||||
|
|
||||||
restitempool = filleritempool + usefulitempool
|
restitempool = usefulitempool + filleritempool
|
||||||
|
|
||||||
remaining_fill(multiworld, defaultlocations, restitempool)
|
remaining_fill(world, defaultlocations, restitempool)
|
||||||
|
|
||||||
unplaced = restitempool
|
unplaced = restitempool
|
||||||
unfilled = defaultlocations
|
unfilled = defaultlocations
|
||||||
|
|
||||||
if unplaced or unfilled:
|
if unplaced or unfilled:
|
||||||
logging.warning(
|
logging.warning(
|
||||||
f"Unplaced items({len(unplaced)}): {unplaced} - Unfilled Locations({len(unfilled)}): {unfilled}")
|
f'Unplaced items({len(unplaced)}): {unplaced} - Unfilled Locations({len(unfilled)}): {unfilled}')
|
||||||
items_counter = Counter(location.item.player for location in multiworld.get_filled_locations())
|
items_counter = Counter(location.item.player for location in world.get_locations() if location.item)
|
||||||
locations_counter = Counter(location.player for location in multiworld.get_locations())
|
locations_counter = Counter(location.player for location in world.get_locations())
|
||||||
items_counter.update(item.player for item in unplaced)
|
items_counter.update(item.player for item in unplaced)
|
||||||
|
locations_counter.update(location.player for location in unfilled)
|
||||||
print_data = {"items": items_counter, "locations": locations_counter}
|
print_data = {"items": items_counter, "locations": locations_counter}
|
||||||
logging.info(f"Per-Player counts: {print_data})")
|
logging.info(f'Per-Player counts: {print_data})')
|
||||||
|
|
||||||
|
|
||||||
def flood_items(multiworld: MultiWorld) -> None:
|
def flood_items(world: MultiWorld) -> None:
|
||||||
# get items to distribute
|
# get items to distribute
|
||||||
multiworld.random.shuffle(multiworld.itempool)
|
world.random.shuffle(world.itempool)
|
||||||
itempool = multiworld.itempool
|
itempool = world.itempool
|
||||||
progress_done = False
|
progress_done = False
|
||||||
|
|
||||||
# sweep once to pick up preplaced items
|
# sweep once to pick up preplaced items
|
||||||
multiworld.state.sweep_for_events()
|
world.state.sweep_for_events()
|
||||||
|
|
||||||
# fill multiworld from top of itempool while we can
|
# fill world from top of itempool while we can
|
||||||
while not progress_done:
|
while not progress_done:
|
||||||
location_list = multiworld.get_unfilled_locations()
|
location_list = world.get_unfilled_locations()
|
||||||
multiworld.random.shuffle(location_list)
|
world.random.shuffle(location_list)
|
||||||
spot_to_fill = None
|
spot_to_fill = None
|
||||||
for location in location_list:
|
for location in location_list:
|
||||||
if location.can_fill(multiworld.state, itempool[0]):
|
if location.can_fill(world.state, itempool[0]):
|
||||||
spot_to_fill = location
|
spot_to_fill = location
|
||||||
break
|
break
|
||||||
|
|
||||||
if spot_to_fill:
|
if spot_to_fill:
|
||||||
item = itempool.pop(0)
|
item = itempool.pop(0)
|
||||||
multiworld.push_item(spot_to_fill, item, True)
|
world.push_item(spot_to_fill, item, True)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# ran out of spots, check if we need to step in and correct things
|
# ran out of spots, check if we need to step in and correct things
|
||||||
if len(multiworld.get_reachable_locations()) == len(multiworld.get_locations()):
|
if len(world.get_reachable_locations()) == len(world.get_locations()):
|
||||||
progress_done = True
|
progress_done = True
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@@ -545,7 +497,7 @@ def flood_items(multiworld: MultiWorld) -> None:
|
|||||||
for item in itempool:
|
for item in itempool:
|
||||||
if item.advancement:
|
if item.advancement:
|
||||||
candidate_item_to_place = item
|
candidate_item_to_place = item
|
||||||
if multiworld.unlocks_new_location(item):
|
if world.unlocks_new_location(item):
|
||||||
item_to_place = item
|
item_to_place = item
|
||||||
break
|
break
|
||||||
|
|
||||||
@@ -558,20 +510,20 @@ def flood_items(multiworld: MultiWorld) -> None:
|
|||||||
raise FillError('No more progress items left to place.')
|
raise FillError('No more progress items left to place.')
|
||||||
|
|
||||||
# find item to replace with progress item
|
# find item to replace with progress item
|
||||||
location_list = multiworld.get_reachable_locations()
|
location_list = world.get_reachable_locations()
|
||||||
multiworld.random.shuffle(location_list)
|
world.random.shuffle(location_list)
|
||||||
for location in location_list:
|
for location in location_list:
|
||||||
if location.item is not None and not location.item.advancement:
|
if location.item is not None and not location.item.advancement:
|
||||||
# safe to replace
|
# safe to replace
|
||||||
replace_item = location.item
|
replace_item = location.item
|
||||||
replace_item.location = None
|
replace_item.location = None
|
||||||
itempool.append(replace_item)
|
itempool.append(replace_item)
|
||||||
multiworld.push_item(location, item_to_place, True)
|
world.push_item(location, item_to_place, True)
|
||||||
itempool.remove(item_to_place)
|
itempool.remove(item_to_place)
|
||||||
break
|
break
|
||||||
|
|
||||||
|
|
||||||
def balance_multiworld_progression(multiworld: MultiWorld) -> None:
|
def balance_multiworld_progression(world: MultiWorld) -> None:
|
||||||
# A system to reduce situations where players have no checks remaining, popularly known as "BK mode."
|
# A system to reduce situations where players have no checks remaining, popularly known as "BK mode."
|
||||||
# Overall progression balancing algorithm:
|
# Overall progression balancing algorithm:
|
||||||
# Gather up all locations in a sphere.
|
# Gather up all locations in a sphere.
|
||||||
@@ -579,28 +531,28 @@ def balance_multiworld_progression(multiworld: MultiWorld) -> None:
|
|||||||
# If other players are below the threshold value, swap progression in this sphere into earlier spheres,
|
# If other players are below the threshold value, swap progression in this sphere into earlier spheres,
|
||||||
# which gives more locations available by this sphere.
|
# which gives more locations available by this sphere.
|
||||||
balanceable_players: typing.Dict[int, float] = {
|
balanceable_players: typing.Dict[int, float] = {
|
||||||
player: multiworld.worlds[player].options.progression_balancing / 100
|
player: world.progression_balancing[player] / 100
|
||||||
for player in multiworld.player_ids
|
for player in world.player_ids
|
||||||
if multiworld.worlds[player].options.progression_balancing > 0
|
if world.progression_balancing[player] > 0
|
||||||
}
|
}
|
||||||
if not balanceable_players:
|
if not balanceable_players:
|
||||||
logging.info('Skipping multiworld progression balancing.')
|
logging.info('Skipping multiworld progression balancing.')
|
||||||
else:
|
else:
|
||||||
logging.info(f'Balancing multiworld progression for {len(balanceable_players)} Players.')
|
logging.info(f'Balancing multiworld progression for {len(balanceable_players)} Players.')
|
||||||
logging.debug(balanceable_players)
|
logging.debug(balanceable_players)
|
||||||
state: CollectionState = CollectionState(multiworld)
|
state: CollectionState = CollectionState(world)
|
||||||
checked_locations: typing.Set[Location] = set()
|
checked_locations: typing.Set[Location] = set()
|
||||||
unchecked_locations: typing.Set[Location] = set(multiworld.get_locations())
|
unchecked_locations: typing.Set[Location] = set(world.get_locations())
|
||||||
|
|
||||||
total_locations_count: typing.Counter[int] = Counter(
|
total_locations_count: typing.Counter[int] = Counter(
|
||||||
location.player
|
location.player
|
||||||
for location in multiworld.get_locations()
|
for location in world.get_locations()
|
||||||
if not location.locked
|
if not location.locked
|
||||||
)
|
)
|
||||||
reachable_locations_count: typing.Dict[int, int] = {
|
reachable_locations_count: typing.Dict[int, int] = {
|
||||||
player: 0
|
player: 0
|
||||||
for player in multiworld.player_ids
|
for player in world.player_ids
|
||||||
if total_locations_count[player] and len(multiworld.get_filled_locations(player)) != 0
|
if total_locations_count[player] and len(world.get_filled_locations(player)) != 0
|
||||||
}
|
}
|
||||||
balanceable_players = {
|
balanceable_players = {
|
||||||
player: balanceable_players[player]
|
player: balanceable_players[player]
|
||||||
@@ -664,7 +616,7 @@ def balance_multiworld_progression(multiworld: MultiWorld) -> None:
|
|||||||
while True:
|
while True:
|
||||||
# Check locations in the current sphere and gather progression items to swap earlier
|
# Check locations in the current sphere and gather progression items to swap earlier
|
||||||
for location in balancing_sphere:
|
for location in balancing_sphere:
|
||||||
if location.advancement:
|
if location.event:
|
||||||
balancing_state.collect(location.item, True, location)
|
balancing_state.collect(location.item, True, location)
|
||||||
player = location.item.player
|
player = location.item.player
|
||||||
# only replace items that end up in another player's world
|
# only replace items that end up in another player's world
|
||||||
@@ -679,7 +631,7 @@ def balance_multiworld_progression(multiworld: MultiWorld) -> None:
|
|||||||
balancing_unchecked_locations.remove(location)
|
balancing_unchecked_locations.remove(location)
|
||||||
if not location.locked:
|
if not location.locked:
|
||||||
balancing_reachables[location.player] += 1
|
balancing_reachables[location.player] += 1
|
||||||
if multiworld.has_beaten_game(balancing_state) or all(
|
if world.has_beaten_game(balancing_state) or all(
|
||||||
item_percentage(player, reachables) >= threshold_percentages[player]
|
item_percentage(player, reachables) >= threshold_percentages[player]
|
||||||
for player, reachables in balancing_reachables.items()
|
for player, reachables in balancing_reachables.items()
|
||||||
if player in threshold_percentages):
|
if player in threshold_percentages):
|
||||||
@@ -696,7 +648,7 @@ def balance_multiworld_progression(multiworld: MultiWorld) -> None:
|
|||||||
locations_to_test = unlocked_locations[player]
|
locations_to_test = unlocked_locations[player]
|
||||||
items_to_test = list(candidate_items[player])
|
items_to_test = list(candidate_items[player])
|
||||||
items_to_test.sort()
|
items_to_test.sort()
|
||||||
multiworld.random.shuffle(items_to_test)
|
world.random.shuffle(items_to_test)
|
||||||
while items_to_test:
|
while items_to_test:
|
||||||
testing = items_to_test.pop()
|
testing = items_to_test.pop()
|
||||||
reducing_state = state.copy()
|
reducing_state = state.copy()
|
||||||
@@ -708,8 +660,8 @@ def balance_multiworld_progression(multiworld: MultiWorld) -> None:
|
|||||||
|
|
||||||
reducing_state.sweep_for_events(locations=locations_to_test)
|
reducing_state.sweep_for_events(locations=locations_to_test)
|
||||||
|
|
||||||
if multiworld.has_beaten_game(balancing_state):
|
if world.has_beaten_game(balancing_state):
|
||||||
if not multiworld.has_beaten_game(reducing_state):
|
if not world.has_beaten_game(reducing_state):
|
||||||
items_to_replace.append(testing)
|
items_to_replace.append(testing)
|
||||||
else:
|
else:
|
||||||
reduced_sphere = get_sphere_locations(reducing_state, locations_to_test)
|
reduced_sphere = get_sphere_locations(reducing_state, locations_to_test)
|
||||||
@@ -717,32 +669,33 @@ def balance_multiworld_progression(multiworld: MultiWorld) -> None:
|
|||||||
if p < threshold_percentages[player]:
|
if p < threshold_percentages[player]:
|
||||||
items_to_replace.append(testing)
|
items_to_replace.append(testing)
|
||||||
|
|
||||||
old_moved_item_count = moved_item_count
|
replaced_items = False
|
||||||
|
|
||||||
# sort then shuffle to maintain deterministic behaviour,
|
# sort then shuffle to maintain deterministic behaviour,
|
||||||
# while allowing use of set for better algorithm growth behaviour elsewhere
|
# while allowing use of set for better algorithm growth behaviour elsewhere
|
||||||
replacement_locations = sorted(l for l in checked_locations if not l.advancement and not l.locked)
|
replacement_locations = sorted(l for l in checked_locations if not l.event and not l.locked)
|
||||||
multiworld.random.shuffle(replacement_locations)
|
world.random.shuffle(replacement_locations)
|
||||||
items_to_replace.sort()
|
items_to_replace.sort()
|
||||||
multiworld.random.shuffle(items_to_replace)
|
world.random.shuffle(items_to_replace)
|
||||||
|
|
||||||
# Start swapping items. Since we swap into earlier spheres, no need for accessibility checks.
|
# Start swapping items. Since we swap into earlier spheres, no need for accessibility checks.
|
||||||
while replacement_locations and items_to_replace:
|
while replacement_locations and items_to_replace:
|
||||||
old_location = items_to_replace.pop()
|
old_location = items_to_replace.pop()
|
||||||
for i, new_location in enumerate(replacement_locations):
|
for new_location in replacement_locations:
|
||||||
if new_location.can_fill(state, old_location.item, False) and \
|
if new_location.can_fill(state, old_location.item, False) and \
|
||||||
old_location.can_fill(state, new_location.item, False):
|
old_location.can_fill(state, new_location.item, False):
|
||||||
replacement_locations.pop(i)
|
replacement_locations.remove(new_location)
|
||||||
swap_location_item(old_location, new_location)
|
swap_location_item(old_location, new_location)
|
||||||
logging.debug(f"Progression balancing moved {new_location.item} to {new_location}, "
|
logging.debug(f"Progression balancing moved {new_location.item} to {new_location}, "
|
||||||
f"displacing {old_location.item} into {old_location}")
|
f"displacing {old_location.item} into {old_location}")
|
||||||
moved_item_count += 1
|
moved_item_count += 1
|
||||||
state.collect(new_location.item, True, new_location)
|
state.collect(new_location.item, True, new_location)
|
||||||
|
replaced_items = True
|
||||||
break
|
break
|
||||||
else:
|
else:
|
||||||
logging.warning(f"Could not Progression Balance {old_location.item}")
|
logging.warning(f"Could not Progression Balance {old_location.item}")
|
||||||
|
|
||||||
if old_moved_item_count < moved_item_count:
|
if replaced_items:
|
||||||
logging.debug(f"Moved {moved_item_count} items so far\n")
|
logging.debug(f"Moved {moved_item_count} items so far\n")
|
||||||
unlocked = {fresh for player in balancing_players for fresh in unlocked_locations[player]}
|
unlocked = {fresh for player in balancing_players for fresh in unlocked_locations[player]}
|
||||||
for location in get_sphere_locations(state, unlocked):
|
for location in get_sphere_locations(state, unlocked):
|
||||||
@@ -752,11 +705,11 @@ def balance_multiworld_progression(multiworld: MultiWorld) -> None:
|
|||||||
sphere_locations.add(location)
|
sphere_locations.add(location)
|
||||||
|
|
||||||
for location in sphere_locations:
|
for location in sphere_locations:
|
||||||
if location.advancement:
|
if location.event:
|
||||||
state.collect(location.item, True, location)
|
state.collect(location.item, True, location)
|
||||||
checked_locations |= sphere_locations
|
checked_locations |= sphere_locations
|
||||||
|
|
||||||
if multiworld.has_beaten_game(state):
|
if world.has_beaten_game(state):
|
||||||
break
|
break
|
||||||
elif not sphere_locations:
|
elif not sphere_locations:
|
||||||
logging.warning("Progression Balancing ran out of paths.")
|
logging.warning("Progression Balancing ran out of paths.")
|
||||||
@@ -773,9 +726,10 @@ def swap_location_item(location_1: Location, location_2: Location, check_locked:
|
|||||||
location_2.item, location_1.item = location_1.item, location_2.item
|
location_2.item, location_1.item = location_1.item, location_2.item
|
||||||
location_1.item.location = location_1
|
location_1.item.location = location_1
|
||||||
location_2.item.location = location_2
|
location_2.item.location = location_2
|
||||||
|
location_1.event, location_2.event = location_2.event, location_1.event
|
||||||
|
|
||||||
|
|
||||||
def distribute_planned(multiworld: MultiWorld) -> None:
|
def distribute_planned(world: MultiWorld) -> None:
|
||||||
def warn(warning: str, force: typing.Union[bool, str]) -> None:
|
def warn(warning: str, force: typing.Union[bool, str]) -> None:
|
||||||
if force in [True, 'fail', 'failure', 'none', False, 'warn', 'warning']:
|
if force in [True, 'fail', 'failure', 'none', False, 'warn', 'warning']:
|
||||||
logging.warning(f'{warning}')
|
logging.warning(f'{warning}')
|
||||||
@@ -788,43 +742,42 @@ def distribute_planned(multiworld: MultiWorld) -> None:
|
|||||||
else:
|
else:
|
||||||
warn(warning, force)
|
warn(warning, force)
|
||||||
|
|
||||||
swept_state = multiworld.state.copy()
|
swept_state = world.state.copy()
|
||||||
swept_state.sweep_for_events()
|
swept_state.sweep_for_events()
|
||||||
reachable = frozenset(multiworld.get_reachable_locations(swept_state))
|
reachable = frozenset(world.get_reachable_locations(swept_state))
|
||||||
early_locations: typing.Dict[int, typing.List[str]] = collections.defaultdict(list)
|
early_locations: typing.Dict[int, typing.List[str]] = collections.defaultdict(list)
|
||||||
non_early_locations: typing.Dict[int, typing.List[str]] = collections.defaultdict(list)
|
non_early_locations: typing.Dict[int, typing.List[str]] = collections.defaultdict(list)
|
||||||
for loc in multiworld.get_unfilled_locations():
|
for loc in world.get_unfilled_locations():
|
||||||
if loc in reachable:
|
if loc in reachable:
|
||||||
early_locations[loc.player].append(loc.name)
|
early_locations[loc.player].append(loc.name)
|
||||||
else: # not reachable with swept state
|
else: # not reachable with swept state
|
||||||
non_early_locations[loc.player].append(loc.name)
|
non_early_locations[loc.player].append(loc.name)
|
||||||
|
|
||||||
world_name_lookup = multiworld.world_name_lookup
|
# TODO: remove. Preferably by implementing key drop
|
||||||
|
from worlds.alttp.Regions import key_drop_data
|
||||||
|
world_name_lookup = world.world_name_lookup
|
||||||
|
|
||||||
block_value = typing.Union[typing.List[str], typing.Dict[str, typing.Any], str]
|
block_value = typing.Union[typing.List[str], typing.Dict[str, typing.Any], str]
|
||||||
plando_blocks: typing.List[typing.Dict[str, typing.Any]] = []
|
plando_blocks: typing.List[typing.Dict[str, typing.Any]] = []
|
||||||
player_ids = set(multiworld.player_ids)
|
player_ids = set(world.player_ids)
|
||||||
for player in player_ids:
|
for player in player_ids:
|
||||||
for block in multiworld.plando_items[player]:
|
for block in world.plando_items[player]:
|
||||||
block['player'] = player
|
block['player'] = player
|
||||||
if 'force' not in block:
|
if 'force' not in block:
|
||||||
block['force'] = 'silent'
|
block['force'] = 'silent'
|
||||||
if 'from_pool' not in block:
|
if 'from_pool' not in block:
|
||||||
block['from_pool'] = True
|
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:
|
if 'world' not in block:
|
||||||
target_world = False
|
target_world = False
|
||||||
else:
|
else:
|
||||||
target_world = block['world']
|
target_world = block['world']
|
||||||
|
|
||||||
if target_world is False or multiworld.players == 1: # target own world
|
if target_world is False or world.players == 1: # target own world
|
||||||
worlds: typing.Set[int] = {player}
|
worlds: typing.Set[int] = {player}
|
||||||
elif target_world is True: # target any worlds besides own
|
elif target_world is True: # target any worlds besides own
|
||||||
worlds = set(multiworld.player_ids) - {player}
|
worlds = set(world.player_ids) - {player}
|
||||||
elif target_world is None: # target all worlds
|
elif target_world is None: # target all worlds
|
||||||
worlds = set(multiworld.player_ids)
|
worlds = set(world.player_ids)
|
||||||
elif type(target_world) == list: # list of target worlds
|
elif type(target_world) == list: # list of target worlds
|
||||||
worlds = set()
|
worlds = set()
|
||||||
for listed_world in target_world:
|
for listed_world in target_world:
|
||||||
@@ -834,9 +787,9 @@ def distribute_planned(multiworld: MultiWorld) -> None:
|
|||||||
continue
|
continue
|
||||||
worlds.add(world_name_lookup[listed_world])
|
worlds.add(world_name_lookup[listed_world])
|
||||||
elif type(target_world) == int: # target world by slot number
|
elif type(target_world) == int: # target world by slot number
|
||||||
if target_world not in range(1, multiworld.players + 1):
|
if target_world not in range(1, world.players + 1):
|
||||||
failed(
|
failed(
|
||||||
f"Cannot place item in world {target_world} as it is not in range of (1, {multiworld.players})",
|
f"Cannot place item in world {target_world} as it is not in range of (1, {world.players})",
|
||||||
block['force'])
|
block['force'])
|
||||||
continue
|
continue
|
||||||
worlds = {target_world}
|
worlds = {target_world}
|
||||||
@@ -864,7 +817,7 @@ def distribute_planned(multiworld: MultiWorld) -> None:
|
|||||||
item_list: typing.List[str] = []
|
item_list: typing.List[str] = []
|
||||||
for key, value in items.items():
|
for key, value in items.items():
|
||||||
if value is True:
|
if value is True:
|
||||||
value = multiworld.itempool.count(multiworld.worlds[player].create_item(key))
|
value = world.itempool.count(world.worlds[player].create_item(key))
|
||||||
item_list += [key] * value
|
item_list += [key] * value
|
||||||
items = item_list
|
items = item_list
|
||||||
if isinstance(items, str):
|
if isinstance(items, str):
|
||||||
@@ -887,14 +840,14 @@ def distribute_planned(multiworld: MultiWorld) -> None:
|
|||||||
|
|
||||||
if "early_locations" in locations:
|
if "early_locations" in locations:
|
||||||
locations.remove("early_locations")
|
locations.remove("early_locations")
|
||||||
for target_player in worlds:
|
for player in worlds:
|
||||||
locations += early_locations[target_player]
|
locations += early_locations[player]
|
||||||
if "non_early_locations" in locations:
|
if "non_early_locations" in locations:
|
||||||
locations.remove("non_early_locations")
|
locations.remove("non_early_locations")
|
||||||
for target_player in worlds:
|
for player in worlds:
|
||||||
locations += non_early_locations[target_player]
|
locations += non_early_locations[player]
|
||||||
|
|
||||||
block['locations'] = list(dict.fromkeys(locations))
|
block['locations'] = locations
|
||||||
|
|
||||||
if not block['count']:
|
if not block['count']:
|
||||||
block['count'] = (min(len(block['items']), len(block['locations'])) if
|
block['count'] = (min(len(block['items']), len(block['locations'])) if
|
||||||
@@ -914,17 +867,17 @@ def distribute_planned(multiworld: MultiWorld) -> None:
|
|||||||
count = block['count']
|
count = block['count']
|
||||||
failed(f"Plando count {count} greater than locations specified", block['force'])
|
failed(f"Plando count {count} greater than locations specified", block['force'])
|
||||||
block['count'] = len(block['locations'])
|
block['count'] = len(block['locations'])
|
||||||
block['count']['target'] = multiworld.random.randint(block['count']['min'], block['count']['max'])
|
block['count']['target'] = world.random.randint(block['count']['min'], block['count']['max'])
|
||||||
|
|
||||||
if block['count']['target'] > 0:
|
if block['count']['target'] > 0:
|
||||||
plando_blocks.append(block)
|
plando_blocks.append(block)
|
||||||
|
|
||||||
# shuffle, but then sort blocks by number of locations minus number of items,
|
# shuffle, but then sort blocks by number of locations minus number of items,
|
||||||
# so less-flexible blocks get priority
|
# so less-flexible blocks get priority
|
||||||
multiworld.random.shuffle(plando_blocks)
|
world.random.shuffle(plando_blocks)
|
||||||
plando_blocks.sort(key=lambda block: (len(block['locations']) - block['count']['target']
|
plando_blocks.sort(key=lambda block: (len(block['locations']) - block['count']['target']
|
||||||
if len(block['locations']) > 0
|
if len(block['locations']) > 0
|
||||||
else len(multiworld.get_unfilled_locations(player)) - block['count']['target']))
|
else len(world.get_unfilled_locations(player)) - block['count']['target']))
|
||||||
|
|
||||||
for placement in plando_blocks:
|
for placement in plando_blocks:
|
||||||
player = placement['player']
|
player = placement['player']
|
||||||
@@ -935,50 +888,52 @@ def distribute_planned(multiworld: MultiWorld) -> None:
|
|||||||
maxcount = placement['count']['target']
|
maxcount = placement['count']['target']
|
||||||
from_pool = placement['from_pool']
|
from_pool = placement['from_pool']
|
||||||
|
|
||||||
candidates = list(multiworld.get_unfilled_locations_for_players(locations, sorted(worlds)))
|
candidates = list(world.get_unfilled_locations_for_players(locations, sorted(worlds)))
|
||||||
multiworld.random.shuffle(candidates)
|
world.random.shuffle(candidates)
|
||||||
multiworld.random.shuffle(items)
|
world.random.shuffle(items)
|
||||||
count = 0
|
count = 0
|
||||||
err: typing.List[str] = []
|
err: typing.List[str] = []
|
||||||
successful_pairs: typing.List[typing.Tuple[Item, Location]] = []
|
successful_pairs: typing.List[typing.Tuple[Item, Location]] = []
|
||||||
for item_name in items:
|
for item_name in items:
|
||||||
item = multiworld.worlds[player].create_item(item_name)
|
item = world.worlds[player].create_item(item_name)
|
||||||
for location in reversed(candidates):
|
for location in reversed(candidates):
|
||||||
if (location.address is None) == (item.code is None): # either both None or both not None
|
if location in key_drop_data:
|
||||||
if not location.item:
|
warn(
|
||||||
if location.item_rule(item):
|
f"Can't place '{item_name}' at '{placement.location}', as key drop shuffle locations are not supported yet.")
|
||||||
if location.can_fill(multiworld.state, item, False):
|
continue
|
||||||
successful_pairs.append((item, location))
|
if not location.item:
|
||||||
candidates.remove(location)
|
if location.item_rule(item):
|
||||||
count = count + 1
|
if location.can_fill(world.state, item, False):
|
||||||
break
|
successful_pairs.append((item, location))
|
||||||
else:
|
candidates.remove(location)
|
||||||
err.append(f"Can't place item at {location} due to fill condition not met.")
|
count = count + 1
|
||||||
|
break
|
||||||
else:
|
else:
|
||||||
err.append(f"{item_name} not allowed at {location}.")
|
err.append(f"Can't place item at {location} due to fill condition not met.")
|
||||||
else:
|
else:
|
||||||
err.append(f"Cannot place {item_name} into already filled location {location}.")
|
err.append(f"{item_name} not allowed at {location}.")
|
||||||
else:
|
else:
|
||||||
err.append(f"Mismatch between {item_name} and {location}, only one is an event.")
|
err.append(f"Cannot place {item_name} into already filled location {location}.")
|
||||||
if count == maxcount:
|
if count == maxcount:
|
||||||
break
|
break
|
||||||
if count < placement['count']['min']:
|
if count < placement['count']['min']:
|
||||||
m = placement['count']['min']
|
m = placement['count']['min']
|
||||||
failed(
|
failed(
|
||||||
f"Plando block failed to place {m - count} of {m} item(s) for {multiworld.player_name[player]}, error(s): {' '.join(err)}",
|
f"Plando block failed to place {m - count} of {m} item(s) for {world.player_name[player]}, error(s): {' '.join(err)}",
|
||||||
placement['force'])
|
placement['force'])
|
||||||
for (item, location) in successful_pairs:
|
for (item, location) in successful_pairs:
|
||||||
multiworld.push_item(location, item, collect=False)
|
world.push_item(location, item, collect=False)
|
||||||
|
location.event = True # flag location to be checked during fill
|
||||||
location.locked = True
|
location.locked = True
|
||||||
logging.debug(f"Plando placed {item} at {location}")
|
logging.debug(f"Plando placed {item} at {location}")
|
||||||
if from_pool:
|
if from_pool:
|
||||||
try:
|
try:
|
||||||
multiworld.itempool.remove(item)
|
world.itempool.remove(item)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
warn(
|
warn(
|
||||||
f"Could not remove {item} from pool for {multiworld.player_name[player]} as it's already missing from it.",
|
f"Could not remove {item} from pool for {world.player_name[player]} as it's already missing from it.",
|
||||||
placement['force'])
|
placement['force'])
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise Exception(
|
raise Exception(
|
||||||
f"Error running plando for player {player} ({multiworld.player_name[player]})") from e
|
f"Error running plando for player {player} ({world.player_name[player]})") from e
|
||||||
|
|||||||
293
Generate.py
293
Generate.py
@@ -7,8 +7,8 @@ import random
|
|||||||
import string
|
import string
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
import urllib.request
|
import urllib.request
|
||||||
from collections import Counter
|
from collections import ChainMap, Counter
|
||||||
from typing import Any, Dict, Tuple, Union
|
from typing import Any, Callable, Dict, Tuple, Union
|
||||||
|
|
||||||
import ModuleUpdate
|
import ModuleUpdate
|
||||||
|
|
||||||
@@ -20,12 +20,12 @@ import Options
|
|||||||
from BaseClasses import seeddigits, get_seed, PlandoOptions
|
from BaseClasses import seeddigits, get_seed, PlandoOptions
|
||||||
from Main import main as ERmain
|
from Main import main as ERmain
|
||||||
from settings import get_settings
|
from settings import get_settings
|
||||||
from Utils import parse_yamls, version_tuple, __version__, tuplize_version
|
from Utils import parse_yamls, version_tuple, __version__, tuplize_version, user_path
|
||||||
|
from worlds.alttp import Options as LttPOptions
|
||||||
from worlds.alttp.EntranceRandomizer import parse_arguments
|
from worlds.alttp.EntranceRandomizer import parse_arguments
|
||||||
from worlds.alttp.Text import TextTable
|
from worlds.alttp.Text import TextTable
|
||||||
from worlds.AutoWorld import AutoWorldRegister
|
from worlds.AutoWorld import AutoWorldRegister
|
||||||
from worlds.generic import PlandoConnection
|
from worlds.generic import PlandoConnection
|
||||||
from worlds import failed_world_loads
|
|
||||||
|
|
||||||
|
|
||||||
def mystery_argparse():
|
def mystery_argparse():
|
||||||
@@ -34,8 +34,8 @@ def mystery_argparse():
|
|||||||
|
|
||||||
parser = argparse.ArgumentParser(description="CMD Generation Interface, defaults come from host.yaml.")
|
parser = argparse.ArgumentParser(description="CMD Generation Interface, defaults come from host.yaml.")
|
||||||
parser.add_argument('--weights_file_path', default=defaults.weights_file_path,
|
parser.add_argument('--weights_file_path', default=defaults.weights_file_path,
|
||||||
help='Path to the weights file to use for rolling game options, urls are also valid')
|
help='Path to the weights file to use for rolling game settings, urls are also valid')
|
||||||
parser.add_argument('--sameoptions', help='Rolls options per weights file rather than per player',
|
parser.add_argument('--samesettings', help='Rolls settings per weights file rather than per player',
|
||||||
action='store_true')
|
action='store_true')
|
||||||
parser.add_argument('--player_files_path', default=defaults.player_files_path,
|
parser.add_argument('--player_files_path', default=defaults.player_files_path,
|
||||||
help="Input directory for player files.")
|
help="Input directory for player files.")
|
||||||
@@ -53,9 +53,6 @@ def mystery_argparse():
|
|||||||
help='List of options that can be set manually. Can be combined, for example "bosses, items"')
|
help='List of options that can be set manually. Can be combined, for example "bosses, items"')
|
||||||
parser.add_argument("--skip_prog_balancing", action="store_true",
|
parser.add_argument("--skip_prog_balancing", action="store_true",
|
||||||
help="Skip progression balancing step during generation.")
|
help="Skip progression balancing step during generation.")
|
||||||
parser.add_argument("--skip_output", action="store_true",
|
|
||||||
help="Skips generation assertion and output stages and skips multidata and spoiler output. "
|
|
||||||
"Intended for debugging and testing purposes.")
|
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
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)
|
||||||
@@ -103,8 +100,8 @@ def main(args=None, callback=ERmain):
|
|||||||
del(meta_weights["meta_description"])
|
del(meta_weights["meta_description"])
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise ValueError("No meta description found for meta.yaml. Unable to verify.") from e
|
raise ValueError("No meta description found for meta.yaml. Unable to verify.") from e
|
||||||
if args.sameoptions:
|
if args.samesettings:
|
||||||
raise Exception("Cannot mix --sameoptions with --meta")
|
raise Exception("Cannot mix --samesettings with --meta")
|
||||||
else:
|
else:
|
||||||
meta_weights = None
|
meta_weights = None
|
||||||
player_id = 1
|
player_id = 1
|
||||||
@@ -120,7 +117,7 @@ def main(args=None, callback=ERmain):
|
|||||||
raise ValueError(f"File {fname} is invalid. Please fix your yaml.") from e
|
raise ValueError(f"File {fname} is invalid. Please fix your yaml.") from e
|
||||||
|
|
||||||
# sort dict for consistent results across platforms:
|
# sort dict for consistent results across platforms:
|
||||||
weights_cache = {key: value for key, value in sorted(weights_cache.items(), key=lambda k: k[0].casefold())}
|
weights_cache = {key: value for key, value in sorted(weights_cache.items())}
|
||||||
for filename, yaml_data in weights_cache.items():
|
for filename, yaml_data in weights_cache.items():
|
||||||
if filename not in {args.meta_file_path, args.weights_file_path}:
|
if filename not in {args.meta_file_path, args.weights_file_path}:
|
||||||
for yaml in yaml_data:
|
for yaml in yaml_data:
|
||||||
@@ -130,13 +127,6 @@ def main(args=None, callback=ERmain):
|
|||||||
player_id += 1
|
player_id += 1
|
||||||
|
|
||||||
args.multi = max(player_id - 1, args.multi)
|
args.multi = max(player_id - 1, args.multi)
|
||||||
|
|
||||||
if args.multi == 0:
|
|
||||||
raise ValueError(
|
|
||||||
"No individual player files found and number of players is 0. "
|
|
||||||
"Provide individual player files or specify the number of players via host.yaml or --multi."
|
|
||||||
)
|
|
||||||
|
|
||||||
logging.info(f"Generating for {args.multi} player{'s' if args.multi > 1 else ''}, "
|
logging.info(f"Generating for {args.multi} player{'s' if args.multi > 1 else ''}, "
|
||||||
f"{seed_name} Seed {seed} with plando: {args.plando}")
|
f"{seed_name} Seed {seed} with plando: {args.plando}")
|
||||||
|
|
||||||
@@ -147,15 +137,15 @@ def main(args=None, callback=ERmain):
|
|||||||
erargs = parse_arguments(['--multi', str(args.multi)])
|
erargs = parse_arguments(['--multi', str(args.multi)])
|
||||||
erargs.seed = seed
|
erargs.seed = seed
|
||||||
erargs.plando_options = args.plando
|
erargs.plando_options = args.plando
|
||||||
|
erargs.glitch_triforce = options.generator.glitch_triforce_room
|
||||||
erargs.spoiler = args.spoiler
|
erargs.spoiler = args.spoiler
|
||||||
erargs.race = args.race
|
erargs.race = args.race
|
||||||
erargs.outputname = seed_name
|
erargs.outputname = seed_name
|
||||||
erargs.outputpath = args.outputpath
|
erargs.outputpath = args.outputpath
|
||||||
erargs.skip_prog_balancing = args.skip_prog_balancing
|
erargs.skip_prog_balancing = args.skip_prog_balancing
|
||||||
erargs.skip_output = args.skip_output
|
|
||||||
|
|
||||||
settings_cache: Dict[str, Tuple[argparse.Namespace, ...]] = \
|
settings_cache: Dict[str, Tuple[argparse.Namespace, ...]] = \
|
||||||
{fname: (tuple(roll_settings(yaml, args.plando) for yaml in yamls) if args.sameoptions else None)
|
{fname: (tuple(roll_settings(yaml, args.plando) for yaml in yamls) if args.samesettings else None)
|
||||||
for fname, yamls in weights_cache.items()}
|
for fname, yamls in weights_cache.items()}
|
||||||
|
|
||||||
if meta_weights:
|
if meta_weights:
|
||||||
@@ -167,8 +157,7 @@ def main(args=None, callback=ERmain):
|
|||||||
for yaml in weights_cache[path]:
|
for yaml in weights_cache[path]:
|
||||||
if category_name is None:
|
if category_name is None:
|
||||||
for category in yaml:
|
for category in yaml:
|
||||||
if category in AutoWorldRegister.world_types and \
|
if category in AutoWorldRegister.world_types and key in Options.common_options:
|
||||||
key in Options.CommonOptions.type_hints:
|
|
||||||
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}.")
|
||||||
@@ -179,7 +168,7 @@ def main(args=None, callback=ERmain):
|
|||||||
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()
|
name_counter = Counter()
|
||||||
erargs.player_options = {}
|
erargs.player_settings = {}
|
||||||
|
|
||||||
player = 1
|
player = 1
|
||||||
while player <= args.multi:
|
while player <= args.multi:
|
||||||
@@ -235,7 +224,7 @@ def main(args=None, callback=ERmain):
|
|||||||
with open(os.path.join(args.outputpath if args.outputpath else ".", f"generate_{seed_name}.yaml"), "wt") as f:
|
with open(os.path.join(args.outputpath if args.outputpath else ".", f"generate_{seed_name}.yaml"), "wt") as f:
|
||||||
yaml.dump(important, f)
|
yaml.dump(important, f)
|
||||||
|
|
||||||
return callback(erargs, seed)
|
callback(erargs, seed)
|
||||||
|
|
||||||
|
|
||||||
def read_weights_yamls(path) -> Tuple[Any, ...]:
|
def read_weights_yamls(path) -> Tuple[Any, ...]:
|
||||||
@@ -301,43 +290,46 @@ def handle_name(name: str, player: int, name_counter: Counter):
|
|||||||
NUMBER=(number if number > 1 else ''),
|
NUMBER=(number if number > 1 else ''),
|
||||||
player=player,
|
player=player,
|
||||||
PLAYER=(player if player > 1 else '')))
|
PLAYER=(player if player > 1 else '')))
|
||||||
# Run .strip twice for edge case where after the initial .slice new_name has a leading whitespace.
|
new_name = new_name.strip()[:16]
|
||||||
# Could cause issues for some clients that cannot handle the additional whitespace.
|
|
||||||
new_name = new_name.strip()[:16].strip()
|
|
||||||
if new_name == "Archipelago":
|
if new_name == "Archipelago":
|
||||||
raise Exception(f"You cannot name yourself \"{new_name}\"")
|
raise Exception(f"You cannot name yourself \"{new_name}\"")
|
||||||
return new_name
|
return new_name
|
||||||
|
|
||||||
|
|
||||||
|
def prefer_int(input_data: str) -> Union[str, int]:
|
||||||
|
try:
|
||||||
|
return int(input_data)
|
||||||
|
except:
|
||||||
|
return input_data
|
||||||
|
|
||||||
|
|
||||||
|
goals = {
|
||||||
|
'ganon': 'ganon',
|
||||||
|
'crystals': 'crystals',
|
||||||
|
'bosses': 'bosses',
|
||||||
|
'pedestal': 'pedestal',
|
||||||
|
'ganon_pedestal': 'ganonpedestal',
|
||||||
|
'triforce_hunt': 'triforcehunt',
|
||||||
|
'local_triforce_hunt': 'localtriforcehunt',
|
||||||
|
'ganon_triforce_hunt': 'ganontriforcehunt',
|
||||||
|
'local_ganon_triforce_hunt': 'localganontriforcehunt',
|
||||||
|
'ice_rod_hunt': 'icerodhunt',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def roll_percentage(percentage: Union[int, float]) -> bool:
|
def roll_percentage(percentage: Union[int, float]) -> bool:
|
||||||
"""Roll a percentage chance.
|
"""Roll a percentage chance.
|
||||||
percentage is expected to be in range [0, 100]"""
|
percentage is expected to be in range [0, 100]"""
|
||||||
return random.random() < (float(percentage) / 100)
|
return random.random() < (float(percentage) / 100)
|
||||||
|
|
||||||
|
|
||||||
def update_weights(weights: dict, new_weights: dict, update_type: str, name: str) -> dict:
|
def update_weights(weights: dict, new_weights: dict, type: str, name: str) -> dict:
|
||||||
logging.debug(f'Applying {new_weights}')
|
logging.debug(f'Applying {new_weights}')
|
||||||
cleaned_weights = {}
|
new_options = set(new_weights) - set(weights)
|
||||||
for option in new_weights:
|
weights.update(new_weights)
|
||||||
option_name = option.lstrip("+")
|
|
||||||
if option.startswith("+") and option_name in weights:
|
|
||||||
cleaned_value = weights[option_name]
|
|
||||||
new_value = new_weights[option]
|
|
||||||
if isinstance(new_value, (set, dict)):
|
|
||||||
cleaned_value.update(new_value)
|
|
||||||
elif isinstance(new_value, list):
|
|
||||||
cleaned_value.extend(new_value)
|
|
||||||
else:
|
|
||||||
raise Exception(f"Cannot apply merge to non-dict, set, or list type {option_name},"
|
|
||||||
f" received {type(new_value).__name__}.")
|
|
||||||
cleaned_weights[option_name] = cleaned_value
|
|
||||||
else:
|
|
||||||
cleaned_weights[option_name] = new_weights[option]
|
|
||||||
new_options = set(cleaned_weights) - set(weights)
|
|
||||||
weights.update(cleaned_weights)
|
|
||||||
if new_options:
|
if new_options:
|
||||||
for new_option in new_options:
|
for new_option in new_options:
|
||||||
logging.warning(f'{update_type} Suboption "{new_option}" of "{name}" did not '
|
logging.warning(f'{type} Suboption "{new_option}" of "{name}" did not '
|
||||||
f'overwrite a root option. '
|
f'overwrite a root option. '
|
||||||
f'This is probably in error.')
|
f'This is probably in error.')
|
||||||
return weights
|
return weights
|
||||||
@@ -348,12 +340,21 @@ def roll_meta_option(option_key, game: str, category_dict: Dict) -> Any:
|
|||||||
return get_choice(option_key, category_dict)
|
return get_choice(option_key, category_dict)
|
||||||
if game in AutoWorldRegister.world_types:
|
if game in AutoWorldRegister.world_types:
|
||||||
game_world = AutoWorldRegister.world_types[game]
|
game_world = AutoWorldRegister.world_types[game]
|
||||||
options = game_world.options_dataclass.type_hints
|
options = ChainMap(game_world.option_definitions, Options.per_game_common_options)
|
||||||
if option_key in options:
|
if option_key in options:
|
||||||
if options[option_key].supports_weighting:
|
if options[option_key].supports_weighting:
|
||||||
return get_choice(option_key, category_dict)
|
return get_choice(option_key, category_dict)
|
||||||
return category_dict[option_key]
|
return category_dict[option_key]
|
||||||
raise Options.OptionError(f"Error generating meta option {option_key} for {game}.")
|
if game == "A Link to the Past": # TODO wow i hate this
|
||||||
|
if option_key in {"glitches_required", "dark_room_logic", "entrance_shuffle", "goals", "triforce_pieces_mode",
|
||||||
|
"triforce_pieces_percentage", "triforce_pieces_available", "triforce_pieces_extra",
|
||||||
|
"triforce_pieces_required", "shop_shuffle", "mode", "item_pool", "item_functionality",
|
||||||
|
"boss_shuffle", "enemy_damage", "enemy_health", "timer", "countdown_start_time",
|
||||||
|
"red_clock_time", "blue_clock_time", "green_clock_time", "dungeon_counters", "shuffle_prizes",
|
||||||
|
"misery_mire_medallion", "turtle_rock_medallion", "sprite_pool", "sprite",
|
||||||
|
"random_sprite_on_event"}:
|
||||||
|
return get_choice(option_key, category_dict)
|
||||||
|
raise Exception(f"Error generating meta option {option_key} for {game}.")
|
||||||
|
|
||||||
|
|
||||||
def roll_linked_options(weights: dict) -> dict:
|
def roll_linked_options(weights: dict) -> dict:
|
||||||
@@ -378,7 +379,7 @@ def roll_linked_options(weights: dict) -> dict:
|
|||||||
return weights
|
return weights
|
||||||
|
|
||||||
|
|
||||||
def roll_triggers(weights: dict, triggers: list, valid_keys: set) -> dict:
|
def roll_triggers(weights: dict, triggers: list) -> dict:
|
||||||
weights = copy.deepcopy(weights) # make sure we don't write back to other weights sets in same_settings
|
weights = copy.deepcopy(weights) # make sure we don't write back to other weights sets in same_settings
|
||||||
weights["_Generator_Version"] = Utils.__version__
|
weights["_Generator_Version"] = Utils.__version__
|
||||||
for i, option_set in enumerate(triggers):
|
for i, option_set in enumerate(triggers):
|
||||||
@@ -401,7 +402,7 @@ def roll_triggers(weights: dict, triggers: list, valid_keys: set) -> dict:
|
|||||||
if category_name:
|
if category_name:
|
||||||
currently_targeted_weights = currently_targeted_weights[category_name]
|
currently_targeted_weights = currently_targeted_weights[category_name]
|
||||||
update_weights(currently_targeted_weights, category_options, "Triggered", option_set["option_name"])
|
update_weights(currently_targeted_weights, category_options, "Triggered", option_set["option_name"])
|
||||||
valid_keys.add(key)
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise ValueError(f"Your trigger number {i + 1} is invalid. "
|
raise ValueError(f"Your trigger number {i + 1} is invalid. "
|
||||||
f"Please fix your triggers.") from e
|
f"Please fix your triggers.") from e
|
||||||
@@ -409,29 +410,27 @@ def roll_triggers(weights: dict, triggers: list, valid_keys: set) -> dict:
|
|||||||
|
|
||||||
|
|
||||||
def handle_option(ret: argparse.Namespace, game_weights: dict, option_key: str, option: type(Options.Option), plando_options: PlandoOptions):
|
def handle_option(ret: argparse.Namespace, game_weights: dict, option_key: str, option: type(Options.Option), plando_options: PlandoOptions):
|
||||||
try:
|
if option_key in game_weights:
|
||||||
if option_key in game_weights:
|
try:
|
||||||
if not option.supports_weighting:
|
if not option.supports_weighting:
|
||||||
player_option = option.from_any(game_weights[option_key])
|
player_option = option.from_any(game_weights[option_key])
|
||||||
else:
|
else:
|
||||||
player_option = option.from_any(get_choice(option_key, game_weights))
|
player_option = option.from_any(get_choice(option_key, game_weights))
|
||||||
del game_weights[option_key]
|
setattr(ret, option_key, player_option)
|
||||||
|
except Exception as e:
|
||||||
|
raise Exception(f"Error generating option {option_key} in {ret.game}") from e
|
||||||
else:
|
else:
|
||||||
player_option = option.from_any(option.default) # call the from_any here to support default "random"
|
player_option.verify(AutoWorldRegister.world_types[ret.game], ret.name, plando_options)
|
||||||
setattr(ret, option_key, player_option)
|
|
||||||
except Exception as e:
|
|
||||||
raise Options.OptionError(f"Error generating option {option_key} in {ret.game}") from e
|
|
||||||
else:
|
else:
|
||||||
player_option.verify(AutoWorldRegister.world_types[ret.game], ret.name, plando_options)
|
setattr(ret, option_key, option.from_any(option.default)) # call the from_any here to support default "random"
|
||||||
|
|
||||||
|
|
||||||
def roll_settings(weights: dict, plando_options: PlandoOptions = PlandoOptions.bosses):
|
def roll_settings(weights: dict, plando_options: PlandoOptions = PlandoOptions.bosses):
|
||||||
if "linked_options" in weights:
|
if "linked_options" in weights:
|
||||||
weights = roll_linked_options(weights)
|
weights = roll_linked_options(weights)
|
||||||
|
|
||||||
valid_trigger_names = set()
|
|
||||||
if "triggers" in weights:
|
if "triggers" in weights:
|
||||||
weights = roll_triggers(weights, weights["triggers"], valid_trigger_names)
|
weights = roll_triggers(weights, weights["triggers"])
|
||||||
|
|
||||||
requirements = weights.get("requires", {})
|
requirements = weights.get("requires", {})
|
||||||
if requirements:
|
if requirements:
|
||||||
@@ -446,17 +445,13 @@ def roll_settings(weights: dict, plando_options: PlandoOptions = PlandoOptions.b
|
|||||||
f"which is not enabled.")
|
f"which is not enabled.")
|
||||||
|
|
||||||
ret = argparse.Namespace()
|
ret = argparse.Namespace()
|
||||||
for option_key in Options.PerGameCommonOptions.type_hints:
|
for option_key in Options.per_game_common_options:
|
||||||
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.common_options:
|
||||||
raise Exception(f"Option {option_key} has to be in a game's section, not on its own.")
|
raise Exception(f"Option {option_key} has to be in a game's section, not on its own.")
|
||||||
|
|
||||||
ret.game = get_choice("game", weights)
|
ret.game = get_choice("game", weights)
|
||||||
if ret.game not in AutoWorldRegister.world_types:
|
if ret.game not in AutoWorldRegister.world_types:
|
||||||
picks = Utils.get_fuzzy_results(ret.game, list(AutoWorldRegister.world_types) + failed_world_loads, limit=1)[0]
|
picks = Utils.get_fuzzy_results(ret.game, AutoWorldRegister.world_types, limit=1)[0]
|
||||||
if picks[0] in failed_world_loads:
|
|
||||||
raise Exception(f"No functional world found to handle game {ret.game}. "
|
|
||||||
f"Did you mean '{picks[0]}' ({picks[1]}% sure)? "
|
|
||||||
f"If so, it appears the world failed to initialize correctly.")
|
|
||||||
raise Exception(f"No world found to handle game {ret.game}. Did you mean '{picks[0]}' ({picks[1]}% sure)? "
|
raise Exception(f"No world found to handle game {ret.game}. Did you mean '{picks[0]}' ({picks[1]}% sure)? "
|
||||||
f"Check your spelling or installation of that world.")
|
f"Check your spelling or installation of that world.")
|
||||||
|
|
||||||
@@ -466,43 +461,137 @@ def roll_settings(weights: dict, plando_options: PlandoOptions = PlandoOptions.b
|
|||||||
world_type = AutoWorldRegister.world_types[ret.game]
|
world_type = AutoWorldRegister.world_types[ret.game]
|
||||||
game_weights = weights[ret.game]
|
game_weights = weights[ret.game]
|
||||||
|
|
||||||
if any(weight.startswith("+") for weight in game_weights) or \
|
|
||||||
any(weight.startswith("+") for weight in weights):
|
|
||||||
raise Exception(f"Merge tag cannot be used outside of trigger contexts.")
|
|
||||||
|
|
||||||
if "triggers" in game_weights:
|
if "triggers" in game_weights:
|
||||||
weights = roll_triggers(weights, game_weights["triggers"], valid_trigger_names)
|
weights = roll_triggers(weights, game_weights["triggers"])
|
||||||
game_weights = weights[ret.game]
|
game_weights = weights[ret.game]
|
||||||
|
|
||||||
ret.name = get_choice('name', weights)
|
ret.name = get_choice('name', weights)
|
||||||
for option_key, option in Options.CommonOptions.type_hints.items():
|
for option_key, option in Options.common_options.items():
|
||||||
setattr(ret, option_key, option.from_any(get_choice(option_key, weights, option.default)))
|
setattr(ret, option_key, option.from_any(get_choice(option_key, weights, option.default)))
|
||||||
|
|
||||||
for option_key, option in world_type.options_dataclass.type_hints.items():
|
for option_key, option in world_type.option_definitions.items():
|
||||||
handle_option(ret, game_weights, option_key, option, plando_options)
|
handle_option(ret, game_weights, option_key, option, plando_options)
|
||||||
for option_key in game_weights:
|
for option_key, option in Options.per_game_common_options.items():
|
||||||
if option_key in {"triggers", *valid_trigger_names}:
|
# skip setting this option if already set from common_options, defaulting to root option
|
||||||
continue
|
if option_key not in world_type.option_definitions and \
|
||||||
logging.warning(f"{option_key} is not a valid option name for {ret.game} and is not present in triggers.")
|
(option_key not in Options.common_options or option_key in game_weights):
|
||||||
|
handle_option(ret, game_weights, option_key, option, plando_options)
|
||||||
if PlandoOptions.items in plando_options:
|
if PlandoOptions.items in plando_options:
|
||||||
ret.plando_items = game_weights.get("plando_items", [])
|
ret.plando_items = game_weights.get("plando_items", [])
|
||||||
if ret.game == "A Link to the Past":
|
if ret.game == "Minecraft" or ret.game == "Ocarina of Time":
|
||||||
roll_alttp_settings(ret, game_weights, plando_options)
|
# bad hardcoded behavior to make this work for now
|
||||||
if PlandoOptions.connections in plando_options:
|
|
||||||
ret.plando_connections = []
|
ret.plando_connections = []
|
||||||
options = game_weights.get("plando_connections", [])
|
if PlandoOptions.connections in plando_options:
|
||||||
for placement in options:
|
options = game_weights.get("plando_connections", [])
|
||||||
if roll_percentage(get_choice("percentage", placement, 100)):
|
for placement in options:
|
||||||
ret.plando_connections.append(PlandoConnection(
|
if roll_percentage(get_choice("percentage", placement, 100)):
|
||||||
get_choice("entrance", placement),
|
ret.plando_connections.append(PlandoConnection(
|
||||||
get_choice("exit", placement),
|
get_choice("entrance", placement),
|
||||||
get_choice("direction", placement, "both")
|
get_choice("exit", placement),
|
||||||
))
|
get_choice("direction", placement)
|
||||||
|
))
|
||||||
|
elif ret.game == "A Link to the Past":
|
||||||
|
roll_alttp_settings(ret, game_weights, plando_options)
|
||||||
|
|
||||||
return ret
|
return ret
|
||||||
|
|
||||||
|
|
||||||
def roll_alttp_settings(ret: argparse.Namespace, weights, plando_options):
|
def roll_alttp_settings(ret: argparse.Namespace, weights, plando_options):
|
||||||
|
if "dungeon_items" in weights and get_choice_legacy('dungeon_items', weights, "none") != "none":
|
||||||
|
raise Exception(f"dungeon_items key in A Link to the Past was removed, but is present in these weights as {get_choice_legacy('dungeon_items', weights, False)}.")
|
||||||
|
glitches_required = get_choice_legacy('glitches_required', weights)
|
||||||
|
if glitches_required not in [None, 'none', 'no_logic', 'overworld_glitches', 'hybrid_major_glitches', 'minor_glitches']:
|
||||||
|
logging.warning("Only NMG, OWG, HMG and No Logic supported")
|
||||||
|
glitches_required = 'none'
|
||||||
|
ret.logic = {None: 'noglitches', 'none': 'noglitches', 'no_logic': 'nologic', 'overworld_glitches': 'owglitches',
|
||||||
|
'minor_glitches': 'minorglitches', 'hybrid_major_glitches': 'hybridglitches'}[
|
||||||
|
glitches_required]
|
||||||
|
|
||||||
|
ret.dark_room_logic = get_choice_legacy("dark_room_logic", weights, "lamp")
|
||||||
|
if not ret.dark_room_logic: # None/False
|
||||||
|
ret.dark_room_logic = "none"
|
||||||
|
if ret.dark_room_logic == "sconces":
|
||||||
|
ret.dark_room_logic = "torches"
|
||||||
|
if ret.dark_room_logic not in {"lamp", "torches", "none"}:
|
||||||
|
raise ValueError(f"Unknown Dark Room Logic: \"{ret.dark_room_logic}\"")
|
||||||
|
|
||||||
|
entrance_shuffle = get_choice_legacy('entrance_shuffle', weights, 'vanilla')
|
||||||
|
if entrance_shuffle.startswith('none-'):
|
||||||
|
ret.shuffle = 'vanilla'
|
||||||
|
else:
|
||||||
|
ret.shuffle = entrance_shuffle if entrance_shuffle != 'none' else 'vanilla'
|
||||||
|
|
||||||
|
goal = get_choice_legacy('goals', weights, 'ganon')
|
||||||
|
|
||||||
|
ret.goal = goals[goal]
|
||||||
|
|
||||||
|
|
||||||
|
extra_pieces = get_choice_legacy('triforce_pieces_mode', weights, 'available')
|
||||||
|
|
||||||
|
ret.triforce_pieces_required = LttPOptions.TriforcePieces.from_any(get_choice_legacy('triforce_pieces_required', weights, 20))
|
||||||
|
|
||||||
|
# sum a percentage to required
|
||||||
|
if extra_pieces == 'percentage':
|
||||||
|
percentage = max(100, float(get_choice_legacy('triforce_pieces_percentage', weights, 150))) / 100
|
||||||
|
ret.triforce_pieces_available = int(round(ret.triforce_pieces_required * percentage, 0))
|
||||||
|
# vanilla mode (specify how many pieces are)
|
||||||
|
elif extra_pieces == 'available':
|
||||||
|
ret.triforce_pieces_available = LttPOptions.TriforcePieces.from_any(
|
||||||
|
get_choice_legacy('triforce_pieces_available', weights, 30))
|
||||||
|
# required pieces + fixed extra
|
||||||
|
elif extra_pieces == 'extra':
|
||||||
|
extra_pieces = max(0, int(get_choice_legacy('triforce_pieces_extra', weights, 10)))
|
||||||
|
ret.triforce_pieces_available = ret.triforce_pieces_required + extra_pieces
|
||||||
|
|
||||||
|
# change minimum to required pieces to avoid problems
|
||||||
|
ret.triforce_pieces_available = min(max(ret.triforce_pieces_required, int(ret.triforce_pieces_available)), 90)
|
||||||
|
|
||||||
|
ret.shop_shuffle = get_choice_legacy('shop_shuffle', weights, '')
|
||||||
|
if not ret.shop_shuffle:
|
||||||
|
ret.shop_shuffle = ''
|
||||||
|
|
||||||
|
ret.mode = get_choice_legacy("mode", weights)
|
||||||
|
|
||||||
|
ret.difficulty = get_choice_legacy('item_pool', weights)
|
||||||
|
|
||||||
|
ret.item_functionality = get_choice_legacy('item_functionality', weights)
|
||||||
|
|
||||||
|
|
||||||
|
ret.enemy_damage = {None: 'default',
|
||||||
|
'default': 'default',
|
||||||
|
'shuffled': 'shuffled',
|
||||||
|
'random': 'chaos', # to be removed
|
||||||
|
'chaos': 'chaos',
|
||||||
|
}[get_choice_legacy('enemy_damage', weights)]
|
||||||
|
|
||||||
|
ret.enemy_health = get_choice_legacy('enemy_health', weights)
|
||||||
|
|
||||||
|
ret.timer = {'none': False,
|
||||||
|
None: False,
|
||||||
|
False: False,
|
||||||
|
'timed': 'timed',
|
||||||
|
'timed_ohko': 'timed-ohko',
|
||||||
|
'ohko': 'ohko',
|
||||||
|
'timed_countdown': 'timed-countdown',
|
||||||
|
'display': 'display'}[get_choice_legacy('timer', weights, False)]
|
||||||
|
|
||||||
|
ret.countdown_start_time = int(get_choice_legacy('countdown_start_time', weights, 10))
|
||||||
|
ret.red_clock_time = int(get_choice_legacy('red_clock_time', weights, -2))
|
||||||
|
ret.blue_clock_time = int(get_choice_legacy('blue_clock_time', weights, 2))
|
||||||
|
ret.green_clock_time = int(get_choice_legacy('green_clock_time', weights, 4))
|
||||||
|
|
||||||
|
ret.dungeon_counters = get_choice_legacy('dungeon_counters', weights, 'default')
|
||||||
|
|
||||||
|
ret.shuffle_prizes = get_choice_legacy('shuffle_prizes', weights, "g")
|
||||||
|
|
||||||
|
ret.required_medallions = [get_choice_legacy("misery_mire_medallion", weights, "random"),
|
||||||
|
get_choice_legacy("turtle_rock_medallion", weights, "random")]
|
||||||
|
|
||||||
|
for index, medallion in enumerate(ret.required_medallions):
|
||||||
|
ret.required_medallions[index] = {"ether": "Ether", "quake": "Quake", "bombos": "Bombos", "random": "random"} \
|
||||||
|
.get(medallion.lower(), None)
|
||||||
|
if not ret.required_medallions[index]:
|
||||||
|
raise Exception(f"unknown Medallion {medallion} for {'misery mire' if index == 0 else 'turtle rock'}")
|
||||||
|
|
||||||
ret.plando_texts = {}
|
ret.plando_texts = {}
|
||||||
if PlandoOptions.texts in plando_options:
|
if PlandoOptions.texts in plando_options:
|
||||||
@@ -516,6 +605,17 @@ def roll_alttp_settings(ret: argparse.Namespace, weights, plando_options):
|
|||||||
raise Exception(f"No text target \"{at}\" found.")
|
raise Exception(f"No text target \"{at}\" found.")
|
||||||
ret.plando_texts[at] = str(get_choice_legacy("text", placement))
|
ret.plando_texts[at] = str(get_choice_legacy("text", placement))
|
||||||
|
|
||||||
|
ret.plando_connections = []
|
||||||
|
if PlandoOptions.connections in plando_options:
|
||||||
|
options = weights.get("plando_connections", [])
|
||||||
|
for placement in options:
|
||||||
|
if roll_percentage(get_choice_legacy("percentage", placement, 100)):
|
||||||
|
ret.plando_connections.append(PlandoConnection(
|
||||||
|
get_choice_legacy("entrance", placement),
|
||||||
|
get_choice_legacy("exit", placement),
|
||||||
|
get_choice_legacy("direction", placement, "both")
|
||||||
|
))
|
||||||
|
|
||||||
ret.sprite_pool = weights.get('sprite_pool', [])
|
ret.sprite_pool = weights.get('sprite_pool', [])
|
||||||
ret.sprite = get_choice_legacy('sprite', weights, "Link")
|
ret.sprite = get_choice_legacy('sprite', weights, "Link")
|
||||||
if 'random_sprite_on_event' in weights:
|
if 'random_sprite_on_event' in weights:
|
||||||
@@ -543,15 +643,6 @@ def roll_alttp_settings(ret: argparse.Namespace, weights, plando_options):
|
|||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
import atexit
|
import atexit
|
||||||
confirmation = atexit.register(input, "Press enter to close.")
|
confirmation = atexit.register(input, "Press enter to close.")
|
||||||
multiworld = main()
|
main()
|
||||||
if __debug__:
|
|
||||||
import gc
|
|
||||||
import sys
|
|
||||||
import weakref
|
|
||||||
weak = weakref.ref(multiworld)
|
|
||||||
del multiworld
|
|
||||||
gc.collect() # need to collect to deref all hard references
|
|
||||||
assert not weak(), f"MultiWorld object was not de-allocated, it's referenced {sys.getrefcount(weak())} times." \
|
|
||||||
" This would be a memory leak."
|
|
||||||
# in case of error-free exit should not need confirmation
|
# in case of error-free exit should not need confirmation
|
||||||
atexit.unregister(confirmation)
|
atexit.unregister(confirmation)
|
||||||
|
|||||||
892
KH2Client.py
892
KH2Client.py
@@ -1,8 +1,894 @@
|
|||||||
|
import os
|
||||||
|
import asyncio
|
||||||
import ModuleUpdate
|
import ModuleUpdate
|
||||||
|
import json
|
||||||
import Utils
|
import Utils
|
||||||
from worlds.kh2.Client import launch
|
from pymem import pymem
|
||||||
|
from worlds.kh2.Items import exclusionItem_table, CheckDupingItems
|
||||||
|
from worlds.kh2 import all_locations, item_dictionary_table, exclusion_table
|
||||||
|
|
||||||
|
from worlds.kh2.WorldLocations import *
|
||||||
|
|
||||||
|
from worlds import network_data_package
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
Utils.init_logging("KH2Client", exception_logger="Client")
|
||||||
|
|
||||||
|
from NetUtils import ClientStatus
|
||||||
|
from CommonClient import gui_enabled, logger, get_base_parser, ClientCommandProcessor, \
|
||||||
|
CommonContext, server_loop
|
||||||
|
|
||||||
ModuleUpdate.update()
|
ModuleUpdate.update()
|
||||||
|
|
||||||
|
kh2_loc_name_to_id = network_data_package["games"]["Kingdom Hearts 2"]["location_name_to_id"]
|
||||||
|
|
||||||
|
|
||||||
|
# class KH2CommandProcessor(ClientCommandProcessor):
|
||||||
|
|
||||||
|
|
||||||
|
class KH2Context(CommonContext):
|
||||||
|
# command_processor: int = KH2CommandProcessor
|
||||||
|
game = "Kingdom Hearts 2"
|
||||||
|
items_handling = 0b101 # Indicates you get items sent from other worlds.
|
||||||
|
|
||||||
|
def __init__(self, server_address, password):
|
||||||
|
super(KH2Context, self).__init__(server_address, password)
|
||||||
|
self.kh2LocalItems = None
|
||||||
|
self.ability = None
|
||||||
|
self.growthlevel = None
|
||||||
|
self.KH2_sync_task = None
|
||||||
|
self.syncing = False
|
||||||
|
self.kh2connected = False
|
||||||
|
self.serverconneced = False
|
||||||
|
self.item_name_to_data = {name: data for name, data, in item_dictionary_table.items()}
|
||||||
|
self.location_name_to_data = {name: data for name, data, in all_locations.items()}
|
||||||
|
self.lookup_id_to_item: typing.Dict[int, str] = {data.code: item_name for item_name, data in
|
||||||
|
item_dictionary_table.items() if data.code}
|
||||||
|
self.lookup_id_to_Location: typing.Dict[int, str] = {data.code: item_name for item_name, data in
|
||||||
|
all_locations.items() if data.code}
|
||||||
|
self.location_name_to_worlddata = {name: data for name, data, in all_world_locations.items()}
|
||||||
|
|
||||||
|
self.location_table = {}
|
||||||
|
self.collectible_table = {}
|
||||||
|
self.collectible_override_flags_address = 0
|
||||||
|
self.collectible_offsets = {}
|
||||||
|
self.sending = []
|
||||||
|
# list used to keep track of locations+items player has. Used for disoneccting
|
||||||
|
self.kh2seedsave = None
|
||||||
|
self.slotDataProgressionNames = {}
|
||||||
|
self.kh2seedname = None
|
||||||
|
self.kh2slotdata = None
|
||||||
|
self.itemamount = {}
|
||||||
|
# sora equipped, valor equipped, master equipped, final equipped
|
||||||
|
self.keybladeAnchorList = (0x24F0, 0x32F4, 0x339C, 0x33D4)
|
||||||
|
if "localappdata" in os.environ:
|
||||||
|
self.game_communication_path = os.path.expandvars(r"%localappdata%\KH2AP")
|
||||||
|
self.amountOfPieces = 0
|
||||||
|
# hooked object
|
||||||
|
self.kh2 = None
|
||||||
|
self.ItemIsSafe = False
|
||||||
|
self.game_connected = False
|
||||||
|
self.finalxemnas = False
|
||||||
|
self.worldid = {
|
||||||
|
# 1: {}, # world of darkness (story cutscenes)
|
||||||
|
2: TT_Checks,
|
||||||
|
# 3: {}, # destiny island doesn't have checks to ima put tt checks here
|
||||||
|
4: HB_Checks,
|
||||||
|
5: BC_Checks,
|
||||||
|
6: Oc_Checks,
|
||||||
|
7: AG_Checks,
|
||||||
|
8: LoD_Checks,
|
||||||
|
9: HundredAcreChecks,
|
||||||
|
10: PL_Checks,
|
||||||
|
11: DC_Checks, # atlantica isn't a supported world. if you go in atlantica it will check dc
|
||||||
|
12: DC_Checks,
|
||||||
|
13: TR_Checks,
|
||||||
|
14: HT_Checks,
|
||||||
|
15: HB_Checks, # world map, but you only go to the world map while on the way to goa so checking hb
|
||||||
|
16: PR_Checks,
|
||||||
|
17: SP_Checks,
|
||||||
|
18: TWTNW_Checks,
|
||||||
|
# 255: {}, # starting screen
|
||||||
|
}
|
||||||
|
# 0x2A09C00+0x40 is the sve anchor. +1 is the last saved room
|
||||||
|
self.sveroom = 0x2A09C00 + 0x41
|
||||||
|
# 0 not in battle 1 in yellow battle 2 red battle #short
|
||||||
|
self.inBattle = 0x2A0EAC4 + 0x40
|
||||||
|
self.onDeath = 0xAB9078
|
||||||
|
# PC Address anchors
|
||||||
|
self.Now = 0x0714DB8
|
||||||
|
self.Save = 0x09A70B0
|
||||||
|
self.Sys3 = 0x2A59DF0
|
||||||
|
self.Bt10 = 0x2A74880
|
||||||
|
self.BtlEnd = 0x2A0D3E0
|
||||||
|
self.Slot1 = 0x2A20C98
|
||||||
|
|
||||||
|
self.chest_set = set(exclusion_table["Chests"])
|
||||||
|
|
||||||
|
self.keyblade_set = set(CheckDupingItems["Weapons"]["Keyblades"])
|
||||||
|
self.staff_set = set(CheckDupingItems["Weapons"]["Staffs"])
|
||||||
|
self.shield_set = set(CheckDupingItems["Weapons"]["Shields"])
|
||||||
|
|
||||||
|
self.all_weapons = self.keyblade_set.union(self.staff_set).union(self.shield_set)
|
||||||
|
|
||||||
|
self.equipment_categories = CheckDupingItems["Equipment"]
|
||||||
|
self.armor_set = set(self.equipment_categories["Armor"])
|
||||||
|
self.accessories_set = set(self.equipment_categories["Accessories"])
|
||||||
|
self.all_equipment = self.armor_set.union(self.accessories_set)
|
||||||
|
|
||||||
|
self.Equipment_Anchor_Dict = {
|
||||||
|
"Armor": [0x2504, 0x2506, 0x2508, 0x250A],
|
||||||
|
"Accessories": [0x2514, 0x2516, 0x2518, 0x251A]}
|
||||||
|
|
||||||
|
self.AbilityQuantityDict = {}
|
||||||
|
self.ability_categories = CheckDupingItems["Abilities"]
|
||||||
|
|
||||||
|
self.sora_ability_set = set(self.ability_categories["Sora"])
|
||||||
|
self.donald_ability_set = set(self.ability_categories["Donald"])
|
||||||
|
self.goofy_ability_set = set(self.ability_categories["Goofy"])
|
||||||
|
|
||||||
|
self.all_abilities = self.sora_ability_set.union(self.donald_ability_set).union(self.goofy_ability_set)
|
||||||
|
|
||||||
|
self.boost_set = set(CheckDupingItems["Boosts"])
|
||||||
|
self.stat_increase_set = set(CheckDupingItems["Stat Increases"])
|
||||||
|
self.AbilityQuantityDict = {item: self.item_name_to_data[item].quantity for item in self.all_abilities}
|
||||||
|
# Growth:[level 1,level 4,slot]
|
||||||
|
self.growth_values_dict = {"High Jump": [0x05E, 0x061, 0x25DA],
|
||||||
|
"Quick Run": [0x62, 0x65, 0x25DC],
|
||||||
|
"Dodge Roll": [0x234, 0x237, 0x25DE],
|
||||||
|
"Aerial Dodge": [0x066, 0x069, 0x25E0],
|
||||||
|
"Glide": [0x6A, 0x6D, 0x25E2]}
|
||||||
|
self.boost_to_anchor_dict = {
|
||||||
|
"Power Boost": 0x24F9,
|
||||||
|
"Magic Boost": 0x24FA,
|
||||||
|
"Defense Boost": 0x24FB,
|
||||||
|
"AP Boost": 0x24F8}
|
||||||
|
|
||||||
|
self.AbilityCodeList = [self.item_name_to_data[item].code for item in exclusionItem_table["Ability"]]
|
||||||
|
self.master_growth = {"High Jump", "Quick Run", "Dodge Roll", "Aerial Dodge", "Glide"}
|
||||||
|
|
||||||
|
self.bitmask_item_code = [
|
||||||
|
0x130000, 0x130001, 0x130002, 0x130003, 0x130004, 0x130005, 0x130006, 0x130007
|
||||||
|
, 0x130008, 0x130009, 0x13000A, 0x13000B, 0x13000C
|
||||||
|
, 0x13001F, 0x130020, 0x130021, 0x130022, 0x130023
|
||||||
|
, 0x13002A, 0x13002B, 0x13002C, 0x13002D]
|
||||||
|
|
||||||
|
async def server_auth(self, password_requested: bool = False):
|
||||||
|
if password_requested and not self.password:
|
||||||
|
await super(KH2Context, self).server_auth(password_requested)
|
||||||
|
await self.get_username()
|
||||||
|
await self.send_connect()
|
||||||
|
|
||||||
|
async def connection_closed(self):
|
||||||
|
self.kh2connected = False
|
||||||
|
self.serverconneced = False
|
||||||
|
if self.kh2seedname is not None and self.auth is not None:
|
||||||
|
with open(os.path.join(self.game_communication_path, f"kh2save{self.kh2seedname}{self.auth}.json"),
|
||||||
|
'w') as f:
|
||||||
|
f.write(json.dumps(self.kh2seedsave, indent=4))
|
||||||
|
await super(KH2Context, self).connection_closed()
|
||||||
|
|
||||||
|
async def disconnect(self, allow_autoreconnect: bool = False):
|
||||||
|
self.kh2connected = False
|
||||||
|
self.serverconneced = False
|
||||||
|
if self.kh2seedname not in {None} and self.auth not in {None}:
|
||||||
|
with open(os.path.join(self.game_communication_path, f"kh2save{self.kh2seedname}{self.auth}.json"),
|
||||||
|
'w') as f:
|
||||||
|
f.write(json.dumps(self.kh2seedsave, indent=4))
|
||||||
|
await super(KH2Context, self).disconnect()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def endpoints(self):
|
||||||
|
if self.server:
|
||||||
|
return [self.server]
|
||||||
|
else:
|
||||||
|
return []
|
||||||
|
|
||||||
|
async def shutdown(self):
|
||||||
|
if self.kh2seedname not in {None} and self.auth not in {None}:
|
||||||
|
with open(os.path.join(self.game_communication_path, f"kh2save{self.kh2seedname}{self.auth}.json"),
|
||||||
|
'w') as f:
|
||||||
|
f.write(json.dumps(self.kh2seedsave, indent=4))
|
||||||
|
await super(KH2Context, self).shutdown()
|
||||||
|
|
||||||
|
def on_package(self, cmd: str, args: dict):
|
||||||
|
if cmd in {"RoomInfo"}:
|
||||||
|
self.kh2seedname = args['seed_name']
|
||||||
|
if not os.path.exists(self.game_communication_path):
|
||||||
|
os.makedirs(self.game_communication_path)
|
||||||
|
if not os.path.exists(self.game_communication_path + f"\kh2save{self.kh2seedname}{self.auth}.json"):
|
||||||
|
self.kh2seedsave = {"itemIndex": -1,
|
||||||
|
# back of soras invo is 0x25E2. Growth should be moved there
|
||||||
|
# Character: [back of invo, front of invo]
|
||||||
|
"SoraInvo": [0x25D8, 0x2546],
|
||||||
|
"DonaldInvo": [0x26F4, 0x2658],
|
||||||
|
"GoofyInvo": [0x280A, 0x276C],
|
||||||
|
"AmountInvo": {
|
||||||
|
"ServerItems": {
|
||||||
|
"Ability": {},
|
||||||
|
"Amount": {},
|
||||||
|
"Growth": {"High Jump": 0, "Quick Run": 0, "Dodge Roll": 0,
|
||||||
|
"Aerial Dodge": 0,
|
||||||
|
"Glide": 0},
|
||||||
|
"Bitmask": [],
|
||||||
|
"Weapon": {"Sora": [], "Donald": [], "Goofy": []},
|
||||||
|
"Equipment": [],
|
||||||
|
"Magic": {},
|
||||||
|
"StatIncrease": {},
|
||||||
|
"Boost": {},
|
||||||
|
},
|
||||||
|
"LocalItems": {
|
||||||
|
"Ability": {},
|
||||||
|
"Amount": {},
|
||||||
|
"Growth": {"High Jump": 0, "Quick Run": 0, "Dodge Roll": 0,
|
||||||
|
"Aerial Dodge": 0, "Glide": 0},
|
||||||
|
"Bitmask": [],
|
||||||
|
"Weapon": {"Sora": [], "Donald": [], "Goofy": []},
|
||||||
|
"Equipment": [],
|
||||||
|
"Magic": {},
|
||||||
|
"StatIncrease": {},
|
||||||
|
"Boost": {},
|
||||||
|
}},
|
||||||
|
# 1,3,255 are in this list in case the player gets locations in those "worlds" and I need to still have them checked
|
||||||
|
"LocationsChecked": [],
|
||||||
|
"Levels": {
|
||||||
|
"SoraLevel": 0,
|
||||||
|
"ValorLevel": 0,
|
||||||
|
"WisdomLevel": 0,
|
||||||
|
"LimitLevel": 0,
|
||||||
|
"MasterLevel": 0,
|
||||||
|
"FinalLevel": 0,
|
||||||
|
},
|
||||||
|
"SoldEquipment": [],
|
||||||
|
"SoldBoosts": {"Power Boost": 0,
|
||||||
|
"Magic Boost": 0,
|
||||||
|
"Defense Boost": 0,
|
||||||
|
"AP Boost": 0}
|
||||||
|
}
|
||||||
|
with open(os.path.join(self.game_communication_path, f"kh2save{self.kh2seedname}{self.auth}.json"),
|
||||||
|
'wt') as f:
|
||||||
|
pass
|
||||||
|
self.locations_checked = set()
|
||||||
|
elif os.path.exists(self.game_communication_path + f"\kh2save{self.kh2seedname}{self.auth}.json"):
|
||||||
|
with open(self.game_communication_path + f"\kh2save{self.kh2seedname}{self.auth}.json", 'r') as f:
|
||||||
|
self.kh2seedsave = json.load(f)
|
||||||
|
self.locations_checked = set(self.kh2seedsave["LocationsChecked"])
|
||||||
|
self.serverconneced = True
|
||||||
|
|
||||||
|
if cmd in {"Connected"}:
|
||||||
|
self.kh2slotdata = args['slot_data']
|
||||||
|
self.kh2LocalItems = {int(location): item for location, item in self.kh2slotdata["LocalItems"].items()}
|
||||||
|
try:
|
||||||
|
self.kh2 = pymem.Pymem(process_name="KINGDOM HEARTS II FINAL MIX")
|
||||||
|
logger.info("You are now auto-tracking")
|
||||||
|
self.kh2connected = True
|
||||||
|
except Exception as e:
|
||||||
|
logger.info("Line 247")
|
||||||
|
if self.kh2connected:
|
||||||
|
logger.info("Connection Lost")
|
||||||
|
self.kh2connected = False
|
||||||
|
logger.info(e)
|
||||||
|
|
||||||
|
if cmd in {"ReceivedItems"}:
|
||||||
|
start_index = args["index"]
|
||||||
|
if start_index == 0:
|
||||||
|
# resetting everything that were sent from the server
|
||||||
|
self.kh2seedsave["SoraInvo"][0] = 0x25D8
|
||||||
|
self.kh2seedsave["DonaldInvo"][0] = 0x26F4
|
||||||
|
self.kh2seedsave["GoofyInvo"][0] = 0x280A
|
||||||
|
self.kh2seedsave["itemIndex"] = - 1
|
||||||
|
self.kh2seedsave["AmountInvo"]["ServerItems"] = {
|
||||||
|
"Ability": {},
|
||||||
|
"Amount": {},
|
||||||
|
"Growth": {"High Jump": 0, "Quick Run": 0, "Dodge Roll": 0,
|
||||||
|
"Aerial Dodge": 0,
|
||||||
|
"Glide": 0},
|
||||||
|
"Bitmask": [],
|
||||||
|
"Weapon": {"Sora": [], "Donald": [], "Goofy": []},
|
||||||
|
"Equipment": [],
|
||||||
|
"Magic": {},
|
||||||
|
"StatIncrease": {},
|
||||||
|
"Boost": {},
|
||||||
|
}
|
||||||
|
if start_index > self.kh2seedsave["itemIndex"]:
|
||||||
|
self.kh2seedsave["itemIndex"] = start_index
|
||||||
|
for item in args['items']:
|
||||||
|
asyncio.create_task(self.give_item(item.item))
|
||||||
|
|
||||||
|
if cmd in {"RoomUpdate"}:
|
||||||
|
if "checked_locations" in args:
|
||||||
|
new_locations = set(args["checked_locations"])
|
||||||
|
# TODO: make this take locations from other players on the same slot so proper coop happens
|
||||||
|
# items_to_give = [self.kh2slotdata["LocalItems"][str(location_id)] for location_id in new_locations if
|
||||||
|
# location_id in self.kh2LocalItems.keys()]
|
||||||
|
self.checked_locations |= new_locations
|
||||||
|
|
||||||
|
async def checkWorldLocations(self):
|
||||||
|
try:
|
||||||
|
currentworldint = int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + 0x0714DB8, 1), "big")
|
||||||
|
if currentworldint in self.worldid:
|
||||||
|
curworldid = self.worldid[currentworldint]
|
||||||
|
for location, data in curworldid.items():
|
||||||
|
locationId = kh2_loc_name_to_id[location]
|
||||||
|
if locationId not in self.locations_checked \
|
||||||
|
and (int.from_bytes(
|
||||||
|
self.kh2.read_bytes(self.kh2.base_address + self.Save + data.addrObtained, 1),
|
||||||
|
"big") & 0x1 << data.bitIndex) > 0:
|
||||||
|
self.sending = self.sending + [(int(locationId))]
|
||||||
|
except Exception as e:
|
||||||
|
logger.info("Line 285")
|
||||||
|
if self.kh2connected:
|
||||||
|
logger.info("Connection Lost.")
|
||||||
|
self.kh2connected = False
|
||||||
|
logger.info(e)
|
||||||
|
|
||||||
|
async def checkLevels(self):
|
||||||
|
try:
|
||||||
|
for location, data in SoraLevels.items():
|
||||||
|
currentLevel = int.from_bytes(
|
||||||
|
self.kh2.read_bytes(self.kh2.base_address + self.Save + 0x24FF, 1), "big")
|
||||||
|
locationId = kh2_loc_name_to_id[location]
|
||||||
|
if locationId not in self.locations_checked \
|
||||||
|
and currentLevel >= data.bitIndex:
|
||||||
|
if self.kh2seedsave["Levels"]["SoraLevel"] < currentLevel:
|
||||||
|
self.kh2seedsave["Levels"]["SoraLevel"] = currentLevel
|
||||||
|
self.sending = self.sending + [(int(locationId))]
|
||||||
|
formDict = {
|
||||||
|
0: ["ValorLevel", ValorLevels], 1: ["WisdomLevel", WisdomLevels], 2: ["LimitLevel", LimitLevels],
|
||||||
|
3: ["MasterLevel", MasterLevels], 4: ["FinalLevel", FinalLevels]}
|
||||||
|
for i in range(5):
|
||||||
|
for location, data in formDict[i][1].items():
|
||||||
|
formlevel = int.from_bytes(
|
||||||
|
self.kh2.read_bytes(self.kh2.base_address + self.Save + data.addrObtained, 1), "big")
|
||||||
|
locationId = kh2_loc_name_to_id[location]
|
||||||
|
if locationId not in self.locations_checked \
|
||||||
|
and formlevel >= data.bitIndex:
|
||||||
|
if formlevel > self.kh2seedsave["Levels"][formDict[i][0]]:
|
||||||
|
self.kh2seedsave["Levels"][formDict[i][0]] = formlevel
|
||||||
|
self.sending = self.sending + [(int(locationId))]
|
||||||
|
except Exception as e:
|
||||||
|
logger.info("Line 312")
|
||||||
|
if self.kh2connected:
|
||||||
|
logger.info("Connection Lost.")
|
||||||
|
self.kh2connected = False
|
||||||
|
logger.info(e)
|
||||||
|
|
||||||
|
async def checkSlots(self):
|
||||||
|
try:
|
||||||
|
for location, data in weaponSlots.items():
|
||||||
|
locationId = kh2_loc_name_to_id[location]
|
||||||
|
if locationId not in self.locations_checked:
|
||||||
|
if int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + self.Save + data.addrObtained, 1),
|
||||||
|
"big") > 0:
|
||||||
|
self.sending = self.sending + [(int(locationId))]
|
||||||
|
|
||||||
|
for location, data in formSlots.items():
|
||||||
|
locationId = kh2_loc_name_to_id[location]
|
||||||
|
if locationId not in self.locations_checked:
|
||||||
|
if int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + self.Save + data.addrObtained, 1),
|
||||||
|
"big") & 0x1 << data.bitIndex > 0:
|
||||||
|
# self.locations_checked
|
||||||
|
self.sending = self.sending + [(int(locationId))]
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
if self.kh2connected:
|
||||||
|
logger.info("Line 333")
|
||||||
|
logger.info("Connection Lost.")
|
||||||
|
self.kh2connected = False
|
||||||
|
logger.info(e)
|
||||||
|
|
||||||
|
async def verifyChests(self):
|
||||||
|
try:
|
||||||
|
for location in self.locations_checked:
|
||||||
|
locationName = self.lookup_id_to_Location[location]
|
||||||
|
if locationName in self.chest_set:
|
||||||
|
if locationName in self.location_name_to_worlddata.keys():
|
||||||
|
locationData = self.location_name_to_worlddata[locationName]
|
||||||
|
if int.from_bytes(
|
||||||
|
self.kh2.read_bytes(self.kh2.base_address + self.Save + locationData.addrObtained, 1),
|
||||||
|
"big") & 0x1 << locationData.bitIndex == 0:
|
||||||
|
roomData = int.from_bytes(
|
||||||
|
self.kh2.read_bytes(self.kh2.base_address + self.Save + locationData.addrObtained,
|
||||||
|
1), "big")
|
||||||
|
self.kh2.write_bytes(self.kh2.base_address + self.Save + locationData.addrObtained,
|
||||||
|
(roomData | 0x01 << locationData.bitIndex).to_bytes(1, 'big'), 1)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
if self.kh2connected:
|
||||||
|
logger.info("Line 350")
|
||||||
|
logger.info("Connection Lost.")
|
||||||
|
self.kh2connected = False
|
||||||
|
logger.info(e)
|
||||||
|
|
||||||
|
async def verifyLevel(self):
|
||||||
|
for leveltype, anchor in {"SoraLevel": 0x24FF,
|
||||||
|
"ValorLevel": 0x32F6,
|
||||||
|
"WisdomLevel": 0x332E,
|
||||||
|
"LimitLevel": 0x3366,
|
||||||
|
"MasterLevel": 0x339E,
|
||||||
|
"FinalLevel": 0x33D6}.items():
|
||||||
|
if int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + self.Save + anchor, 1), "big") < \
|
||||||
|
self.kh2seedsave["Levels"][leveltype]:
|
||||||
|
self.kh2.write_bytes(self.kh2.base_address + self.Save + anchor,
|
||||||
|
(self.kh2seedsave["Levels"][leveltype]).to_bytes(1, 'big'), 1)
|
||||||
|
|
||||||
|
async def give_item(self, item, ItemType="ServerItems"):
|
||||||
|
try:
|
||||||
|
itemname = self.lookup_id_to_item[item]
|
||||||
|
itemcode = self.item_name_to_data[itemname]
|
||||||
|
if itemcode.ability:
|
||||||
|
abilityInvoType = 0
|
||||||
|
TwilightZone = 2
|
||||||
|
if ItemType == "LocalItems":
|
||||||
|
abilityInvoType = 1
|
||||||
|
TwilightZone = -2
|
||||||
|
if itemname in {"High Jump", "Quick Run", "Dodge Roll", "Aerial Dodge", "Glide"}:
|
||||||
|
self.kh2seedsave["AmountInvo"][ItemType]["Growth"][itemname] += 1
|
||||||
|
return
|
||||||
|
|
||||||
|
if itemname not in self.kh2seedsave["AmountInvo"][ItemType]["Ability"]:
|
||||||
|
self.kh2seedsave["AmountInvo"][ItemType]["Ability"][itemname] = []
|
||||||
|
# appending the slot that the ability should be in
|
||||||
|
|
||||||
|
if len(self.kh2seedsave["AmountInvo"][ItemType]["Ability"][itemname]) < \
|
||||||
|
self.AbilityQuantityDict[itemname]:
|
||||||
|
if itemname in self.sora_ability_set:
|
||||||
|
self.kh2seedsave["AmountInvo"][ItemType]["Ability"][itemname].append(
|
||||||
|
self.kh2seedsave["SoraInvo"][abilityInvoType])
|
||||||
|
self.kh2seedsave["SoraInvo"][abilityInvoType] -= TwilightZone
|
||||||
|
elif itemname in self.donald_ability_set:
|
||||||
|
self.kh2seedsave["AmountInvo"][ItemType]["Ability"][itemname].append(
|
||||||
|
self.kh2seedsave["DonaldInvo"][abilityInvoType])
|
||||||
|
self.kh2seedsave["DonaldInvo"][abilityInvoType] -= TwilightZone
|
||||||
|
else:
|
||||||
|
self.kh2seedsave["AmountInvo"][ItemType]["Ability"][itemname].append(
|
||||||
|
self.kh2seedsave["GoofyInvo"][abilityInvoType])
|
||||||
|
self.kh2seedsave["GoofyInvo"][abilityInvoType] -= TwilightZone
|
||||||
|
|
||||||
|
elif itemcode.code in self.bitmask_item_code:
|
||||||
|
|
||||||
|
if itemname not in self.kh2seedsave["AmountInvo"][ItemType]["Bitmask"]:
|
||||||
|
self.kh2seedsave["AmountInvo"][ItemType]["Bitmask"].append(itemname)
|
||||||
|
|
||||||
|
elif itemcode.memaddr in {0x3594, 0x3595, 0x3596, 0x3597, 0x35CF, 0x35D0}:
|
||||||
|
|
||||||
|
if itemname in self.kh2seedsave["AmountInvo"][ItemType]["Magic"]:
|
||||||
|
self.kh2seedsave["AmountInvo"][ItemType]["Magic"][itemname] += 1
|
||||||
|
else:
|
||||||
|
self.kh2seedsave["AmountInvo"][ItemType]["Magic"][itemname] = 1
|
||||||
|
elif itemname in self.all_equipment:
|
||||||
|
|
||||||
|
self.kh2seedsave["AmountInvo"][ItemType]["Equipment"].append(itemname)
|
||||||
|
|
||||||
|
elif itemname in self.all_weapons:
|
||||||
|
if itemname in self.keyblade_set:
|
||||||
|
self.kh2seedsave["AmountInvo"][ItemType]["Weapon"]["Sora"].append(itemname)
|
||||||
|
elif itemname in self.staff_set:
|
||||||
|
self.kh2seedsave["AmountInvo"][ItemType]["Weapon"]["Donald"].append(itemname)
|
||||||
|
else:
|
||||||
|
self.kh2seedsave["AmountInvo"][ItemType]["Weapon"]["Goofy"].append(itemname)
|
||||||
|
|
||||||
|
elif itemname in self.boost_set:
|
||||||
|
if itemname in self.kh2seedsave["AmountInvo"][ItemType]["Boost"]:
|
||||||
|
self.kh2seedsave["AmountInvo"][ItemType]["Boost"][itemname] += 1
|
||||||
|
else:
|
||||||
|
self.kh2seedsave["AmountInvo"][ItemType]["Boost"][itemname] = 1
|
||||||
|
|
||||||
|
elif itemname in self.stat_increase_set:
|
||||||
|
|
||||||
|
if itemname in self.kh2seedsave["AmountInvo"][ItemType]["StatIncrease"]:
|
||||||
|
self.kh2seedsave["AmountInvo"][ItemType]["StatIncrease"][itemname] += 1
|
||||||
|
else:
|
||||||
|
self.kh2seedsave["AmountInvo"][ItemType]["StatIncrease"][itemname] = 1
|
||||||
|
|
||||||
|
else:
|
||||||
|
if itemname in self.kh2seedsave["AmountInvo"][ItemType]["Amount"]:
|
||||||
|
self.kh2seedsave["AmountInvo"][ItemType]["Amount"][itemname] += 1
|
||||||
|
else:
|
||||||
|
self.kh2seedsave["AmountInvo"][ItemType]["Amount"][itemname] = 1
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
if self.kh2connected:
|
||||||
|
logger.info("Line 398")
|
||||||
|
logger.info("Connection Lost.")
|
||||||
|
self.kh2connected = False
|
||||||
|
logger.info(e)
|
||||||
|
|
||||||
|
def run_gui(self):
|
||||||
|
"""Import kivy UI system and start running it as self.ui_task."""
|
||||||
|
from kvui import GameManager
|
||||||
|
|
||||||
|
class KH2Manager(GameManager):
|
||||||
|
logging_pairs = [
|
||||||
|
("Client", "Archipelago")
|
||||||
|
]
|
||||||
|
base_title = "Archipelago KH2 Client"
|
||||||
|
|
||||||
|
self.ui = KH2Manager(self)
|
||||||
|
self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI")
|
||||||
|
|
||||||
|
async def IsInShop(self, sellable, master_boost):
|
||||||
|
# journal = 0x741230 shop = 0x741320
|
||||||
|
# if journal=-1 and shop = 5 then in shop
|
||||||
|
# if journam !=-1 and shop = 10 then journal
|
||||||
|
journal = self.kh2.read_short(self.kh2.base_address + 0x741230)
|
||||||
|
shop = self.kh2.read_short(self.kh2.base_address + 0x741320)
|
||||||
|
if (journal == -1 and shop == 5) or (journal != -1 and shop == 10):
|
||||||
|
# print("your in the shop")
|
||||||
|
sellable_dict = {}
|
||||||
|
for itemName in sellable:
|
||||||
|
itemdata = self.item_name_to_data[itemName]
|
||||||
|
amount = int.from_bytes(
|
||||||
|
self.kh2.read_bytes(self.kh2.base_address + self.Save + itemdata.memaddr, 1), "big")
|
||||||
|
sellable_dict[itemName] = amount
|
||||||
|
while (journal == -1 and shop == 5) or (journal != -1 and shop == 10):
|
||||||
|
journal = self.kh2.read_short(self.kh2.base_address + 0x741230)
|
||||||
|
shop = self.kh2.read_short(self.kh2.base_address + 0x741320)
|
||||||
|
await asyncio.sleep(0.5)
|
||||||
|
for item, amount in sellable_dict.items():
|
||||||
|
itemdata = self.item_name_to_data[item]
|
||||||
|
afterShop = int.from_bytes(
|
||||||
|
self.kh2.read_bytes(self.kh2.base_address + self.Save + itemdata.memaddr, 1), "big")
|
||||||
|
if afterShop < amount:
|
||||||
|
if item in master_boost:
|
||||||
|
self.kh2seedsave["SoldBoosts"][item] += (amount - afterShop)
|
||||||
|
else:
|
||||||
|
self.kh2seedsave["SoldEquipment"].append(item)
|
||||||
|
|
||||||
|
async def verifyItems(self):
|
||||||
|
try:
|
||||||
|
local_amount = set(self.kh2seedsave["AmountInvo"]["LocalItems"]["Amount"].keys())
|
||||||
|
server_amount = set(self.kh2seedsave["AmountInvo"]["ServerItems"]["Amount"].keys())
|
||||||
|
master_amount = local_amount | server_amount
|
||||||
|
|
||||||
|
local_ability = set(self.kh2seedsave["AmountInvo"]["LocalItems"]["Ability"].keys())
|
||||||
|
server_ability = set(self.kh2seedsave["AmountInvo"]["ServerItems"]["Ability"].keys())
|
||||||
|
master_ability = local_ability | server_ability
|
||||||
|
|
||||||
|
local_bitmask = set(self.kh2seedsave["AmountInvo"]["LocalItems"]["Bitmask"])
|
||||||
|
server_bitmask = set(self.kh2seedsave["AmountInvo"]["ServerItems"]["Bitmask"])
|
||||||
|
master_bitmask = local_bitmask | server_bitmask
|
||||||
|
|
||||||
|
local_keyblade = set(self.kh2seedsave["AmountInvo"]["LocalItems"]["Weapon"]["Sora"])
|
||||||
|
local_staff = set(self.kh2seedsave["AmountInvo"]["LocalItems"]["Weapon"]["Donald"])
|
||||||
|
local_shield = set(self.kh2seedsave["AmountInvo"]["LocalItems"]["Weapon"]["Goofy"])
|
||||||
|
|
||||||
|
server_keyblade = set(self.kh2seedsave["AmountInvo"]["ServerItems"]["Weapon"]["Sora"])
|
||||||
|
server_staff = set(self.kh2seedsave["AmountInvo"]["ServerItems"]["Weapon"]["Donald"])
|
||||||
|
server_shield = set(self.kh2seedsave["AmountInvo"]["ServerItems"]["Weapon"]["Goofy"])
|
||||||
|
|
||||||
|
master_keyblade = local_keyblade | server_keyblade
|
||||||
|
master_staff = local_staff | server_staff
|
||||||
|
master_shield = local_shield | server_shield
|
||||||
|
|
||||||
|
local_equipment = set(self.kh2seedsave["AmountInvo"]["LocalItems"]["Equipment"])
|
||||||
|
server_equipment = set(self.kh2seedsave["AmountInvo"]["ServerItems"]["Equipment"])
|
||||||
|
master_equipment = local_equipment | server_equipment
|
||||||
|
|
||||||
|
local_magic = set(self.kh2seedsave["AmountInvo"]["LocalItems"]["Magic"].keys())
|
||||||
|
server_magic = set(self.kh2seedsave["AmountInvo"]["ServerItems"]["Magic"].keys())
|
||||||
|
master_magic = local_magic | server_magic
|
||||||
|
|
||||||
|
local_stat = set(self.kh2seedsave["AmountInvo"]["LocalItems"]["StatIncrease"].keys())
|
||||||
|
server_stat = set(self.kh2seedsave["AmountInvo"]["ServerItems"]["StatIncrease"].keys())
|
||||||
|
master_stat = local_stat | server_stat
|
||||||
|
|
||||||
|
local_boost = set(self.kh2seedsave["AmountInvo"]["LocalItems"]["Boost"].keys())
|
||||||
|
server_boost = set(self.kh2seedsave["AmountInvo"]["ServerItems"]["Boost"].keys())
|
||||||
|
master_boost = local_boost | server_boost
|
||||||
|
|
||||||
|
master_sell = master_equipment | master_staff | master_shield | master_boost
|
||||||
|
await asyncio.create_task(self.IsInShop(master_sell, master_boost))
|
||||||
|
for itemName in master_amount:
|
||||||
|
itemData = self.item_name_to_data[itemName]
|
||||||
|
amountOfItems = 0
|
||||||
|
if itemName in local_amount:
|
||||||
|
amountOfItems += self.kh2seedsave["AmountInvo"]["LocalItems"]["Amount"][itemName]
|
||||||
|
if itemName in server_amount:
|
||||||
|
amountOfItems += self.kh2seedsave["AmountInvo"]["ServerItems"]["Amount"][itemName]
|
||||||
|
|
||||||
|
if itemName == "Torn Page":
|
||||||
|
# Torn Pages are handled differently because they can be consumed.
|
||||||
|
# Will check the progression in 100 acre and - the amount of visits
|
||||||
|
# amountofitems-amount of visits done
|
||||||
|
for location, data in tornPageLocks.items():
|
||||||
|
if int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + self.Save + data.addrObtained, 1),
|
||||||
|
"big") & 0x1 << data.bitIndex > 0:
|
||||||
|
amountOfItems -= 1
|
||||||
|
if int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + self.Save + itemData.memaddr, 1),
|
||||||
|
"big") != amountOfItems and amountOfItems >= 0:
|
||||||
|
self.kh2.write_bytes(self.kh2.base_address + self.Save + itemData.memaddr,
|
||||||
|
amountOfItems.to_bytes(1, 'big'), 1)
|
||||||
|
|
||||||
|
for itemName in master_keyblade:
|
||||||
|
itemData = self.item_name_to_data[itemName]
|
||||||
|
# if the inventory slot for that keyblade is less than the amount they should have
|
||||||
|
if int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + self.Save + itemData.memaddr, 1),
|
||||||
|
"big") != 1 and int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + 0x1CFF, 1),
|
||||||
|
"big") != 13:
|
||||||
|
# Checking form anchors for the keyblade
|
||||||
|
if self.kh2.read_short(self.kh2.base_address + self.Save + 0x24F0) == itemData.kh2id \
|
||||||
|
or self.kh2.read_short(self.kh2.base_address + self.Save + 0x32F4) == itemData.kh2id \
|
||||||
|
or self.kh2.read_short(self.kh2.base_address + self.Save + 0x339C) == itemData.kh2id \
|
||||||
|
or self.kh2.read_short(self.kh2.base_address + self.Save + 0x33D4) == itemData.kh2id:
|
||||||
|
self.kh2.write_bytes(self.kh2.base_address + self.Save + itemData.memaddr,
|
||||||
|
(0).to_bytes(1, 'big'), 1)
|
||||||
|
else:
|
||||||
|
self.kh2.write_bytes(self.kh2.base_address + self.Save + itemData.memaddr,
|
||||||
|
(1).to_bytes(1, 'big'), 1)
|
||||||
|
for itemName in master_staff:
|
||||||
|
itemData = self.item_name_to_data[itemName]
|
||||||
|
if int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + self.Save + itemData.memaddr, 1),
|
||||||
|
"big") != 1 \
|
||||||
|
and self.kh2.read_short(self.kh2.base_address + self.Save + 0x2604) != itemData.kh2id \
|
||||||
|
and itemName not in self.kh2seedsave["SoldEquipment"]:
|
||||||
|
self.kh2.write_bytes(self.kh2.base_address + self.Save + itemData.memaddr,
|
||||||
|
(1).to_bytes(1, 'big'), 1)
|
||||||
|
|
||||||
|
for itemName in master_shield:
|
||||||
|
itemData = self.item_name_to_data[itemName]
|
||||||
|
if int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + self.Save + itemData.memaddr, 1),
|
||||||
|
"big") != 1 \
|
||||||
|
and self.kh2.read_short(self.kh2.base_address + self.Save + 0x2718) != itemData.kh2id \
|
||||||
|
and itemName not in self.kh2seedsave["SoldEquipment"]:
|
||||||
|
self.kh2.write_bytes(self.kh2.base_address + self.Save + itemData.memaddr,
|
||||||
|
(1).to_bytes(1, 'big'), 1)
|
||||||
|
|
||||||
|
for itemName in master_ability:
|
||||||
|
itemData = self.item_name_to_data[itemName]
|
||||||
|
ability_slot = []
|
||||||
|
if itemName in local_ability:
|
||||||
|
ability_slot += self.kh2seedsave["AmountInvo"]["LocalItems"]["Ability"][itemName]
|
||||||
|
if itemName in server_ability:
|
||||||
|
ability_slot += self.kh2seedsave["AmountInvo"]["ServerItems"]["Ability"][itemName]
|
||||||
|
for slot in ability_slot:
|
||||||
|
current = self.kh2.read_short(self.kh2.base_address + self.Save + slot)
|
||||||
|
ability = current & 0x0FFF
|
||||||
|
if ability | 0x8000 != (0x8000 + itemData.memaddr):
|
||||||
|
if current - 0x8000 > 0:
|
||||||
|
self.kh2.write_short(self.kh2.base_address + self.Save + slot, (0x8000 + itemData.memaddr))
|
||||||
|
else:
|
||||||
|
self.kh2.write_short(self.kh2.base_address + self.Save + slot, itemData.memaddr)
|
||||||
|
# removes the duped ability if client gave faster than the game.
|
||||||
|
for charInvo in {"SoraInvo", "DonaldInvo", "GoofyInvo"}:
|
||||||
|
if self.kh2.read_short(self.kh2.base_address + self.Save + self.kh2seedsave[charInvo][1]) != 0 and \
|
||||||
|
self.kh2seedsave[charInvo][1] + 2 < self.kh2seedsave[charInvo][0]:
|
||||||
|
self.kh2.write_short(self.kh2.base_address + self.Save + self.kh2seedsave[charInvo][1], 0)
|
||||||
|
# remove the dummy level 1 growths if they are in these invo slots.
|
||||||
|
for inventorySlot in {0x25CE, 0x25D0, 0x25D2, 0x25D4, 0x25D6, 0x25D8}:
|
||||||
|
current = self.kh2.read_short(self.kh2.base_address + self.Save + inventorySlot)
|
||||||
|
ability = current & 0x0FFF
|
||||||
|
if 0x05E <= ability <= 0x06D:
|
||||||
|
self.kh2.write_short(self.kh2.base_address + self.Save + inventorySlot, 0)
|
||||||
|
|
||||||
|
for itemName in self.master_growth:
|
||||||
|
growthLevel = self.kh2seedsave["AmountInvo"]["ServerItems"]["Growth"][itemName] \
|
||||||
|
+ self.kh2seedsave["AmountInvo"]["LocalItems"]["Growth"][itemName]
|
||||||
|
if growthLevel > 0:
|
||||||
|
slot = self.growth_values_dict[itemName][2]
|
||||||
|
min_growth = self.growth_values_dict[itemName][0]
|
||||||
|
max_growth = self.growth_values_dict[itemName][1]
|
||||||
|
if growthLevel > 4:
|
||||||
|
growthLevel = 4
|
||||||
|
current_growth_level = self.kh2.read_short(self.kh2.base_address + self.Save + slot)
|
||||||
|
ability = current_growth_level & 0x0FFF
|
||||||
|
# if the player should be getting a growth ability
|
||||||
|
if ability | 0x8000 != 0x8000 + min_growth - 1 + growthLevel:
|
||||||
|
# if it should be level one of that growth
|
||||||
|
if 0x8000 + min_growth - 1 + growthLevel <= 0x8000 + min_growth or ability < min_growth:
|
||||||
|
self.kh2.write_short(self.kh2.base_address + self.Save + slot, min_growth)
|
||||||
|
# if it is already in the inventory
|
||||||
|
elif ability | 0x8000 < (0x8000 + max_growth):
|
||||||
|
self.kh2.write_short(self.kh2.base_address + self.Save + slot, current_growth_level + 1)
|
||||||
|
|
||||||
|
for itemName in master_bitmask:
|
||||||
|
itemData = self.item_name_to_data[itemName]
|
||||||
|
itemMemory = int.from_bytes(
|
||||||
|
self.kh2.read_bytes(self.kh2.base_address + self.Save + itemData.memaddr, 1), "big")
|
||||||
|
if (int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + self.Save + itemData.memaddr, 1),
|
||||||
|
"big") & 0x1 << itemData.bitmask) == 0:
|
||||||
|
# when getting a form anti points should be reset to 0 but bit-shift doesn't trigger the game.
|
||||||
|
if itemName in {"Valor Form", "Wisdom Form", "Limit Form", "Master Form", "Final Form"}:
|
||||||
|
self.kh2.write_bytes(self.kh2.base_address + self.Save + 0x3410,
|
||||||
|
(0).to_bytes(1, 'big'), 1)
|
||||||
|
self.kh2.write_bytes(self.kh2.base_address + self.Save + itemData.memaddr,
|
||||||
|
(itemMemory | 0x01 << itemData.bitmask).to_bytes(1, 'big'), 1)
|
||||||
|
|
||||||
|
for itemName in master_equipment:
|
||||||
|
itemData = self.item_name_to_data[itemName]
|
||||||
|
isThere = False
|
||||||
|
if itemName in self.accessories_set:
|
||||||
|
Equipment_Anchor_List = self.Equipment_Anchor_Dict["Accessories"]
|
||||||
|
else:
|
||||||
|
Equipment_Anchor_List = self.Equipment_Anchor_Dict["Armor"]
|
||||||
|
# Checking form anchors for the equipment
|
||||||
|
for slot in Equipment_Anchor_List:
|
||||||
|
if self.kh2.read_short(self.kh2.base_address + self.Save + slot) == itemData.kh2id:
|
||||||
|
isThere = True
|
||||||
|
if int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + self.Save + itemData.memaddr, 1),
|
||||||
|
"big") != 0:
|
||||||
|
self.kh2.write_bytes(self.kh2.base_address + self.Save + itemData.memaddr,
|
||||||
|
(0).to_bytes(1, 'big'), 1)
|
||||||
|
break
|
||||||
|
if not isThere and itemName not in self.kh2seedsave["SoldEquipment"]:
|
||||||
|
if int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + self.Save + itemData.memaddr, 1),
|
||||||
|
"big") != 1:
|
||||||
|
self.kh2.write_bytes(self.kh2.base_address + self.Save + itemData.memaddr,
|
||||||
|
(1).to_bytes(1, 'big'), 1)
|
||||||
|
|
||||||
|
for itemName in master_magic:
|
||||||
|
itemData = self.item_name_to_data[itemName]
|
||||||
|
amountOfItems = 0
|
||||||
|
if itemName in local_magic:
|
||||||
|
amountOfItems += self.kh2seedsave["AmountInvo"]["LocalItems"]["Magic"][itemName]
|
||||||
|
if itemName in server_magic:
|
||||||
|
amountOfItems += self.kh2seedsave["AmountInvo"]["ServerItems"]["Magic"][itemName]
|
||||||
|
if int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + self.Save + itemData.memaddr, 1),
|
||||||
|
"big") != amountOfItems \
|
||||||
|
and int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + 0x741320, 1), "big") in {10, 8}:
|
||||||
|
self.kh2.write_bytes(self.kh2.base_address + self.Save + itemData.memaddr,
|
||||||
|
amountOfItems.to_bytes(1, 'big'), 1)
|
||||||
|
|
||||||
|
for itemName in master_stat:
|
||||||
|
itemData = self.item_name_to_data[itemName]
|
||||||
|
amountOfItems = 0
|
||||||
|
if itemName in local_stat:
|
||||||
|
amountOfItems += self.kh2seedsave["AmountInvo"]["LocalItems"]["StatIncrease"][itemName]
|
||||||
|
if itemName in server_stat:
|
||||||
|
amountOfItems += self.kh2seedsave["AmountInvo"]["ServerItems"]["StatIncrease"][itemName]
|
||||||
|
|
||||||
|
# 0x130293 is Crit_1's location id for touching the computer
|
||||||
|
if int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + self.Save + itemData.memaddr, 1),
|
||||||
|
"big") != amountOfItems \
|
||||||
|
and int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + self.Slot1 + 0x1B2, 1),
|
||||||
|
"big") >= 5 and int.from_bytes(
|
||||||
|
self.kh2.read_bytes(self.kh2.base_address + self.Save + 0x23DF, 1),
|
||||||
|
"big") > 0:
|
||||||
|
self.kh2.write_bytes(self.kh2.base_address + self.Save + itemData.memaddr,
|
||||||
|
amountOfItems.to_bytes(1, 'big'), 1)
|
||||||
|
|
||||||
|
for itemName in master_boost:
|
||||||
|
itemData = self.item_name_to_data[itemName]
|
||||||
|
amountOfItems = 0
|
||||||
|
if itemName in local_boost:
|
||||||
|
amountOfItems += self.kh2seedsave["AmountInvo"]["LocalItems"]["Boost"][itemName]
|
||||||
|
if itemName in server_boost:
|
||||||
|
amountOfItems += self.kh2seedsave["AmountInvo"]["ServerItems"]["Boost"][itemName]
|
||||||
|
amountOfBoostsInInvo = int.from_bytes(
|
||||||
|
self.kh2.read_bytes(self.kh2.base_address + self.Save + itemData.memaddr, 1),
|
||||||
|
"big")
|
||||||
|
amountOfUsedBoosts = int.from_bytes(
|
||||||
|
self.kh2.read_bytes(self.kh2.base_address + self.Save + self.boost_to_anchor_dict[itemName], 1),
|
||||||
|
"big")
|
||||||
|
# Ap Boots start at +50 for some reason
|
||||||
|
if itemName == "AP Boost":
|
||||||
|
amountOfUsedBoosts -= 50
|
||||||
|
totalBoosts = (amountOfBoostsInInvo + amountOfUsedBoosts)
|
||||||
|
if totalBoosts <= amountOfItems - self.kh2seedsave["SoldBoosts"][
|
||||||
|
itemName] and amountOfBoostsInInvo < 255:
|
||||||
|
self.kh2.write_bytes(self.kh2.base_address + self.Save + itemData.memaddr,
|
||||||
|
(amountOfBoostsInInvo + 1).to_bytes(1, 'big'), 1)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.info("Line 573")
|
||||||
|
if self.kh2connected:
|
||||||
|
logger.info("Connection Lost.")
|
||||||
|
self.kh2connected = False
|
||||||
|
logger.info(e)
|
||||||
|
|
||||||
|
|
||||||
|
def finishedGame(ctx: KH2Context, message):
|
||||||
|
if ctx.kh2slotdata['FinalXemnas'] == 1:
|
||||||
|
if 0x1301ED in message[0]["locations"]:
|
||||||
|
ctx.finalxemnas = True
|
||||||
|
# three proofs
|
||||||
|
if ctx.kh2slotdata['Goal'] == 0:
|
||||||
|
if int.from_bytes(ctx.kh2.read_bytes(ctx.kh2.base_address + ctx.Save + 0x36B2, 1), "big") > 0 \
|
||||||
|
and int.from_bytes(ctx.kh2.read_bytes(ctx.kh2.base_address + ctx.Save + 0x36B3, 1), "big") > 0 \
|
||||||
|
and int.from_bytes(ctx.kh2.read_bytes(ctx.kh2.base_address + ctx.Save + 0x36B4, 1), "big") > 0:
|
||||||
|
if ctx.kh2slotdata['FinalXemnas'] == 1:
|
||||||
|
if ctx.finalxemnas:
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
return False
|
||||||
|
else:
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
return False
|
||||||
|
elif ctx.kh2slotdata['Goal'] == 1:
|
||||||
|
if int.from_bytes(ctx.kh2.read_bytes(ctx.kh2.base_address + ctx.Save + 0x3641, 1), "big") >= \
|
||||||
|
ctx.kh2slotdata['LuckyEmblemsRequired']:
|
||||||
|
ctx.kh2.write_bytes(ctx.kh2.base_address + ctx.Save + 0x36B2, (1).to_bytes(1, 'big'), 1)
|
||||||
|
ctx.kh2.write_bytes(ctx.kh2.base_address + ctx.Save + 0x36B3, (1).to_bytes(1, 'big'), 1)
|
||||||
|
ctx.kh2.write_bytes(ctx.kh2.base_address + ctx.Save + 0x36B4, (1).to_bytes(1, 'big'), 1)
|
||||||
|
if ctx.kh2slotdata['FinalXemnas'] == 1:
|
||||||
|
if ctx.finalxemnas:
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
return False
|
||||||
|
else:
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
return False
|
||||||
|
elif ctx.kh2slotdata['Goal'] == 2:
|
||||||
|
for boss in ctx.kh2slotdata["hitlist"]:
|
||||||
|
if boss in message[0]["locations"]:
|
||||||
|
ctx.amountOfPieces += 1
|
||||||
|
if ctx.amountOfPieces >= ctx.kh2slotdata["BountyRequired"]:
|
||||||
|
ctx.kh2.write_bytes(ctx.kh2.base_address + ctx.Save + 0x36B2, (1).to_bytes(1, 'big'), 1)
|
||||||
|
ctx.kh2.write_bytes(ctx.kh2.base_address + ctx.Save + 0x36B3, (1).to_bytes(1, 'big'), 1)
|
||||||
|
ctx.kh2.write_bytes(ctx.kh2.base_address + ctx.Save + 0x36B4, (1).to_bytes(1, 'big'), 1)
|
||||||
|
if ctx.kh2slotdata['FinalXemnas'] == 1:
|
||||||
|
if ctx.finalxemnas:
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
return False
|
||||||
|
else:
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
async def kh2_watcher(ctx: KH2Context):
|
||||||
|
while not ctx.exit_event.is_set():
|
||||||
|
try:
|
||||||
|
if ctx.kh2connected and ctx.serverconneced:
|
||||||
|
ctx.sending = []
|
||||||
|
await asyncio.create_task(ctx.checkWorldLocations())
|
||||||
|
await asyncio.create_task(ctx.checkLevels())
|
||||||
|
await asyncio.create_task(ctx.checkSlots())
|
||||||
|
await asyncio.create_task(ctx.verifyChests())
|
||||||
|
await asyncio.create_task(ctx.verifyItems())
|
||||||
|
await asyncio.create_task(ctx.verifyLevel())
|
||||||
|
message = [{"cmd": 'LocationChecks', "locations": ctx.sending}]
|
||||||
|
if finishedGame(ctx, message):
|
||||||
|
await ctx.send_msgs([{"cmd": "StatusUpdate", "status": ClientStatus.CLIENT_GOAL}])
|
||||||
|
ctx.finished_game = True
|
||||||
|
location_ids = []
|
||||||
|
location_ids = [location for location in message[0]["locations"] if location not in location_ids]
|
||||||
|
for location in location_ids:
|
||||||
|
if location not in ctx.locations_checked:
|
||||||
|
ctx.locations_checked.add(location)
|
||||||
|
ctx.kh2seedsave["LocationsChecked"].append(location)
|
||||||
|
if location in ctx.kh2LocalItems:
|
||||||
|
item = ctx.kh2slotdata["LocalItems"][str(location)]
|
||||||
|
await asyncio.create_task(ctx.give_item(item, "LocalItems"))
|
||||||
|
await ctx.send_msgs(message)
|
||||||
|
elif not ctx.kh2connected and ctx.serverconneced:
|
||||||
|
logger.info("Game is not open. Disconnecting from Server.")
|
||||||
|
await ctx.disconnect()
|
||||||
|
except Exception as e:
|
||||||
|
logger.info("Line 661")
|
||||||
|
if ctx.kh2connected:
|
||||||
|
logger.info("Connection Lost.")
|
||||||
|
ctx.kh2connected = False
|
||||||
|
logger.info(e)
|
||||||
|
await asyncio.sleep(0.5)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
Utils.init_logging("KH2Client", exception_logger="Client")
|
async def main(args):
|
||||||
launch()
|
ctx = KH2Context(args.connect, args.password)
|
||||||
|
ctx.server_task = asyncio.create_task(server_loop(ctx), name="server loop")
|
||||||
|
if gui_enabled:
|
||||||
|
ctx.run_gui()
|
||||||
|
ctx.run_cli()
|
||||||
|
progression_watcher = asyncio.create_task(
|
||||||
|
kh2_watcher(ctx), name="KH2ProgressionWatcher")
|
||||||
|
|
||||||
|
await ctx.exit_event.wait()
|
||||||
|
ctx.server_address = None
|
||||||
|
|
||||||
|
await progression_watcher
|
||||||
|
|
||||||
|
await ctx.shutdown()
|
||||||
|
|
||||||
|
|
||||||
|
import colorama
|
||||||
|
|
||||||
|
parser = get_base_parser(description="KH2 Client, for text interfacing.")
|
||||||
|
|
||||||
|
args, rest = parser.parse_known_args()
|
||||||
|
colorama.init()
|
||||||
|
asyncio.run(main(args))
|
||||||
|
colorama.deinit()
|
||||||
|
|||||||
71
Launcher.py
71
Launcher.py
@@ -50,22 +50,17 @@ def open_host_yaml():
|
|||||||
def open_patch():
|
def open_patch():
|
||||||
suffixes = []
|
suffixes = []
|
||||||
for c in components:
|
for c in components:
|
||||||
if c.type == Type.CLIENT and \
|
if isfile(get_exe(c)[-1]):
|
||||||
isinstance(c.file_identifier, SuffixIdentifier) and \
|
suffixes += c.file_identifier.suffixes if c.type == Type.CLIENT and \
|
||||||
(c.script_name is None or isfile(get_exe(c)[-1])):
|
isinstance(c.file_identifier, SuffixIdentifier) else []
|
||||||
suffixes += c.file_identifier.suffixes
|
|
||||||
try:
|
try:
|
||||||
filename = open_filename("Select patch", (("Patches", suffixes),))
|
filename = open_filename('Select patch', (('Patches', suffixes),))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
messagebox("Error", str(e), error=True)
|
messagebox('Error', str(e), error=True)
|
||||||
else:
|
else:
|
||||||
file, component = identify(filename)
|
file, component = identify(filename)
|
||||||
if file and component:
|
if file and component:
|
||||||
exe = get_exe(component)
|
launch([*get_exe(component), file], component.cli)
|
||||||
if exe is None or not isfile(exe[-1]):
|
|
||||||
exe = get_exe("Launcher")
|
|
||||||
|
|
||||||
launch([*exe, file], component.cli)
|
|
||||||
|
|
||||||
|
|
||||||
def generate_yamls():
|
def generate_yamls():
|
||||||
@@ -100,9 +95,9 @@ components.extend([
|
|||||||
# Functions
|
# Functions
|
||||||
Component("Open host.yaml", func=open_host_yaml),
|
Component("Open host.yaml", func=open_host_yaml),
|
||||||
Component("Open Patch", func=open_patch),
|
Component("Open Patch", func=open_patch),
|
||||||
Component("Generate Template Options", func=generate_yamls),
|
Component("Generate Template Settings", func=generate_yamls),
|
||||||
Component("Discord Server", icon="discord", func=lambda: webbrowser.open("https://discord.gg/8Z65BR2")),
|
Component("Discord Server", icon="discord", func=lambda: webbrowser.open("https://discord.gg/8Z65BR2")),
|
||||||
Component("Unrated/18+ Discord Server", icon="discord", func=lambda: webbrowser.open("https://discord.gg/fqvNCCRsu4")),
|
Component("18+ Discord Server", icon="discord", func=lambda: webbrowser.open("https://discord.gg/fqvNCCRsu4")),
|
||||||
Component("Browse Files", func=browse_files),
|
Component("Browse Files", func=browse_files),
|
||||||
])
|
])
|
||||||
|
|
||||||
@@ -112,7 +107,7 @@ def identify(path: Union[None, str]):
|
|||||||
return None, None
|
return None, None
|
||||||
for component in components:
|
for component in components:
|
||||||
if component.handles_file(path):
|
if component.handles_file(path):
|
||||||
return path, component
|
return path, component
|
||||||
elif path == component.display_name or path == component.script_name:
|
elif path == component.display_name or path == component.script_name:
|
||||||
return None, component
|
return None, component
|
||||||
return None, None
|
return None, None
|
||||||
@@ -122,25 +117,25 @@ 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
|
||||||
if name.startswith("Archipelago"):
|
if name.startswith('Archipelago'):
|
||||||
name = name[11:]
|
name = name[11:]
|
||||||
if name.endswith(".exe"):
|
if name.endswith('.exe'):
|
||||||
name = name[:-4]
|
name = name[:-4]
|
||||||
if name.endswith(".py"):
|
if name.endswith('.py'):
|
||||||
name = name[:-3]
|
name = name[:-3]
|
||||||
if not name:
|
if not name:
|
||||||
return None
|
return None
|
||||||
for c in components:
|
for c in components:
|
||||||
if c.script_name == name or c.frozen_name == f"Archipelago{name}":
|
if c.script_name == name or c.frozen_name == f'Archipelago{name}':
|
||||||
component = c
|
component = c
|
||||||
break
|
break
|
||||||
if not component:
|
if not component:
|
||||||
return None
|
return None
|
||||||
if is_frozen():
|
if is_frozen():
|
||||||
suffix = ".exe" if is_windows else ""
|
suffix = '.exe' if is_windows else ''
|
||||||
return [local_path(f"{component.frozen_name}{suffix}")] if component.frozen_name else None
|
return [local_path(f'{component.frozen_name}{suffix}')]
|
||||||
else:
|
else:
|
||||||
return [sys.executable, local_path(f"{component.script_name}.py")] if component.script_name else None
|
return [sys.executable, local_path(f'{component.script_name}.py')]
|
||||||
|
|
||||||
|
|
||||||
def launch(exe, in_terminal=False):
|
def launch(exe, in_terminal=False):
|
||||||
@@ -161,7 +156,7 @@ def launch(exe, in_terminal=False):
|
|||||||
|
|
||||||
|
|
||||||
def run_gui():
|
def run_gui():
|
||||||
from kvui import App, ContainerLayout, GridLayout, Button, Label, ScrollBox, Widget
|
from kvui import App, ContainerLayout, GridLayout, Button, Label
|
||||||
from kivy.uix.image import AsyncImage
|
from kivy.uix.image import AsyncImage
|
||||||
from kivy.uix.relativelayout import RelativeLayout
|
from kivy.uix.relativelayout import RelativeLayout
|
||||||
|
|
||||||
@@ -185,16 +180,11 @@ def run_gui():
|
|||||||
self.container = ContainerLayout()
|
self.container = ContainerLayout()
|
||||||
self.grid = GridLayout(cols=2)
|
self.grid = GridLayout(cols=2)
|
||||||
self.container.add_widget(self.grid)
|
self.container.add_widget(self.grid)
|
||||||
self.grid.add_widget(Label(text="General", size_hint_y=None, height=40))
|
self.grid.add_widget(Label(text="General"))
|
||||||
self.grid.add_widget(Label(text="Clients", size_hint_y=None, height=40))
|
self.grid.add_widget(Label(text="Clients"))
|
||||||
tool_layout = ScrollBox()
|
button_layout = self.grid # make buttons fill the window
|
||||||
tool_layout.layout.orientation = "vertical"
|
|
||||||
self.grid.add_widget(tool_layout)
|
|
||||||
client_layout = ScrollBox()
|
|
||||||
client_layout.layout.orientation = "vertical"
|
|
||||||
self.grid.add_widget(client_layout)
|
|
||||||
|
|
||||||
def build_button(component: Component) -> Widget:
|
def build_button(component: Component):
|
||||||
"""
|
"""
|
||||||
Builds a button widget for a given component.
|
Builds a button widget for a given component.
|
||||||
|
|
||||||
@@ -205,26 +195,31 @@ def run_gui():
|
|||||||
None. The button is added to the parent grid layout.
|
None. The button is added to the parent grid layout.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
button = Button(text=component.display_name, size_hint_y=None, height=40)
|
button = Button(text=component.display_name)
|
||||||
button.component = component
|
button.component = component
|
||||||
button.bind(on_release=self.component_action)
|
button.bind(on_release=self.component_action)
|
||||||
if component.icon != "icon":
|
if component.icon != "icon":
|
||||||
image = AsyncImage(source=icon_paths[component.icon],
|
image = AsyncImage(source=icon_paths[component.icon],
|
||||||
size=(38, 38), size_hint=(None, 1), pos=(5, 0))
|
size=(38, 38), size_hint=(None, 1), pos=(5, 0))
|
||||||
box_layout = RelativeLayout(size_hint_y=None, height=40)
|
box_layout = RelativeLayout()
|
||||||
box_layout.add_widget(button)
|
box_layout.add_widget(button)
|
||||||
box_layout.add_widget(image)
|
box_layout.add_widget(image)
|
||||||
return box_layout
|
button_layout.add_widget(box_layout)
|
||||||
return button
|
else:
|
||||||
|
button_layout.add_widget(button)
|
||||||
|
|
||||||
for (tool, client) in itertools.zip_longest(itertools.chain(
|
for (tool, client) in itertools.zip_longest(itertools.chain(
|
||||||
self._tools.items(), self._miscs.items(), self._adjusters.items()), self._clients.items()):
|
self._tools.items(), self._miscs.items(), self._adjusters.items()), self._clients.items()):
|
||||||
# column 1
|
# column 1
|
||||||
if tool:
|
if tool:
|
||||||
tool_layout.layout.add_widget(build_button(tool[1]))
|
build_button(tool[1])
|
||||||
|
else:
|
||||||
|
button_layout.add_widget(Label())
|
||||||
# column 2
|
# column 2
|
||||||
if client:
|
if client:
|
||||||
client_layout.layout.add_widget(build_button(client[1]))
|
build_button(client[1])
|
||||||
|
else:
|
||||||
|
button_layout.add_widget(Label())
|
||||||
|
|
||||||
return self.container
|
return self.container
|
||||||
|
|
||||||
@@ -259,7 +254,7 @@ def main(args: Optional[Union[argparse.Namespace, dict]] = None):
|
|||||||
elif not args:
|
elif not args:
|
||||||
args = {}
|
args = {}
|
||||||
|
|
||||||
if args.get("Patch|Game|Component", None) is not None:
|
if "Patch|Game|Component" in args:
|
||||||
file, component = identify(args["Patch|Game|Component"])
|
file, component = identify(args["Patch|Game|Component"])
|
||||||
if file:
|
if file:
|
||||||
args['file'] = file
|
args['file'] = file
|
||||||
|
|||||||
@@ -348,8 +348,7 @@ class LinksAwakeningClient():
|
|||||||
await asyncio.sleep(1.0)
|
await asyncio.sleep(1.0)
|
||||||
continue
|
continue
|
||||||
self.stop_bizhawk_spam = False
|
self.stop_bizhawk_spam = False
|
||||||
logger.info(f"Connected to Retroarch {version.decode('ascii', errors='replace')} "
|
logger.info(f"Connected to Retroarch {version.decode('ascii')} running {rom_name.decode('ascii')}")
|
||||||
f"running {rom_name.decode('ascii', errors='replace')}")
|
|
||||||
return
|
return
|
||||||
except (BlockingIOError, TimeoutError, ConnectionResetError):
|
except (BlockingIOError, TimeoutError, ConnectionResetError):
|
||||||
await asyncio.sleep(1.0)
|
await asyncio.sleep(1.0)
|
||||||
|
|||||||
@@ -23,7 +23,8 @@ from urllib.request import urlopen
|
|||||||
import ModuleUpdate
|
import ModuleUpdate
|
||||||
ModuleUpdate.update()
|
ModuleUpdate.update()
|
||||||
|
|
||||||
from worlds.alttp.Rom import Sprite, LocalRom, apply_rom_settings, get_base_rom_bytes
|
from worlds.alttp.Rom import LocalRom, apply_rom_settings, get_base_rom_bytes
|
||||||
|
from worlds.alttp.Sprites import Sprite
|
||||||
from Utils import output_path, local_path, user_path, open_file, get_cert_none_ssl_context, persistent_store, \
|
from Utils import output_path, local_path, user_path, open_file, get_cert_none_ssl_context, persistent_store, \
|
||||||
get_adjuster_settings, get_adjuster_settings_no_defaults, tkinter_center_window, init_logging
|
get_adjuster_settings, get_adjuster_settings_no_defaults, tkinter_center_window, init_logging
|
||||||
|
|
||||||
@@ -1004,7 +1005,6 @@ class SpriteSelector():
|
|||||||
self.add_to_sprite_pool(sprite)
|
self.add_to_sprite_pool(sprite)
|
||||||
|
|
||||||
def icon_section(self, frame_label, path, no_results_label):
|
def icon_section(self, frame_label, path, no_results_label):
|
||||||
os.makedirs(path, exist_ok=True)
|
|
||||||
frame = LabelFrame(self.window, labelwidget=frame_label, padx=5, pady=5)
|
frame = LabelFrame(self.window, labelwidget=frame_label, padx=5, pady=5)
|
||||||
frame.pack(side=TOP, fill=X)
|
frame.pack(side=TOP, fill=X)
|
||||||
|
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ class MMBN3CommandProcessor(ClientCommandProcessor):
|
|||||||
class MMBN3Context(CommonContext):
|
class MMBN3Context(CommonContext):
|
||||||
command_processor = MMBN3CommandProcessor
|
command_processor = MMBN3CommandProcessor
|
||||||
game = "MegaMan Battle Network 3"
|
game = "MegaMan Battle Network 3"
|
||||||
items_handling = 0b101 # full local except starting items
|
items_handling = 0b001 # full local
|
||||||
|
|
||||||
def __init__(self, server_address, password):
|
def __init__(self, server_address, password):
|
||||||
super().__init__(server_address, password)
|
super().__init__(server_address, password)
|
||||||
|
|||||||
303
Main.py
303
Main.py
@@ -13,8 +13,8 @@ import worlds
|
|||||||
from BaseClasses import CollectionState, Item, Location, LocationProgressType, MultiWorld, Region
|
from BaseClasses import CollectionState, Item, Location, LocationProgressType, MultiWorld, Region
|
||||||
from Fill import balance_multiworld_progression, distribute_items_restrictive, distribute_planned, flood_items
|
from Fill import balance_multiworld_progression, distribute_items_restrictive, distribute_planned, flood_items
|
||||||
from Options import StartInventoryPool
|
from Options import StartInventoryPool
|
||||||
from Utils import __version__, output_path, version_tuple
|
|
||||||
from settings import get_settings
|
from settings import get_settings
|
||||||
|
from Utils import __version__, output_path, version_tuple
|
||||||
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
|
||||||
|
|
||||||
@@ -30,24 +30,49 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
|||||||
output_path.cached_path = args.outputpath
|
output_path.cached_path = args.outputpath
|
||||||
|
|
||||||
start = time.perf_counter()
|
start = time.perf_counter()
|
||||||
# initialize the multiworld
|
# initialize the world
|
||||||
multiworld = MultiWorld(args.multi)
|
world = MultiWorld(args.multi)
|
||||||
|
|
||||||
logger = logging.getLogger()
|
logger = logging.getLogger()
|
||||||
multiworld.set_seed(seed, args.race, str(args.outputname) if args.outputname else None)
|
world.set_seed(seed, args.race, str(args.outputname) if args.outputname else None)
|
||||||
multiworld.plando_options = args.plando_options
|
world.plando_options = args.plando_options
|
||||||
multiworld.plando_items = args.plando_items.copy()
|
|
||||||
multiworld.plando_texts = args.plando_texts.copy()
|
|
||||||
multiworld.plando_connections = args.plando_connections.copy()
|
|
||||||
multiworld.game = args.game.copy()
|
|
||||||
multiworld.player_name = args.name.copy()
|
|
||||||
multiworld.sprite = args.sprite.copy()
|
|
||||||
multiworld.sprite_pool = args.sprite_pool.copy()
|
|
||||||
|
|
||||||
multiworld.set_options(args)
|
world.shuffle = args.shuffle.copy()
|
||||||
multiworld.set_item_links()
|
world.logic = args.logic.copy()
|
||||||
multiworld.state = CollectionState(multiworld)
|
world.mode = args.mode.copy()
|
||||||
logger.info('Archipelago Version %s - Seed: %s\n', __version__, multiworld.seed)
|
world.difficulty = args.difficulty.copy()
|
||||||
|
world.item_functionality = args.item_functionality.copy()
|
||||||
|
world.timer = args.timer.copy()
|
||||||
|
world.goal = args.goal.copy()
|
||||||
|
world.boss_shuffle = args.shufflebosses.copy()
|
||||||
|
world.enemy_health = args.enemy_health.copy()
|
||||||
|
world.enemy_damage = args.enemy_damage.copy()
|
||||||
|
world.beemizer_total_chance = args.beemizer_total_chance.copy()
|
||||||
|
world.beemizer_trap_chance = args.beemizer_trap_chance.copy()
|
||||||
|
world.countdown_start_time = args.countdown_start_time.copy()
|
||||||
|
world.red_clock_time = args.red_clock_time.copy()
|
||||||
|
world.blue_clock_time = args.blue_clock_time.copy()
|
||||||
|
world.green_clock_time = args.green_clock_time.copy()
|
||||||
|
world.dungeon_counters = args.dungeon_counters.copy()
|
||||||
|
world.triforce_pieces_available = args.triforce_pieces_available.copy()
|
||||||
|
world.triforce_pieces_required = args.triforce_pieces_required.copy()
|
||||||
|
world.shop_shuffle = args.shop_shuffle.copy()
|
||||||
|
world.shuffle_prizes = args.shuffle_prizes.copy()
|
||||||
|
world.sprite_pool = args.sprite_pool.copy()
|
||||||
|
world.dark_room_logic = args.dark_room_logic.copy()
|
||||||
|
world.plando_items = args.plando_items.copy()
|
||||||
|
world.plando_texts = args.plando_texts.copy()
|
||||||
|
world.plando_connections = args.plando_connections.copy()
|
||||||
|
world.required_medallions = args.required_medallions.copy()
|
||||||
|
world.game = args.game.copy()
|
||||||
|
world.player_name = args.name.copy()
|
||||||
|
world.sprite = args.sprite.copy()
|
||||||
|
world.glitch_triforce = args.glitch_triforce # This is enabled/disabled globally, no per player option.
|
||||||
|
|
||||||
|
world.set_options(args)
|
||||||
|
world.set_item_links()
|
||||||
|
world.state = CollectionState(world)
|
||||||
|
logger.info('Archipelago Version %s - Seed: %s\n', __version__, world.seed)
|
||||||
|
|
||||||
logger.info(f"Found {len(AutoWorld.AutoWorldRegister.world_types)} World Types:")
|
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)
|
||||||
@@ -76,95 +101,73 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
|||||||
|
|
||||||
del item_digits, location_digits, 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.
|
AutoWorld.call_stage(world, "assert_generate")
|
||||||
if not args.skip_output:
|
|
||||||
AutoWorld.call_stage(multiworld, "assert_generate")
|
|
||||||
|
|
||||||
AutoWorld.call_all(multiworld, "generate_early")
|
AutoWorld.call_all(world, "generate_early")
|
||||||
|
|
||||||
logger.info('')
|
logger.info('')
|
||||||
|
|
||||||
for player in multiworld.player_ids:
|
for player in world.player_ids:
|
||||||
for item_name, count in multiworld.worlds[player].options.start_inventory.value.items():
|
for item_name, count in world.start_inventory[player].value.items():
|
||||||
for _ in range(count):
|
for _ in range(count):
|
||||||
multiworld.push_precollected(multiworld.create_item(item_name, player))
|
world.push_precollected(world.create_item(item_name, player))
|
||||||
|
|
||||||
for item_name, count in getattr(multiworld.worlds[player].options,
|
for item_name, count in world.start_inventory_from_pool.setdefault(player, StartInventoryPool({})).value.items():
|
||||||
"start_inventory_from_pool",
|
|
||||||
StartInventoryPool({})).value.items():
|
|
||||||
for _ in range(count):
|
for _ in range(count):
|
||||||
multiworld.push_precollected(multiworld.create_item(item_name, player))
|
world.push_precollected(world.create_item(item_name, player))
|
||||||
# remove from_pool items also from early items handling, as starting is plenty early.
|
|
||||||
early = multiworld.early_items[player].get(item_name, 0)
|
|
||||||
if early:
|
|
||||||
multiworld.early_items[player][item_name] = max(0, early-count)
|
|
||||||
remaining_count = count-early
|
|
||||||
if remaining_count > 0:
|
|
||||||
local_early = multiworld.early_local_items[player].get(item_name, 0)
|
|
||||||
if local_early:
|
|
||||||
multiworld.early_items[player][item_name] = max(0, local_early - remaining_count)
|
|
||||||
del local_early
|
|
||||||
del early
|
|
||||||
|
|
||||||
logger.info('Creating MultiWorld.')
|
logger.info('Creating World.')
|
||||||
AutoWorld.call_all(multiworld, "create_regions")
|
AutoWorld.call_all(world, "create_regions")
|
||||||
|
|
||||||
logger.info('Creating Items.')
|
logger.info('Creating Items.')
|
||||||
AutoWorld.call_all(multiworld, "create_items")
|
AutoWorld.call_all(world, "create_items")
|
||||||
|
|
||||||
|
# All worlds should have finished creating all regions, locations, and entrances.
|
||||||
|
# Recache to ensure that they are all visible for locality rules.
|
||||||
|
world._recache()
|
||||||
|
|
||||||
logger.info('Calculating Access Rules.')
|
logger.info('Calculating Access Rules.')
|
||||||
|
|
||||||
for player in multiworld.player_ids:
|
for player in world.player_ids:
|
||||||
# items can't be both local and non-local, prefer local
|
# items can't be both local and non-local, prefer local
|
||||||
multiworld.worlds[player].options.non_local_items.value -= multiworld.worlds[player].options.local_items.value
|
world.non_local_items[player].value -= world.local_items[player].value
|
||||||
multiworld.worlds[player].options.non_local_items.value -= set(multiworld.local_early_items[player])
|
world.non_local_items[player].value -= set(world.local_early_items[player])
|
||||||
|
|
||||||
AutoWorld.call_all(multiworld, "set_rules")
|
AutoWorld.call_all(world, "set_rules")
|
||||||
|
|
||||||
for player in multiworld.player_ids:
|
for player in world.player_ids:
|
||||||
exclusion_rules(multiworld, player, multiworld.worlds[player].options.exclude_locations.value)
|
exclusion_rules(world, player, world.exclude_locations[player].value)
|
||||||
multiworld.worlds[player].options.priority_locations.value -= multiworld.worlds[player].options.exclude_locations.value
|
world.priority_locations[player].value -= world.exclude_locations[player].value
|
||||||
for location_name in multiworld.worlds[player].options.priority_locations.value:
|
for location_name in world.priority_locations[player].value:
|
||||||
try:
|
world.get_location(location_name, player).progress_type = LocationProgressType.PRIORITY
|
||||||
location = multiworld.get_location(location_name, player)
|
|
||||||
except KeyError as e: # failed to find the given location. Check if it's a legitimate location
|
|
||||||
if location_name not in multiworld.worlds[player].location_name_to_id:
|
|
||||||
raise Exception(f"Unable to prioritize location {location_name} in player {player}'s world.") from e
|
|
||||||
else:
|
|
||||||
location.progress_type = LocationProgressType.PRIORITY
|
|
||||||
|
|
||||||
# Set local and non-local item rules.
|
# Set local and non-local item rules.
|
||||||
if multiworld.players > 1:
|
if world.players > 1:
|
||||||
locality_rules(multiworld)
|
locality_rules(world)
|
||||||
else:
|
else:
|
||||||
multiworld.worlds[1].options.non_local_items.value = set()
|
world.non_local_items[1].value = set()
|
||||||
multiworld.worlds[1].options.local_items.value = set()
|
world.local_items[1].value = set()
|
||||||
|
|
||||||
AutoWorld.call_all(multiworld, "generate_basic")
|
AutoWorld.call_all(world, "generate_basic")
|
||||||
|
|
||||||
# remove starting inventory from pool items.
|
# remove starting inventory from pool items.
|
||||||
# Because some worlds don't actually create items during create_items this has to be as late as possible.
|
# Because some worlds don't actually create items during create_items this has to be as late as possible.
|
||||||
if any(getattr(multiworld.worlds[player].options, "start_inventory_from_pool", None) for player in multiworld.player_ids):
|
if any(world.start_inventory_from_pool[player].value for player in world.player_ids):
|
||||||
new_items: List[Item] = []
|
new_items: List[Item] = []
|
||||||
depletion_pool: Dict[int, Dict[str, int]] = {
|
depletion_pool: Dict[int, Dict[str, int]] = {
|
||||||
player: getattr(multiworld.worlds[player].options,
|
player: world.start_inventory_from_pool[player].value.copy() for player in world.player_ids}
|
||||||
"start_inventory_from_pool",
|
|
||||||
StartInventoryPool({})).value.copy()
|
|
||||||
for player in multiworld.player_ids
|
|
||||||
}
|
|
||||||
for player, items in depletion_pool.items():
|
for player, items in depletion_pool.items():
|
||||||
player_world: AutoWorld.World = multiworld.worlds[player]
|
player_world: AutoWorld.World = world.worlds[player]
|
||||||
for count in items.values():
|
for count in items.values():
|
||||||
for _ in range(count):
|
new_items.append(player_world.create_filler())
|
||||||
new_items.append(player_world.create_filler())
|
|
||||||
target: int = sum(sum(items.values()) for items in depletion_pool.values())
|
target: int = sum(sum(items.values()) for items in depletion_pool.values())
|
||||||
for i, item in enumerate(multiworld.itempool):
|
for i, item in enumerate(world.itempool):
|
||||||
if depletion_pool[item.player].get(item.name, 0):
|
if depletion_pool[item.player].get(item.name, 0):
|
||||||
target -= 1
|
target -= 1
|
||||||
depletion_pool[item.player][item.name] -= 1
|
depletion_pool[item.player][item.name] -= 1
|
||||||
# quick abort if we have found all items
|
# quick abort if we have found all items
|
||||||
if not target:
|
if not target:
|
||||||
new_items.extend(multiworld.itempool[i+1:])
|
new_items.extend(world.itempool[i+1:])
|
||||||
break
|
break
|
||||||
else:
|
else:
|
||||||
new_items.append(item)
|
new_items.append(item)
|
||||||
@@ -174,19 +177,18 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
|||||||
for player, remaining_items in depletion_pool.items():
|
for player, remaining_items in depletion_pool.items():
|
||||||
remaining_items = {name: count for name, count in remaining_items.items() if count}
|
remaining_items = {name: count for name, count in remaining_items.items() if count}
|
||||||
if remaining_items:
|
if remaining_items:
|
||||||
raise Exception(f"{multiworld.get_player_name(player)}"
|
raise Exception(f"{world.get_player_name(player)}"
|
||||||
f" is trying to remove items from their pool that don't exist: {remaining_items}")
|
f" is trying to remove items from their pool that don't exist: {remaining_items}")
|
||||||
assert len(multiworld.itempool) == len(new_items), "Item Pool amounts should not change."
|
world.itempool[:] = new_items
|
||||||
multiworld.itempool[:] = new_items
|
|
||||||
|
|
||||||
# temporary home for item links, should be moved out of Main
|
# temporary home for item links, should be moved out of Main
|
||||||
for group_id, group in multiworld.groups.items():
|
for group_id, group in world.groups.items():
|
||||||
def find_common_pool(players: Set[int], shared_pool: Set[str]) -> Tuple[
|
def find_common_pool(players: Set[int], shared_pool: Set[str]) -> Tuple[
|
||||||
Optional[Dict[int, Dict[str, int]]], Optional[Dict[str, int]]
|
Optional[Dict[int, Dict[str, int]]], Optional[Dict[str, int]]
|
||||||
]:
|
]:
|
||||||
classifications: Dict[str, int] = collections.defaultdict(int)
|
classifications: Dict[str, int] = collections.defaultdict(int)
|
||||||
counters = {player: {name: 0 for name in shared_pool} for player in players}
|
counters = {player: {name: 0 for name in shared_pool} for player in players}
|
||||||
for item in multiworld.itempool:
|
for item in world.itempool:
|
||||||
if item.player in counters and item.name in shared_pool:
|
if item.player in counters and item.name in shared_pool:
|
||||||
counters[item.player][item.name] += 1
|
counters[item.player][item.name] += 1
|
||||||
classifications[item.name] |= item.classification
|
classifications[item.name] |= item.classification
|
||||||
@@ -221,13 +223,13 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
|||||||
new_item.classification |= classifications[item_name]
|
new_item.classification |= classifications[item_name]
|
||||||
new_itempool.append(new_item)
|
new_itempool.append(new_item)
|
||||||
|
|
||||||
region = Region("Menu", group_id, multiworld, "ItemLink")
|
region = Region("Menu", group_id, world, "ItemLink")
|
||||||
multiworld.regions.append(region)
|
world.regions.append(region)
|
||||||
locations = region.locations
|
locations = region.locations = []
|
||||||
for item in multiworld.itempool:
|
for item in world.itempool:
|
||||||
count = common_item_count.get(item.player, {}).get(item.name, 0)
|
count = common_item_count.get(item.player, {}).get(item.name, 0)
|
||||||
if count:
|
if count:
|
||||||
loc = Location(group_id, f"Item Link: {item.name} -> {multiworld.player_name[item.player]} {count}",
|
loc = Location(group_id, f"Item Link: {item.name} -> {world.player_name[item.player]} {count}",
|
||||||
None, region)
|
None, region)
|
||||||
loc.access_rule = lambda state, item_name = item.name, group_id_ = group_id, count_ = count: \
|
loc.access_rule = lambda state, item_name = item.name, group_id_ = group_id, count_ = count: \
|
||||||
state.has(item_name, group_id_, count_)
|
state.has(item_name, group_id_, count_)
|
||||||
@@ -238,10 +240,10 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
|||||||
else:
|
else:
|
||||||
new_itempool.append(item)
|
new_itempool.append(item)
|
||||||
|
|
||||||
itemcount = len(multiworld.itempool)
|
itemcount = len(world.itempool)
|
||||||
multiworld.itempool = new_itempool
|
world.itempool = new_itempool
|
||||||
|
|
||||||
while itemcount > len(multiworld.itempool):
|
while itemcount > len(world.itempool):
|
||||||
items_to_add = []
|
items_to_add = []
|
||||||
for player in group["players"]:
|
for player in group["players"]:
|
||||||
if group["link_replacement"]:
|
if group["link_replacement"]:
|
||||||
@@ -249,64 +251,61 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
|||||||
else:
|
else:
|
||||||
item_player = player
|
item_player = player
|
||||||
if group["replacement_items"][player]:
|
if group["replacement_items"][player]:
|
||||||
items_to_add.append(AutoWorld.call_single(multiworld, "create_item", item_player,
|
items_to_add.append(AutoWorld.call_single(world, "create_item", item_player,
|
||||||
group["replacement_items"][player]))
|
group["replacement_items"][player]))
|
||||||
else:
|
else:
|
||||||
items_to_add.append(AutoWorld.call_single(multiworld, "create_filler", item_player))
|
items_to_add.append(AutoWorld.call_single(world, "create_filler", item_player))
|
||||||
multiworld.random.shuffle(items_to_add)
|
world.random.shuffle(items_to_add)
|
||||||
multiworld.itempool.extend(items_to_add[:itemcount - len(multiworld.itempool)])
|
world.itempool.extend(items_to_add[:itemcount - len(world.itempool)])
|
||||||
|
|
||||||
if any(multiworld.item_links.values()):
|
if any(world.item_links.values()):
|
||||||
multiworld._all_state = None
|
world._recache()
|
||||||
|
world._all_state = None
|
||||||
|
|
||||||
logger.info("Running Item Plando.")
|
logger.info("Running Item Plando")
|
||||||
|
|
||||||
distribute_planned(multiworld)
|
distribute_planned(world)
|
||||||
|
|
||||||
logger.info('Running Pre Main Fill.')
|
logger.info('Running Pre Main Fill.')
|
||||||
|
|
||||||
AutoWorld.call_all(multiworld, "pre_fill")
|
AutoWorld.call_all(world, "pre_fill")
|
||||||
|
|
||||||
logger.info(f'Filling the multiworld with {len(multiworld.itempool)} items.')
|
logger.info(f'Filling the world with {len(world.itempool)} items.')
|
||||||
|
|
||||||
if multiworld.algorithm == 'flood':
|
if world.algorithm == 'flood':
|
||||||
flood_items(multiworld) # different algo, biased towards early game progress items
|
flood_items(world) # different algo, biased towards early game progress items
|
||||||
elif multiworld.algorithm == 'balanced':
|
elif world.algorithm == 'balanced':
|
||||||
distribute_items_restrictive(multiworld)
|
distribute_items_restrictive(world)
|
||||||
|
|
||||||
AutoWorld.call_all(multiworld, 'post_fill')
|
AutoWorld.call_all(world, 'post_fill')
|
||||||
|
|
||||||
if multiworld.players > 1 and not args.skip_prog_balancing:
|
if world.players > 1 and not args.skip_prog_balancing:
|
||||||
balance_multiworld_progression(multiworld)
|
balance_multiworld_progression(world)
|
||||||
else:
|
else:
|
||||||
logger.info("Progression balancing skipped.")
|
logger.info("Progression balancing skipped.")
|
||||||
|
|
||||||
# we're about to output using multithreading, so we're removing the global random state to prevent accidental use
|
|
||||||
multiworld.random.passthrough = False
|
|
||||||
|
|
||||||
if args.skip_output:
|
|
||||||
logger.info('Done. Skipped output/spoiler generation. Total Time: %s', time.perf_counter() - start)
|
|
||||||
return multiworld
|
|
||||||
|
|
||||||
logger.info(f'Beginning output...')
|
logger.info(f'Beginning output...')
|
||||||
outfilebase = 'AP_' + multiworld.seed_name
|
|
||||||
|
# we're about to output using multithreading, so we're removing the global random state to prevent accidental use
|
||||||
|
world.random.passthrough = False
|
||||||
|
|
||||||
|
outfilebase = 'AP_' + world.seed_name
|
||||||
|
|
||||||
output = tempfile.TemporaryDirectory()
|
output = tempfile.TemporaryDirectory()
|
||||||
with output as temp_dir:
|
with output as temp_dir:
|
||||||
output_players = [player for player in multiworld.player_ids if AutoWorld.World.generate_output.__code__
|
with concurrent.futures.ThreadPoolExecutor(world.players + 2) as pool:
|
||||||
is not multiworld.worlds[player].generate_output.__code__]
|
check_accessibility_task = pool.submit(world.fulfills_accessibility)
|
||||||
with concurrent.futures.ThreadPoolExecutor(len(output_players) + 2) as pool:
|
|
||||||
check_accessibility_task = pool.submit(multiworld.fulfills_accessibility)
|
|
||||||
|
|
||||||
output_file_futures = [pool.submit(AutoWorld.call_stage, multiworld, "generate_output", temp_dir)]
|
output_file_futures = [pool.submit(AutoWorld.call_stage, world, "generate_output", temp_dir)]
|
||||||
for player in output_players:
|
for player in world.player_ids:
|
||||||
# skip starting a thread for methods that say "pass".
|
# skip starting a thread for methods that say "pass".
|
||||||
output_file_futures.append(
|
if AutoWorld.World.generate_output.__code__ is not world.worlds[player].generate_output.__code__:
|
||||||
pool.submit(AutoWorld.call_single, multiworld, "generate_output", player, temp_dir))
|
output_file_futures.append(
|
||||||
|
pool.submit(AutoWorld.call_single, world, "generate_output", player, temp_dir))
|
||||||
|
|
||||||
# collect ER hint info
|
# collect ER hint info
|
||||||
er_hint_data: Dict[int, Dict[int, str]] = {}
|
er_hint_data: Dict[int, Dict[int, str]] = {}
|
||||||
AutoWorld.call_all(multiworld, 'extend_hint_information', er_hint_data)
|
AutoWorld.call_all(world, 'extend_hint_information', er_hint_data)
|
||||||
|
|
||||||
def write_multidata():
|
def write_multidata():
|
||||||
import NetUtils
|
import NetUtils
|
||||||
@@ -315,59 +314,56 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
|||||||
games = {}
|
games = {}
|
||||||
minimum_versions = {"server": AutoWorld.World.required_server_version, "clients": client_versions}
|
minimum_versions = {"server": AutoWorld.World.required_server_version, "clients": client_versions}
|
||||||
slot_info = {}
|
slot_info = {}
|
||||||
names = [[name for player, name in sorted(multiworld.player_name.items())]]
|
names = [[name for player, name in sorted(world.player_name.items())]]
|
||||||
for slot in multiworld.player_ids:
|
for slot in world.player_ids:
|
||||||
player_world: AutoWorld.World = multiworld.worlds[slot]
|
player_world: AutoWorld.World = world.worlds[slot]
|
||||||
minimum_versions["server"] = max(minimum_versions["server"], player_world.required_server_version)
|
minimum_versions["server"] = max(minimum_versions["server"], player_world.required_server_version)
|
||||||
client_versions[slot] = player_world.required_client_version
|
client_versions[slot] = player_world.required_client_version
|
||||||
games[slot] = multiworld.game[slot]
|
games[slot] = world.game[slot]
|
||||||
slot_info[slot] = NetUtils.NetworkSlot(names[0][slot - 1], multiworld.game[slot],
|
slot_info[slot] = NetUtils.NetworkSlot(names[0][slot - 1], world.game[slot],
|
||||||
multiworld.player_types[slot])
|
world.player_types[slot])
|
||||||
for slot, group in multiworld.groups.items():
|
for slot, group in world.groups.items():
|
||||||
games[slot] = multiworld.game[slot]
|
games[slot] = world.game[slot]
|
||||||
slot_info[slot] = NetUtils.NetworkSlot(group["name"], multiworld.game[slot], multiworld.player_types[slot],
|
slot_info[slot] = NetUtils.NetworkSlot(group["name"], world.game[slot], world.player_types[slot],
|
||||||
group_members=sorted(group["players"]))
|
group_members=sorted(group["players"]))
|
||||||
precollected_items = {player: [item.code for item in world_precollected if type(item.code) == int]
|
precollected_items = {player: [item.code for item in world_precollected if type(item.code) == int]
|
||||||
for player, world_precollected in multiworld.precollected_items.items()}
|
for player, world_precollected in world.precollected_items.items()}
|
||||||
precollected_hints = {player: set() for player in range(1, multiworld.players + 1 + len(multiworld.groups))}
|
precollected_hints = {player: set() for player in range(1, world.players + 1 + len(world.groups))}
|
||||||
|
|
||||||
for slot in multiworld.player_ids:
|
for slot in world.player_ids:
|
||||||
slot_data[slot] = multiworld.worlds[slot].fill_slot_data()
|
slot_data[slot] = world.worlds[slot].fill_slot_data()
|
||||||
|
|
||||||
def precollect_hint(location):
|
def precollect_hint(location):
|
||||||
entrance = er_hint_data.get(location.player, {}).get(location.address, "")
|
entrance = er_hint_data.get(location.player, {}).get(location.address, "")
|
||||||
hint = NetUtils.Hint(location.item.player, location.player, location.address,
|
hint = NetUtils.Hint(location.item.player, location.player, location.address,
|
||||||
location.item.code, False, entrance, location.item.flags)
|
location.item.code, False, entrance, location.item.flags)
|
||||||
precollected_hints[location.player].add(hint)
|
precollected_hints[location.player].add(hint)
|
||||||
if location.item.player not in multiworld.groups:
|
if location.item.player not in world.groups:
|
||||||
precollected_hints[location.item.player].add(hint)
|
precollected_hints[location.item.player].add(hint)
|
||||||
else:
|
else:
|
||||||
for player in multiworld.groups[location.item.player]["players"]:
|
for player in world.groups[location.item.player]["players"]:
|
||||||
precollected_hints[player].add(hint)
|
precollected_hints[player].add(hint)
|
||||||
|
|
||||||
locations_data: Dict[int, Dict[int, Tuple[int, int, int]]] = {player: {} for player in multiworld.player_ids}
|
locations_data: Dict[int, Dict[int, Tuple[int, int, int]]] = {player: {} for player in world.player_ids}
|
||||||
for location in multiworld.get_filled_locations():
|
for location in world.get_filled_locations():
|
||||||
if type(location.address) == int:
|
if type(location.address) == int:
|
||||||
assert location.item.code is not None, "item code None should be event, " \
|
assert location.item.code is not None, "item code None should be event, " \
|
||||||
"location.address should then also be None. Location: " \
|
"location.address should then also be None. Location: " \
|
||||||
f" {location}"
|
f" {location}"
|
||||||
assert location.address not in locations_data[location.player], (
|
|
||||||
f"Locations with duplicate address. {location} and "
|
|
||||||
f"{locations_data[location.player][location.address]}")
|
|
||||||
locations_data[location.player][location.address] = \
|
locations_data[location.player][location.address] = \
|
||||||
location.item.code, location.item.player, location.item.flags
|
location.item.code, location.item.player, location.item.flags
|
||||||
if location.name in multiworld.worlds[location.player].options.start_location_hints:
|
if location.name in world.start_location_hints[location.player]:
|
||||||
precollect_hint(location)
|
precollect_hint(location)
|
||||||
elif location.item.name in multiworld.worlds[location.item.player].options.start_hints:
|
elif location.item.name in world.start_hints[location.item.player]:
|
||||||
precollect_hint(location)
|
precollect_hint(location)
|
||||||
elif any([location.item.name in multiworld.worlds[player].options.start_hints
|
elif any([location.item.name in world.start_hints[player]
|
||||||
for player in multiworld.groups.get(location.item.player, {}).get("players", [])]):
|
for player in world.groups.get(location.item.player, {}).get("players", [])]):
|
||||||
precollect_hint(location)
|
precollect_hint(location)
|
||||||
|
|
||||||
# embedded data package
|
# embedded data package
|
||||||
data_package = {
|
data_package = {
|
||||||
game_world.game: worlds.network_data_package["games"][game_world.game]
|
game_world.game: worlds.network_data_package["games"][game_world.game]
|
||||||
for game_world in multiworld.worlds.values()
|
for game_world in world.worlds.values()
|
||||||
}
|
}
|
||||||
|
|
||||||
checks_in_area: Dict[int, Dict[str, Union[int, List[int]]]] = {}
|
checks_in_area: Dict[int, Dict[str, Union[int, List[int]]]] = {}
|
||||||
@@ -375,7 +371,7 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
|||||||
multidata = {
|
multidata = {
|
||||||
"slot_data": slot_data,
|
"slot_data": slot_data,
|
||||||
"slot_info": slot_info,
|
"slot_info": slot_info,
|
||||||
"connect_names": {name: (0, player) for player, name in multiworld.player_name.items()},
|
"connect_names": {name: (0, player) for player, name in world.player_name.items()},
|
||||||
"locations": locations_data,
|
"locations": locations_data,
|
||||||
"checks_in_area": checks_in_area,
|
"checks_in_area": checks_in_area,
|
||||||
"server_options": baked_server_options,
|
"server_options": baked_server_options,
|
||||||
@@ -385,10 +381,10 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
|||||||
"version": tuple(version_tuple),
|
"version": tuple(version_tuple),
|
||||||
"tags": ["AP"],
|
"tags": ["AP"],
|
||||||
"minimum_versions": minimum_versions,
|
"minimum_versions": minimum_versions,
|
||||||
"seed_name": multiworld.seed_name,
|
"seed_name": world.seed_name,
|
||||||
"datapackage": data_package,
|
"datapackage": data_package,
|
||||||
}
|
}
|
||||||
AutoWorld.call_all(multiworld, "modify_multidata", multidata)
|
AutoWorld.call_all(world, "modify_multidata", multidata)
|
||||||
|
|
||||||
multidata = zlib.compress(pickle.dumps(multidata), 9)
|
multidata = zlib.compress(pickle.dumps(multidata), 9)
|
||||||
|
|
||||||
@@ -396,14 +392,15 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
|||||||
f.write(bytes([3])) # version of format
|
f.write(bytes([3])) # version of format
|
||||||
f.write(multidata)
|
f.write(multidata)
|
||||||
|
|
||||||
output_file_futures.append(pool.submit(write_multidata))
|
multidata_task = pool.submit(write_multidata)
|
||||||
if not check_accessibility_task.result():
|
if not check_accessibility_task.result():
|
||||||
if not multiworld.can_beat_game():
|
if not world.can_beat_game():
|
||||||
raise Exception("Game appears as unbeatable. Aborting.")
|
raise Exception("Game appears as unbeatable. Aborting.")
|
||||||
else:
|
else:
|
||||||
logger.warning("Location Accessibility requirements not fulfilled.")
|
logger.warning("Location Accessibility requirements not fulfilled.")
|
||||||
|
|
||||||
# retrieve exceptions via .result() if they occurred.
|
# retrieve exceptions via .result() if they occurred.
|
||||||
|
multidata_task.result()
|
||||||
for i, future in enumerate(concurrent.futures.as_completed(output_file_futures), start=1):
|
for i, future in enumerate(concurrent.futures.as_completed(output_file_futures), start=1):
|
||||||
if i % 10 == 0 or i == len(output_file_futures):
|
if i % 10 == 0 or i == len(output_file_futures):
|
||||||
logger.info(f'Generating output files ({i}/{len(output_file_futures)}).')
|
logger.info(f'Generating output files ({i}/{len(output_file_futures)}).')
|
||||||
@@ -411,12 +408,12 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
|||||||
|
|
||||||
if args.spoiler > 1:
|
if args.spoiler > 1:
|
||||||
logger.info('Calculating playthrough.')
|
logger.info('Calculating playthrough.')
|
||||||
multiworld.spoiler.create_playthrough(create_paths=args.spoiler > 2)
|
world.spoiler.create_playthrough(create_paths=args.spoiler > 2)
|
||||||
|
|
||||||
if args.spoiler:
|
if args.spoiler:
|
||||||
multiworld.spoiler.to_file(os.path.join(temp_dir, '%s_Spoiler.txt' % outfilebase))
|
world.spoiler.to_file(os.path.join(temp_dir, '%s_Spoiler.txt' % outfilebase))
|
||||||
|
|
||||||
zipfilename = output_path(f"AP_{multiworld.seed_name}.zip")
|
zipfilename = output_path(f"AP_{world.seed_name}.zip")
|
||||||
logger.info(f"Creating final archive at {zipfilename}")
|
logger.info(f"Creating final archive at {zipfilename}")
|
||||||
with zipfile.ZipFile(zipfilename, mode="w", compression=zipfile.ZIP_DEFLATED,
|
with zipfile.ZipFile(zipfilename, mode="w", compression=zipfile.ZIP_DEFLATED,
|
||||||
compresslevel=9) as zf:
|
compresslevel=9) as zf:
|
||||||
@@ -424,4 +421,4 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
|||||||
zf.write(file.path, arcname=file.name)
|
zf.write(file.path, arcname=file.name)
|
||||||
|
|
||||||
logger.info('Done. Enjoy. Total Time: %s', time.perf_counter() - start)
|
logger.info('Done. Enjoy. Total Time: %s', time.perf_counter() - start)
|
||||||
return multiworld
|
return world
|
||||||
|
|||||||
@@ -4,29 +4,14 @@ import subprocess
|
|||||||
import multiprocessing
|
import multiprocessing
|
||||||
import warnings
|
import warnings
|
||||||
|
|
||||||
|
local_dir = os.path.dirname(__file__)
|
||||||
|
requirements_files = {os.path.join(local_dir, 'requirements.txt')}
|
||||||
|
|
||||||
if sys.version_info < (3, 8, 6):
|
if sys.version_info < (3, 8, 6):
|
||||||
raise RuntimeError("Incompatible Python Version. 3.8.7+ is supported.")
|
raise RuntimeError("Incompatible Python Version. 3.8.7+ is supported.")
|
||||||
|
|
||||||
# don't run update if environment is frozen/compiled or if not the parent process (skip in subprocess)
|
# don't run update if environment is frozen/compiled or if not the parent process (skip in subprocess)
|
||||||
_skip_update = bool(getattr(sys, "frozen", False) or multiprocessing.parent_process())
|
update_ran = getattr(sys, "frozen", False) or multiprocessing.parent_process()
|
||||||
update_ran = _skip_update
|
|
||||||
|
|
||||||
|
|
||||||
class RequirementsSet(set):
|
|
||||||
def add(self, e):
|
|
||||||
global update_ran
|
|
||||||
update_ran &= _skip_update
|
|
||||||
super().add(e)
|
|
||||||
|
|
||||||
def update(self, *s):
|
|
||||||
global update_ran
|
|
||||||
update_ran &= _skip_update
|
|
||||||
super().update(*s)
|
|
||||||
|
|
||||||
|
|
||||||
local_dir = os.path.dirname(__file__)
|
|
||||||
requirements_files = RequirementsSet((os.path.join(local_dir, 'requirements.txt'),))
|
|
||||||
|
|
||||||
if not update_ran:
|
if not update_ran:
|
||||||
for entry in os.scandir(os.path.join(local_dir, "worlds")):
|
for entry in os.scandir(os.path.join(local_dir, "worlds")):
|
||||||
@@ -70,7 +55,7 @@ def install_pkg_resources(yes=False):
|
|||||||
subprocess.call([sys.executable, "-m", "pip", "install", "--upgrade", "setuptools"])
|
subprocess.call([sys.executable, "-m", "pip", "install", "--upgrade", "setuptools"])
|
||||||
|
|
||||||
|
|
||||||
def update(yes: bool = False, force: bool = False) -> None:
|
def update(yes=False, force=False):
|
||||||
global update_ran
|
global update_ran
|
||||||
if not update_ran:
|
if not update_ran:
|
||||||
update_ran = True
|
update_ran = True
|
||||||
@@ -82,23 +67,14 @@ def update(yes: bool = False, force: bool = False) -> None:
|
|||||||
install_pkg_resources(yes=yes)
|
install_pkg_resources(yes=yes)
|
||||||
import pkg_resources
|
import pkg_resources
|
||||||
|
|
||||||
prev = "" # if a line ends in \ we store here and merge later
|
|
||||||
for req_file in requirements_files:
|
for req_file in requirements_files:
|
||||||
path = os.path.join(os.path.dirname(sys.argv[0]), req_file)
|
path = os.path.join(os.path.dirname(sys.argv[0]), req_file)
|
||||||
if not os.path.exists(path):
|
if not os.path.exists(path):
|
||||||
path = os.path.join(os.path.dirname(__file__), req_file)
|
path = os.path.join(os.path.dirname(__file__), req_file)
|
||||||
with open(path) as requirementsfile:
|
with open(path) as requirementsfile:
|
||||||
for line in requirementsfile:
|
for line in requirementsfile:
|
||||||
if not line or line.lstrip(" \t")[0] == "#":
|
if not line or line[0] == "#":
|
||||||
if not prev:
|
continue # ignore comments
|
||||||
continue # ignore comments
|
|
||||||
line = ""
|
|
||||||
elif line.rstrip("\r\n").endswith("\\"):
|
|
||||||
prev = prev + line.rstrip("\r\n")[:-1] + " " # continue on next line
|
|
||||||
continue
|
|
||||||
line = prev + line
|
|
||||||
line = line.split("--hash=")[0] # remove hashes from requirement for version checking
|
|
||||||
prev = ""
|
|
||||||
if line.startswith(("https://", "git+https://")):
|
if line.startswith(("https://", "git+https://")):
|
||||||
# extract name and version for url
|
# extract name and version for url
|
||||||
rest = line.split('/')[-1]
|
rest = line.split('/')[-1]
|
||||||
|
|||||||
309
MultiServer.py
309
MultiServer.py
@@ -2,15 +2,14 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import asyncio
|
import asyncio
|
||||||
import collections
|
|
||||||
import copy
|
import copy
|
||||||
|
import collections
|
||||||
import datetime
|
import datetime
|
||||||
import functools
|
import functools
|
||||||
import hashlib
|
import hashlib
|
||||||
import inspect
|
import inspect
|
||||||
import itertools
|
import itertools
|
||||||
import logging
|
import logging
|
||||||
import math
|
|
||||||
import operator
|
import operator
|
||||||
import pickle
|
import pickle
|
||||||
import random
|
import random
|
||||||
@@ -68,25 +67,21 @@ def update_dict(dictionary, entries):
|
|||||||
|
|
||||||
# functions callable on storable data on the server by clients
|
# functions callable on storable data on the server by clients
|
||||||
modify_functions = {
|
modify_functions = {
|
||||||
# generic:
|
|
||||||
"replace": lambda old, new: new,
|
|
||||||
"default": lambda old, new: old,
|
|
||||||
# numeric:
|
|
||||||
"add": operator.add, # add together two objects, using python's "+" operator (works on strings and lists as append)
|
"add": operator.add, # add together two objects, using python's "+" operator (works on strings and lists as append)
|
||||||
"mul": operator.mul,
|
"mul": operator.mul,
|
||||||
"pow": operator.pow,
|
|
||||||
"mod": operator.mod,
|
"mod": operator.mod,
|
||||||
"floor": lambda value, _: math.floor(value),
|
|
||||||
"ceil": lambda value, _: math.ceil(value),
|
|
||||||
"max": max,
|
"max": max,
|
||||||
"min": min,
|
"min": min,
|
||||||
|
"replace": lambda old, new: new,
|
||||||
|
"default": lambda old, new: old,
|
||||||
|
"pow": operator.pow,
|
||||||
# bitwise:
|
# bitwise:
|
||||||
"xor": operator.xor,
|
"xor": operator.xor,
|
||||||
"or": operator.or_,
|
"or": operator.or_,
|
||||||
"and": operator.and_,
|
"and": operator.and_,
|
||||||
"left_shift": operator.lshift,
|
"left_shift": operator.lshift,
|
||||||
"right_shift": operator.rshift,
|
"right_shift": operator.rshift,
|
||||||
# lists/dicts:
|
# lists/dicts
|
||||||
"remove": remove_from_list,
|
"remove": remove_from_list,
|
||||||
"pop": pop_from_container,
|
"pop": pop_from_container,
|
||||||
"update": update_dict,
|
"update": update_dict,
|
||||||
@@ -175,13 +170,11 @@ class Context:
|
|||||||
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.Set[str]]
|
non_hintable_names: typing.Dict[str, typing.Set[str]]
|
||||||
logger: logging.Logger
|
|
||||||
|
|
||||||
def __init__(self, host: str, port: int, server_password: str, password: str, location_check_points: int,
|
def __init__(self, host: str, port: int, server_password: str, password: str, location_check_points: int,
|
||||||
hint_cost: int, item_cheat: bool, release_mode: str = "disabled", collect_mode="disabled",
|
hint_cost: int, item_cheat: bool, release_mode: str = "disabled", collect_mode="disabled",
|
||||||
remaining_mode: str = "disabled", auto_shutdown: typing.SupportsFloat = 0, compatibility: int = 2,
|
remaining_mode: str = "disabled", auto_shutdown: typing.SupportsFloat = 0, compatibility: int = 2,
|
||||||
log_network: bool = False, logger: logging.Logger = logging.getLogger()):
|
log_network: bool = False):
|
||||||
self.logger = logger
|
|
||||||
super(Context, self).__init__()
|
super(Context, self).__init__()
|
||||||
self.slot_info = {}
|
self.slot_info = {}
|
||||||
self.log_network = log_network
|
self.log_network = log_network
|
||||||
@@ -289,12 +282,12 @@ class Context:
|
|||||||
try:
|
try:
|
||||||
await endpoint.socket.send(msg)
|
await endpoint.socket.send(msg)
|
||||||
except websockets.ConnectionClosed:
|
except websockets.ConnectionClosed:
|
||||||
self.logger.exception(f"Exception during send_msgs, could not send {msg}")
|
logging.exception(f"Exception during send_msgs, could not send {msg}")
|
||||||
await self.disconnect(endpoint)
|
await self.disconnect(endpoint)
|
||||||
return False
|
return False
|
||||||
else:
|
else:
|
||||||
if self.log_network:
|
if self.log_network:
|
||||||
self.logger.info(f"Outgoing message: {msg}")
|
logging.info(f"Outgoing message: {msg}")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
async def send_encoded_msgs(self, endpoint: Endpoint, msg: str) -> bool:
|
async def send_encoded_msgs(self, endpoint: Endpoint, msg: str) -> bool:
|
||||||
@@ -303,12 +296,12 @@ class Context:
|
|||||||
try:
|
try:
|
||||||
await endpoint.socket.send(msg)
|
await endpoint.socket.send(msg)
|
||||||
except websockets.ConnectionClosed:
|
except websockets.ConnectionClosed:
|
||||||
self.logger.exception("Exception during send_encoded_msgs")
|
logging.exception("Exception during send_encoded_msgs")
|
||||||
await self.disconnect(endpoint)
|
await self.disconnect(endpoint)
|
||||||
return False
|
return False
|
||||||
else:
|
else:
|
||||||
if self.log_network:
|
if self.log_network:
|
||||||
self.logger.info(f"Outgoing message: {msg}")
|
logging.info(f"Outgoing message: {msg}")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
async def broadcast_send_encoded_msgs(self, endpoints: typing.Iterable[Endpoint], msg: str) -> bool:
|
async def broadcast_send_encoded_msgs(self, endpoints: typing.Iterable[Endpoint], msg: str) -> bool:
|
||||||
@@ -319,11 +312,11 @@ class Context:
|
|||||||
try:
|
try:
|
||||||
websockets.broadcast(sockets, msg)
|
websockets.broadcast(sockets, msg)
|
||||||
except RuntimeError:
|
except RuntimeError:
|
||||||
self.logger.exception("Exception during broadcast_send_encoded_msgs")
|
logging.exception("Exception during broadcast_send_encoded_msgs")
|
||||||
return False
|
return False
|
||||||
else:
|
else:
|
||||||
if self.log_network:
|
if self.log_network:
|
||||||
self.logger.info(f"Outgoing broadcast: {msg}")
|
logging.info(f"Outgoing broadcast: {msg}")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def broadcast_all(self, msgs: typing.List[dict]):
|
def broadcast_all(self, msgs: typing.List[dict]):
|
||||||
@@ -332,7 +325,7 @@ class Context:
|
|||||||
async_start(self.broadcast_send_encoded_msgs(endpoints, msgs))
|
async_start(self.broadcast_send_encoded_msgs(endpoints, msgs))
|
||||||
|
|
||||||
def broadcast_text_all(self, text: str, additional_arguments: dict = {}):
|
def broadcast_text_all(self, text: str, additional_arguments: dict = {}):
|
||||||
self.logger.info("Notice (all): %s" % text)
|
logging.info("Notice (all): %s" % text)
|
||||||
self.broadcast_all([{**{"cmd": "PrintJSON", "data": [{ "text": text }]}, **additional_arguments}])
|
self.broadcast_all([{**{"cmd": "PrintJSON", "data": [{ "text": text }]}, **additional_arguments}])
|
||||||
|
|
||||||
def broadcast_team(self, team: int, msgs: typing.List[dict]):
|
def broadcast_team(self, team: int, msgs: typing.List[dict]):
|
||||||
@@ -354,7 +347,7 @@ class Context:
|
|||||||
def notify_client(self, client: Client, text: str, additional_arguments: dict = {}):
|
def notify_client(self, client: Client, text: str, additional_arguments: dict = {}):
|
||||||
if not client.auth:
|
if not client.auth:
|
||||||
return
|
return
|
||||||
self.logger.info("Notice (Player %s in team %d): %s" % (client.name, client.team + 1, text))
|
logging.info("Notice (Player %s in team %d): %s" % (client.name, client.team + 1, text))
|
||||||
async_start(self.send_msgs(client, [{"cmd": "PrintJSON", "data": [{ "text": text }], **additional_arguments}]))
|
async_start(self.send_msgs(client, [{"cmd": "PrintJSON", "data": [{ "text": text }], **additional_arguments}]))
|
||||||
|
|
||||||
def notify_client_multiple(self, client: Client, texts: typing.List[str], additional_arguments: dict = {}):
|
def notify_client_multiple(self, client: Client, texts: typing.List[str], additional_arguments: dict = {}):
|
||||||
@@ -419,8 +412,6 @@ class Context:
|
|||||||
self.player_name_lookup[slot_info.name] = 0, slot_id
|
self.player_name_lookup[slot_info.name] = 0, slot_id
|
||||||
self.read_data[f"hints_{0}_{slot_id}"] = lambda local_team=0, local_player=slot_id: \
|
self.read_data[f"hints_{0}_{slot_id}"] = lambda local_team=0, local_player=slot_id: \
|
||||||
list(self.get_rechecked_hints(local_team, local_player))
|
list(self.get_rechecked_hints(local_team, local_player))
|
||||||
self.read_data[f"client_status_{0}_{slot_id}"] = lambda local_team=0, local_player=slot_id: \
|
|
||||||
self.client_game_state[local_team, local_player]
|
|
||||||
|
|
||||||
self.seed_name = decoded_obj["seed_name"]
|
self.seed_name = decoded_obj["seed_name"]
|
||||||
self.random.seed(self.seed_name)
|
self.random.seed(self.seed_name)
|
||||||
@@ -453,7 +444,7 @@ class Context:
|
|||||||
for game_name, data in decoded_obj.get("datapackage", {}).items():
|
for game_name, data in decoded_obj.get("datapackage", {}).items():
|
||||||
if game_name in game_data_packages:
|
if game_name in game_data_packages:
|
||||||
data = game_data_packages[game_name]
|
data = game_data_packages[game_name]
|
||||||
self.logger.info(f"Loading embedded data package for game {game_name}")
|
logging.info(f"Loading embedded data package for game {game_name}")
|
||||||
self.gamespackage[game_name] = data
|
self.gamespackage[game_name] = data
|
||||||
self.item_name_groups[game_name] = data["item_name_groups"]
|
self.item_name_groups[game_name] = data["item_name_groups"]
|
||||||
if "location_name_groups" in data:
|
if "location_name_groups" in data:
|
||||||
@@ -485,7 +476,7 @@ class Context:
|
|||||||
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))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.exception(e)
|
logging.exception(e)
|
||||||
return False
|
return False
|
||||||
else:
|
else:
|
||||||
return True
|
return True
|
||||||
@@ -503,12 +494,12 @@ class Context:
|
|||||||
save_data = restricted_loads(zlib.decompress(f.read()))
|
save_data = restricted_loads(zlib.decompress(f.read()))
|
||||||
self.set_save(save_data)
|
self.set_save(save_data)
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
self.logger.error('No save data found, starting a new game')
|
logging.error('No save data found, starting a new game')
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.exception(e)
|
logging.exception(e)
|
||||||
self._start_async_saving()
|
self._start_async_saving()
|
||||||
|
|
||||||
def _start_async_saving(self, atexit_save: bool = True):
|
def _start_async_saving(self):
|
||||||
if not self.auto_saver_thread:
|
if not self.auto_saver_thread:
|
||||||
def save_regularly():
|
def save_regularly():
|
||||||
# time.time() is platform dependent, so using the expensive datetime method instead
|
# time.time() is platform dependent, so using the expensive datetime method instead
|
||||||
@@ -522,19 +513,18 @@ class Context:
|
|||||||
next_wakeup = (second - get_datetime_second()) % self.auto_save_interval
|
next_wakeup = (second - get_datetime_second()) % self.auto_save_interval
|
||||||
time.sleep(max(1.0, next_wakeup))
|
time.sleep(max(1.0, next_wakeup))
|
||||||
if self.save_dirty:
|
if self.save_dirty:
|
||||||
self.logger.debug("Saving via thread.")
|
logging.debug("Saving via thread.")
|
||||||
self._save()
|
self._save()
|
||||||
except OperationalError as e:
|
except OperationalError as e:
|
||||||
self.logger.exception(e)
|
logging.exception(e)
|
||||||
self.logger.info(f"Saving failed. Retry in {self.auto_save_interval} seconds.")
|
logging.info(f"Saving failed. Retry in {self.auto_save_interval} seconds.")
|
||||||
else:
|
else:
|
||||||
self.save_dirty = False
|
self.save_dirty = False
|
||||||
self.auto_saver_thread = threading.Thread(target=save_regularly, daemon=True)
|
self.auto_saver_thread = threading.Thread(target=save_regularly, daemon=True)
|
||||||
self.auto_saver_thread.start()
|
self.auto_saver_thread.start()
|
||||||
|
|
||||||
if atexit_save:
|
import atexit
|
||||||
import atexit
|
atexit.register(self._save, True) # make sure we save on exit too
|
||||||
atexit.register(self._save, True) # make sure we save on exit too
|
|
||||||
|
|
||||||
def get_save(self) -> dict:
|
def get_save(self) -> dict:
|
||||||
self.recheck_hints()
|
self.recheck_hints()
|
||||||
@@ -589,7 +579,7 @@ class Context:
|
|||||||
self.location_check_points = savedata["game_options"]["location_check_points"]
|
self.location_check_points = savedata["game_options"]["location_check_points"]
|
||||||
self.server_password = savedata["game_options"]["server_password"]
|
self.server_password = savedata["game_options"]["server_password"]
|
||||||
self.password = savedata["game_options"]["password"]
|
self.password = savedata["game_options"]["password"]
|
||||||
self.release_mode = savedata["game_options"]["release_mode"]
|
self.release_mode = savedata["game_options"].get("release_mode", savedata["game_options"].get("forfeit_mode", "goal"))
|
||||||
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.item_cheat = savedata["game_options"]["item_cheat"]
|
self.item_cheat = savedata["game_options"]["item_cheat"]
|
||||||
@@ -601,7 +591,7 @@ class Context:
|
|||||||
if "stored_data" in savedata:
|
if "stored_data" in savedata:
|
||||||
self.stored_data = savedata["stored_data"]
|
self.stored_data = savedata["stored_data"]
|
||||||
# count items and slots from lists for items_handling = remote
|
# count items and slots from lists for items_handling = remote
|
||||||
self.logger.info(
|
logging.info(
|
||||||
f'Loaded save file with {sum([len(v) for k, v in self.received_items.items() if k[2]])} received items '
|
f'Loaded save file with {sum([len(v) for k, v in self.received_items.items() if k[2]])} received items '
|
||||||
f'for {sum(k[2] for k in self.received_items)} players')
|
f'for {sum(k[2] for k in self.received_items)} players')
|
||||||
|
|
||||||
@@ -634,6 +624,8 @@ class Context:
|
|||||||
|
|
||||||
def _set_options(self, server_options: dict):
|
def _set_options(self, server_options: dict):
|
||||||
for key, value in server_options.items():
|
for key, value in server_options.items():
|
||||||
|
if key == "forfeit_mode":
|
||||||
|
key = "release_mode"
|
||||||
data_type = self.simple_options.get(key, None)
|
data_type = self.simple_options.get(key, None)
|
||||||
if data_type is not None:
|
if data_type is not None:
|
||||||
if value not in {False, True, None}: # some can be boolean OR text, such as password
|
if value not in {False, True, None}: # some can be boolean OR text, such as password
|
||||||
@@ -643,13 +635,13 @@ class Context:
|
|||||||
try:
|
try:
|
||||||
raise Exception(f"Could not set server option {key}, skipping.") from e
|
raise Exception(f"Could not set server option {key}, skipping.") from e
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.exception(e)
|
logging.exception(e)
|
||||||
self.logger.debug(f"Setting server option {key} to {value} from supplied multidata")
|
logging.debug(f"Setting server option {key} to {value} from supplied multidata")
|
||||||
setattr(self, key, value)
|
setattr(self, key, value)
|
||||||
elif key == "disable_item_cheat":
|
elif key == "disable_item_cheat":
|
||||||
self.item_cheat = not bool(value)
|
self.item_cheat = not bool(value)
|
||||||
else:
|
else:
|
||||||
self.logger.debug(f"Unrecognized server option {key}")
|
logging.debug(f"Unrecognized server option {key}")
|
||||||
|
|
||||||
def get_aliased_name(self, team: int, slot: int):
|
def get_aliased_name(self, team: int, slot: int):
|
||||||
if (team, slot) in self.name_aliases:
|
if (team, slot) in self.name_aliases:
|
||||||
@@ -657,8 +649,7 @@ class Context:
|
|||||||
else:
|
else:
|
||||||
return self.player_names[team, slot]
|
return self.player_names[team, slot]
|
||||||
|
|
||||||
def notify_hints(self, team: int, hints: typing.List[NetUtils.Hint], only_new: bool = False,
|
def notify_hints(self, team: int, hints: typing.List[NetUtils.Hint], only_new: bool = False):
|
||||||
recipients: typing.Sequence[int] = None):
|
|
||||||
"""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]]
|
||||||
@@ -683,17 +674,16 @@ class Context:
|
|||||||
self.hints[team, player].add(hint)
|
self.hints[team, player].add(hint)
|
||||||
new_hint_events.add(player)
|
new_hint_events.add(player)
|
||||||
|
|
||||||
self.logger.info("Notice (Team #%d): %s" % (team + 1, format_hint(self, team, hint)))
|
logging.info("Notice (Team #%d): %s" % (team + 1, format_hint(self, team, hint)))
|
||||||
for slot in new_hint_events:
|
for slot in new_hint_events:
|
||||||
self.on_new_hint(team, slot)
|
self.on_new_hint(team, slot)
|
||||||
for slot, hint_data in concerns.items():
|
for slot, hint_data in concerns.items():
|
||||||
if recipients is None or slot in recipients:
|
clients = self.clients[team].get(slot)
|
||||||
clients = self.clients[team].get(slot)
|
if not clients:
|
||||||
if not clients:
|
continue
|
||||||
continue
|
client_hints = [datum[1] for datum in sorted(hint_data, key=lambda x: x[0].finding_player == slot)]
|
||||||
client_hints = [datum[1] for datum in sorted(hint_data, key=lambda x: x[0].finding_player != slot)]
|
for client in clients:
|
||||||
for client in clients:
|
async_start(self.send_msgs(client, client_hints))
|
||||||
async_start(self.send_msgs(client, client_hints))
|
|
||||||
|
|
||||||
# "events"
|
# "events"
|
||||||
|
|
||||||
@@ -708,23 +698,14 @@ class Context:
|
|||||||
self.save() # save goal completion flag
|
self.save() # save goal completion flag
|
||||||
|
|
||||||
def on_new_hint(self, team: int, slot: int):
|
def on_new_hint(self, team: int, slot: int):
|
||||||
self.on_changed_hints(team, slot)
|
|
||||||
self.broadcast(self.clients[team][slot], [{
|
|
||||||
"cmd": "RoomUpdate",
|
|
||||||
"hint_points": get_slot_points(self, team, slot)
|
|
||||||
}])
|
|
||||||
|
|
||||||
def on_changed_hints(self, team: int, slot: int):
|
|
||||||
key: str = f"_read_hints_{team}_{slot}"
|
key: str = f"_read_hints_{team}_{slot}"
|
||||||
targets: typing.Set[Client] = set(self.stored_data_notification_clients[key])
|
targets: typing.Set[Client] = set(self.stored_data_notification_clients[key])
|
||||||
if targets:
|
if targets:
|
||||||
self.broadcast(targets, [{"cmd": "SetReply", "key": key, "value": self.hints[team, slot]}])
|
self.broadcast(targets, [{"cmd": "SetReply", "key": key, "value": self.hints[team, slot]}])
|
||||||
|
self.broadcast(self.clients[team][slot], [{
|
||||||
def on_client_status_change(self, team: int, slot: int):
|
"cmd": "RoomUpdate",
|
||||||
key: str = f"_read_client_status_{team}_{slot}"
|
"hint_points": get_slot_points(self, team, slot)
|
||||||
targets: typing.Set[Client] = set(self.stored_data_notification_clients[key])
|
}])
|
||||||
if targets:
|
|
||||||
self.broadcast(targets, [{"cmd": "SetReply", "key": key, "value": self.client_game_state[team, slot]}])
|
|
||||||
|
|
||||||
|
|
||||||
def update_aliases(ctx: Context, team: int):
|
def update_aliases(ctx: Context, team: int):
|
||||||
@@ -742,21 +723,21 @@ async def server(websocket, path: str = "/", ctx: Context = None):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
if ctx.log_network:
|
if ctx.log_network:
|
||||||
ctx.logger.info("Incoming connection")
|
logging.info("Incoming connection")
|
||||||
await on_client_connected(ctx, client)
|
await on_client_connected(ctx, client)
|
||||||
if ctx.log_network:
|
if ctx.log_network:
|
||||||
ctx.logger.info("Sent Room Info")
|
logging.info("Sent Room Info")
|
||||||
async for data in websocket:
|
async for data in websocket:
|
||||||
if ctx.log_network:
|
if ctx.log_network:
|
||||||
ctx.logger.info(f"Incoming message: {data}")
|
logging.info(f"Incoming message: {data}")
|
||||||
for msg in decode(data):
|
for msg in decode(data):
|
||||||
await process_client_cmd(ctx, client, msg)
|
await process_client_cmd(ctx, client, msg)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
if not isinstance(e, websockets.WebSocketException):
|
if not isinstance(e, websockets.WebSocketException):
|
||||||
ctx.logger.exception(e)
|
logging.exception(e)
|
||||||
finally:
|
finally:
|
||||||
if ctx.log_network:
|
if ctx.log_network:
|
||||||
ctx.logger.info("Disconnected")
|
logging.info("Disconnected")
|
||||||
await ctx.disconnect(client)
|
await ctx.disconnect(client)
|
||||||
|
|
||||||
|
|
||||||
@@ -806,25 +787,14 @@ async def on_client_disconnected(ctx: Context, client: Client):
|
|||||||
await on_client_left(ctx, client)
|
await on_client_left(ctx, client)
|
||||||
|
|
||||||
|
|
||||||
_non_game_messages = {"HintGame": "hinting", "Tracker": "tracking", "TextOnly": "viewing"}
|
|
||||||
""" { tag: ui_message } """
|
|
||||||
|
|
||||||
|
|
||||||
async def on_client_joined(ctx: Context, client: Client):
|
async def on_client_joined(ctx: Context, client: Client):
|
||||||
if ctx.client_game_state[client.team, client.slot] == ClientStatus.CLIENT_UNKNOWN:
|
if ctx.client_game_state[client.team, client.slot] == ClientStatus.CLIENT_UNKNOWN:
|
||||||
update_client_status(ctx, client, ClientStatus.CLIENT_CONNECTED)
|
update_client_status(ctx, client, ClientStatus.CLIENT_CONNECTED)
|
||||||
version_str = '.'.join(str(x) for x in client.version)
|
version_str = '.'.join(str(x) for x in client.version)
|
||||||
|
verb = "tracking" if "Tracker" in client.tags else "playing"
|
||||||
for tag, verb in _non_game_messages.items():
|
|
||||||
if tag in client.tags:
|
|
||||||
final_verb = verb
|
|
||||||
break
|
|
||||||
else:
|
|
||||||
final_verb = "playing"
|
|
||||||
|
|
||||||
ctx.broadcast_text_all(
|
ctx.broadcast_text_all(
|
||||||
f"{ctx.get_aliased_name(client.team, client.slot)} (Team #{client.team + 1}) "
|
f"{ctx.get_aliased_name(client.team, client.slot)} (Team #{client.team + 1}) "
|
||||||
f"{final_verb} {ctx.games[client.slot]} has joined. "
|
f"{verb} {ctx.games[client.slot]} has joined. "
|
||||||
f"Client({version_str}), {client.tags}.",
|
f"Client({version_str}), {client.tags}.",
|
||||||
{"type": "Join", "team": client.team, "slot": client.slot, "tags": client.tags})
|
{"type": "Join", "team": client.team, "slot": client.slot, "tags": client.tags})
|
||||||
ctx.notify_client(client, "Now that you are connected, "
|
ctx.notify_client(client, "Now that you are connected, "
|
||||||
@@ -839,19 +809,8 @@ async def on_client_left(ctx: Context, client: Client):
|
|||||||
if len(ctx.clients[client.team][client.slot]) < 1:
|
if len(ctx.clients[client.team][client.slot]) < 1:
|
||||||
update_client_status(ctx, client, ClientStatus.CLIENT_UNKNOWN)
|
update_client_status(ctx, client, ClientStatus.CLIENT_UNKNOWN)
|
||||||
ctx.client_connection_timers[client.team, client.slot] = datetime.datetime.now(datetime.timezone.utc)
|
ctx.client_connection_timers[client.team, client.slot] = datetime.datetime.now(datetime.timezone.utc)
|
||||||
|
|
||||||
version_str = '.'.join(str(x) for x in client.version)
|
|
||||||
|
|
||||||
for tag, verb in _non_game_messages.items():
|
|
||||||
if tag in client.tags:
|
|
||||||
final_verb = f"stopped {verb}"
|
|
||||||
break
|
|
||||||
else:
|
|
||||||
final_verb = "left"
|
|
||||||
|
|
||||||
ctx.broadcast_text_all(
|
ctx.broadcast_text_all(
|
||||||
f"{ctx.get_aliased_name(client.team, client.slot)} (Team #{client.team + 1}) has {final_verb} the game. "
|
"%s (Team #%d) has left the game" % (ctx.get_aliased_name(client.team, client.slot), client.team + 1),
|
||||||
f"Client({version_str}), {client.tags}.",
|
|
||||||
{"type": "Part", "team": client.team, "slot": client.slot})
|
{"type": "Part", "team": client.team, "slot": client.slot})
|
||||||
|
|
||||||
|
|
||||||
@@ -988,7 +947,7 @@ def register_location_checks(ctx: Context, team: int, slot: int, locations: typi
|
|||||||
new_item = NetworkItem(item_id, location, slot, flags)
|
new_item = NetworkItem(item_id, location, slot, flags)
|
||||||
send_items_to(ctx, team, target_player, new_item)
|
send_items_to(ctx, team, target_player, new_item)
|
||||||
|
|
||||||
ctx.logger.info('(Team #%d) %s sent %s to %s (%s)' % (
|
logging.info('(Team #%d) %s sent %s to %s (%s)' % (
|
||||||
team + 1, ctx.player_names[(team, slot)], ctx.item_names[item_id],
|
team + 1, ctx.player_names[(team, slot)], ctx.item_names[item_id],
|
||||||
ctx.player_names[(team, target_player)], ctx.location_names[location]))
|
ctx.player_names[(team, target_player)], ctx.location_names[location]))
|
||||||
info_text = json_format_send_event(new_item, target_player)
|
info_text = json_format_send_event(new_item, target_player)
|
||||||
@@ -1001,10 +960,7 @@ def register_location_checks(ctx: Context, team: int, slot: int, locations: typi
|
|||||||
"hint_points": get_slot_points(ctx, team, slot),
|
"hint_points": get_slot_points(ctx, team, slot),
|
||||||
"checked_locations": new_locations, # send back new checks only
|
"checked_locations": new_locations, # send back new checks only
|
||||||
}])
|
}])
|
||||||
old_hints = ctx.hints[team, slot].copy()
|
|
||||||
ctx.recheck_hints(team, slot)
|
|
||||||
if old_hints != ctx.hints[team, slot]:
|
|
||||||
ctx.on_changed_hints(team, slot)
|
|
||||||
ctx.save()
|
ctx.save()
|
||||||
|
|
||||||
|
|
||||||
@@ -1081,19 +1037,17 @@ def get_intended_text(input_text: str, possible_answers) -> typing.Tuple[str, bo
|
|||||||
if picks[0][1] == 100:
|
if picks[0][1] == 100:
|
||||||
return picks[0][0], True, "Perfect Match"
|
return picks[0][0], True, "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, " \
|
||||||
f"did you mean '{picks[0][0]}'? ({picks[0][1]}% sure)"
|
f"did you mean {picks[0][0]}? ({picks[0][1]}% sure)"
|
||||||
elif dif > 5:
|
elif dif > 5:
|
||||||
return picks[0][0], True, "Close Match"
|
return picks[0][0], True, "Close Match"
|
||||||
else:
|
else:
|
||||||
return picks[0][0], False, f"Too many close matches for '{input_text}', " \
|
return picks[0][0], False, f"Too many close matches, did you mean {picks[0][0]}? ({picks[0][1]}% sure)"
|
||||||
f"did you mean '{picks[0][0]}'? ({picks[0][1]}% sure)"
|
|
||||||
else:
|
else:
|
||||||
if picks[0][1] > 90:
|
if picks[0][1] > 90:
|
||||||
return picks[0][0], True, "Only Option Match"
|
return picks[0][0], True, "Only Option Match"
|
||||||
else:
|
else:
|
||||||
return picks[0][0], False, f"Didn't find something that closely matches '{input_text}', " \
|
return picks[0][0], False, f"Did you mean {picks[0][0]}? ({picks[0][1]}% sure)"
|
||||||
f"did you mean '{picks[0][0]}'? ({picks[0][1]}% sure)"
|
|
||||||
|
|
||||||
|
|
||||||
class CommandMeta(type):
|
class CommandMeta(type):
|
||||||
@@ -1370,7 +1324,6 @@ class ClientMessageProcessor(CommonCommandProcessor):
|
|||||||
"Sorry, !remaining requires you to have beaten the game on this server")
|
"Sorry, !remaining requires you to have beaten the game on this server")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@mark_raw
|
|
||||||
def _cmd_missing(self, filter_text="") -> bool:
|
def _cmd_missing(self, filter_text="") -> bool:
|
||||||
"""List all missing location checks from the server's perspective.
|
"""List all missing location checks from the server's perspective.
|
||||||
Can be given text, which will be used as filter."""
|
Can be given text, which will be used as filter."""
|
||||||
@@ -1380,11 +1333,7 @@ class ClientMessageProcessor(CommonCommandProcessor):
|
|||||||
if locations:
|
if locations:
|
||||||
names = [self.ctx.location_names[location] for location in locations]
|
names = [self.ctx.location_names[location] for location in locations]
|
||||||
if filter_text:
|
if filter_text:
|
||||||
location_groups = self.ctx.location_name_groups[self.ctx.games[self.client.slot]]
|
names = [name for name in names if filter_text in name]
|
||||||
if filter_text in location_groups: # location group name
|
|
||||||
names = [name for name in names if name in location_groups[filter_text]]
|
|
||||||
else:
|
|
||||||
names = [name for name in names if filter_text in name]
|
|
||||||
texts = [f'Missing: {name}' for name in names]
|
texts = [f'Missing: {name}' for name in names]
|
||||||
if filter_text:
|
if filter_text:
|
||||||
texts.append(f"Found {len(locations)} missing location checks, displaying {len(names)} of them.")
|
texts.append(f"Found {len(locations)} missing location checks, displaying {len(names)} of them.")
|
||||||
@@ -1395,7 +1344,6 @@ class ClientMessageProcessor(CommonCommandProcessor):
|
|||||||
self.output("No missing location checks found.")
|
self.output("No missing location checks found.")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@mark_raw
|
|
||||||
def _cmd_checked(self, filter_text="") -> bool:
|
def _cmd_checked(self, filter_text="") -> bool:
|
||||||
"""List all done location checks from the server's perspective.
|
"""List all done location checks from the server's perspective.
|
||||||
Can be given text, which will be used as filter."""
|
Can be given text, which will be used as filter."""
|
||||||
@@ -1405,11 +1353,7 @@ class ClientMessageProcessor(CommonCommandProcessor):
|
|||||||
if locations:
|
if locations:
|
||||||
names = [self.ctx.location_names[location] for location in locations]
|
names = [self.ctx.location_names[location] for location in locations]
|
||||||
if filter_text:
|
if filter_text:
|
||||||
location_groups = self.ctx.location_name_groups[self.ctx.games[self.client.slot]]
|
names = [name for name in names if filter_text in name]
|
||||||
if filter_text in location_groups: # location group name
|
|
||||||
names = [name for name in names if name in location_groups[filter_text]]
|
|
||||||
else:
|
|
||||||
names = [name for name in names if filter_text in name]
|
|
||||||
texts = [f'Checked: {name}' for name in names]
|
texts = [f'Checked: {name}' for name in names]
|
||||||
if filter_text:
|
if filter_text:
|
||||||
texts.append(f"Found {len(locations)} done location checks, displaying {len(names)} of them.")
|
texts.append(f"Found {len(locations)} done location checks, displaying {len(names)} of them.")
|
||||||
@@ -1472,13 +1416,9 @@ class ClientMessageProcessor(CommonCommandProcessor):
|
|||||||
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]}
|
||||||
self.ctx.hints[self.client.team, self.client.slot] = hints
|
self.ctx.hints[self.client.team, self.client.slot] = hints
|
||||||
self.ctx.notify_hints(self.client.team, list(hints), recipients=(self.client.slot,))
|
self.ctx.notify_hints(self.client.team, list(hints))
|
||||||
self.output(f"A hint costs {self.ctx.get_hint_cost(self.client.slot)} points. "
|
self.output(f"A hint costs {self.ctx.get_hint_cost(self.client.slot)} points. "
|
||||||
f"You have {points_available} points.")
|
f"You have {points_available} points.")
|
||||||
if hints and Utils.version_tuple < (0, 5, 0):
|
|
||||||
self.output("It was recently changed, so that the above hints are only shown to you. "
|
|
||||||
"If you meant to alert another player of an above hint, "
|
|
||||||
"please let them know of the content or to run !hint themselves.")
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
elif input_text.isnumeric():
|
elif input_text.isnumeric():
|
||||||
@@ -1532,13 +1472,15 @@ class ClientMessageProcessor(CommonCommandProcessor):
|
|||||||
|
|
||||||
if hints:
|
if hints:
|
||||||
new_hints = set(hints) - self.ctx.hints[self.client.team, self.client.slot]
|
new_hints = set(hints) - self.ctx.hints[self.client.team, self.client.slot]
|
||||||
old_hints = list(set(hints) - new_hints)
|
old_hints = set(hints) - new_hints
|
||||||
if old_hints and not new_hints:
|
if old_hints:
|
||||||
self.ctx.notify_hints(self.client.team, old_hints)
|
self.ctx.notify_hints(self.client.team, list(old_hints))
|
||||||
self.output("Hint was previously used, no points deducted.")
|
if not new_hints:
|
||||||
|
self.output("Hint was previously used, no points deducted.")
|
||||||
if new_hints:
|
if new_hints:
|
||||||
found_hints = [hint for hint in new_hints if hint.found]
|
found_hints = [hint for hint in new_hints if hint.found]
|
||||||
not_found_hints = [hint for hint in new_hints if not hint.found]
|
not_found_hints = [hint for hint in new_hints if not hint.found]
|
||||||
|
|
||||||
if not not_found_hints: # everything's been found, no need to pay
|
if not not_found_hints: # everything's been found, no need to pay
|
||||||
can_pay = 1000
|
can_pay = 1000
|
||||||
elif cost:
|
elif cost:
|
||||||
@@ -1550,7 +1492,7 @@ class ClientMessageProcessor(CommonCommandProcessor):
|
|||||||
# By popular vote, make hints prefer non-local placements
|
# By popular vote, make hints prefer non-local placements
|
||||||
not_found_hints.sort(key=lambda hint: int(hint.receiving_player != hint.finding_player))
|
not_found_hints.sort(key=lambda hint: int(hint.receiving_player != hint.finding_player))
|
||||||
|
|
||||||
hints = found_hints + old_hints
|
hints = found_hints
|
||||||
while can_pay > 0:
|
while can_pay > 0:
|
||||||
if not not_found_hints:
|
if not not_found_hints:
|
||||||
break
|
break
|
||||||
@@ -1560,7 +1502,6 @@ class ClientMessageProcessor(CommonCommandProcessor):
|
|||||||
self.ctx.hints_used[self.client.team, self.client.slot] += 1
|
self.ctx.hints_used[self.client.team, self.client.slot] += 1
|
||||||
points_available = get_client_points(self.ctx, self.client)
|
points_available = get_client_points(self.ctx, self.client)
|
||||||
|
|
||||||
self.ctx.notify_hints(self.client.team, hints)
|
|
||||||
if not_found_hints:
|
if not_found_hints:
|
||||||
if hints and cost and int((points_available // cost) == 0):
|
if hints and cost and int((points_available // cost) == 0):
|
||||||
self.output(
|
self.output(
|
||||||
@@ -1574,6 +1515,7 @@ class ClientMessageProcessor(CommonCommandProcessor):
|
|||||||
self.output(f"You can't afford the hint. "
|
self.output(f"You can't afford the hint. "
|
||||||
f"You have {points_available} points and need at least "
|
f"You have {points_available} points and need at least "
|
||||||
f"{self.ctx.get_hint_cost(self.client.slot)}.")
|
f"{self.ctx.get_hint_cost(self.client.slot)}.")
|
||||||
|
self.ctx.notify_hints(self.client.team, hints)
|
||||||
self.ctx.save()
|
self.ctx.save()
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -1628,7 +1570,7 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
|
|||||||
try:
|
try:
|
||||||
cmd: str = args["cmd"]
|
cmd: str = args["cmd"]
|
||||||
except:
|
except:
|
||||||
ctx.logger.exception(f"Could not get command from {args}")
|
logging.exception(f"Could not get command from {args}")
|
||||||
await ctx.send_msgs(client, [{'cmd': 'InvalidPacket', "type": "cmd", "original_cmd": None,
|
await ctx.send_msgs(client, [{'cmd': 'InvalidPacket', "type": "cmd", "original_cmd": None,
|
||||||
"text": f"Could not get command from {args} at `cmd`"}])
|
"text": f"Could not get command from {args} at `cmd`"}])
|
||||||
raise
|
raise
|
||||||
@@ -1654,9 +1596,7 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
|
|||||||
else:
|
else:
|
||||||
team, slot = ctx.connect_names[args['name']]
|
team, slot = ctx.connect_names[args['name']]
|
||||||
game = ctx.games[slot]
|
game = ctx.games[slot]
|
||||||
|
ignore_game = ("TextOnly" in args["tags"] or "Tracker" in args["tags"]) and not args.get("game")
|
||||||
ignore_game = not args.get("game") and any(tag in _non_game_messages for tag in args["tags"])
|
|
||||||
|
|
||||||
if not ignore_game and args['game'] != game:
|
if not ignore_game and args['game'] != game:
|
||||||
errors.add('InvalidGame')
|
errors.add('InvalidGame')
|
||||||
minver = min_client_version if ignore_game else ctx.minimum_client_versions[slot]
|
minver = min_client_version if ignore_game else ctx.minimum_client_versions[slot]
|
||||||
@@ -1671,7 +1611,7 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
|
|||||||
if ctx.compatibility == 0 and args['version'] != version_tuple:
|
if ctx.compatibility == 0 and args['version'] != version_tuple:
|
||||||
errors.add('IncompatibleVersion')
|
errors.add('IncompatibleVersion')
|
||||||
if errors:
|
if errors:
|
||||||
ctx.logger.info(f"A client connection was refused due to: {errors}, the sent connect information was {args}.")
|
logging.info(f"A client connection was refused due to: {errors}, the sent connect information was {args}.")
|
||||||
await ctx.send_msgs(client, [{"cmd": "ConnectionRefused", "errors": list(errors)}])
|
await ctx.send_msgs(client, [{"cmd": "ConnectionRefused", "errors": list(errors)}])
|
||||||
else:
|
else:
|
||||||
team, slot = ctx.connect_names[args['name']]
|
team, slot = ctx.connect_names[args['name']]
|
||||||
@@ -1872,14 +1812,8 @@ def update_client_status(ctx: Context, client: Client, new_status: ClientStatus)
|
|||||||
if current != ClientStatus.CLIENT_GOAL: # can't undo goal completion
|
if current != ClientStatus.CLIENT_GOAL: # can't undo goal completion
|
||||||
if new_status == ClientStatus.CLIENT_GOAL:
|
if new_status == ClientStatus.CLIENT_GOAL:
|
||||||
ctx.on_goal_achieved(client)
|
ctx.on_goal_achieved(client)
|
||||||
# if player has yet to ever connect to the server, they will not be in client_game_state
|
|
||||||
if all(player in ctx.client_game_state and ctx.client_game_state[player] == ClientStatus.CLIENT_GOAL
|
|
||||||
for player in ctx.player_names
|
|
||||||
if player[0] == client.team and player[1] != client.slot):
|
|
||||||
ctx.broadcast_text_all(f"Team #{client.team + 1} has completed all of their games! Congratulations!")
|
|
||||||
|
|
||||||
ctx.client_game_state[client.team, client.slot] = new_status
|
ctx.client_game_state[client.team, client.slot] = new_status
|
||||||
ctx.on_client_status_change(client.team, client.slot)
|
|
||||||
ctx.save()
|
ctx.save()
|
||||||
|
|
||||||
|
|
||||||
@@ -1930,7 +1864,7 @@ class ServerCommandProcessor(CommonCommandProcessor):
|
|||||||
@mark_raw
|
@mark_raw
|
||||||
def _cmd_alias(self, player_name_then_alias_name):
|
def _cmd_alias(self, player_name_then_alias_name):
|
||||||
"""Set a player's alias, by listing their base name and then their intended alias."""
|
"""Set a player's alias, by listing their base name and then their intended alias."""
|
||||||
player_name, _, alias_name = player_name_then_alias_name.partition(" ")
|
player_name, alias_name = player_name_then_alias_name.split(" ", 1)
|
||||||
player_name, usable, response = get_intended_text(player_name, self.ctx.player_names.values())
|
player_name, usable, response = get_intended_text(player_name, self.ctx.player_names.values())
|
||||||
if usable:
|
if usable:
|
||||||
for (team, slot), name in self.ctx.player_names.items():
|
for (team, slot), name in self.ctx.player_names.items():
|
||||||
@@ -2010,7 +1944,7 @@ class ServerCommandProcessor(CommonCommandProcessor):
|
|||||||
|
|
||||||
@mark_raw
|
@mark_raw
|
||||||
def _cmd_forbid_release(self, player_name: str) -> bool:
|
def _cmd_forbid_release(self, player_name: str) -> bool:
|
||||||
"""Disallow the specified player from using the !release command."""
|
""""Disallow the specified player from using the !release command."""
|
||||||
player = self.resolve_player(player_name)
|
player = self.resolve_player(player_name)
|
||||||
if player:
|
if player:
|
||||||
team, slot, name = player
|
team, slot, name = player
|
||||||
@@ -2130,8 +2064,8 @@ class ServerCommandProcessor(CommonCommandProcessor):
|
|||||||
|
|
||||||
if full_name.isnumeric():
|
if full_name.isnumeric():
|
||||||
location, usable, response = int(full_name), True, None
|
location, usable, response = int(full_name), True, None
|
||||||
elif game in self.ctx.all_location_and_group_names:
|
elif self.ctx.location_names_for_game(game) is not None:
|
||||||
location, usable, response = get_intended_text(full_name, self.ctx.all_location_and_group_names[game])
|
location, usable, response = get_intended_text(full_name, self.ctx.location_names_for_game(game))
|
||||||
else:
|
else:
|
||||||
self.output("Can't look up location for unknown game. Hint for ID instead.")
|
self.output("Can't look up location for unknown game. Hint for ID instead.")
|
||||||
return False
|
return False
|
||||||
@@ -2139,11 +2073,6 @@ 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)
|
||||||
elif game in self.ctx.location_name_groups and location in self.ctx.location_name_groups[game]:
|
|
||||||
hints = []
|
|
||||||
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):
|
|
||||||
hints.extend(collect_hint_location_name(self.ctx, team, slot, loc_name_from_group))
|
|
||||||
else:
|
else:
|
||||||
hints = collect_hint_location_name(self.ctx, team, slot, location)
|
hints = collect_hint_location_name(self.ctx, team, slot, location)
|
||||||
if hints:
|
if hints:
|
||||||
@@ -2159,47 +2088,32 @@ class ServerCommandProcessor(CommonCommandProcessor):
|
|||||||
self.output(response)
|
self.output(response)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def _cmd_option(self, option_name: str, option_value: str):
|
def _cmd_option(self, option_name: str, option: str):
|
||||||
"""Set an option for the server."""
|
"""Set options for the server."""
|
||||||
value_type = self.ctx.simple_options.get(option_name, None)
|
|
||||||
if not value_type:
|
attrtype = self.ctx.simple_options.get(option_name, None)
|
||||||
known_options = (f"{option}: {option_type}" for option, option_type in self.ctx.simple_options.items())
|
if attrtype:
|
||||||
self.output(f"Unrecognized option '{option_name}', known: {', '.join(known_options)}")
|
if attrtype == bool:
|
||||||
|
def attrtype(input_text: str):
|
||||||
|
return input_text.lower() not in {"off", "0", "false", "none", "null", "no"}
|
||||||
|
elif attrtype == str and option_name.endswith("password"):
|
||||||
|
def attrtype(input_text: str):
|
||||||
|
if input_text.lower() in {"null", "none", '""', "''"}:
|
||||||
|
return None
|
||||||
|
return input_text
|
||||||
|
setattr(self.ctx, option_name, attrtype(option))
|
||||||
|
self.output(f"Set option {option_name} to {getattr(self.ctx, option_name)}")
|
||||||
|
if option_name in {"release_mode", "remaining_mode", "collect_mode"}:
|
||||||
|
self.ctx.broadcast_all([{"cmd": "RoomUpdate", 'permissions': get_permissions(self.ctx)}])
|
||||||
|
elif option_name in {"hint_cost", "location_check_points"}:
|
||||||
|
self.ctx.broadcast_all([{"cmd": "RoomUpdate", option_name: getattr(self.ctx, option_name)}])
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
known = (f"{option}:{otype}" for option, otype in self.ctx.simple_options.items())
|
||||||
|
self.output(f"Unrecognized Option {option_name}, known: "
|
||||||
|
f"{', '.join(known)}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
if value_type == bool:
|
|
||||||
def value_type(input_text: str):
|
|
||||||
return input_text.lower() not in {"off", "0", "false", "none", "null", "no"}
|
|
||||||
elif value_type == str and option_name.endswith("password"):
|
|
||||||
def value_type(input_text: str):
|
|
||||||
return None if input_text.lower() in {"null", "none", '""', "''"} else input_text
|
|
||||||
elif value_type == str and option_name.endswith("mode"):
|
|
||||||
valid_values = {"goal", "enabled", "disabled"}
|
|
||||||
valid_values.update(("auto", "auto_enabled") if option_name != "remaining_mode" else [])
|
|
||||||
if option_value.lower() not in valid_values:
|
|
||||||
self.output(f"Unrecognized {option_name} value '{option_value}', known: {', '.join(valid_values)}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
setattr(self.ctx, option_name, value_type(option_value))
|
|
||||||
self.output(f"Set option {option_name} to {getattr(self.ctx, option_name)}")
|
|
||||||
if option_name in {"release_mode", "remaining_mode", "collect_mode"}:
|
|
||||||
self.ctx.broadcast_all([{"cmd": "RoomUpdate", 'permissions': get_permissions(self.ctx)}])
|
|
||||||
elif option_name in {"hint_cost", "location_check_points"}:
|
|
||||||
self.ctx.broadcast_all([{"cmd": "RoomUpdate", option_name: getattr(self.ctx, option_name)}])
|
|
||||||
return True
|
|
||||||
|
|
||||||
def _cmd_datastore(self):
|
|
||||||
"""Debug Tool: list writable datastorage keys and approximate the size of their values with pickle."""
|
|
||||||
total: int = 0
|
|
||||||
texts = []
|
|
||||||
for key, value in self.ctx.stored_data.items():
|
|
||||||
size = len(pickle.dumps(value))
|
|
||||||
total += size
|
|
||||||
texts.append(f"Key: {key} | Size: {size}B")
|
|
||||||
texts.insert(0, f"Found {len(self.ctx.stored_data)} keys, "
|
|
||||||
f"approximately totaling {Utils.format_SI_prefix(total, power=1024)}B")
|
|
||||||
self.output("\n".join(texts))
|
|
||||||
|
|
||||||
|
|
||||||
async def console(ctx: Context):
|
async def console(ctx: Context):
|
||||||
import sys
|
import sys
|
||||||
@@ -2223,7 +2137,7 @@ async def console(ctx: Context):
|
|||||||
|
|
||||||
def parse_args() -> argparse.Namespace:
|
def parse_args() -> argparse.Namespace:
|
||||||
parser = argparse.ArgumentParser()
|
parser = argparse.ArgumentParser()
|
||||||
defaults = Utils.get_settings()["server_options"].as_dict()
|
defaults = Utils.get_options()["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)
|
||||||
@@ -2282,24 +2196,25 @@ def parse_args() -> argparse.Namespace:
|
|||||||
|
|
||||||
async def auto_shutdown(ctx, to_cancel=None):
|
async def auto_shutdown(ctx, to_cancel=None):
|
||||||
await asyncio.sleep(ctx.auto_shutdown)
|
await asyncio.sleep(ctx.auto_shutdown)
|
||||||
|
|
||||||
def inactivity_shutdown():
|
|
||||||
ctx.server.ws_server.close()
|
|
||||||
ctx.exit_event.set()
|
|
||||||
if to_cancel:
|
|
||||||
for task in to_cancel:
|
|
||||||
task.cancel()
|
|
||||||
ctx.logger.info("Shutting down due to inactivity.")
|
|
||||||
|
|
||||||
while not ctx.exit_event.is_set():
|
while not ctx.exit_event.is_set():
|
||||||
if not ctx.client_activity_timers.values():
|
if not ctx.client_activity_timers.values():
|
||||||
inactivity_shutdown()
|
ctx.server.ws_server.close()
|
||||||
|
ctx.exit_event.set()
|
||||||
|
if to_cancel:
|
||||||
|
for task in to_cancel:
|
||||||
|
task.cancel()
|
||||||
|
logging.info("Shutting down due to inactivity.")
|
||||||
else:
|
else:
|
||||||
newest_activity = max(ctx.client_activity_timers.values())
|
newest_activity = max(ctx.client_activity_timers.values())
|
||||||
delta = datetime.datetime.now(datetime.timezone.utc) - newest_activity
|
delta = datetime.datetime.now(datetime.timezone.utc) - newest_activity
|
||||||
seconds = ctx.auto_shutdown - delta.total_seconds()
|
seconds = ctx.auto_shutdown - delta.total_seconds()
|
||||||
if seconds < 0:
|
if seconds < 0:
|
||||||
inactivity_shutdown()
|
ctx.server.ws_server.close()
|
||||||
|
ctx.exit_event.set()
|
||||||
|
if to_cancel:
|
||||||
|
for task in to_cancel:
|
||||||
|
task.cancel()
|
||||||
|
logging.info("Shutting down due to inactivity.")
|
||||||
else:
|
else:
|
||||||
await asyncio.sleep(seconds)
|
await asyncio.sleep(seconds)
|
||||||
|
|
||||||
|
|||||||
30
NetUtils.py
30
NetUtils.py
@@ -290,8 +290,8 @@ def add_json_item(parts: list, item_id: int, player: int = 0, item_flags: int =
|
|||||||
parts.append({"text": str(item_id), "player": player, "flags": item_flags, "type": JSONTypes.item_id, **kwargs})
|
parts.append({"text": str(item_id), "player": player, "flags": item_flags, "type": JSONTypes.item_id, **kwargs})
|
||||||
|
|
||||||
|
|
||||||
def add_json_location(parts: list, location_id: int, player: int = 0, **kwargs) -> None:
|
def add_json_location(parts: list, item_id: int, player: int = 0, **kwargs) -> None:
|
||||||
parts.append({"text": str(location_id), "player": player, "type": JSONTypes.location_id, **kwargs})
|
parts.append({"text": str(item_id), "player": player, "type": JSONTypes.location_id, **kwargs})
|
||||||
|
|
||||||
|
|
||||||
class Hint(typing.NamedTuple):
|
class Hint(typing.NamedTuple):
|
||||||
@@ -408,21 +408,13 @@ if typing.TYPE_CHECKING: # type-check with pure python implementation until we
|
|||||||
LocationStore = _LocationStore
|
LocationStore = _LocationStore
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
from _speedups import LocationStore
|
import pyximport
|
||||||
import _speedups
|
pyximport.install()
|
||||||
import os.path
|
|
||||||
if os.path.isfile("_speedups.pyx") and os.path.getctime(_speedups.__file__) < os.path.getctime("_speedups.pyx"):
|
|
||||||
warnings.warn(f"{_speedups.__file__} outdated! "
|
|
||||||
f"Please rebuild with `cythonize -b -i _speedups.pyx` or delete it!")
|
|
||||||
except ImportError:
|
except ImportError:
|
||||||
try:
|
pyximport = None
|
||||||
import pyximport
|
try:
|
||||||
pyximport.install()
|
from _speedups import LocationStore
|
||||||
except ImportError:
|
except ImportError:
|
||||||
pyximport = None
|
warnings.warn("_speedups not available. Falling back to pure python LocationStore. "
|
||||||
try:
|
"Install a matching C++ compiler for your platform to compile _speedups.")
|
||||||
from _speedups import LocationStore
|
LocationStore = _LocationStore
|
||||||
except ImportError:
|
|
||||||
warnings.warn("_speedups not available. Falling back to pure python LocationStore. "
|
|
||||||
"Install a matching C++ compiler for your platform to compile _speedups.")
|
|
||||||
LocationStore = _LocationStore
|
|
||||||
|
|||||||
@@ -195,10 +195,10 @@ def set_icon(window):
|
|||||||
window.tk.call('wm', 'iconphoto', window._w, logo)
|
window.tk.call('wm', 'iconphoto', window._w, logo)
|
||||||
|
|
||||||
def adjust(args):
|
def adjust(args):
|
||||||
# Create a fake multiworld and OOTWorld to use as a base
|
# Create a fake world and OOTWorld to use as a base
|
||||||
multiworld = MultiWorld(1)
|
world = MultiWorld(1)
|
||||||
multiworld.per_slot_randoms = {1: random}
|
world.per_slot_randoms = {1: random}
|
||||||
ootworld = OOTWorld(multiworld, 1)
|
ootworld = OOTWorld(world, 1)
|
||||||
# Set options in the fake OOTWorld
|
# Set options in the fake OOTWorld
|
||||||
for name, option in chain(cosmetic_options.items(), sfx_options.items()):
|
for name, option in chain(cosmetic_options.items(), sfx_options.items()):
|
||||||
result = getattr(args, name, None)
|
result = getattr(args, name, None)
|
||||||
|
|||||||
229
Options.py
229
Options.py
@@ -1,19 +1,16 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import abc
|
import abc
|
||||||
import functools
|
|
||||||
import logging
|
import logging
|
||||||
import math
|
import math
|
||||||
import numbers
|
import numbers
|
||||||
import random
|
import random
|
||||||
import typing
|
import typing
|
||||||
import enum
|
|
||||||
from copy import deepcopy
|
from copy import deepcopy
|
||||||
from dataclasses import dataclass
|
|
||||||
|
|
||||||
from schema import And, Optional, Or, Schema
|
from schema import And, Optional, Or, Schema
|
||||||
|
|
||||||
from Utils import get_fuzzy_results, is_iterable_except_str
|
from Utils import get_fuzzy_results
|
||||||
|
|
||||||
if typing.TYPE_CHECKING:
|
if typing.TYPE_CHECKING:
|
||||||
from BaseClasses import PlandoOptions
|
from BaseClasses import PlandoOptions
|
||||||
@@ -21,19 +18,6 @@ if typing.TYPE_CHECKING:
|
|||||||
import pathlib
|
import pathlib
|
||||||
|
|
||||||
|
|
||||||
class OptionError(ValueError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class Visibility(enum.IntFlag):
|
|
||||||
none = 0b0000
|
|
||||||
template = 0b0001
|
|
||||||
simple_ui = 0b0010 # show option in simple menus, such as player-options
|
|
||||||
complex_ui = 0b0100 # show option in complex menus, such as weighted-options
|
|
||||||
spoiler = 0b1000
|
|
||||||
all = 0b1111
|
|
||||||
|
|
||||||
|
|
||||||
class AssembleOptions(abc.ABCMeta):
|
class AssembleOptions(abc.ABCMeta):
|
||||||
def __new__(mcs, name, bases, attrs):
|
def __new__(mcs, name, bases, attrs):
|
||||||
options = attrs["options"] = {}
|
options = attrs["options"] = {}
|
||||||
@@ -55,11 +39,6 @@ class AssembleOptions(abc.ABCMeta):
|
|||||||
aliases = {name[6:].lower(): option_id for name, option_id in attrs.items() if
|
aliases = {name[6:].lower(): option_id for name, option_id in attrs.items() if
|
||||||
name.startswith("alias_")}
|
name.startswith("alias_")}
|
||||||
|
|
||||||
assert (
|
|
||||||
name in {"Option", "VerifyKeys"} or # base abstract classes don't need default
|
|
||||||
"default" in attrs or
|
|
||||||
any(hasattr(base, "default") for base in bases)
|
|
||||||
), f"Option class {name} needs default value"
|
|
||||||
assert "random" not in aliases, "Choice option 'random' cannot be manually assigned."
|
assert "random" not in aliases, "Choice option 'random' cannot be manually assigned."
|
||||||
|
|
||||||
# auto-alias Off and On being parsed as True and False
|
# auto-alias Off and On being parsed as True and False
|
||||||
@@ -77,7 +56,6 @@ class AssembleOptions(abc.ABCMeta):
|
|||||||
def verify(self, *args, **kwargs) -> None:
|
def verify(self, *args, **kwargs) -> None:
|
||||||
for f in verifiers:
|
for f in verifiers:
|
||||||
f(self, *args, **kwargs)
|
f(self, *args, **kwargs)
|
||||||
|
|
||||||
attrs["verify"] = verify
|
attrs["verify"] = verify
|
||||||
else:
|
else:
|
||||||
assert verifiers, "class Option is supposed to implement def verify"
|
assert verifiers, "class Option is supposed to implement def verify"
|
||||||
@@ -115,8 +93,7 @@ T = typing.TypeVar('T')
|
|||||||
|
|
||||||
class Option(typing.Generic[T], metaclass=AssembleOptions):
|
class Option(typing.Generic[T], metaclass=AssembleOptions):
|
||||||
value: T
|
value: T
|
||||||
default: typing.ClassVar[typing.Any] # something that __init__ will be able to convert to the correct type
|
default = 0
|
||||||
visibility = Visibility.all
|
|
||||||
|
|
||||||
# convert option_name_long into Name Long as display_name, otherwise name_long is the result.
|
# convert option_name_long into Name Long as display_name, otherwise name_long is the result.
|
||||||
# Handled in get_option_name()
|
# Handled in get_option_name()
|
||||||
@@ -126,9 +103,8 @@ class Option(typing.Generic[T], metaclass=AssembleOptions):
|
|||||||
supports_weighting = True
|
supports_weighting = True
|
||||||
|
|
||||||
# filled by AssembleOptions:
|
# filled by AssembleOptions:
|
||||||
name_lookup: typing.ClassVar[typing.Dict[T, str]] # type: ignore
|
name_lookup: typing.Dict[T, str]
|
||||||
# https://github.com/python/typing/discussions/1460 the reason for this type: ignore
|
options: typing.Dict[str, int]
|
||||||
options: typing.ClassVar[typing.Dict[str, int]]
|
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
def __repr__(self) -> str:
|
||||||
return f"{self.__class__.__name__}({self.current_option_name})"
|
return f"{self.__class__.__name__}({self.current_option_name})"
|
||||||
@@ -140,6 +116,12 @@ class Option(typing.Generic[T], metaclass=AssembleOptions):
|
|||||||
def current_key(self) -> str:
|
def current_key(self) -> str:
|
||||||
return self.name_lookup[self.value]
|
return self.name_lookup[self.value]
|
||||||
|
|
||||||
|
def get_current_option_name(self) -> str:
|
||||||
|
"""Deprecated. use current_option_name instead. TODO remove around 0.4"""
|
||||||
|
logging.warning(DeprecationWarning(f"get_current_option_name for {self.__class__.__name__} is deprecated."
|
||||||
|
f" use current_option_name instead. Worlds should use {self}.current_key"))
|
||||||
|
return self.current_option_name
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def current_option_name(self) -> str:
|
def current_option_name(self) -> str:
|
||||||
"""For display purposes. Worlds should be using current_key."""
|
"""For display purposes. Worlds should be using current_key."""
|
||||||
@@ -175,8 +157,6 @@ class FreeText(Option[str]):
|
|||||||
"""Text option that allows users to enter strings.
|
"""Text option that allows users to enter strings.
|
||||||
Needs to be validated by the world or option definition."""
|
Needs to be validated by the world or option definition."""
|
||||||
|
|
||||||
default = ""
|
|
||||||
|
|
||||||
def __init__(self, value: str):
|
def __init__(self, value: str):
|
||||||
assert isinstance(value, str), "value of FreeText must be a string"
|
assert isinstance(value, str), "value of FreeText must be a string"
|
||||||
self.value = value
|
self.value = value
|
||||||
@@ -197,18 +177,9 @@ class FreeText(Option[str]):
|
|||||||
def get_option_name(cls, value: str) -> str:
|
def get_option_name(cls, value: str) -> str:
|
||||||
return value
|
return value
|
||||||
|
|
||||||
def __eq__(self, other):
|
|
||||||
if isinstance(other, self.__class__):
|
|
||||||
return other.value == self.value
|
|
||||||
elif isinstance(other, str):
|
|
||||||
return other == self.value
|
|
||||||
else:
|
|
||||||
raise TypeError(f"Can't compare {self.__class__.__name__} with {other.__class__.__name__}")
|
|
||||||
|
|
||||||
|
|
||||||
class NumericOption(Option[int], numbers.Integral, abc.ABC):
|
class NumericOption(Option[int], numbers.Integral, abc.ABC):
|
||||||
default = 0
|
default = 0
|
||||||
|
|
||||||
# note: some of the `typing.Any`` here is a result of unresolved issue in python standards
|
# note: some of the `typing.Any`` here is a result of unresolved issue in python standards
|
||||||
# `int` is not a `numbers.Integral` according to the official typestubs
|
# `int` is not a `numbers.Integral` according to the official typestubs
|
||||||
# (even though isinstance(5, numbers.Integral) == True)
|
# (even though isinstance(5, numbers.Integral) == True)
|
||||||
@@ -240,12 +211,6 @@ class NumericOption(Option[int], numbers.Integral, abc.ABC):
|
|||||||
else:
|
else:
|
||||||
return self.value > other
|
return self.value > other
|
||||||
|
|
||||||
def __ge__(self, other: typing.Union[int, NumericOption]) -> bool:
|
|
||||||
if isinstance(other, NumericOption):
|
|
||||||
return self.value >= other.value
|
|
||||||
else:
|
|
||||||
return self.value >= other
|
|
||||||
|
|
||||||
def __bool__(self) -> bool:
|
def __bool__(self) -> bool:
|
||||||
return bool(self.value)
|
return bool(self.value)
|
||||||
|
|
||||||
@@ -382,8 +347,7 @@ class Toggle(NumericOption):
|
|||||||
default = 0
|
default = 0
|
||||||
|
|
||||||
def __init__(self, value: int):
|
def __init__(self, value: int):
|
||||||
# if user puts in an invalid value, make it valid
|
assert value == 0 or value == 1, "value of Toggle can only be 0 or 1"
|
||||||
value = int(bool(value))
|
|
||||||
self.value = value
|
self.value = value
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -625,7 +589,7 @@ class PlandoBosses(TextChoice, metaclass=BossMeta):
|
|||||||
if isinstance(self.value, int):
|
if isinstance(self.value, int):
|
||||||
return
|
return
|
||||||
from BaseClasses import PlandoOptions
|
from BaseClasses import PlandoOptions
|
||||||
if not (PlandoOptions.bosses & plando_options):
|
if not(PlandoOptions.bosses & plando_options):
|
||||||
# plando is disabled but plando options were given so pull the option and change it to an int
|
# plando is disabled but plando options were given so pull the option and change it to an int
|
||||||
option = self.value.split(";")[-1]
|
option = self.value.split(";")[-1]
|
||||||
self.value = self.options[option]
|
self.value = self.options[option]
|
||||||
@@ -723,19 +687,11 @@ class Range(NumericOption):
|
|||||||
return int(round(random.triangular(lower, end, tri), 0))
|
return int(round(random.triangular(lower, end, tri), 0))
|
||||||
|
|
||||||
|
|
||||||
class NamedRange(Range):
|
class SpecialRange(Range):
|
||||||
|
special_range_cutoff = 0
|
||||||
special_range_names: typing.Dict[str, int] = {}
|
special_range_names: typing.Dict[str, int] = {}
|
||||||
"""Special Range names have to be all lowercase as matching is done with text.lower()"""
|
"""Special Range names have to be all lowercase as matching is done with text.lower()"""
|
||||||
|
|
||||||
def __init__(self, value: int) -> None:
|
|
||||||
if value < self.range_start and value not in self.special_range_names.values():
|
|
||||||
raise Exception(f"{value} is lower than minimum {self.range_start} for option {self.__class__.__name__} " +
|
|
||||||
f"and is also not one of the supported named special values: {self.special_range_names}")
|
|
||||||
elif value > self.range_end and value not in self.special_range_names.values():
|
|
||||||
raise Exception(f"{value} is higher than maximum {self.range_end} for option {self.__class__.__name__} " +
|
|
||||||
f"and is also not one of the supported named special values: {self.special_range_names}")
|
|
||||||
self.value = value
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_text(cls, text: str) -> Range:
|
def from_text(cls, text: str) -> Range:
|
||||||
text = text.lower()
|
text = text.lower()
|
||||||
@@ -743,10 +699,27 @@ class NamedRange(Range):
|
|||||||
return cls(cls.special_range_names[text])
|
return cls(cls.special_range_names[text])
|
||||||
return super().from_text(text)
|
return super().from_text(text)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def weighted_range(cls, text) -> Range:
|
||||||
|
if text == "random-low":
|
||||||
|
return cls(cls.triangular(cls.special_range_cutoff, cls.range_end, cls.special_range_cutoff))
|
||||||
|
elif text == "random-high":
|
||||||
|
return cls(cls.triangular(cls.special_range_cutoff, cls.range_end, cls.range_end))
|
||||||
|
elif text == "random-middle":
|
||||||
|
return cls(cls.triangular(cls.special_range_cutoff, cls.range_end))
|
||||||
|
elif text.startswith("random-range-"):
|
||||||
|
return cls.custom_range(text)
|
||||||
|
elif text == "random":
|
||||||
|
return cls(random.randint(cls.special_range_cutoff, cls.range_end))
|
||||||
|
else:
|
||||||
|
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>.")
|
||||||
|
|
||||||
|
|
||||||
class FreezeValidKeys(AssembleOptions):
|
class FreezeValidKeys(AssembleOptions):
|
||||||
def __new__(mcs, name, bases, attrs):
|
def __new__(mcs, name, bases, attrs):
|
||||||
assert not "_valid_keys" in attrs, "'_valid_keys' gets set by FreezeValidKeys, define 'valid_keys' instead."
|
|
||||||
if "valid_keys" in attrs:
|
if "valid_keys" in attrs:
|
||||||
attrs["_valid_keys"] = frozenset(attrs["valid_keys"])
|
attrs["_valid_keys"] = frozenset(attrs["valid_keys"])
|
||||||
return super(FreezeValidKeys, mcs).__new__(mcs, name, bases, attrs)
|
return super(FreezeValidKeys, mcs).__new__(mcs, name, bases, attrs)
|
||||||
@@ -762,7 +735,7 @@ class VerifyKeys(metaclass=FreezeValidKeys):
|
|||||||
value: typing.Any
|
value: typing.Any
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def verify_keys(cls, data: typing.Iterable[str]) -> None:
|
def verify_keys(cls, data: typing.List[str]):
|
||||||
if cls.valid_keys:
|
if cls.valid_keys:
|
||||||
data = set(data)
|
data = set(data)
|
||||||
dataset = set(word.casefold() for word in data) if cls.valid_keys_casefold else set(data)
|
dataset = set(word.casefold() for word in data) if cls.valid_keys_casefold else set(data)
|
||||||
@@ -799,7 +772,7 @@ class VerifyKeys(metaclass=FreezeValidKeys):
|
|||||||
|
|
||||||
|
|
||||||
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: typing.Dict[str, typing.Any] = {}
|
||||||
supports_weighting = False
|
supports_weighting = False
|
||||||
|
|
||||||
def __init__(self, value: typing.Dict[str, typing.Any]):
|
def __init__(self, value: typing.Dict[str, typing.Any]):
|
||||||
@@ -840,11 +813,11 @@ class OptionList(Option[typing.List[typing.Any]], VerifyKeys):
|
|||||||
# If only unique entries are needed and input order of elements does not matter, OptionSet should be used instead.
|
# If only unique entries are needed and input order of elements does not matter, OptionSet should be used instead.
|
||||||
# Not a docstring so it doesn't get grabbed by the options system.
|
# Not a docstring so it doesn't get grabbed by the options system.
|
||||||
|
|
||||||
default = ()
|
default: typing.List[typing.Any] = []
|
||||||
supports_weighting = False
|
supports_weighting = False
|
||||||
|
|
||||||
def __init__(self, value: typing.Iterable[typing.Any]):
|
def __init__(self, value: typing.List[typing.Any]):
|
||||||
self.value = list(deepcopy(value))
|
self.value = deepcopy(value)
|
||||||
super(OptionList, self).__init__()
|
super(OptionList, self).__init__()
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -853,7 +826,7 @@ class OptionList(Option[typing.List[typing.Any]], VerifyKeys):
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_any(cls, data: typing.Any):
|
def from_any(cls, data: typing.Any):
|
||||||
if is_iterable_except_str(data):
|
if type(data) == list:
|
||||||
cls.verify_keys(data)
|
cls.verify_keys(data)
|
||||||
return cls(data)
|
return cls(data)
|
||||||
return cls.from_text(str(data))
|
return cls.from_text(str(data))
|
||||||
@@ -866,7 +839,7 @@ 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: typing.Union[typing.Set[str], typing.FrozenSet[str]] = frozenset()
|
||||||
supports_weighting = False
|
supports_weighting = False
|
||||||
|
|
||||||
def __init__(self, value: typing.Iterable[str]):
|
def __init__(self, value: typing.Iterable[str]):
|
||||||
@@ -879,7 +852,7 @@ class OptionSet(Option[typing.Set[str]], VerifyKeys):
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_any(cls, data: typing.Any):
|
def from_any(cls, data: typing.Any):
|
||||||
if is_iterable_except_str(data):
|
if isinstance(data, (list, set, frozenset)):
|
||||||
cls.verify_keys(data)
|
cls.verify_keys(data)
|
||||||
return cls(data)
|
return cls(data)
|
||||||
return cls.from_text(str(data))
|
return cls.from_text(str(data))
|
||||||
@@ -909,7 +882,7 @@ class Accessibility(Choice):
|
|||||||
default = 1
|
default = 1
|
||||||
|
|
||||||
|
|
||||||
class ProgressionBalancing(NamedRange):
|
class ProgressionBalancing(SpecialRange):
|
||||||
"""A system that can move progression earlier, to try and prevent the player from getting stuck and bored early.
|
"""A system that can move progression earlier, to try and prevent the player from getting stuck and bored early.
|
||||||
A lower setting means more getting stuck. A higher setting means less getting stuck."""
|
A lower setting means more getting stuck. A higher setting means less getting stuck."""
|
||||||
default = 50
|
default = 50
|
||||||
@@ -923,58 +896,10 @@ class ProgressionBalancing(NamedRange):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class OptionsMetaProperty(type):
|
common_options = {
|
||||||
def __new__(mcs,
|
"progression_balancing": ProgressionBalancing,
|
||||||
name: str,
|
"accessibility": Accessibility
|
||||||
bases: typing.Tuple[type, ...],
|
}
|
||||||
attrs: typing.Dict[str, typing.Any]) -> "OptionsMetaProperty":
|
|
||||||
for attr_type in attrs.values():
|
|
||||||
assert not isinstance(attr_type, AssembleOptions), \
|
|
||||||
f"Options for {name} should be type hinted on the class, not assigned"
|
|
||||||
return super().__new__(mcs, name, bases, attrs)
|
|
||||||
|
|
||||||
@property
|
|
||||||
@functools.lru_cache(maxsize=None)
|
|
||||||
def type_hints(cls) -> typing.Dict[str, typing.Type[Option[typing.Any]]]:
|
|
||||||
"""Returns type hints of the class as a dictionary."""
|
|
||||||
return typing.get_type_hints(cls)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class CommonOptions(metaclass=OptionsMetaProperty):
|
|
||||||
progression_balancing: ProgressionBalancing
|
|
||||||
accessibility: Accessibility
|
|
||||||
|
|
||||||
def as_dict(self, *option_names: str, casing: str = "snake") -> typing.Dict[str, typing.Any]:
|
|
||||||
"""
|
|
||||||
Returns a dictionary of [str, Option.value]
|
|
||||||
|
|
||||||
:param option_names: names of the options to return
|
|
||||||
:param casing: case of the keys to return. Supports `snake`, `camel`, `pascal`, `kebab`
|
|
||||||
"""
|
|
||||||
option_results = {}
|
|
||||||
for option_name in option_names:
|
|
||||||
if option_name in type(self).type_hints:
|
|
||||||
if casing == "snake":
|
|
||||||
display_name = option_name
|
|
||||||
elif casing == "camel":
|
|
||||||
split_name = [name.title() for name in option_name.split("_")]
|
|
||||||
split_name[0] = split_name[0].lower()
|
|
||||||
display_name = "".join(split_name)
|
|
||||||
elif casing == "pascal":
|
|
||||||
display_name = "".join([name.title() for name in option_name.split("_")])
|
|
||||||
elif casing == "kebab":
|
|
||||||
display_name = option_name.replace("_", "-")
|
|
||||||
else:
|
|
||||||
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)
|
|
||||||
option_results[display_name] = value
|
|
||||||
else:
|
|
||||||
raise ValueError(f"{option_name} not found in {tuple(type(self).type_hints)}")
|
|
||||||
return option_results
|
|
||||||
|
|
||||||
|
|
||||||
class LocalItems(ItemSet):
|
class LocalItems(ItemSet):
|
||||||
@@ -1093,43 +1018,19 @@ class ItemLinks(OptionList):
|
|||||||
raise Exception(f"item_link {link['name']} has {intersection} "
|
raise Exception(f"item_link {link['name']} has {intersection} "
|
||||||
f"items in both its local_items and non_local_items pool.")
|
f"items in both its local_items and non_local_items pool.")
|
||||||
link.setdefault("link_replacement", None)
|
link.setdefault("link_replacement", None)
|
||||||
link["item_pool"] = list(pool)
|
|
||||||
|
|
||||||
|
|
||||||
class Removed(FreeText):
|
per_game_common_options = {
|
||||||
"""This Option has been Removed."""
|
**common_options, # can be overwritten per-game
|
||||||
default = ""
|
"local_items": LocalItems,
|
||||||
visibility = Visibility.none
|
"non_local_items": NonLocalItems,
|
||||||
|
"start_inventory": StartInventory,
|
||||||
def __init__(self, value: str):
|
"start_hints": StartHints,
|
||||||
if value:
|
"start_location_hints": StartLocationHints,
|
||||||
raise Exception("Option removed, please update your options file.")
|
"exclude_locations": ExcludeLocations,
|
||||||
super().__init__(value)
|
"priority_locations": PriorityLocations,
|
||||||
|
"item_links": ItemLinks
|
||||||
|
}
|
||||||
@dataclass
|
|
||||||
class PerGameCommonOptions(CommonOptions):
|
|
||||||
local_items: LocalItems
|
|
||||||
non_local_items: NonLocalItems
|
|
||||||
start_inventory: StartInventory
|
|
||||||
start_hints: StartHints
|
|
||||||
start_location_hints: StartLocationHints
|
|
||||||
exclude_locations: ExcludeLocations
|
|
||||||
priority_locations: PriorityLocations
|
|
||||||
item_links: ItemLinks
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class DeathLinkMixin:
|
|
||||||
death_link: DeathLink
|
|
||||||
|
|
||||||
|
|
||||||
class OptionGroup(typing.NamedTuple):
|
|
||||||
"""Define a grouping of options."""
|
|
||||||
name: str
|
|
||||||
"""Name of the group to categorize these options in for display on the WebHost and in generated YAMLS."""
|
|
||||||
options: typing.List[typing.Type[Option[typing.Any]]]
|
|
||||||
"""Options to be in the defined group."""
|
|
||||||
|
|
||||||
|
|
||||||
def generate_yaml_templates(target_folder: typing.Union[str, "pathlib.Path"], generate_hidden: bool = True):
|
def generate_yaml_templates(target_folder: typing.Union[str, "pathlib.Path"], generate_hidden: bool = True):
|
||||||
@@ -1151,7 +1052,7 @@ 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)
|
||||||
|
|
||||||
def dictify_range(option: Range):
|
def dictify_range(option: typing.Union[Range, SpecialRange]):
|
||||||
data = {option.default: 50}
|
data = {option.default: 50}
|
||||||
for sub_option in ["random", "random-low", "random-high"]:
|
for sub_option in ["random", "random-low", "random-high"]:
|
||||||
if sub_option != option.default:
|
if sub_option != option.default:
|
||||||
@@ -1170,21 +1071,15 @@ def generate_yaml_templates(target_folder: typing.Union[str, "pathlib.Path"], ge
|
|||||||
|
|
||||||
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:
|
||||||
|
all_options: typing.Dict[str, AssembleOptions] = {
|
||||||
option_groups = {option: option_group.name
|
**per_game_common_options,
|
||||||
for option_group in world.web.option_groups
|
**world.option_definitions
|
||||||
for option in option_group.options}
|
}
|
||||||
ordered_groups = ["Game Options"]
|
|
||||||
[ordered_groups.append(group) for group in option_groups.values() if group not in ordered_groups]
|
|
||||||
grouped_options = {group: {} for group in ordered_groups}
|
|
||||||
for option_name, option in world.options_dataclass.type_hints.items():
|
|
||||||
if option.visibility >= Visibility.template:
|
|
||||||
grouped_options[option_groups.get(option, "Game Options")][option_name] = option
|
|
||||||
|
|
||||||
with open(local_path("data", "options.yaml")) as f:
|
with open(local_path("data", "options.yaml")) as f:
|
||||||
file_data = f.read()
|
file_data = f.read()
|
||||||
res = Template(file_data).render(
|
res = Template(file_data).render(
|
||||||
option_groups=grouped_options,
|
options=all_options,
|
||||||
__version__=__version__, game=game_name, yaml_dump=yaml.dump,
|
__version__=__version__, game=game_name, yaml_dump=yaml.dump,
|
||||||
dictify_range=dictify_range,
|
dictify_range=dictify_range,
|
||||||
)
|
)
|
||||||
|
|||||||
4
Patch.py
4
Patch.py
@@ -8,7 +8,7 @@ if __name__ == "__main__":
|
|||||||
import ModuleUpdate
|
import ModuleUpdate
|
||||||
ModuleUpdate.update()
|
ModuleUpdate.update()
|
||||||
|
|
||||||
from worlds.Files import AutoPatchRegister, APAutoPatchInterface
|
from worlds.Files import AutoPatchRegister, APDeltaPatch
|
||||||
|
|
||||||
|
|
||||||
class RomMeta(TypedDict):
|
class RomMeta(TypedDict):
|
||||||
@@ -20,7 +20,7 @@ class RomMeta(TypedDict):
|
|||||||
def create_rom_file(patch_file: str) -> Tuple[RomMeta, str]:
|
def create_rom_file(patch_file: str) -> Tuple[RomMeta, str]:
|
||||||
auto_handler = AutoPatchRegister.get_handler(patch_file)
|
auto_handler = AutoPatchRegister.get_handler(patch_file)
|
||||||
if auto_handler:
|
if auto_handler:
|
||||||
handler: APAutoPatchInterface = auto_handler(patch_file)
|
handler: APDeltaPatch = auto_handler(patch_file)
|
||||||
target = os.path.splitext(patch_file)[0]+handler.result_file_ending
|
target = os.path.splitext(patch_file)[0]+handler.result_file_ending
|
||||||
handler.patch(target)
|
handler.patch(target)
|
||||||
return {"server": handler.server,
|
return {"server": handler.server,
|
||||||
|
|||||||
382
PokemonClient.py
Normal file
382
PokemonClient.py
Normal file
@@ -0,0 +1,382 @@
|
|||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
import os
|
||||||
|
import bsdiff4
|
||||||
|
import subprocess
|
||||||
|
import zipfile
|
||||||
|
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
|
||||||
|
|
||||||
|
from worlds.pokemon_rb.locations import location_data
|
||||||
|
from worlds.pokemon_rb.rom import RedDeltaPatch, BlueDeltaPatch
|
||||||
|
|
||||||
|
location_map = {"Rod": {}, "EventFlag": {}, "Missable": {}, "Hidden": {}, "list": {}, "DexSanityFlag": {}}
|
||||||
|
location_bytes_bits = {}
|
||||||
|
for location in location_data:
|
||||||
|
if location.ram_address is not None:
|
||||||
|
if type(location.ram_address) == list:
|
||||||
|
location_map[type(location.ram_address).__name__][(location.ram_address[0].flag, location.ram_address[1].flag)] = location.address
|
||||||
|
location_bytes_bits[location.address] = [{'byte': location.ram_address[0].byte, 'bit': location.ram_address[0].bit},
|
||||||
|
{'byte': location.ram_address[1].byte, 'bit': location.ram_address[1].bit}]
|
||||||
|
else:
|
||||||
|
location_map[type(location.ram_address).__name__][location.ram_address.flag] = location.address
|
||||||
|
location_bytes_bits[location.address] = {'byte': location.ram_address.byte, 'bit': location.ram_address.bit}
|
||||||
|
|
||||||
|
location_name_to_id = {location.name: location.address for location in location_data if location.type == "Item"
|
||||||
|
and location.address is not None}
|
||||||
|
|
||||||
|
SYSTEM_MESSAGE_ID = 0
|
||||||
|
|
||||||
|
CONNECTION_TIMING_OUT_STATUS = "Connection timing out. Please restart your emulator, then restart pkmn_rb.lua"
|
||||||
|
CONNECTION_REFUSED_STATUS = "Connection Refused. Please start your emulator and make sure pkmn_rb.lua is running"
|
||||||
|
CONNECTION_RESET_STATUS = "Connection was reset. Please restart your emulator, then restart pkmn_rb.lua"
|
||||||
|
CONNECTION_TENTATIVE_STATUS = "Initial Connection Made"
|
||||||
|
CONNECTION_CONNECTED_STATUS = "Connected"
|
||||||
|
CONNECTION_INITIAL_STATUS = "Connection has not been initiated"
|
||||||
|
|
||||||
|
DISPLAY_MSGS = True
|
||||||
|
|
||||||
|
SCRIPT_VERSION = 3
|
||||||
|
|
||||||
|
|
||||||
|
class GBCommandProcessor(ClientCommandProcessor):
|
||||||
|
def __init__(self, ctx: CommonContext):
|
||||||
|
super().__init__(ctx)
|
||||||
|
|
||||||
|
def _cmd_gb(self):
|
||||||
|
"""Check Gameboy Connection State"""
|
||||||
|
if isinstance(self.ctx, GBContext):
|
||||||
|
logger.info(f"Gameboy Status: {self.ctx.gb_status}")
|
||||||
|
|
||||||
|
|
||||||
|
class GBContext(CommonContext):
|
||||||
|
command_processor = GBCommandProcessor
|
||||||
|
game = 'Pokemon Red and Blue'
|
||||||
|
|
||||||
|
def __init__(self, server_address, password):
|
||||||
|
super().__init__(server_address, password)
|
||||||
|
self.gb_streams: (StreamReader, StreamWriter) = None
|
||||||
|
self.gb_sync_task = None
|
||||||
|
self.messages = {}
|
||||||
|
self.locations_array = None
|
||||||
|
self.gb_status = CONNECTION_INITIAL_STATUS
|
||||||
|
self.awaiting_rom = False
|
||||||
|
self.display_msgs = True
|
||||||
|
self.deathlink_pending = False
|
||||||
|
self.set_deathlink = False
|
||||||
|
self.client_compatibility_mode = 0
|
||||||
|
self.items_handling = 0b001
|
||||||
|
self.sent_release = False
|
||||||
|
self.sent_collect = False
|
||||||
|
self.auto_hints = set()
|
||||||
|
|
||||||
|
async def server_auth(self, password_requested: bool = False):
|
||||||
|
if password_requested and not self.password:
|
||||||
|
await super(GBContext, self).server_auth(password_requested)
|
||||||
|
if not self.auth:
|
||||||
|
self.awaiting_rom = True
|
||||||
|
logger.info('Awaiting connection to EmuHawk 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':
|
||||||
|
self.locations_array = None
|
||||||
|
if 'death_link' in args['slot_data'] and args['slot_data']['death_link']:
|
||||||
|
self.set_deathlink = True
|
||||||
|
elif cmd == "RoomInfo":
|
||||||
|
self.seed_name = args['seed_name']
|
||||||
|
elif cmd == 'Print':
|
||||||
|
msg = args['text']
|
||||||
|
if ': !' not in msg:
|
||||||
|
self._set_message(msg, SYSTEM_MESSAGE_ID)
|
||||||
|
elif cmd == "ReceivedItems":
|
||||||
|
msg = f"Received {', '.join([self.item_names[item.item] for item in args['items']])}"
|
||||||
|
self._set_message(msg, SYSTEM_MESSAGE_ID)
|
||||||
|
|
||||||
|
def on_deathlink(self, data: dict):
|
||||||
|
self.deathlink_pending = True
|
||||||
|
super().on_deathlink(data)
|
||||||
|
|
||||||
|
def run_gui(self):
|
||||||
|
from kvui import GameManager
|
||||||
|
|
||||||
|
class GBManager(GameManager):
|
||||||
|
logging_pairs = [
|
||||||
|
("Client", "Archipelago")
|
||||||
|
]
|
||||||
|
base_title = "Archipelago Pokémon Client"
|
||||||
|
|
||||||
|
self.ui = GBManager(self)
|
||||||
|
self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI")
|
||||||
|
|
||||||
|
|
||||||
|
def get_payload(ctx: GBContext):
|
||||||
|
current_time = time.time()
|
||||||
|
ret = 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},
|
||||||
|
"deathlink": ctx.deathlink_pending,
|
||||||
|
"options": ((ctx.permissions['release'] in ('goal', 'enabled')) * 2) + (ctx.permissions['collect'] in ('goal', 'enabled'))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
ctx.deathlink_pending = False
|
||||||
|
return ret
|
||||||
|
|
||||||
|
|
||||||
|
async def parse_locations(data: List, ctx: GBContext):
|
||||||
|
locations = []
|
||||||
|
flags = {"EventFlag": data[:0x140], "Missable": data[0x140:0x140 + 0x20],
|
||||||
|
"Hidden": data[0x140 + 0x20: 0x140 + 0x20 + 0x0E],
|
||||||
|
"Rod": data[0x140 + 0x20 + 0x0E:0x140 + 0x20 + 0x0E + 0x01]}
|
||||||
|
|
||||||
|
if len(data) > 0x140 + 0x20 + 0x0E + 0x01:
|
||||||
|
flags["DexSanityFlag"] = data[0x140 + 0x20 + 0x0E + 0x01:]
|
||||||
|
else:
|
||||||
|
flags["DexSanityFlag"] = [0] * 19
|
||||||
|
|
||||||
|
for flag_type, loc_map in location_map.items():
|
||||||
|
for flag, loc_id in loc_map.items():
|
||||||
|
if flag_type == "list":
|
||||||
|
if (flags["EventFlag"][location_bytes_bits[loc_id][0]['byte']] & 1 << location_bytes_bits[loc_id][0]['bit']
|
||||||
|
and flags["Missable"][location_bytes_bits[loc_id][1]['byte']] & 1 << location_bytes_bits[loc_id][1]['bit']):
|
||||||
|
locations.append(loc_id)
|
||||||
|
elif flags[flag_type][location_bytes_bits[loc_id]['byte']] & 1 << location_bytes_bits[loc_id]['bit']:
|
||||||
|
locations.append(loc_id)
|
||||||
|
|
||||||
|
hints = []
|
||||||
|
if flags["EventFlag"][280] & 16:
|
||||||
|
hints.append("Cerulean Bicycle Shop")
|
||||||
|
if flags["EventFlag"][280] & 32:
|
||||||
|
hints.append("Route 2 Gate - Oak's Aide")
|
||||||
|
if flags["EventFlag"][280] & 64:
|
||||||
|
hints.append("Route 11 Gate 2F - Oak's Aide")
|
||||||
|
if flags["EventFlag"][280] & 128:
|
||||||
|
hints.append("Route 15 Gate 2F - Oak's Aide")
|
||||||
|
if flags["EventFlag"][281] & 1:
|
||||||
|
hints += ["Celadon Prize Corner - Item Prize 1", "Celadon Prize Corner - Item Prize 2",
|
||||||
|
"Celadon Prize Corner - Item Prize 3"]
|
||||||
|
if (location_name_to_id["Fossil - Choice A"] in ctx.checked_locations and location_name_to_id["Fossil - Choice B"]
|
||||||
|
not in ctx.checked_locations):
|
||||||
|
hints.append("Fossil - Choice B")
|
||||||
|
elif (location_name_to_id["Fossil - Choice B"] in ctx.checked_locations and location_name_to_id["Fossil - Choice A"]
|
||||||
|
not in ctx.checked_locations):
|
||||||
|
hints.append("Fossil - Choice A")
|
||||||
|
hints = [
|
||||||
|
location_name_to_id[loc] for loc in hints if location_name_to_id[loc] not in ctx.auto_hints and
|
||||||
|
location_name_to_id[loc] in ctx.missing_locations and location_name_to_id[loc] not in ctx.locations_checked
|
||||||
|
]
|
||||||
|
if hints:
|
||||||
|
await ctx.send_msgs([{"cmd": "LocationScouts", "locations": hints, "create_as_hint": 2}])
|
||||||
|
ctx.auto_hints.update(hints)
|
||||||
|
|
||||||
|
if flags["EventFlag"][280] & 1 and not ctx.finished_game:
|
||||||
|
await ctx.send_msgs([
|
||||||
|
{"cmd": "StatusUpdate",
|
||||||
|
"status": 30}
|
||||||
|
])
|
||||||
|
ctx.finished_game = True
|
||||||
|
if locations == ctx.locations_array:
|
||||||
|
return
|
||||||
|
ctx.locations_array = locations
|
||||||
|
if locations is not None:
|
||||||
|
await ctx.send_msgs([{"cmd": "LocationChecks", "locations": locations}])
|
||||||
|
|
||||||
|
|
||||||
|
async def gb_sync_task(ctx: GBContext):
|
||||||
|
logger.info("Starting GB connector. Use /gb for status information")
|
||||||
|
while not ctx.exit_event.is_set():
|
||||||
|
error_status = None
|
||||||
|
if ctx.gb_streams:
|
||||||
|
(reader, writer) = ctx.gb_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())
|
||||||
|
if 'scriptVersion' not in data_decoded or data_decoded['scriptVersion'] != SCRIPT_VERSION:
|
||||||
|
msg = "You are connecting with an incompatible Lua script version. Ensure your connector Lua " \
|
||||||
|
"and PokemonClient are from the same Archipelago installation."
|
||||||
|
logger.info(msg, extra={'compact_gui': True})
|
||||||
|
ctx.gui_error('Error', msg)
|
||||||
|
error_status = CONNECTION_RESET_STATUS
|
||||||
|
ctx.client_compatibility_mode = data_decoded['clientCompatibilityVersion']
|
||||||
|
if ctx.client_compatibility_mode == 0:
|
||||||
|
ctx.items_handling = 0b101 # old patches will not have local start inventory, must be requested
|
||||||
|
if ctx.seed_name and ctx.seed_name != ''.join([chr(i) for i in data_decoded['seedName'] if i != 0]):
|
||||||
|
msg = "The server is running a different multiworld than your client is. (invalid seed_name)"
|
||||||
|
logger.info(msg, extra={'compact_gui': True})
|
||||||
|
ctx.gui_error('Error', msg)
|
||||||
|
error_status = CONNECTION_RESET_STATUS
|
||||||
|
ctx.seed_name = ''.join([chr(i) for i in data_decoded['seedName'] if i != 0])
|
||||||
|
if not ctx.auth:
|
||||||
|
ctx.auth = ''.join([chr(i) for i in data_decoded['playerName'] if i != 0])
|
||||||
|
if ctx.auth == '':
|
||||||
|
msg = "Invalid ROM detected. No player name built into the ROM."
|
||||||
|
logger.info(msg, extra={'compact_gui': True})
|
||||||
|
ctx.gui_error('Error', msg)
|
||||||
|
error_status = CONNECTION_RESET_STATUS
|
||||||
|
if ctx.awaiting_rom:
|
||||||
|
await ctx.server_auth(False)
|
||||||
|
if 'locations' in data_decoded and ctx.game and ctx.gb_status == CONNECTION_CONNECTED_STATUS \
|
||||||
|
and not error_status and ctx.auth:
|
||||||
|
# Not just a keep alive ping, parse
|
||||||
|
async_start(parse_locations(data_decoded['locations'], ctx))
|
||||||
|
if 'deathLink' in data_decoded and data_decoded['deathLink'] and 'DeathLink' in ctx.tags:
|
||||||
|
await ctx.send_death(ctx.auth + " is out of usable Pokémon! " + ctx.auth + " blacked out!")
|
||||||
|
if 'options' in data_decoded:
|
||||||
|
msgs = []
|
||||||
|
if data_decoded['options'] & 4 and not ctx.sent_release:
|
||||||
|
ctx.sent_release = True
|
||||||
|
msgs.append({"cmd": "Say", "text": "!release"})
|
||||||
|
if data_decoded['options'] & 8 and not ctx.sent_collect:
|
||||||
|
ctx.sent_collect = True
|
||||||
|
msgs.append({"cmd": "Say", "text": "!collect"})
|
||||||
|
if msgs:
|
||||||
|
await ctx.send_msgs(msgs)
|
||||||
|
if ctx.set_deathlink:
|
||||||
|
await ctx.update_death_link(True)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
logger.debug("Read Timed Out, Reconnecting")
|
||||||
|
error_status = CONNECTION_TIMING_OUT_STATUS
|
||||||
|
writer.close()
|
||||||
|
ctx.gb_streams = None
|
||||||
|
except ConnectionResetError as e:
|
||||||
|
logger.debug("Read failed due to Connection Lost, Reconnecting")
|
||||||
|
error_status = CONNECTION_RESET_STATUS
|
||||||
|
writer.close()
|
||||||
|
ctx.gb_streams = None
|
||||||
|
except TimeoutError:
|
||||||
|
logger.debug("Connection Timed Out, Reconnecting")
|
||||||
|
error_status = CONNECTION_TIMING_OUT_STATUS
|
||||||
|
writer.close()
|
||||||
|
ctx.gb_streams = None
|
||||||
|
except ConnectionResetError:
|
||||||
|
logger.debug("Connection Lost, Reconnecting")
|
||||||
|
error_status = CONNECTION_RESET_STATUS
|
||||||
|
writer.close()
|
||||||
|
ctx.gb_streams = None
|
||||||
|
if ctx.gb_status == CONNECTION_TENTATIVE_STATUS:
|
||||||
|
if not error_status:
|
||||||
|
logger.info("Successfully Connected to Gameboy")
|
||||||
|
ctx.gb_status = CONNECTION_CONNECTED_STATUS
|
||||||
|
else:
|
||||||
|
ctx.gb_status = f"Was tentatively connected but error occured: {error_status}"
|
||||||
|
elif error_status:
|
||||||
|
ctx.gb_status = error_status
|
||||||
|
logger.info("Lost connection to Gameboy and attempting to reconnect. Use /gb for status updates")
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
logger.debug("Attempting to connect to Gameboy")
|
||||||
|
ctx.gb_streams = await asyncio.wait_for(asyncio.open_connection("localhost", 17242), timeout=10)
|
||||||
|
ctx.gb_status = CONNECTION_TENTATIVE_STATUS
|
||||||
|
except TimeoutError:
|
||||||
|
logger.debug("Connection Timed Out, Trying Again")
|
||||||
|
ctx.gb_status = CONNECTION_TIMING_OUT_STATUS
|
||||||
|
continue
|
||||||
|
except ConnectionRefusedError:
|
||||||
|
logger.debug("Connection Refused, Trying Again")
|
||||||
|
ctx.gb_status = CONNECTION_REFUSED_STATUS
|
||||||
|
continue
|
||||||
|
|
||||||
|
|
||||||
|
async def run_game(romfile):
|
||||||
|
auto_start = Utils.get_options()["pokemon_rb_options"].get("rom_start", True)
|
||||||
|
if auto_start is True:
|
||||||
|
import webbrowser
|
||||||
|
webbrowser.open(romfile)
|
||||||
|
elif os.path.isfile(auto_start):
|
||||||
|
subprocess.Popen([auto_start, romfile],
|
||||||
|
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||||
|
|
||||||
|
|
||||||
|
async def patch_and_run_game(game_version, patch_file, ctx):
|
||||||
|
base_name = os.path.splitext(patch_file)[0]
|
||||||
|
comp_path = base_name + '.gb'
|
||||||
|
if game_version == "blue":
|
||||||
|
delta_patch = BlueDeltaPatch
|
||||||
|
else:
|
||||||
|
delta_patch = RedDeltaPatch
|
||||||
|
|
||||||
|
try:
|
||||||
|
base_rom = delta_patch.get_source_data()
|
||||||
|
except Exception as msg:
|
||||||
|
logger.info(msg, extra={'compact_gui': True})
|
||||||
|
ctx.gui_error('Error', msg)
|
||||||
|
|
||||||
|
with zipfile.ZipFile(patch_file, 'r') as patch_archive:
|
||||||
|
with patch_archive.open('delta.bsdiff4', 'r') as stream:
|
||||||
|
patch = stream.read()
|
||||||
|
patched_rom_data = bsdiff4.patch(base_rom, patch)
|
||||||
|
|
||||||
|
with open(comp_path, "wb") as patched_rom_file:
|
||||||
|
patched_rom_file.write(patched_rom_data)
|
||||||
|
|
||||||
|
async_start(run_game(comp_path))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
|
||||||
|
Utils.init_logging("PokemonClient")
|
||||||
|
|
||||||
|
options = Utils.get_options()
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
parser = get_base_parser()
|
||||||
|
parser.add_argument('patch_file', default="", type=str, nargs="?",
|
||||||
|
help='Path to an APRED or APBLUE patch file')
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
ctx = GBContext(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.gb_sync_task = asyncio.create_task(gb_sync_task(ctx), name="GB Sync")
|
||||||
|
|
||||||
|
if args.patch_file:
|
||||||
|
ext = args.patch_file.split(".")[len(args.patch_file.split(".")) - 1].lower()
|
||||||
|
if ext == "apred":
|
||||||
|
logger.info("APRED file supplied, beginning patching process...")
|
||||||
|
async_start(patch_and_run_game("red", args.patch_file, ctx))
|
||||||
|
elif ext == "apblue":
|
||||||
|
logger.info("APBLUE file supplied, beginning patching process...")
|
||||||
|
async_start(patch_and_run_game("blue", args.patch_file, ctx))
|
||||||
|
else:
|
||||||
|
logger.warning(f"Unknown patch file extension {ext}")
|
||||||
|
|
||||||
|
await ctx.exit_event.wait()
|
||||||
|
ctx.server_address = None
|
||||||
|
|
||||||
|
await ctx.shutdown()
|
||||||
|
|
||||||
|
if ctx.gb_sync_task:
|
||||||
|
await ctx.gb_sync_task
|
||||||
|
|
||||||
|
|
||||||
|
import colorama
|
||||||
|
|
||||||
|
colorama.init()
|
||||||
|
|
||||||
|
asyncio.run(main())
|
||||||
|
colorama.deinit()
|
||||||
23
README.md
23
README.md
@@ -25,7 +25,7 @@ Currently, the following games are supported:
|
|||||||
* Hollow Knight
|
* Hollow Knight
|
||||||
* The Witness
|
* The Witness
|
||||||
* Sonic Adventure 2: Battle
|
* Sonic Adventure 2: Battle
|
||||||
* Starcraft 2
|
* Starcraft 2: Wings of Liberty
|
||||||
* Donkey Kong Country 3
|
* Donkey Kong Country 3
|
||||||
* Dark Souls 3
|
* Dark Souls 3
|
||||||
* Super Mario World
|
* Super Mario World
|
||||||
@@ -51,23 +51,6 @@ Currently, the following games are supported:
|
|||||||
* Muse Dash
|
* Muse Dash
|
||||||
* DOOM 1993
|
* DOOM 1993
|
||||||
* Terraria
|
* Terraria
|
||||||
* Lingo
|
|
||||||
* Pokémon Emerald
|
|
||||||
* DOOM II
|
|
||||||
* Shivers
|
|
||||||
* Heretic
|
|
||||||
* Landstalker: The Treasures of King Nole
|
|
||||||
* Final Fantasy Mystic Quest
|
|
||||||
* TUNIC
|
|
||||||
* Kirby's Dream Land 3
|
|
||||||
* Celeste 64
|
|
||||||
* Zork Grand Inquisitor
|
|
||||||
* Castlevania 64
|
|
||||||
* A Short Hike
|
|
||||||
* Yoshi's Island
|
|
||||||
* Mario & Luigi: Superstar Saga
|
|
||||||
* Bomb Rush Cyberfunk
|
|
||||||
* Yu-Gi-Oh! Ultimate Masters: World Championship Tournament 2006
|
|
||||||
|
|
||||||
For setup and instructions check out our [tutorials page](https://archipelago.gg/tutorial/).
|
For setup and instructions check out our [tutorials page](https://archipelago.gg/tutorial/).
|
||||||
Downloads can be found at [Releases](https://github.com/ArchipelagoMW/Archipelago/releases), including compiled
|
Downloads can be found at [Releases](https://github.com/ArchipelagoMW/Archipelago/releases), including compiled
|
||||||
@@ -89,9 +72,9 @@ We recognize that there is a strong community of incredibly smart people that ha
|
|||||||
Archipelago was directly forked from bonta0's `multiworld_31` branch of ALttPEntranceRandomizer (this project has a long legacy of its own, please check it out linked above) on January 12, 2020. The repository was then named to _MultiWorld-Utilities_ to better encompass its intended function. As Archipelago matured, then known as "Berserker's MultiWorld" by some, we found it necessary to transform our repository into a root level repository (as opposed to a 'forked repo') and change the name (which came later) to better reflect our project.
|
Archipelago was directly forked from bonta0's `multiworld_31` branch of ALttPEntranceRandomizer (this project has a long legacy of its own, please check it out linked above) on January 12, 2020. The repository was then named to _MultiWorld-Utilities_ to better encompass its intended function. As Archipelago matured, then known as "Berserker's MultiWorld" by some, we found it necessary to transform our repository into a root level repository (as opposed to a 'forked repo') and change the name (which came later) to better reflect our project.
|
||||||
|
|
||||||
## Running Archipelago
|
## Running Archipelago
|
||||||
For most people, all you need to do is head over to the [releases](https://github.com/ArchipelagoMW/Archipelago/releases) page then download and run the appropriate installer, or AppImage for Linux-based systems.
|
For most people all you need to do is head over to the [releases](https://github.com/ArchipelagoMW/Archipelago/releases) page then download and run the appropriate installer. The installers function on Windows only.
|
||||||
|
|
||||||
If you are a developer or are running on a platform with no compiled releases available, please see our doc on [running Archipelago from source](docs/running%20from%20source.md).
|
If you are running Archipelago from a non-Windows system then the likely scenario is that you are comfortable running source code directly. Please see our doc on [running Archipelago from source](docs/running%20from%20source.md).
|
||||||
|
|
||||||
## Related Repositories
|
## Related Repositories
|
||||||
This project makes use of multiple other projects. We wouldn't be here without these other repositories and the contributions of their developers, past and present.
|
This project makes use of multiple other projects. We wouldn't be here without these other repositories and the contributions of their developers, past and present.
|
||||||
|
|||||||
16
SNIClient.py
16
SNIClient.py
@@ -68,11 +68,12 @@ class SNIClientCommandProcessor(ClientCommandProcessor):
|
|||||||
options = snes_options.split()
|
options = snes_options.split()
|
||||||
num_options = len(options)
|
num_options = len(options)
|
||||||
|
|
||||||
|
if num_options > 0:
|
||||||
|
snes_device_number = int(options[0])
|
||||||
|
|
||||||
if num_options > 1:
|
if num_options > 1:
|
||||||
snes_address = options[0]
|
snes_address = options[0]
|
||||||
snes_device_number = int(options[1])
|
snes_device_number = int(options[1])
|
||||||
elif num_options > 0:
|
|
||||||
snes_device_number = int(options[0])
|
|
||||||
|
|
||||||
self.ctx.snes_reconnect_address = None
|
self.ctx.snes_reconnect_address = None
|
||||||
if self.ctx.snes_connect_task:
|
if self.ctx.snes_connect_task:
|
||||||
@@ -85,7 +86,6 @@ class SNIClientCommandProcessor(ClientCommandProcessor):
|
|||||||
"""Close connection to a currently connected snes"""
|
"""Close connection to a currently connected snes"""
|
||||||
self.ctx.snes_reconnect_address = None
|
self.ctx.snes_reconnect_address = None
|
||||||
self.ctx.cancel_snes_autoreconnect()
|
self.ctx.cancel_snes_autoreconnect()
|
||||||
self.ctx.snes_state = SNESState.SNES_DISCONNECTED
|
|
||||||
if self.ctx.snes_socket and not self.ctx.snes_socket.closed:
|
if self.ctx.snes_socket and not self.ctx.snes_socket.closed:
|
||||||
async_start(self.ctx.snes_socket.close())
|
async_start(self.ctx.snes_socket.close())
|
||||||
return True
|
return True
|
||||||
@@ -208,12 +208,12 @@ class SNIContext(CommonContext):
|
|||||||
self.killing_player_task = asyncio.create_task(deathlink_kill_player(self))
|
self.killing_player_task = asyncio.create_task(deathlink_kill_player(self))
|
||||||
super(SNIContext, self).on_deathlink(data)
|
super(SNIContext, self).on_deathlink(data)
|
||||||
|
|
||||||
async def handle_deathlink_state(self, currently_dead: bool, death_text: str = "") -> None:
|
async def handle_deathlink_state(self, currently_dead: bool) -> None:
|
||||||
# in this state we only care about triggering a death send
|
# in this state we only care about triggering a death send
|
||||||
if self.death_state == DeathState.alive:
|
if self.death_state == DeathState.alive:
|
||||||
if currently_dead:
|
if currently_dead:
|
||||||
self.death_state = DeathState.dead
|
self.death_state = DeathState.dead
|
||||||
await self.send_death(death_text)
|
await self.send_death()
|
||||||
# in this state we care about confirming a kill, to move state to dead
|
# in this state we care about confirming a kill, to move state to dead
|
||||||
elif self.death_state == DeathState.killing_player:
|
elif self.death_state == DeathState.killing_player:
|
||||||
# this is being handled in deathlink_kill_player(ctx) already
|
# this is being handled in deathlink_kill_player(ctx) already
|
||||||
@@ -282,7 +282,7 @@ class SNESState(enum.IntEnum):
|
|||||||
|
|
||||||
|
|
||||||
def launch_sni() -> None:
|
def launch_sni() -> None:
|
||||||
sni_path = Utils.get_settings()["sni_options"]["sni_path"]
|
sni_path = Utils.get_options()["sni_options"]["sni_path"]
|
||||||
|
|
||||||
if not os.path.isdir(sni_path):
|
if not os.path.isdir(sni_path):
|
||||||
sni_path = Utils.local_path(sni_path)
|
sni_path = Utils.local_path(sni_path)
|
||||||
@@ -566,6 +566,8 @@ async def snes_write(ctx: SNIContext, write_list: typing.List[typing.Tuple[int,
|
|||||||
try:
|
try:
|
||||||
for address, data in write_list:
|
for address, data in write_list:
|
||||||
PutAddress_Request['Operands'] = [hex(address)[2:], hex(len(data))[2:]]
|
PutAddress_Request['Operands'] = [hex(address)[2:], hex(len(data))[2:]]
|
||||||
|
# REVIEW: above: `if snes_socket is None: return False`
|
||||||
|
# Does it need to be checked again?
|
||||||
if ctx.snes_socket is not None:
|
if ctx.snes_socket is not None:
|
||||||
await ctx.snes_socket.send(dumps(PutAddress_Request))
|
await ctx.snes_socket.send(dumps(PutAddress_Request))
|
||||||
await ctx.snes_socket.send(data)
|
await ctx.snes_socket.send(data)
|
||||||
@@ -654,7 +656,7 @@ async def game_watcher(ctx: SNIContext) -> None:
|
|||||||
|
|
||||||
async def run_game(romfile: str) -> None:
|
async def run_game(romfile: str) -> None:
|
||||||
auto_start = typing.cast(typing.Union[bool, str],
|
auto_start = typing.cast(typing.Union[bool, str],
|
||||||
Utils.get_settings()["sni_options"].get("snes_rom_start", True))
|
Utils.get_options()["sni_options"].get("snes_rom_start", True))
|
||||||
if auto_start is True:
|
if auto_start is True:
|
||||||
import webbrowser
|
import webbrowser
|
||||||
webbrowser.open(romfile)
|
webbrowser.open(romfile)
|
||||||
|
|||||||
1050
Starcraft2Client.py
1050
Starcraft2Client.py
File diff suppressed because it is too large
Load Diff
@@ -27,33 +27,33 @@ class UndertaleCommandProcessor(ClientCommandProcessor):
|
|||||||
self.ctx.syncing = True
|
self.ctx.syncing = True
|
||||||
|
|
||||||
def _cmd_patch(self):
|
def _cmd_patch(self):
|
||||||
"""Patch the game. Only use this command if /auto_patch fails."""
|
"""Patch the game."""
|
||||||
if isinstance(self.ctx, UndertaleContext):
|
if isinstance(self.ctx, UndertaleContext):
|
||||||
os.makedirs(name=os.path.join(os.getcwd(), "Undertale"), exist_ok=True)
|
os.makedirs(name=os.getcwd() + "\\Undertale", exist_ok=True)
|
||||||
self.ctx.patch_game()
|
self.ctx.patch_game()
|
||||||
self.output("Patched.")
|
self.output("Patched.")
|
||||||
|
|
||||||
def _cmd_savepath(self, directory: str):
|
def _cmd_savepath(self, directory: str):
|
||||||
"""Redirect to proper save data folder. This is necessary for Linux users to use before connecting."""
|
"""Redirect to proper save data folder. (Use before connecting!)"""
|
||||||
if isinstance(self.ctx, UndertaleContext):
|
if isinstance(self.ctx, UndertaleContext):
|
||||||
self.ctx.save_game_folder = directory
|
UndertaleContext.save_game_folder = directory
|
||||||
self.output("Changed to the following directory: " + self.ctx.save_game_folder)
|
self.output("Changed to the following directory: " + directory)
|
||||||
|
|
||||||
@mark_raw
|
@mark_raw
|
||||||
def _cmd_auto_patch(self, steaminstall: typing.Optional[str] = None):
|
def _cmd_auto_patch(self, steaminstall: typing.Optional[str] = None):
|
||||||
"""Patch the game automatically."""
|
"""Patch the game automatically."""
|
||||||
if isinstance(self.ctx, UndertaleContext):
|
if isinstance(self.ctx, UndertaleContext):
|
||||||
os.makedirs(name=os.path.join(os.getcwd(), "Undertale"), exist_ok=True)
|
os.makedirs(name=os.getcwd() + "\\Undertale", exist_ok=True)
|
||||||
tempInstall = steaminstall
|
tempInstall = steaminstall
|
||||||
if not os.path.isfile(os.path.join(tempInstall, "data.win")):
|
if not os.path.isfile(os.path.join(tempInstall, "data.win")):
|
||||||
tempInstall = None
|
tempInstall = None
|
||||||
if tempInstall is None:
|
if tempInstall is None:
|
||||||
tempInstall = "C:\\Program Files (x86)\\Steam\\steamapps\\common\\Undertale"
|
tempInstall = "C:\\Program Files (x86)\\Steam\\steamapps\\common\\Undertale"
|
||||||
if not os.path.exists(tempInstall):
|
if not os.path.exists("C:\\Program Files (x86)\\Steam\\steamapps\\common\\Undertale"):
|
||||||
tempInstall = "C:\\Program Files\\Steam\\steamapps\\common\\Undertale"
|
tempInstall = "C:\\Program Files\\Steam\\steamapps\\common\\Undertale"
|
||||||
elif not os.path.exists(tempInstall):
|
elif not os.path.exists(tempInstall):
|
||||||
tempInstall = "C:\\Program Files (x86)\\Steam\\steamapps\\common\\Undertale"
|
tempInstall = "C:\\Program Files (x86)\\Steam\\steamapps\\common\\Undertale"
|
||||||
if not os.path.exists(tempInstall):
|
if not os.path.exists("C:\\Program Files (x86)\\Steam\\steamapps\\common\\Undertale"):
|
||||||
tempInstall = "C:\\Program Files\\Steam\\steamapps\\common\\Undertale"
|
tempInstall = "C:\\Program Files\\Steam\\steamapps\\common\\Undertale"
|
||||||
if not os.path.exists(tempInstall) or not os.path.exists(tempInstall) or not os.path.isfile(os.path.join(tempInstall, "data.win")):
|
if not os.path.exists(tempInstall) or not os.path.exists(tempInstall) or not os.path.isfile(os.path.join(tempInstall, "data.win")):
|
||||||
self.output("ERROR: Cannot find Undertale. Please rerun the command with the correct folder."
|
self.output("ERROR: Cannot find Undertale. Please rerun the command with the correct folder."
|
||||||
@@ -61,13 +61,13 @@ class UndertaleCommandProcessor(ClientCommandProcessor):
|
|||||||
else:
|
else:
|
||||||
for file_name in os.listdir(tempInstall):
|
for file_name in os.listdir(tempInstall):
|
||||||
if file_name != "steam_api.dll":
|
if file_name != "steam_api.dll":
|
||||||
shutil.copy(os.path.join(tempInstall, file_name),
|
shutil.copy(tempInstall+"\\"+file_name,
|
||||||
os.path.join(os.getcwd(), "Undertale", file_name))
|
os.getcwd() + "\\Undertale\\" + file_name)
|
||||||
self.ctx.patch_game()
|
self.ctx.patch_game()
|
||||||
self.output("Patching successful!")
|
self.output("Patching successful!")
|
||||||
|
|
||||||
def _cmd_online(self):
|
def _cmd_online(self):
|
||||||
"""Toggles seeing other Undertale players."""
|
"""Makes you no longer able to see other Undertale players."""
|
||||||
if isinstance(self.ctx, UndertaleContext):
|
if isinstance(self.ctx, UndertaleContext):
|
||||||
self.ctx.update_online_mode(not ("Online" in self.ctx.tags))
|
self.ctx.update_online_mode(not ("Online" in self.ctx.tags))
|
||||||
if "Online" in self.ctx.tags:
|
if "Online" in self.ctx.tags:
|
||||||
@@ -111,13 +111,13 @@ class UndertaleContext(CommonContext):
|
|||||||
self.save_game_folder = os.path.expandvars(r"%localappdata%/UNDERTALE")
|
self.save_game_folder = os.path.expandvars(r"%localappdata%/UNDERTALE")
|
||||||
|
|
||||||
def patch_game(self):
|
def patch_game(self):
|
||||||
with open(os.path.join(os.getcwd(), "Undertale", "data.win"), "rb") as f:
|
with open(os.getcwd() + "/Undertale/data.win", "rb") as f:
|
||||||
patchedFile = bsdiff4.patch(f.read(), undertale.data_path("patch.bsdiff"))
|
patchedFile = bsdiff4.patch(f.read(), undertale.data_path("patch.bsdiff"))
|
||||||
with open(os.path.join(os.getcwd(), "Undertale", "data.win"), "wb") as f:
|
with open(os.getcwd() + "/Undertale/data.win", "wb") as f:
|
||||||
f.write(patchedFile)
|
f.write(patchedFile)
|
||||||
os.makedirs(name=os.path.join(os.getcwd(), "Undertale", "Custom Sprites"), exist_ok=True)
|
os.makedirs(name=os.getcwd() + "\\Undertale\\" + "Custom Sprites", exist_ok=True)
|
||||||
with open(os.path.expandvars(os.path.join(os.getcwd(), "Undertale", "Custom Sprites",
|
with open(os.path.expandvars(os.getcwd() + "\\Undertale\\" + "Custom Sprites\\" +
|
||||||
"Which Character.txt")), "w") as f:
|
"Which Character.txt"), "w") as f:
|
||||||
f.writelines(["// Put the folder name of the sprites you want to play as, make sure it is the only "
|
f.writelines(["// Put the folder name of the sprites you want to play as, make sure it is the only "
|
||||||
"line other than this one.\n", "frisk"])
|
"line other than this one.\n", "frisk"])
|
||||||
f.close()
|
f.close()
|
||||||
@@ -385,7 +385,7 @@ async def multi_watcher(ctx: UndertaleContext):
|
|||||||
for root, dirs, files in os.walk(path):
|
for root, dirs, files in os.walk(path):
|
||||||
for file in files:
|
for file in files:
|
||||||
if "spots.mine" in file and "Online" in ctx.tags:
|
if "spots.mine" in file and "Online" in ctx.tags:
|
||||||
with open(os.path.join(root, file), "r") as mine:
|
with open(root + "/" + file, "r") as mine:
|
||||||
this_x = mine.readline()
|
this_x = mine.readline()
|
||||||
this_y = mine.readline()
|
this_y = mine.readline()
|
||||||
this_room = mine.readline()
|
this_room = mine.readline()
|
||||||
@@ -408,7 +408,7 @@ async def game_watcher(ctx: UndertaleContext):
|
|||||||
for root, dirs, files in os.walk(path):
|
for root, dirs, files in os.walk(path):
|
||||||
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(root+"/"+file)
|
||||||
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)})
|
||||||
@@ -424,13 +424,13 @@ async def game_watcher(ctx: UndertaleContext):
|
|||||||
for root, dirs, files in os.walk(path):
|
for root, dirs, files in os.walk(path):
|
||||||
for file in files:
|
for file in files:
|
||||||
if "DontBeMad.mad" in file:
|
if "DontBeMad.mad" in file:
|
||||||
os.remove(os.path.join(root, file))
|
os.remove(root+"/"+file)
|
||||||
if "DeathLink" in ctx.tags:
|
if "DeathLink" in ctx.tags:
|
||||||
await ctx.send_death()
|
await ctx.send_death()
|
||||||
if "scout" == file:
|
if "scout" == file:
|
||||||
sending = []
|
sending = []
|
||||||
try:
|
try:
|
||||||
with open(os.path.join(root, file), "r") as f:
|
with open(root+"/"+file, "r") as f:
|
||||||
lines = f.readlines()
|
lines = f.readlines()
|
||||||
for l in lines:
|
for l in lines:
|
||||||
if ctx.server_locations.__contains__(int(l)+12000):
|
if ctx.server_locations.__contains__(int(l)+12000):
|
||||||
@@ -438,11 +438,11 @@ async def game_watcher(ctx: UndertaleContext):
|
|||||||
finally:
|
finally:
|
||||||
await ctx.send_msgs([{"cmd": "LocationScouts", "locations": sending,
|
await ctx.send_msgs([{"cmd": "LocationScouts", "locations": sending,
|
||||||
"create_as_hint": int(2)}])
|
"create_as_hint": int(2)}])
|
||||||
os.remove(os.path.join(root, file))
|
os.remove(root+"/"+file)
|
||||||
if "check.spot" in file:
|
if "check.spot" in file:
|
||||||
sending = []
|
sending = []
|
||||||
try:
|
try:
|
||||||
with open(os.path.join(root, file), "r") as f:
|
with open(root+"/"+file, "r") as f:
|
||||||
lines = f.readlines()
|
lines = f.readlines()
|
||||||
for l in lines:
|
for l in lines:
|
||||||
sending = sending+[(int(l.rstrip('\n')))+12000]
|
sending = sending+[(int(l.rstrip('\n')))+12000]
|
||||||
@@ -451,7 +451,7 @@ async def game_watcher(ctx: UndertaleContext):
|
|||||||
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:
|
||||||
os.remove(os.path.join(root, file))
|
os.remove(root+"/"+file)
|
||||||
if "victory" in file:
|
if "victory" in file:
|
||||||
if str(ctx.route) == "all_routes":
|
if str(ctx.route) == "all_routes":
|
||||||
if "neutral" in file and ctx.completed_routes["neutral"] != 1:
|
if "neutral" in file and ctx.completed_routes["neutral"] != 1:
|
||||||
|
|||||||
284
Utils.py
284
Utils.py
@@ -5,7 +5,6 @@ import json
|
|||||||
import typing
|
import typing
|
||||||
import builtins
|
import builtins
|
||||||
import os
|
import os
|
||||||
import itertools
|
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import pickle
|
import pickle
|
||||||
@@ -14,23 +13,22 @@ import io
|
|||||||
import collections
|
import collections
|
||||||
import importlib
|
import importlib
|
||||||
import logging
|
import logging
|
||||||
import warnings
|
|
||||||
|
|
||||||
from argparse import Namespace
|
from argparse import Namespace
|
||||||
from settings import Settings, get_settings
|
from settings import Settings, get_settings
|
||||||
from typing import BinaryIO, Coroutine, Optional, Set, Dict, Any, Union
|
from typing import BinaryIO, Coroutine, Optional, Set, Dict, Any, Union
|
||||||
from typing_extensions import TypeGuard
|
from yaml import load, load_all, dump, SafeLoader
|
||||||
from yaml import load, load_all, dump
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from yaml import CLoader as UnsafeLoader, CSafeLoader as SafeLoader, CDumper as Dumper
|
from yaml import CLoader as UnsafeLoader
|
||||||
|
from yaml import CDumper as Dumper
|
||||||
except ImportError:
|
except ImportError:
|
||||||
from yaml import Loader as UnsafeLoader, SafeLoader, Dumper
|
from yaml import Loader as UnsafeLoader
|
||||||
|
from yaml import Dumper
|
||||||
|
|
||||||
if typing.TYPE_CHECKING:
|
if typing.TYPE_CHECKING:
|
||||||
import tkinter
|
import tkinter
|
||||||
import pathlib
|
import pathlib
|
||||||
from BaseClasses import Region
|
|
||||||
|
|
||||||
|
|
||||||
def tuplize_version(version: str) -> Version:
|
def tuplize_version(version: str) -> Version:
|
||||||
@@ -46,7 +44,7 @@ class Version(typing.NamedTuple):
|
|||||||
return ".".join(str(item) for item in self)
|
return ".".join(str(item) for item in self)
|
||||||
|
|
||||||
|
|
||||||
__version__ = "0.4.6"
|
__version__ = "0.4.2"
|
||||||
version_tuple = tuplize_version(__version__)
|
version_tuple = tuplize_version(__version__)
|
||||||
|
|
||||||
is_linux = sys.platform.startswith("linux")
|
is_linux = sys.platform.startswith("linux")
|
||||||
@@ -73,8 +71,6 @@ def snes_to_pc(value: int) -> int:
|
|||||||
|
|
||||||
|
|
||||||
RetType = typing.TypeVar("RetType")
|
RetType = typing.TypeVar("RetType")
|
||||||
S = typing.TypeVar("S")
|
|
||||||
T = typing.TypeVar("T")
|
|
||||||
|
|
||||||
|
|
||||||
def cache_argsless(function: typing.Callable[[], RetType]) -> typing.Callable[[], RetType]:
|
def cache_argsless(function: typing.Callable[[], RetType]) -> typing.Callable[[], RetType]:
|
||||||
@@ -92,31 +88,6 @@ def cache_argsless(function: typing.Callable[[], RetType]) -> typing.Callable[[]
|
|||||||
return _wrap
|
return _wrap
|
||||||
|
|
||||||
|
|
||||||
def cache_self1(function: typing.Callable[[S, T], RetType]) -> typing.Callable[[S, T], RetType]:
|
|
||||||
"""Specialized cache for self + 1 arg. Does not keep global ref to self and skips building a dict key tuple."""
|
|
||||||
|
|
||||||
assert function.__code__.co_argcount == 2, "Can only cache 2 argument functions with this cache."
|
|
||||||
|
|
||||||
cache_name = f"__cache_{function.__name__}__"
|
|
||||||
|
|
||||||
@functools.wraps(function)
|
|
||||||
def wrap(self: S, arg: T) -> RetType:
|
|
||||||
cache: Optional[Dict[T, RetType]] = typing.cast(Optional[Dict[T, RetType]],
|
|
||||||
getattr(self, cache_name, None))
|
|
||||||
if cache is None:
|
|
||||||
res = function(self, arg)
|
|
||||||
setattr(self, cache_name, {arg: res})
|
|
||||||
return res
|
|
||||||
try:
|
|
||||||
return cache[arg]
|
|
||||||
except KeyError:
|
|
||||||
res = function(self, arg)
|
|
||||||
cache[arg] = res
|
|
||||||
return res
|
|
||||||
|
|
||||||
return wrap
|
|
||||||
|
|
||||||
|
|
||||||
def is_frozen() -> bool:
|
def is_frozen() -> bool:
|
||||||
return typing.cast(bool, getattr(sys, 'frozen', False))
|
return typing.cast(bool, getattr(sys, 'frozen', False))
|
||||||
|
|
||||||
@@ -173,16 +144,12 @@ def user_path(*path: str) -> str:
|
|||||||
if user_path.cached_path != local_path():
|
if user_path.cached_path != local_path():
|
||||||
import filecmp
|
import filecmp
|
||||||
if not os.path.exists(user_path("manifest.json")) or \
|
if not os.path.exists(user_path("manifest.json")) or \
|
||||||
not os.path.exists(local_path("manifest.json")) or \
|
|
||||||
not filecmp.cmp(local_path("manifest.json"), user_path("manifest.json"), shallow=True):
|
not filecmp.cmp(local_path("manifest.json"), user_path("manifest.json"), shallow=True):
|
||||||
import shutil
|
import shutil
|
||||||
for dn in ("Players", "data/sprites", "data/lua"):
|
for dn in ("Players", "data/sprites"):
|
||||||
shutil.copytree(local_path(dn), user_path(dn), dirs_exist_ok=True)
|
shutil.copytree(local_path(dn), user_path(dn), dirs_exist_ok=True)
|
||||||
if not os.path.exists(local_path("manifest.json")):
|
for fn in ("manifest.json",):
|
||||||
warnings.warn(f"Upgrading {user_path()} from something that is not a proper install")
|
shutil.copy2(local_path(fn), user_path(fn))
|
||||||
else:
|
|
||||||
shutil.copy2(local_path("manifest.json"), user_path("manifest.json"))
|
|
||||||
os.makedirs(user_path("worlds"), exist_ok=True)
|
|
||||||
|
|
||||||
return os.path.join(user_path.cached_path, *path)
|
return os.path.join(user_path.cached_path, *path)
|
||||||
|
|
||||||
@@ -201,7 +168,7 @@ def cache_path(*path: str) -> str:
|
|||||||
def output_path(*path: str) -> str:
|
def output_path(*path: str) -> str:
|
||||||
if hasattr(output_path, 'cached_path'):
|
if hasattr(output_path, 'cached_path'):
|
||||||
return os.path.join(output_path.cached_path, *path)
|
return os.path.join(output_path.cached_path, *path)
|
||||||
output_path.cached_path = user_path(get_settings()["general_options"]["output_path"])
|
output_path.cached_path = user_path(get_options()["general_options"]["output_path"])
|
||||||
path = os.path.join(output_path.cached_path, *path)
|
path = os.path.join(output_path.cached_path, *path)
|
||||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||||
return path
|
return path
|
||||||
@@ -225,9 +192,6 @@ class UniqueKeyLoader(SafeLoader):
|
|||||||
if key in mapping:
|
if key in mapping:
|
||||||
logging.error(f"YAML duplicates sanity check failed{key_node.start_mark}")
|
logging.error(f"YAML duplicates sanity check failed{key_node.start_mark}")
|
||||||
raise KeyError(f"Duplicate key {key} found in YAML. Already found keys: {mapping}.")
|
raise KeyError(f"Duplicate key {key} found in YAML. Already found keys: {mapping}.")
|
||||||
if (str(key).startswith("+") and (str(key)[1:] in mapping)) or (f"+{key}" in mapping):
|
|
||||||
logging.error(f"YAML merge duplicates sanity check failed{key_node.start_mark}")
|
|
||||||
raise KeyError(f"Equivalent key {key} found in YAML. Already found keys: {mapping}.")
|
|
||||||
mapping.add(key)
|
mapping.add(key)
|
||||||
return super().construct_mapping(node, deep)
|
return super().construct_mapping(node, deep)
|
||||||
|
|
||||||
@@ -251,13 +215,7 @@ def get_cert_none_ssl_context():
|
|||||||
def get_public_ipv4() -> str:
|
def get_public_ipv4() -> str:
|
||||||
import socket
|
import socket
|
||||||
import urllib.request
|
import urllib.request
|
||||||
try:
|
ip = socket.gethostbyname(socket.gethostname())
|
||||||
ip = socket.gethostbyname(socket.gethostname())
|
|
||||||
except socket.gaierror:
|
|
||||||
# if hostname or resolvconf is not set up properly, this may fail
|
|
||||||
warnings.warn("Could not resolve own hostname, falling back to 127.0.0.1")
|
|
||||||
ip = "127.0.0.1"
|
|
||||||
|
|
||||||
ctx = get_cert_none_ssl_context()
|
ctx = get_cert_none_ssl_context()
|
||||||
try:
|
try:
|
||||||
ip = urllib.request.urlopen("https://checkip.amazonaws.com/", context=ctx, timeout=10).read().decode("utf8").strip()
|
ip = urllib.request.urlopen("https://checkip.amazonaws.com/", context=ctx, timeout=10).read().decode("utf8").strip()
|
||||||
@@ -275,13 +233,7 @@ def get_public_ipv4() -> str:
|
|||||||
def get_public_ipv6() -> str:
|
def get_public_ipv6() -> str:
|
||||||
import socket
|
import socket
|
||||||
import urllib.request
|
import urllib.request
|
||||||
try:
|
ip = socket.gethostbyname(socket.gethostname())
|
||||||
ip = socket.gethostbyname(socket.gethostname())
|
|
||||||
except socket.gaierror:
|
|
||||||
# if hostname or resolvconf is not set up properly, this may fail
|
|
||||||
warnings.warn("Could not resolve own hostname, falling back to ::1")
|
|
||||||
ip = "::1"
|
|
||||||
|
|
||||||
ctx = get_cert_none_ssl_context()
|
ctx = get_cert_none_ssl_context()
|
||||||
try:
|
try:
|
||||||
ip = urllib.request.urlopen("https://v6.ident.me", context=ctx, timeout=10).read().decode("utf8").strip()
|
ip = urllib.request.urlopen("https://v6.ident.me", context=ctx, timeout=10).read().decode("utf8").strip()
|
||||||
@@ -291,13 +243,15 @@ def get_public_ipv6() -> str:
|
|||||||
return ip
|
return ip
|
||||||
|
|
||||||
|
|
||||||
OptionsType = Settings # TODO: remove when removing get_options
|
OptionsType = Settings # TODO: remove ~2 versions after 0.4.1
|
||||||
|
|
||||||
|
|
||||||
def get_options() -> Settings:
|
@cache_argsless
|
||||||
# TODO: switch to Utils.deprecate after 0.4.4
|
def get_default_options() -> Settings: # TODO: remove ~2 versions after 0.4.1
|
||||||
warnings.warn("Utils.get_options() is deprecated. Use the settings API instead.", DeprecationWarning)
|
return Settings(None)
|
||||||
return get_settings()
|
|
||||||
|
|
||||||
|
get_options = get_settings # TODO: add a warning ~2 versions after 0.4.1 and remove once all games are ported
|
||||||
|
|
||||||
|
|
||||||
def persistent_store(category: str, key: typing.Any, value: typing.Any):
|
def persistent_store(category: str, key: typing.Any, value: typing.Any):
|
||||||
@@ -405,13 +359,11 @@ safe_builtins = frozenset((
|
|||||||
|
|
||||||
|
|
||||||
class RestrictedUnpickler(pickle.Unpickler):
|
class RestrictedUnpickler(pickle.Unpickler):
|
||||||
generic_properties_module: Optional[object]
|
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
super(RestrictedUnpickler, self).__init__(*args, **kwargs)
|
super(RestrictedUnpickler, self).__init__(*args, **kwargs)
|
||||||
self.options_module = importlib.import_module("Options")
|
self.options_module = importlib.import_module("Options")
|
||||||
self.net_utils_module = importlib.import_module("NetUtils")
|
self.net_utils_module = importlib.import_module("NetUtils")
|
||||||
self.generic_properties_module = None
|
self.generic_properties_module = importlib.import_module("worlds.generic")
|
||||||
|
|
||||||
def find_class(self, module, name):
|
def find_class(self, module, name):
|
||||||
if module == "builtins" and name in safe_builtins:
|
if module == "builtins" and name in safe_builtins:
|
||||||
@@ -421,8 +373,6 @@ class RestrictedUnpickler(pickle.Unpickler):
|
|||||||
return getattr(self.net_utils_module, name)
|
return getattr(self.net_utils_module, name)
|
||||||
# Options and Plando are unpickled by WebHost -> Generate
|
# Options and Plando are unpickled by WebHost -> Generate
|
||||||
if module == "worlds.generic" and name in {"PlandoItem", "PlandoConnection"}:
|
if module == "worlds.generic" and name in {"PlandoItem", "PlandoConnection"}:
|
||||||
if not self.generic_properties_module:
|
|
||||||
self.generic_properties_module = importlib.import_module("worlds.generic")
|
|
||||||
return getattr(self.generic_properties_module, name)
|
return getattr(self.generic_properties_module, name)
|
||||||
# pep 8 specifies that modules should have "all-lowercase names" (options, not Options)
|
# pep 8 specifies that modules should have "all-lowercase names" (options, not Options)
|
||||||
if module.lower().endswith("options"):
|
if module.lower().endswith("options"):
|
||||||
@@ -491,21 +441,11 @@ def init_logging(name: str, loglevel: typing.Union[str, int] = logging.INFO, wri
|
|||||||
write_mode,
|
write_mode,
|
||||||
encoding="utf-8-sig")
|
encoding="utf-8-sig")
|
||||||
file_handler.setFormatter(logging.Formatter(log_format))
|
file_handler.setFormatter(logging.Formatter(log_format))
|
||||||
|
|
||||||
class Filter(logging.Filter):
|
|
||||||
def __init__(self, filter_name, condition):
|
|
||||||
super().__init__(filter_name)
|
|
||||||
self.condition = condition
|
|
||||||
|
|
||||||
def filter(self, record: logging.LogRecord) -> bool:
|
|
||||||
return self.condition(record)
|
|
||||||
|
|
||||||
file_handler.addFilter(Filter("NoStream", lambda record: not getattr(record, "NoFile", False)))
|
|
||||||
root_logger.addHandler(file_handler)
|
root_logger.addHandler(file_handler)
|
||||||
if sys.stdout:
|
if sys.stdout:
|
||||||
stream_handler = logging.StreamHandler(sys.stdout)
|
root_logger.addHandler(
|
||||||
stream_handler.addFilter(Filter("NoFile", lambda record: not getattr(record, "NoStream", False)))
|
logging.StreamHandler(sys.stdout)
|
||||||
root_logger.addHandler(stream_handler)
|
)
|
||||||
|
|
||||||
# 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
|
||||||
@@ -619,8 +559,6 @@ def get_fuzzy_results(input_word: str, wordlist: typing.Sequence[str], limit: ty
|
|||||||
|
|
||||||
def open_filename(title: str, filetypes: typing.Sequence[typing.Tuple[str, typing.Sequence[str]]], suggest: str = "") \
|
def open_filename(title: str, filetypes: typing.Sequence[typing.Tuple[str, typing.Sequence[str]]], suggest: str = "") \
|
||||||
-> typing.Optional[str]:
|
-> typing.Optional[str]:
|
||||||
logging.info(f"Opening file input dialog for {title}.")
|
|
||||||
|
|
||||||
def run(*args: str):
|
def run(*args: str):
|
||||||
return subprocess.run(args, capture_output=True, text=True).stdout.split("\n", 1)[0] or None
|
return subprocess.run(args, capture_output=True, text=True).stdout.split("\n", 1)[0] or None
|
||||||
|
|
||||||
@@ -634,7 +572,7 @@ def open_filename(title: str, filetypes: typing.Sequence[typing.Tuple[str, typin
|
|||||||
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(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
|
||||||
@@ -646,10 +584,7 @@ def open_filename(title: str, filetypes: typing.Sequence[typing.Tuple[str, typin
|
|||||||
f'This attempt was made because open_filename was used for "{title}".')
|
f'This attempt was made because open_filename was used for "{title}".')
|
||||||
raise e
|
raise e
|
||||||
else:
|
else:
|
||||||
try:
|
root = tkinter.Tk()
|
||||||
root = tkinter.Tk()
|
|
||||||
except tkinter.TclError:
|
|
||||||
return None # GUI not available. None is the same as a user clicking "cancel"
|
|
||||||
root.withdraw()
|
root.withdraw()
|
||||||
return tkinter.filedialog.askopenfilename(title=title, filetypes=((t[0], ' '.join(t[1])) for t in filetypes),
|
return tkinter.filedialog.askopenfilename(title=title, filetypes=((t[0], ' '.join(t[1])) for t in filetypes),
|
||||||
initialfile=suggest or None)
|
initialfile=suggest or None)
|
||||||
@@ -662,14 +597,13 @@ def open_directory(title: str, suggest: str = "") -> typing.Optional[str]:
|
|||||||
if is_linux:
|
if is_linux:
|
||||||
# prefer native dialog
|
# prefer native dialog
|
||||||
from shutil import which
|
from shutil import which
|
||||||
kdialog = which("kdialog")
|
kdialog = None#which("kdialog")
|
||||||
if kdialog:
|
if kdialog:
|
||||||
return run(kdialog, f"--title={title}", "--getexistingdirectory",
|
return run(kdialog, f"--title={title}", "--getexistingdirectory", suggest or ".")
|
||||||
os.path.abspath(suggest) if suggest else ".")
|
zenity = None#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="{suggest}',) if suggest else ()
|
||||||
return run(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
|
||||||
@@ -681,10 +615,7 @@ def open_directory(title: str, suggest: str = "") -> typing.Optional[str]:
|
|||||||
f'This attempt was made because open_filename was used for "{title}".')
|
f'This attempt was made because open_filename was used for "{title}".')
|
||||||
raise e
|
raise e
|
||||||
else:
|
else:
|
||||||
try:
|
root = tkinter.Tk()
|
||||||
root = tkinter.Tk()
|
|
||||||
except tkinter.TclError:
|
|
||||||
return None # GUI not available. None is the same as a user clicking "cancel"
|
|
||||||
root.withdraw()
|
root.withdraw()
|
||||||
return tkinter.filedialog.askdirectory(title=title, mustexist=True, initialdir=suggest or None)
|
return tkinter.filedialog.askdirectory(title=title, mustexist=True, initialdir=suggest or None)
|
||||||
|
|
||||||
@@ -714,11 +645,6 @@ def messagebox(title: str, text: str, error: bool = False) -> None:
|
|||||||
if zenity:
|
if zenity:
|
||||||
return run(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:
|
|
||||||
import ctypes
|
|
||||||
style = 0x10 if error else 0x0
|
|
||||||
return ctypes.windll.user32.MessageBoxW(0, text, title, style)
|
|
||||||
|
|
||||||
# fall back to tk
|
# fall back to tk
|
||||||
try:
|
try:
|
||||||
import tkinter
|
import tkinter
|
||||||
@@ -783,25 +709,6 @@ def deprecate(message: str):
|
|||||||
import warnings
|
import warnings
|
||||||
warnings.warn(message)
|
warnings.warn(message)
|
||||||
|
|
||||||
|
|
||||||
class DeprecateDict(dict):
|
|
||||||
log_message: str
|
|
||||||
should_error: bool
|
|
||||||
|
|
||||||
def __init__(self, message, error: bool = False) -> None:
|
|
||||||
self.log_message = message
|
|
||||||
self.should_error = error
|
|
||||||
super().__init__()
|
|
||||||
|
|
||||||
def __getitem__(self, item: Any) -> Any:
|
|
||||||
if self.should_error:
|
|
||||||
deprecate(self.log_message)
|
|
||||||
elif __debug__:
|
|
||||||
import warnings
|
|
||||||
warnings.warn(self.log_message)
|
|
||||||
return super().__getitem__(item)
|
|
||||||
|
|
||||||
|
|
||||||
def _extend_freeze_support() -> None:
|
def _extend_freeze_support() -> None:
|
||||||
"""Extend multiprocessing.freeze_support() to also work on Non-Windows for spawn."""
|
"""Extend multiprocessing.freeze_support() to also work on Non-Windows for spawn."""
|
||||||
# upstream issue: https://github.com/python/cpython/issues/76327
|
# upstream issue: https://github.com/python/cpython/issues/76327
|
||||||
@@ -848,134 +755,3 @@ def freeze_support() -> None:
|
|||||||
import multiprocessing
|
import multiprocessing
|
||||||
_extend_freeze_support()
|
_extend_freeze_support()
|
||||||
multiprocessing.freeze_support()
|
multiprocessing.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) -> None:
|
|
||||||
"""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 file_name: The name of the destination .puml file.
|
|
||||||
:param show_entrance_names: (default False) If enabled, the name of the entrance will be shown near each connection.
|
|
||||||
:param show_locations: (default True) If enabled, the locations will be listed inside each region.
|
|
||||||
Priority locations will be shown in bold.
|
|
||||||
Excluded locations will be stricken out.
|
|
||||||
Locations without ID will be shown in italics.
|
|
||||||
Locked locations will be shown with a padlock icon.
|
|
||||||
For filled locations, the item name will be shown after the location name.
|
|
||||||
Progression items will be shown in bold.
|
|
||||||
Items without ID will be shown in italics.
|
|
||||||
:param show_other_regions: (default True) If enabled, regions that can't be reached by traversing exits are shown.
|
|
||||||
:param linetype_ortho: (default True) If enabled, orthogonal straight line parts will be used; otherwise polylines.
|
|
||||||
|
|
||||||
Example usage in World code:
|
|
||||||
from Utils import visualize_regions
|
|
||||||
visualize_regions(self.multiworld.get_region("Menu", self.player), "my_world.puml")
|
|
||||||
|
|
||||||
Example usage in Main code:
|
|
||||||
from Utils import visualize_regions
|
|
||||||
for player in multiworld.player_ids:
|
|
||||||
visualize_regions(multiworld.get_region("Menu", player), f"{multiworld.get_out_file_name_base(player)}.puml")
|
|
||||||
"""
|
|
||||||
assert root_region.multiworld, "The multiworld attribute of root_region has to be filled"
|
|
||||||
from BaseClasses import Entrance, Item, Location, LocationProgressType, MultiWorld, Region
|
|
||||||
from collections import deque
|
|
||||||
import re
|
|
||||||
|
|
||||||
uml: typing.List[str] = list()
|
|
||||||
seen: typing.Set[Region] = set()
|
|
||||||
regions: typing.Deque[Region] = deque((root_region,))
|
|
||||||
multiworld: MultiWorld = root_region.multiworld
|
|
||||||
|
|
||||||
def fmt(obj: Union[Entrance, Item, Location, Region]) -> str:
|
|
||||||
name = obj.name
|
|
||||||
if isinstance(obj, Item):
|
|
||||||
name = multiworld.get_name_string_for_object(obj)
|
|
||||||
if obj.advancement:
|
|
||||||
name = f"**{name}**"
|
|
||||||
if obj.code is None:
|
|
||||||
name = f"//{name}//"
|
|
||||||
if isinstance(obj, Location):
|
|
||||||
if obj.progress_type == LocationProgressType.PRIORITY:
|
|
||||||
name = f"**{name}**"
|
|
||||||
elif obj.progress_type == LocationProgressType.EXCLUDED:
|
|
||||||
name = f"--{name}--"
|
|
||||||
if obj.address is None:
|
|
||||||
name = f"//{name}//"
|
|
||||||
return re.sub("[\".:]", "", name)
|
|
||||||
|
|
||||||
def visualize_exits(region: Region) -> None:
|
|
||||||
for exit_ in region.exits:
|
|
||||||
if exit_.connected_region:
|
|
||||||
if show_entrance_names:
|
|
||||||
uml.append(f"\"{fmt(region)}\" --> \"{fmt(exit_.connected_region)}\" : \"{fmt(exit_)}\"")
|
|
||||||
else:
|
|
||||||
try:
|
|
||||||
uml.remove(f"\"{fmt(exit_.connected_region)}\" --> \"{fmt(region)}\"")
|
|
||||||
uml.append(f"\"{fmt(exit_.connected_region)}\" <--> \"{fmt(region)}\"")
|
|
||||||
except ValueError:
|
|
||||||
uml.append(f"\"{fmt(region)}\" --> \"{fmt(exit_.connected_region)}\"")
|
|
||||||
else:
|
|
||||||
uml.append(f"circle \"unconnected exit:\\n{fmt(exit_)}\"")
|
|
||||||
uml.append(f"\"{fmt(region)}\" --> \"unconnected exit:\\n{fmt(exit_)}\"")
|
|
||||||
|
|
||||||
def visualize_locations(region: Region) -> None:
|
|
||||||
any_lock = any(location.locked for location in region.locations)
|
|
||||||
for location in region.locations:
|
|
||||||
lock = "<&lock-locked> " if location.locked else "<&lock-unlocked,color=transparent> " if any_lock else ""
|
|
||||||
if location.item:
|
|
||||||
uml.append(f"\"{fmt(region)}\" : {{method}} {lock}{fmt(location)}: {fmt(location.item)}")
|
|
||||||
else:
|
|
||||||
uml.append(f"\"{fmt(region)}\" : {{field}} {lock}{fmt(location)}")
|
|
||||||
|
|
||||||
def visualize_region(region: Region) -> None:
|
|
||||||
uml.append(f"class \"{fmt(region)}\"")
|
|
||||||
if show_locations:
|
|
||||||
visualize_locations(region)
|
|
||||||
visualize_exits(region)
|
|
||||||
|
|
||||||
def visualize_other_regions() -> None:
|
|
||||||
if other_regions := [region for region in multiworld.get_regions(root_region.player) if region not in seen]:
|
|
||||||
uml.append("package \"other regions\" <<Cloud>> {")
|
|
||||||
for region in other_regions:
|
|
||||||
uml.append(f"class \"{fmt(region)}\"")
|
|
||||||
uml.append("}")
|
|
||||||
|
|
||||||
uml.append("@startuml")
|
|
||||||
uml.append("hide circle")
|
|
||||||
uml.append("hide empty members")
|
|
||||||
if linetype_ortho:
|
|
||||||
uml.append("skinparam linetype ortho")
|
|
||||||
while regions:
|
|
||||||
if (current_region := regions.popleft()) not in seen:
|
|
||||||
seen.add(current_region)
|
|
||||||
visualize_region(current_region)
|
|
||||||
regions.extend(exit_.connected_region for exit_ in current_region.exits if exit_.connected_region)
|
|
||||||
if show_other_regions:
|
|
||||||
visualize_other_regions()
|
|
||||||
uml.append("@enduml")
|
|
||||||
|
|
||||||
with open(file_name, "wt", encoding="utf-8") as f:
|
|
||||||
f.write("\n".join(uml))
|
|
||||||
|
|
||||||
|
|
||||||
class RepeatableChain:
|
|
||||||
def __init__(self, iterable: typing.Iterable):
|
|
||||||
self.iterable = iterable
|
|
||||||
|
|
||||||
def __iter__(self):
|
|
||||||
return itertools.chain.from_iterable(self.iterable)
|
|
||||||
|
|
||||||
def __bool__(self):
|
|
||||||
return any(sub_iterable for sub_iterable in self.iterable)
|
|
||||||
|
|
||||||
def __len__(self):
|
|
||||||
return sum(len(iterable) for iterable in self.iterable)
|
|
||||||
|
|
||||||
|
|
||||||
def is_iterable_except_str(obj: object) -> TypeGuard[typing.Iterable[typing.Any]]:
|
|
||||||
""" `str` is `Iterable`, but that's not what we want """
|
|
||||||
if isinstance(obj, str):
|
|
||||||
return False
|
|
||||||
return isinstance(obj, typing.Iterable)
|
|
||||||
|
|||||||
@@ -113,9 +113,6 @@ class WargrooveContext(CommonContext):
|
|||||||
async def connection_closed(self):
|
async def connection_closed(self):
|
||||||
await super(WargrooveContext, self).connection_closed()
|
await super(WargrooveContext, self).connection_closed()
|
||||||
self.remove_communication_files()
|
self.remove_communication_files()
|
||||||
self.checked_locations.clear()
|
|
||||||
self.server_locations.clear()
|
|
||||||
self.finished_game = False
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def endpoints(self):
|
def endpoints(self):
|
||||||
@@ -127,9 +124,6 @@ class WargrooveContext(CommonContext):
|
|||||||
async def shutdown(self):
|
async def shutdown(self):
|
||||||
await super(WargrooveContext, self).shutdown()
|
await super(WargrooveContext, self).shutdown()
|
||||||
self.remove_communication_files()
|
self.remove_communication_files()
|
||||||
self.checked_locations.clear()
|
|
||||||
self.server_locations.clear()
|
|
||||||
self.finished_game = False
|
|
||||||
|
|
||||||
def remove_communication_files(self):
|
def remove_communication_files(self):
|
||||||
for root, dirs, files in os.walk(self.game_communication_path):
|
for root, dirs, files in os.walk(self.game_communication_path):
|
||||||
@@ -408,10 +402,8 @@ async def game_watcher(ctx: WargrooveContext):
|
|||||||
if file.find("send") > -1:
|
if file.find("send") > -1:
|
||||||
st = file.split("send", -1)[1]
|
st = file.split("send", -1)[1]
|
||||||
sending = sending+[(int(st))]
|
sending = sending+[(int(st))]
|
||||||
os.remove(os.path.join(ctx.game_communication_path, file))
|
|
||||||
if file.find("victory") > -1:
|
if file.find("victory") > -1:
|
||||||
victory = True
|
victory = True
|
||||||
os.remove(os.path.join(ctx.game_communication_path, file))
|
|
||||||
ctx.locations_checked = sending
|
ctx.locations_checked = sending
|
||||||
message = [{"cmd": 'LocationChecks', "locations": sending}]
|
message = [{"cmd": 'LocationChecks', "locations": sending}]
|
||||||
await ctx.send_msgs(message)
|
await ctx.send_msgs(message)
|
||||||
|
|||||||
47
WebHost.py
47
WebHost.py
@@ -13,6 +13,15 @@ import Utils
|
|||||||
import settings
|
import settings
|
||||||
|
|
||||||
Utils.local_path.cached_path = os.path.dirname(__file__) or "." # py3.8 is not abs. remove "." when dropping 3.8
|
Utils.local_path.cached_path = os.path.dirname(__file__) or "." # py3.8 is not abs. remove "." when dropping 3.8
|
||||||
|
|
||||||
|
from WebHostLib import register, app as raw_app
|
||||||
|
from waitress import serve
|
||||||
|
|
||||||
|
from WebHostLib.models import db
|
||||||
|
from WebHostLib.autolauncher import autohost, autogen
|
||||||
|
from WebHostLib.options import create as create_options_files
|
||||||
|
import worlds
|
||||||
|
|
||||||
settings.no_gui = True
|
settings.no_gui = True
|
||||||
configpath = os.path.abspath("config.yaml")
|
configpath = os.path.abspath("config.yaml")
|
||||||
if not os.path.exists(configpath): # fall back to config.yaml in home
|
if not os.path.exists(configpath): # fall back to config.yaml in home
|
||||||
@@ -20,9 +29,7 @@ if not os.path.exists(configpath): # fall back to config.yaml in home
|
|||||||
|
|
||||||
|
|
||||||
def get_app():
|
def get_app():
|
||||||
from WebHostLib import register, cache, app as raw_app
|
register()
|
||||||
from WebHostLib.models import db
|
|
||||||
|
|
||||||
app = raw_app
|
app = raw_app
|
||||||
if os.path.exists(configpath) and not app.config["TESTING"]:
|
if os.path.exists(configpath) and not app.config["TESTING"]:
|
||||||
import yaml
|
import yaml
|
||||||
@@ -33,10 +40,15 @@ def get_app():
|
|||||||
app.config["HOST_ADDRESS"] = Utils.get_public_ipv4()
|
app.config["HOST_ADDRESS"] = Utils.get_public_ipv4()
|
||||||
logging.info(f"HOST_ADDRESS was set to {app.config['HOST_ADDRESS']}")
|
logging.info(f"HOST_ADDRESS was set to {app.config['HOST_ADDRESS']}")
|
||||||
|
|
||||||
register()
|
|
||||||
cache.init_app(app)
|
|
||||||
db.bind(**app.config["PONY"])
|
db.bind(**app.config["PONY"])
|
||||||
db.generate_mapping(create_tables=True)
|
db.generate_mapping(create_tables=True)
|
||||||
|
|
||||||
|
for world in worlds.AutoWorldRegister.world_types.values():
|
||||||
|
try:
|
||||||
|
world.web.run_webhost_app_setup(app)
|
||||||
|
except Exception as e:
|
||||||
|
logging.exception(e)
|
||||||
|
|
||||||
return app
|
return app
|
||||||
|
|
||||||
|
|
||||||
@@ -116,16 +128,16 @@ 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
|
for world in worlds.AutoWorldRegister.world_types.values():
|
||||||
from WebHostLib.autolauncher import autohost, autogen, stop
|
try:
|
||||||
from WebHostLib.options import create as create_options_files
|
world.web.run_webhost_setup()
|
||||||
|
except Exception as e:
|
||||||
|
logging.exception(e)
|
||||||
|
|
||||||
try:
|
|
||||||
update_sprites_lttp()
|
|
||||||
except Exception as e:
|
|
||||||
logging.exception(e)
|
|
||||||
logging.warning("Could not update LttP sprites.")
|
|
||||||
app = get_app()
|
app = get_app()
|
||||||
|
|
||||||
|
del world, worlds
|
||||||
|
|
||||||
create_options_files()
|
create_options_files()
|
||||||
create_ordered_tutorials_file()
|
create_ordered_tutorials_file()
|
||||||
if app.config["SELFLAUNCH"]:
|
if app.config["SELFLAUNCH"]:
|
||||||
@@ -136,13 +148,4 @@ if __name__ == "__main__":
|
|||||||
if app.config["DEBUG"]:
|
if app.config["DEBUG"]:
|
||||||
app.run(debug=True, port=app.config["PORT"])
|
app.run(debug=True, port=app.config["PORT"])
|
||||||
else:
|
else:
|
||||||
from waitress import serve
|
|
||||||
serve(app, port=app.config["PORT"], threads=app.config["WAITRESS_THREADS"])
|
serve(app, port=app.config["PORT"], threads=app.config["WAITRESS_THREADS"])
|
||||||
else:
|
|
||||||
from time import sleep
|
|
||||||
try:
|
|
||||||
while True:
|
|
||||||
sleep(1) # wait for process to be killed
|
|
||||||
except (SystemExit, KeyboardInterrupt):
|
|
||||||
pass
|
|
||||||
stop() # stop worker threads
|
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ app.jinja_env.filters['all'] = all
|
|||||||
|
|
||||||
app.config["SELFHOST"] = True # application process is in charge of running the websites
|
app.config["SELFHOST"] = True # application process is in charge of running the websites
|
||||||
app.config["GENERATORS"] = 8 # maximum concurrent world gens
|
app.config["GENERATORS"] = 8 # maximum concurrent world gens
|
||||||
app.config["HOSTERS"] = 8 # maximum concurrent room hosters
|
|
||||||
app.config["SELFLAUNCH"] = True # application process is in charge of launching Rooms.
|
app.config["SELFLAUNCH"] = True # application process is in charge of launching Rooms.
|
||||||
app.config["SELFLAUNCHCERT"] = None # can point to a SSL Certificate to encrypt Room websocket connections
|
app.config["SELFLAUNCHCERT"] = None # can point to a SSL Certificate to encrypt Room websocket connections
|
||||||
app.config["SELFLAUNCHKEY"] = None # can point to a SSL Certificate Key to encrypt Room websocket connections
|
app.config["SELFLAUNCHKEY"] = None # can point to a SSL Certificate Key to encrypt Room websocket connections
|
||||||
@@ -50,11 +49,11 @@ app.config["PONY"] = {
|
|||||||
'create_db': True
|
'create_db': True
|
||||||
}
|
}
|
||||||
app.config["MAX_ROLL"] = 20
|
app.config["MAX_ROLL"] = 20
|
||||||
app.config["CACHE_TYPE"] = "SimpleCache"
|
app.config["CACHE_TYPE"] = "flask_caching.backends.SimpleCache"
|
||||||
|
app.config["JSON_AS_ASCII"] = False
|
||||||
app.config["HOST_ADDRESS"] = ""
|
app.config["HOST_ADDRESS"] = ""
|
||||||
app.config["ASSET_RIGHTS"] = False
|
|
||||||
|
|
||||||
cache = Cache()
|
cache = Cache(app)
|
||||||
Compress(app)
|
Compress(app)
|
||||||
|
|
||||||
|
|
||||||
@@ -84,6 +83,6 @@ def register():
|
|||||||
|
|
||||||
from WebHostLib.customserver import run_server_process
|
from WebHostLib.customserver import run_server_process
|
||||||
# to trigger app routing picking up on it
|
# to trigger app routing picking up on it
|
||||||
from . import tracker, upload, landing, check, generate, downloads, api, stats, misc, robots, options
|
from . import tracker, upload, landing, check, generate, downloads, api, stats, misc
|
||||||
|
|
||||||
app.register_blueprint(api.api_endpoints)
|
app.register_blueprint(api.api_endpoints)
|
||||||
|
|||||||
@@ -2,9 +2,8 @@
|
|||||||
from typing import List, Tuple
|
from typing import List, Tuple
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from flask import Blueprint, abort, url_for
|
from flask import Blueprint, abort
|
||||||
|
|
||||||
import worlds.Files
|
|
||||||
from .. import cache
|
from .. import cache
|
||||||
from ..models import Room, Seed
|
from ..models import Room, Seed
|
||||||
|
|
||||||
@@ -22,30 +21,12 @@ def room_info(room: UUID):
|
|||||||
room = Room.get(id=room)
|
room = Room.get(id=room)
|
||||||
if room is None:
|
if room is None:
|
||||||
return abort(404)
|
return abort(404)
|
||||||
|
|
||||||
def supports_apdeltapatch(game: str):
|
|
||||||
return game in worlds.Files.AutoPatchRegister.patch_types
|
|
||||||
downloads = []
|
|
||||||
for slot in sorted(room.seed.slots):
|
|
||||||
if slot.data and not supports_apdeltapatch(slot.game):
|
|
||||||
slot_download = {
|
|
||||||
"slot": slot.player_id,
|
|
||||||
"download": url_for("download_slot_file", room_id=room.id, player_id=slot.player_id)
|
|
||||||
}
|
|
||||||
downloads.append(slot_download)
|
|
||||||
elif slot.data:
|
|
||||||
slot_download = {
|
|
||||||
"slot": slot.player_id,
|
|
||||||
"download": url_for("download_patch", patch_id=slot.id, room_id=room.id)
|
|
||||||
}
|
|
||||||
downloads.append(slot_download)
|
|
||||||
return {
|
return {
|
||||||
"tracker": 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,
|
||||||
"timeout": room.timeout,
|
"timeout": room.timeout
|
||||||
"downloads": downloads,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -20,8 +20,8 @@ def generate_api():
|
|||||||
race = False
|
race = False
|
||||||
meta_options_source = {}
|
meta_options_source = {}
|
||||||
if 'file' in request.files:
|
if 'file' in request.files:
|
||||||
files = request.files.getlist('file')
|
file = request.files['file']
|
||||||
options = get_yaml_data(files)
|
options = get_yaml_data(file)
|
||||||
if isinstance(options, Markup):
|
if isinstance(options, Markup):
|
||||||
return {"text": options.striptags()}, 400
|
return {"text": options.striptags()}, 400
|
||||||
if isinstance(options, str):
|
if isinstance(options, str):
|
||||||
|
|||||||
@@ -3,25 +3,75 @@ from __future__ import annotations
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import multiprocessing
|
import multiprocessing
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
import typing
|
import typing
|
||||||
from datetime import timedelta, datetime
|
from datetime import timedelta, datetime
|
||||||
from threading import Event, Thread
|
|
||||||
from uuid import UUID
|
|
||||||
|
|
||||||
from pony.orm import db_session, select, commit
|
from pony.orm import db_session, select, commit
|
||||||
|
|
||||||
from Utils import restricted_loads
|
from Utils import restricted_loads
|
||||||
from .locker import Locker, AlreadyRunningException
|
|
||||||
|
|
||||||
_stop_event = Event()
|
|
||||||
|
|
||||||
|
|
||||||
def stop():
|
class CommonLocker():
|
||||||
"""Stops previously launched threads"""
|
"""Uses a file lock to signal that something is already running"""
|
||||||
global _stop_event
|
lock_folder = "file_locks"
|
||||||
stop_event = _stop_event
|
|
||||||
_stop_event = Event() # new event for new threads
|
def __init__(self, lockname: str, folder=None):
|
||||||
stop_event.set()
|
if folder:
|
||||||
|
self.lock_folder = folder
|
||||||
|
os.makedirs(self.lock_folder, exist_ok=True)
|
||||||
|
self.lockname = lockname
|
||||||
|
self.lockfile = os.path.join(self.lock_folder, f"{self.lockname}.lck")
|
||||||
|
|
||||||
|
|
||||||
|
class AlreadyRunningException(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
if sys.platform == 'win32':
|
||||||
|
class Locker(CommonLocker):
|
||||||
|
def __enter__(self):
|
||||||
|
try:
|
||||||
|
if os.path.exists(self.lockfile):
|
||||||
|
os.unlink(self.lockfile)
|
||||||
|
self.fp = os.open(
|
||||||
|
self.lockfile, os.O_CREAT | os.O_EXCL | os.O_RDWR)
|
||||||
|
except OSError as e:
|
||||||
|
raise AlreadyRunningException() from e
|
||||||
|
|
||||||
|
def __exit__(self, _type, value, tb):
|
||||||
|
fp = getattr(self, "fp", None)
|
||||||
|
if fp:
|
||||||
|
os.close(self.fp)
|
||||||
|
os.unlink(self.lockfile)
|
||||||
|
else: # unix
|
||||||
|
import fcntl
|
||||||
|
|
||||||
|
|
||||||
|
class Locker(CommonLocker):
|
||||||
|
def __enter__(self):
|
||||||
|
try:
|
||||||
|
self.fp = open(self.lockfile, "wb")
|
||||||
|
fcntl.flock(self.fp.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||||
|
except OSError as e:
|
||||||
|
raise AlreadyRunningException() from e
|
||||||
|
|
||||||
|
def __exit__(self, _type, value, tb):
|
||||||
|
fcntl.flock(self.fp.fileno(), fcntl.LOCK_UN)
|
||||||
|
self.fp.close()
|
||||||
|
|
||||||
|
|
||||||
|
def launch_room(room: Room, config: dict):
|
||||||
|
# requires db_session!
|
||||||
|
if room.last_activity >= datetime.utcnow() - timedelta(seconds=room.timeout):
|
||||||
|
multiworld = multiworlds.get(room.id, None)
|
||||||
|
if not multiworld:
|
||||||
|
multiworld = MultiworldInstance(room, config)
|
||||||
|
|
||||||
|
multiworld.start()
|
||||||
|
|
||||||
|
|
||||||
def handle_generation_success(seed_id):
|
def handle_generation_success(seed_id):
|
||||||
@@ -58,50 +108,29 @@ def init_db(pony_config: dict):
|
|||||||
db.generate_mapping()
|
db.generate_mapping()
|
||||||
|
|
||||||
|
|
||||||
def cleanup():
|
|
||||||
"""delete unowned user-content"""
|
|
||||||
with db_session:
|
|
||||||
# >>> bool(uuid.UUID(int=0))
|
|
||||||
# True
|
|
||||||
rooms = Room.select(lambda room: room.owner == UUID(int=0)).delete(bulk=True)
|
|
||||||
seeds = Seed.select(lambda seed: seed.owner == UUID(int=0) and not seed.rooms).delete(bulk=True)
|
|
||||||
slots = Slot.select(lambda slot: not slot.seed).delete(bulk=True)
|
|
||||||
# Command gets deleted by ponyorm Cascade Delete, as Room is Required
|
|
||||||
if rooms or seeds or slots:
|
|
||||||
logging.info(f"{rooms} Rooms, {seeds} Seeds and {slots} Slots have been deleted.")
|
|
||||||
|
|
||||||
|
|
||||||
def autohost(config: dict):
|
def autohost(config: dict):
|
||||||
def keep_running():
|
def keep_running():
|
||||||
stop_event = _stop_event
|
|
||||||
try:
|
try:
|
||||||
with Locker("autohost"):
|
with Locker("autohost"):
|
||||||
cleanup()
|
run_guardian()
|
||||||
hosters = []
|
while 1:
|
||||||
for x in range(config["HOSTERS"]):
|
time.sleep(0.1)
|
||||||
hoster = MultiworldInstance(config, x)
|
|
||||||
hosters.append(hoster)
|
|
||||||
hoster.start()
|
|
||||||
|
|
||||||
while not stop_event.wait(0.1):
|
|
||||||
with db_session:
|
with db_session:
|
||||||
rooms = select(
|
rooms = select(
|
||||||
room for room in Room if
|
room for room in Room if
|
||||||
room.last_activity >= datetime.utcnow() - timedelta(days=3))
|
room.last_activity >= datetime.utcnow() - timedelta(days=3))
|
||||||
for room in rooms:
|
for room in rooms:
|
||||||
# we have to filter twice, as the per-room timeout can't currently be PonyORM transpiled.
|
launch_room(room, config)
|
||||||
if room.last_activity >= datetime.utcnow() - timedelta(seconds=room.timeout + 5):
|
|
||||||
hosters[room.id.int % len(hosters)].start_room(room.id)
|
|
||||||
|
|
||||||
except AlreadyRunningException:
|
except AlreadyRunningException:
|
||||||
logging.info("Autohost reports as already running, not starting another.")
|
logging.info("Autohost reports as already running, not starting another.")
|
||||||
|
|
||||||
Thread(target=keep_running, name="AP_Autohost").start()
|
import threading
|
||||||
|
threading.Thread(target=keep_running, name="AP_Autohost").start()
|
||||||
|
|
||||||
|
|
||||||
def autogen(config: dict):
|
def autogen(config: dict):
|
||||||
def keep_running():
|
def keep_running():
|
||||||
stop_event = _stop_event
|
|
||||||
try:
|
try:
|
||||||
with Locker("autogen"):
|
with Locker("autogen"):
|
||||||
|
|
||||||
@@ -122,7 +151,8 @@ def autogen(config: dict):
|
|||||||
commit()
|
commit()
|
||||||
select(generation for generation in Generation if generation.state == STATE_ERROR).delete()
|
select(generation for generation in Generation if generation.state == STATE_ERROR).delete()
|
||||||
|
|
||||||
while not stop_event.wait(0.1):
|
while 1:
|
||||||
|
time.sleep(0.1)
|
||||||
with db_session:
|
with db_session:
|
||||||
# for update locks the database row(s) during transaction, preventing writes from elsewhere
|
# for update locks the database row(s) during transaction, preventing writes from elsewhere
|
||||||
to_start = select(
|
to_start = select(
|
||||||
@@ -133,45 +163,37 @@ def autogen(config: dict):
|
|||||||
except AlreadyRunningException:
|
except AlreadyRunningException:
|
||||||
logging.info("Autogen reports as already running, not starting another.")
|
logging.info("Autogen reports as already running, not starting another.")
|
||||||
|
|
||||||
Thread(target=keep_running, name="AP_Autogen").start()
|
import threading
|
||||||
|
threading.Thread(target=keep_running, name="AP_Autogen").start()
|
||||||
|
|
||||||
|
|
||||||
multiworlds: typing.Dict[type(Room.id), MultiworldInstance] = {}
|
multiworlds: typing.Dict[type(Room.id), MultiworldInstance] = {}
|
||||||
|
|
||||||
|
|
||||||
class MultiworldInstance():
|
class MultiworldInstance():
|
||||||
def __init__(self, config: dict, id: int):
|
def __init__(self, room: Room, config: dict):
|
||||||
self.room_ids = set()
|
self.room_id = room.id
|
||||||
self.process: typing.Optional[multiprocessing.Process] = None
|
self.process: typing.Optional[multiprocessing.Process] = None
|
||||||
|
with guardian_lock:
|
||||||
|
multiworlds[self.room_id] = self
|
||||||
self.ponyconfig = config["PONY"]
|
self.ponyconfig = config["PONY"]
|
||||||
self.cert = config["SELFLAUNCHCERT"]
|
self.cert = config["SELFLAUNCHCERT"]
|
||||||
self.key = config["SELFLAUNCHKEY"]
|
self.key = config["SELFLAUNCHKEY"]
|
||||||
self.host = config["HOST_ADDRESS"]
|
self.host = config["HOST_ADDRESS"]
|
||||||
self.rooms_to_start = multiprocessing.Queue()
|
|
||||||
self.rooms_shutting_down = multiprocessing.Queue()
|
|
||||||
self.name = f"MultiHoster{id}"
|
|
||||||
|
|
||||||
def start(self):
|
def start(self):
|
||||||
if self.process and self.process.is_alive():
|
if self.process and self.process.is_alive():
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
logging.info(f"Spinning up {self.room_id}")
|
||||||
process = multiprocessing.Process(group=None, target=run_server_process,
|
process = multiprocessing.Process(group=None, target=run_server_process,
|
||||||
args=(self.name, self.ponyconfig, get_static_server_data(),
|
args=(self.room_id, self.ponyconfig, get_static_server_data(),
|
||||||
self.cert, self.key, self.host,
|
self.cert, self.key, self.host),
|
||||||
self.rooms_to_start, self.rooms_shutting_down),
|
name="MultiHost")
|
||||||
name=self.name)
|
|
||||||
process.start()
|
process.start()
|
||||||
|
# bind after start to prevent thread sync issues with guardian.
|
||||||
self.process = process
|
self.process = process
|
||||||
|
|
||||||
def start_room(self, room_id):
|
|
||||||
while not self.rooms_shutting_down.empty():
|
|
||||||
self.room_ids.remove(self.rooms_shutting_down.get(block=True, timeout=None))
|
|
||||||
if room_id in self.room_ids:
|
|
||||||
pass # should already be hosted currently.
|
|
||||||
else:
|
|
||||||
self.room_ids.add(room_id)
|
|
||||||
self.rooms_to_start.put(room_id)
|
|
||||||
|
|
||||||
def stop(self):
|
def stop(self):
|
||||||
if self.process:
|
if self.process:
|
||||||
self.process.terminate()
|
self.process.terminate()
|
||||||
@@ -185,6 +207,40 @@ class MultiworldInstance():
|
|||||||
self.process = None
|
self.process = None
|
||||||
|
|
||||||
|
|
||||||
from .models import Room, Generation, STATE_QUEUED, STATE_STARTED, STATE_ERROR, db, Seed, Slot
|
guardian = None
|
||||||
|
guardian_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def run_guardian():
|
||||||
|
global guardian
|
||||||
|
global multiworlds
|
||||||
|
with guardian_lock:
|
||||||
|
if not guardian:
|
||||||
|
try:
|
||||||
|
import resource
|
||||||
|
except ModuleNotFoundError:
|
||||||
|
pass # unix only module
|
||||||
|
else:
|
||||||
|
# Each Server is another file handle, so request as many as we can from the system
|
||||||
|
file_limit = resource.getrlimit(resource.RLIMIT_NOFILE)[1]
|
||||||
|
# set soft limit to hard limit
|
||||||
|
resource.setrlimit(resource.RLIMIT_NOFILE, (file_limit, file_limit))
|
||||||
|
|
||||||
|
def guard():
|
||||||
|
while 1:
|
||||||
|
time.sleep(1)
|
||||||
|
done = []
|
||||||
|
with guardian_lock:
|
||||||
|
for key, instance in multiworlds.items():
|
||||||
|
if instance.done():
|
||||||
|
instance.collect()
|
||||||
|
done.append(key)
|
||||||
|
for key in done:
|
||||||
|
del (multiworlds[key])
|
||||||
|
|
||||||
|
guardian = threading.Thread(name="Guardian", target=guard)
|
||||||
|
|
||||||
|
|
||||||
|
from .models import Room, Generation, STATE_QUEUED, STATE_STARTED, STATE_ERROR, db, Seed
|
||||||
from .customserver import run_server_process, get_static_server_data
|
from .customserver import run_server_process, get_static_server_data
|
||||||
from .generate import gen_game
|
from .generate import gen_game
|
||||||
|
|||||||
@@ -1,13 +1,17 @@
|
|||||||
import os
|
|
||||||
import zipfile
|
import zipfile
|
||||||
import base64
|
from typing import *
|
||||||
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
|
||||||
|
|
||||||
from WebHostLib import app
|
from WebHostLib import app
|
||||||
from WebHostLib.upload import allowed_options, allowed_options_extensions, banned_file
|
|
||||||
|
banned_zip_contents = (".sfc",)
|
||||||
|
|
||||||
|
|
||||||
|
def allowed_file(filename):
|
||||||
|
return filename.endswith(('.txt', ".yaml", ".zip"))
|
||||||
|
|
||||||
|
|
||||||
from Generate import roll_settings, PlandoOptions
|
from Generate import roll_settings, PlandoOptions
|
||||||
from Utils import parse_yamls
|
from Utils import parse_yamls
|
||||||
@@ -20,21 +24,13 @@ def check():
|
|||||||
if 'file' not in request.files:
|
if 'file' not in request.files:
|
||||||
flash('No file part')
|
flash('No file part')
|
||||||
else:
|
else:
|
||||||
files = request.files.getlist('file')
|
file = request.files['file']
|
||||||
options = get_yaml_data(files)
|
options = get_yaml_data(file)
|
||||||
if isinstance(options, str):
|
if isinstance(options, str):
|
||||||
flash(options)
|
flash(options)
|
||||||
else:
|
else:
|
||||||
results, _ = roll_options(options)
|
results, _ = roll_options(options)
|
||||||
if len(options) > 1:
|
return render_template("checkResult.html", results=results)
|
||||||
# offer combined file back
|
|
||||||
combined_yaml = "\n---\n".join(f"# original filename: {file_name}\n{file_content.decode('utf-8-sig')}"
|
|
||||||
for file_name, file_content in options.items())
|
|
||||||
combined_yaml = base64.b64encode(combined_yaml.encode("utf-8-sig")).decode()
|
|
||||||
else:
|
|
||||||
combined_yaml = ""
|
|
||||||
return render_template("checkResult.html",
|
|
||||||
results=results, combined_yaml=combined_yaml)
|
|
||||||
return render_template("check.html")
|
return render_template("check.html")
|
||||||
|
|
||||||
|
|
||||||
@@ -43,44 +39,32 @@ def mysterycheck():
|
|||||||
return redirect(url_for("check"), 301)
|
return redirect(url_for("check"), 301)
|
||||||
|
|
||||||
|
|
||||||
def get_yaml_data(files) -> Union[Dict[str, str], str, Markup]:
|
def get_yaml_data(file) -> Union[Dict[str, str], str, Markup]:
|
||||||
options = {}
|
options = {}
|
||||||
for uploaded_file in files:
|
# if user does not select file, browser also
|
||||||
if banned_file(uploaded_file.filename):
|
# submit an empty part without filename
|
||||||
return ("Uploaded data contained a rom file, which is likely to contain copyrighted material. "
|
if file.filename == '':
|
||||||
"Your file was deleted.")
|
return 'No selected file'
|
||||||
# If the user does not select file, the browser will still submit an empty string without a file name.
|
elif file and allowed_file(file.filename):
|
||||||
elif uploaded_file.filename == "":
|
if file.filename.endswith(".zip"):
|
||||||
return "No selected file."
|
|
||||||
elif uploaded_file.filename in options:
|
|
||||||
return f"Conflicting files named {uploaded_file.filename} submitted."
|
|
||||||
elif uploaded_file and allowed_options(uploaded_file.filename):
|
|
||||||
if uploaded_file.filename.endswith(".zip"):
|
|
||||||
if not zipfile.is_zipfile(uploaded_file):
|
|
||||||
return f"Uploaded file {uploaded_file.filename} is not a valid .zip file and cannot be opened."
|
|
||||||
|
|
||||||
uploaded_file.seek(0) # offset from is_zipfile check
|
with zipfile.ZipFile(file, 'r') as zfile:
|
||||||
with zipfile.ZipFile(uploaded_file, "r") as zfile:
|
infolist = zfile.infolist()
|
||||||
for file in zfile.infolist():
|
|
||||||
# Remove folder pathing from str (e.g. "__MACOSX/" folder paths from archives created by macOS).
|
|
||||||
base_filename = os.path.basename(file.filename)
|
|
||||||
|
|
||||||
if base_filename.endswith(".archipelago"):
|
if any(file.filename.endswith(".archipelago") for file in infolist):
|
||||||
return Markup("Error: Your .zip file contains an .archipelago file. "
|
return Markup("Error: Your .zip file contains an .archipelago file. "
|
||||||
'Did you mean to <a href="/uploads">host a game</a>?')
|
'Did you mean to <a href="/uploads">host a game</a>?')
|
||||||
elif base_filename.endswith(".zip"):
|
|
||||||
return "Nested .zip files inside a .zip are not supported."
|
|
||||||
elif banned_file(base_filename):
|
|
||||||
return ("Uploaded data contained a rom file, which is likely to contain copyrighted "
|
|
||||||
"material. Your file was deleted.")
|
|
||||||
# Ignore dot-files.
|
|
||||||
elif not base_filename.startswith(".") and allowed_options(base_filename):
|
|
||||||
options[file.filename] = zfile.open(file, "r").read()
|
|
||||||
else:
|
|
||||||
options[uploaded_file.filename] = uploaded_file.read()
|
|
||||||
|
|
||||||
|
for file in infolist:
|
||||||
|
if file.filename.endswith(banned_zip_contents):
|
||||||
|
return "Uploaded data contained a rom file, which is likely to contain copyrighted material. " \
|
||||||
|
"Your file was deleted."
|
||||||
|
elif file.filename.endswith((".yaml", ".json", ".yml", ".txt")):
|
||||||
|
options[file.filename] = zfile.open(file, "r").read()
|
||||||
|
else:
|
||||||
|
options = {file.filename: file.read()}
|
||||||
if not options:
|
if not options:
|
||||||
return f"Did not find any valid files to process. Accepted formats: {allowed_options_extensions}"
|
return "Did not find a .yaml file to process."
|
||||||
return options
|
return options
|
||||||
|
|
||||||
|
|
||||||
@@ -108,10 +92,7 @@ def roll_options(options: Dict[str, Union[dict, str]],
|
|||||||
rolled_results[f"{filename}/{i + 1}"] = roll_settings(yaml_data,
|
rolled_results[f"{filename}/{i + 1}"] = roll_settings(yaml_data,
|
||||||
plando_options=plando_options)
|
plando_options=plando_options)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
if e.__cause__:
|
results[filename] = f"Failed to generate options in {filename}: {e}"
|
||||||
results[filename] = f"Failed to generate options in {filename}: {e} - {e.__cause__}"
|
|
||||||
else:
|
|
||||||
results[filename] = f"Failed to generate options in {filename}: {e}"
|
|
||||||
else:
|
else:
|
||||||
results[filename] = True
|
results[filename] = True
|
||||||
return results, rolled_results
|
return results, rolled_results
|
||||||
|
|||||||
@@ -5,14 +5,12 @@ import collections
|
|||||||
import datetime
|
import datetime
|
||||||
import functools
|
import functools
|
||||||
import logging
|
import logging
|
||||||
import multiprocessing
|
|
||||||
import pickle
|
import pickle
|
||||||
import random
|
import random
|
||||||
import socket
|
import socket
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
import typing
|
import typing
|
||||||
import sys
|
|
||||||
|
|
||||||
import websockets
|
import websockets
|
||||||
from pony.orm import commit, db_session, select
|
from pony.orm import commit, db_session, select
|
||||||
@@ -21,17 +19,14 @@ import Utils
|
|||||||
|
|
||||||
from MultiServer import Context, server, auto_shutdown, ServerCommandProcessor, ClientMessageProcessor, load_server_cert
|
from MultiServer import Context, server, auto_shutdown, ServerCommandProcessor, ClientMessageProcessor, load_server_cert
|
||||||
from Utils import restricted_loads, cache_argsless
|
from Utils import restricted_loads, cache_argsless
|
||||||
from .locker import Locker
|
|
||||||
from .models import Command, GameDataPackage, Room, db
|
from .models import Command, GameDataPackage, Room, db
|
||||||
|
|
||||||
|
|
||||||
class CustomClientMessageProcessor(ClientMessageProcessor):
|
class CustomClientMessageProcessor(ClientMessageProcessor):
|
||||||
ctx: WebHostContext
|
ctx: WebHostContext
|
||||||
|
|
||||||
def _cmd_video(self, platform: str, user: str):
|
def _cmd_video(self, platform, user):
|
||||||
"""Set a link for your name in the WebHostLib tracker pointing to a video stream.
|
"""Set a link for your name in the WebHostLib tracker pointing to a video stream"""
|
||||||
Currently, only YouTube and Twitch platforms are supported.
|
|
||||||
"""
|
|
||||||
if platform.lower().startswith("t"): # twitch
|
if platform.lower().startswith("t"): # twitch
|
||||||
self.ctx.video[self.client.team, self.client.slot] = "Twitch", user
|
self.ctx.video[self.client.team, self.client.slot] = "Twitch", user
|
||||||
self.ctx.save()
|
self.ctx.save()
|
||||||
@@ -54,19 +49,17 @@ del MultiServer
|
|||||||
|
|
||||||
class DBCommandProcessor(ServerCommandProcessor):
|
class DBCommandProcessor(ServerCommandProcessor):
|
||||||
def output(self, text: str):
|
def output(self, text: str):
|
||||||
self.ctx.logger.info(text)
|
logging.info(text)
|
||||||
|
|
||||||
|
|
||||||
class WebHostContext(Context):
|
class WebHostContext(Context):
|
||||||
room_id: int
|
room_id: int
|
||||||
|
|
||||||
def __init__(self, static_server_data: dict, logger: logging.Logger):
|
def __init__(self, static_server_data: dict):
|
||||||
# static server data is used during _load_game_data to load required data,
|
# static server data is used during _load_game_data to load required data,
|
||||||
# without needing to import worlds system, which takes quite a bit of memory
|
# without needing to import worlds system, which takes quite a bit of memory
|
||||||
self.static_server_data = static_server_data
|
self.static_server_data = static_server_data
|
||||||
super(WebHostContext, self).__init__("", 0, "", "", 1,
|
super(WebHostContext, self).__init__("", 0, "", "", 1, 40, True, "enabled", "enabled", "enabled", 0, 2)
|
||||||
40, True, "enabled", "enabled",
|
|
||||||
"enabled", 0, 2, logger=logger)
|
|
||||||
del self.static_server_data
|
del self.static_server_data
|
||||||
self.main_loop = asyncio.get_running_loop()
|
self.main_loop = asyncio.get_running_loop()
|
||||||
self.video = {}
|
self.video = {}
|
||||||
@@ -74,7 +67,6 @@ class WebHostContext(Context):
|
|||||||
|
|
||||||
def _load_game_data(self):
|
def _load_game_data(self):
|
||||||
for key, value in self.static_server_data.items():
|
for key, value in self.static_server_data.items():
|
||||||
# NOTE: attributes are mutable and shared, so they will have to be copied before being modified
|
|
||||||
setattr(self, key, value)
|
setattr(self, key, value)
|
||||||
self.non_hintable_names = collections.defaultdict(frozenset, self.non_hintable_names)
|
self.non_hintable_names = collections.defaultdict(frozenset, self.non_hintable_names)
|
||||||
|
|
||||||
@@ -102,37 +94,18 @@ class WebHostContext(Context):
|
|||||||
|
|
||||||
multidata = self.decompress(room.seed.multidata)
|
multidata = self.decompress(room.seed.multidata)
|
||||||
game_data_packages = {}
|
game_data_packages = {}
|
||||||
|
|
||||||
static_gamespackage = self.gamespackage # this is shared across all rooms
|
|
||||||
static_item_name_groups = self.item_name_groups
|
|
||||||
static_location_name_groups = self.location_name_groups
|
|
||||||
self.gamespackage = {"Archipelago": static_gamespackage["Archipelago"]} # this may be modified by _load
|
|
||||||
self.item_name_groups = {}
|
|
||||||
self.location_name_groups = {}
|
|
||||||
|
|
||||||
for game in list(multidata.get("datapackage", {})):
|
for game in list(multidata.get("datapackage", {})):
|
||||||
game_data = multidata["datapackage"][game]
|
game_data = multidata["datapackage"][game]
|
||||||
if "checksum" in game_data:
|
if "checksum" in game_data:
|
||||||
if static_gamespackage.get(game, {}).get("checksum") == game_data["checksum"]:
|
if self.gamespackage.get(game, {}).get("checksum") == game_data["checksum"]:
|
||||||
# non-custom. remove from multidata and use static data
|
# non-custom. remove from multidata
|
||||||
# games package could be dropped from static data once all rooms embed data package
|
# games package could be dropped from static data once all rooms embed data package
|
||||||
del multidata["datapackage"][game]
|
del multidata["datapackage"][game]
|
||||||
else:
|
else:
|
||||||
row = GameDataPackage.get(checksum=game_data["checksum"])
|
row = GameDataPackage.get(checksum=game_data["checksum"])
|
||||||
if row: # None if rolled on >= 0.3.9 but uploaded to <= 0.3.8. multidata should be complete
|
if row: # None if rolled on >= 0.3.9 but uploaded to <= 0.3.8. multidata should be complete
|
||||||
game_data_packages[game] = Utils.restricted_loads(row.data)
|
game_data_packages[game] = Utils.restricted_loads(row.data)
|
||||||
continue
|
|
||||||
else:
|
|
||||||
self.logger.warning(f"Did not find game_data_package for {game}: {game_data['checksum']}")
|
|
||||||
self.gamespackage[game] = static_gamespackage.get(game, {})
|
|
||||||
self.item_name_groups[game] = static_item_name_groups.get(game, {})
|
|
||||||
self.location_name_groups[game] = static_location_name_groups.get(game, {})
|
|
||||||
|
|
||||||
if not game_data_packages:
|
|
||||||
# all static -> use the static dicts directly
|
|
||||||
self.gamespackage = static_gamespackage
|
|
||||||
self.item_name_groups = static_item_name_groups
|
|
||||||
self.location_name_groups = static_location_name_groups
|
|
||||||
return self._load(multidata, game_data_packages, True)
|
return self._load(multidata, game_data_packages, True)
|
||||||
|
|
||||||
@db_session
|
@db_session
|
||||||
@@ -142,7 +115,7 @@ class WebHostContext(Context):
|
|||||||
savegame_data = Room.get(id=self.room_id).multisave
|
savegame_data = Room.get(id=self.room_id).multisave
|
||||||
if savegame_data:
|
if savegame_data:
|
||||||
self.set_save(restricted_loads(Room.get(id=self.room_id).multisave))
|
self.set_save(restricted_loads(Room.get(id=self.room_id).multisave))
|
||||||
self._start_async_saving(atexit_save=False)
|
self._start_async_saving()
|
||||||
threading.Thread(target=self.listen_to_db_commands, daemon=True).start()
|
threading.Thread(target=self.listen_to_db_commands, daemon=True).start()
|
||||||
|
|
||||||
@db_session
|
@db_session
|
||||||
@@ -182,121 +155,62 @@ def get_static_server_data() -> dict:
|
|||||||
return data
|
return data
|
||||||
|
|
||||||
|
|
||||||
def set_up_logging(room_id) -> logging.Logger:
|
def run_server_process(room_id, ponyconfig: dict, static_server_data: dict,
|
||||||
import os
|
|
||||||
# logger setup
|
|
||||||
logger = logging.getLogger(f"RoomLogger {room_id}")
|
|
||||||
|
|
||||||
# this *should* be empty, but just in case.
|
|
||||||
for handler in logger.handlers[:]:
|
|
||||||
logger.removeHandler(handler)
|
|
||||||
handler.close()
|
|
||||||
|
|
||||||
file_handler = logging.FileHandler(
|
|
||||||
os.path.join(Utils.user_path("logs"), f"{room_id}.txt"),
|
|
||||||
"a",
|
|
||||||
encoding="utf-8-sig")
|
|
||||||
file_handler.setFormatter(logging.Formatter("[%(asctime)s]: %(message)s"))
|
|
||||||
logger.setLevel(logging.INFO)
|
|
||||||
logger.addHandler(file_handler)
|
|
||||||
return logger
|
|
||||||
|
|
||||||
|
|
||||||
def run_server_process(name: str, ponyconfig: dict, static_server_data: dict,
|
|
||||||
cert_file: typing.Optional[str], cert_key_file: typing.Optional[str],
|
cert_file: typing.Optional[str], cert_key_file: typing.Optional[str],
|
||||||
host: str, rooms_to_run: multiprocessing.Queue, rooms_shutting_down: multiprocessing.Queue):
|
host: str):
|
||||||
Utils.init_logging(name)
|
|
||||||
try:
|
|
||||||
import resource
|
|
||||||
except ModuleNotFoundError:
|
|
||||||
pass # unix only module
|
|
||||||
else:
|
|
||||||
# Each Server is another file handle, so request as many as we can from the system
|
|
||||||
file_limit = resource.getrlimit(resource.RLIMIT_NOFILE)[1]
|
|
||||||
# set soft limit to hard limit
|
|
||||||
resource.setrlimit(resource.RLIMIT_NOFILE, (file_limit, file_limit))
|
|
||||||
del resource, file_limit
|
|
||||||
|
|
||||||
# establish DB connection for multidata and multisave
|
# establish DB connection for multidata and multisave
|
||||||
db.bind(**ponyconfig)
|
db.bind(**ponyconfig)
|
||||||
db.generate_mapping(check_tables=False)
|
db.generate_mapping(check_tables=False)
|
||||||
|
|
||||||
if "worlds" in sys.modules:
|
async def main():
|
||||||
raise Exception("Worlds system should not be loaded in the custom server.")
|
Utils.init_logging(str(room_id), write_mode="a")
|
||||||
|
ctx = WebHostContext(static_server_data)
|
||||||
|
ctx.load(room_id)
|
||||||
|
ctx.init_save()
|
||||||
|
ssl_context = load_server_cert(cert_file, cert_key_file) if cert_file else None
|
||||||
|
try:
|
||||||
|
ctx.server = websockets.serve(functools.partial(server, ctx=ctx), ctx.host, ctx.port, ssl=ssl_context)
|
||||||
|
|
||||||
import gc
|
await ctx.server
|
||||||
ssl_context = load_server_cert(cert_file, cert_key_file) if cert_file else None
|
except Exception: # likely port in use - in windows this is OSError, but I didn't check the others
|
||||||
del cert_file, cert_key_file, ponyconfig
|
ctx.server = websockets.serve(functools.partial(server, ctx=ctx), ctx.host, 0, ssl=ssl_context)
|
||||||
gc.collect() # free intermediate objects used during setup
|
|
||||||
|
|
||||||
loop = asyncio.get_event_loop()
|
await ctx.server
|
||||||
|
port = 0
|
||||||
|
for wssocket in ctx.server.ws_server.sockets:
|
||||||
|
socketname = wssocket.getsockname()
|
||||||
|
if wssocket.family == socket.AF_INET6:
|
||||||
|
# Prefer IPv4, as most users seem to not have working ipv6 support
|
||||||
|
if not port:
|
||||||
|
port = socketname[1]
|
||||||
|
elif wssocket.family == socket.AF_INET:
|
||||||
|
port = socketname[1]
|
||||||
|
if port:
|
||||||
|
logging.info(f'Hosting game at {host}:{port}')
|
||||||
|
with db_session:
|
||||||
|
room = Room.get(id=ctx.room_id)
|
||||||
|
room.last_port = port
|
||||||
|
else:
|
||||||
|
logging.exception("Could not determine port. Likely hosting failure.")
|
||||||
|
with db_session:
|
||||||
|
ctx.auto_shutdown = Room.get(id=room_id).timeout
|
||||||
|
ctx.shutdown_task = asyncio.create_task(auto_shutdown(ctx, []))
|
||||||
|
await ctx.shutdown_task
|
||||||
|
logging.info("Shutting down")
|
||||||
|
|
||||||
async def start_room(room_id):
|
from .autolauncher import Locker
|
||||||
with Locker(f"RoomLocker {room_id}"):
|
with Locker(room_id):
|
||||||
try:
|
try:
|
||||||
logger = set_up_logging(room_id)
|
asyncio.run(main())
|
||||||
ctx = WebHostContext(static_server_data, logger)
|
except KeyboardInterrupt:
|
||||||
ctx.load(room_id)
|
with db_session:
|
||||||
ctx.init_save()
|
room = Room.get(id=room_id)
|
||||||
try:
|
# ensure the Room does not spin up again on its own, minute of safety buffer
|
||||||
ctx.server = websockets.serve(
|
room.last_activity = datetime.datetime.utcnow() - datetime.timedelta(minutes=1, seconds=room.timeout)
|
||||||
functools.partial(server, ctx=ctx), ctx.host, ctx.port, ssl=ssl_context)
|
except:
|
||||||
|
with db_session:
|
||||||
await ctx.server
|
room = Room.get(id=room_id)
|
||||||
except OSError: # likely port in use
|
room.last_port = -1
|
||||||
ctx.server = websockets.serve(
|
# ensure the Room does not spin up again on its own, minute of safety buffer
|
||||||
functools.partial(server, ctx=ctx), ctx.host, 0, ssl=ssl_context)
|
room.last_activity = datetime.datetime.utcnow() - datetime.timedelta(minutes=1, seconds=room.timeout)
|
||||||
|
raise
|
||||||
await ctx.server
|
|
||||||
port = 0
|
|
||||||
for wssocket in ctx.server.ws_server.sockets:
|
|
||||||
socketname = wssocket.getsockname()
|
|
||||||
if wssocket.family == socket.AF_INET6:
|
|
||||||
# Prefer IPv4, as most users seem to not have working ipv6 support
|
|
||||||
if not port:
|
|
||||||
port = socketname[1]
|
|
||||||
elif wssocket.family == socket.AF_INET:
|
|
||||||
port = socketname[1]
|
|
||||||
if port:
|
|
||||||
ctx.logger.info(f'Hosting game at {host}:{port}')
|
|
||||||
with db_session:
|
|
||||||
room = Room.get(id=ctx.room_id)
|
|
||||||
room.last_port = port
|
|
||||||
else:
|
|
||||||
ctx.logger.exception("Could not determine port. Likely hosting failure.")
|
|
||||||
with db_session:
|
|
||||||
ctx.auto_shutdown = Room.get(id=room_id).timeout
|
|
||||||
ctx.shutdown_task = asyncio.create_task(auto_shutdown(ctx, []))
|
|
||||||
await ctx.shutdown_task
|
|
||||||
|
|
||||||
except (KeyboardInterrupt, SystemExit):
|
|
||||||
pass
|
|
||||||
except Exception:
|
|
||||||
with db_session:
|
|
||||||
room = Room.get(id=room_id)
|
|
||||||
room.last_port = -1
|
|
||||||
raise
|
|
||||||
finally:
|
|
||||||
try:
|
|
||||||
ctx._save()
|
|
||||||
with (db_session):
|
|
||||||
# ensure the Room does not spin up again on its own, minute of safety buffer
|
|
||||||
room = Room.get(id=room_id)
|
|
||||||
room.last_activity = datetime.datetime.utcnow() - \
|
|
||||||
datetime.timedelta(minutes=1, seconds=room.timeout)
|
|
||||||
logging.info(f"Shutting down room {room_id} on {name}.")
|
|
||||||
finally:
|
|
||||||
await asyncio.sleep(5)
|
|
||||||
rooms_shutting_down.put(room_id)
|
|
||||||
|
|
||||||
class Starter(threading.Thread):
|
|
||||||
def run(self):
|
|
||||||
while 1:
|
|
||||||
next_room = rooms_to_run.get(block=True, timeout=None)
|
|
||||||
asyncio.run_coroutine_threadsafe(start_room(next_room), loop)
|
|
||||||
logging.info(f"Starting room {next_room} on {name}.")
|
|
||||||
|
|
||||||
starter = Starter()
|
|
||||||
starter.daemon = True
|
|
||||||
starter.start()
|
|
||||||
loop.run_forever()
|
|
||||||
|
|||||||
@@ -90,8 +90,6 @@ def download_slot_file(room_id, player_id: int):
|
|||||||
fname = f"AP_{app.jinja_env.filters['suuid'](room_id)}.json"
|
fname = f"AP_{app.jinja_env.filters['suuid'](room_id)}.json"
|
||||||
elif slot_data.game == "Kingdom Hearts 2":
|
elif slot_data.game == "Kingdom Hearts 2":
|
||||||
fname = f"AP_{app.jinja_env.filters['suuid'](room_id)}_P{slot_data.player_id}_{slot_data.player_name}.zip"
|
fname = f"AP_{app.jinja_env.filters['suuid'](room_id)}_P{slot_data.player_id}_{slot_data.player_name}.zip"
|
||||||
elif slot_data.game == "Final Fantasy Mystic Quest":
|
|
||||||
fname = f"AP+{app.jinja_env.filters['suuid'](room_id)}_P{slot_data.player_id}_{slot_data.player_name}.apmq"
|
|
||||||
else:
|
else:
|
||||||
return "Game download not supported."
|
return "Game download not supported."
|
||||||
return send_file(io.BytesIO(slot_data.data), as_attachment=True, download_name=fname)
|
return send_file(io.BytesIO(slot_data.data), as_attachment=True, download_name=fname)
|
||||||
|
|||||||
@@ -1,18 +1,18 @@
|
|||||||
import concurrent.futures
|
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import pickle
|
import pickle
|
||||||
import random
|
import random
|
||||||
import tempfile
|
import tempfile
|
||||||
import zipfile
|
import zipfile
|
||||||
|
import concurrent.futures
|
||||||
from collections import Counter
|
from collections import Counter
|
||||||
from typing import Any, Dict, List, Optional, Union
|
from typing import Dict, Optional, Any, Union, List
|
||||||
|
|
||||||
from flask import flash, redirect, render_template, request, session, url_for
|
from flask import request, flash, redirect, url_for, session, render_template
|
||||||
from pony.orm import commit, db_session
|
from pony.orm import commit, db_session
|
||||||
|
|
||||||
from BaseClasses import get_seed, seeddigits
|
from BaseClasses import seeddigits, get_seed
|
||||||
from Generate import PlandoOptions, handle_name
|
from Generate import handle_name, PlandoOptions
|
||||||
from Main import main as ERmain
|
from Main import main as ERmain
|
||||||
from Utils import __version__
|
from Utils import __version__
|
||||||
from WebHostLib import app
|
from WebHostLib import app
|
||||||
@@ -64,47 +64,43 @@ def generate(race=False):
|
|||||||
if 'file' not in request.files:
|
if 'file' not in request.files:
|
||||||
flash('No file part')
|
flash('No file part')
|
||||||
else:
|
else:
|
||||||
files = request.files.getlist('file')
|
file = request.files['file']
|
||||||
options = get_yaml_data(files)
|
options = get_yaml_data(file)
|
||||||
if isinstance(options, str):
|
if isinstance(options, str):
|
||||||
flash(options)
|
flash(options)
|
||||||
else:
|
else:
|
||||||
meta = get_meta(request.form, race)
|
meta = get_meta(request.form, race)
|
||||||
return start_generation(options, meta)
|
results, gen_options = roll_options(options, set(meta["plando_options"]))
|
||||||
|
|
||||||
|
if any(type(result) == str for result in results.values()):
|
||||||
|
return render_template("checkResult.html", results=results)
|
||||||
|
elif len(gen_options) > app.config["MAX_ROLL"]:
|
||||||
|
flash(f"Sorry, generating of multiworlds is limited to {app.config['MAX_ROLL']} players. "
|
||||||
|
f"If you have a larger group, please generate it yourself and upload it.")
|
||||||
|
elif len(gen_options) >= app.config["JOB_THRESHOLD"]:
|
||||||
|
gen = Generation(
|
||||||
|
options=pickle.dumps({name: vars(options) for name, options in gen_options.items()}),
|
||||||
|
# convert to json compatible
|
||||||
|
meta=json.dumps(meta),
|
||||||
|
state=STATE_QUEUED,
|
||||||
|
owner=session["_id"])
|
||||||
|
commit()
|
||||||
|
|
||||||
|
return redirect(url_for("wait_seed", seed=gen.id))
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
seed_id = gen_game({name: vars(options) for name, options in gen_options.items()},
|
||||||
|
meta=meta, owner=session["_id"].int)
|
||||||
|
except BaseException as e:
|
||||||
|
from .autolauncher import handle_generation_failure
|
||||||
|
handle_generation_failure(e)
|
||||||
|
return render_template("seedError.html", seed_error=(e.__class__.__name__ + ": " + str(e)))
|
||||||
|
|
||||||
|
return redirect(url_for("view_seed", seed=seed_id))
|
||||||
|
|
||||||
return render_template("generate.html", race=race, version=__version__)
|
return render_template("generate.html", race=race, version=__version__)
|
||||||
|
|
||||||
|
|
||||||
def start_generation(options: Dict[str, Union[dict, str]], meta: Dict[str, Any]):
|
|
||||||
results, gen_options = roll_options(options, set(meta["plando_options"]))
|
|
||||||
|
|
||||||
if any(type(result) == str for result in results.values()):
|
|
||||||
return render_template("checkResult.html", results=results)
|
|
||||||
elif len(gen_options) > app.config["MAX_ROLL"]:
|
|
||||||
flash(f"Sorry, generating of multiworlds is limited to {app.config['MAX_ROLL']} players. "
|
|
||||||
f"If you have a larger group, please generate it yourself and upload it.")
|
|
||||||
elif len(gen_options) >= app.config["JOB_THRESHOLD"]:
|
|
||||||
gen = Generation(
|
|
||||||
options=pickle.dumps({name: vars(options) for name, options in gen_options.items()}),
|
|
||||||
# convert to json compatible
|
|
||||||
meta=json.dumps(meta),
|
|
||||||
state=STATE_QUEUED,
|
|
||||||
owner=session["_id"])
|
|
||||||
commit()
|
|
||||||
|
|
||||||
return redirect(url_for("wait_seed", seed=gen.id))
|
|
||||||
else:
|
|
||||||
try:
|
|
||||||
seed_id = gen_game({name: vars(options) for name, options in gen_options.items()},
|
|
||||||
meta=meta, owner=session["_id"].int)
|
|
||||||
except BaseException as e:
|
|
||||||
from .autolauncher import handle_generation_failure
|
|
||||||
handle_generation_failure(e)
|
|
||||||
return render_template("seedError.html", seed_error=(e.__class__.__name__ + ": " + str(e)))
|
|
||||||
|
|
||||||
return redirect(url_for("view_seed", seed=seed_id))
|
|
||||||
|
|
||||||
|
|
||||||
def gen_game(gen_options: dict, meta: Optional[Dict[str, Any]] = None, owner=None, sid=None):
|
def gen_game(gen_options: dict, meta: Optional[Dict[str, Any]] = None, owner=None, sid=None):
|
||||||
if not meta:
|
if not meta:
|
||||||
meta: Dict[str, Any] = {}
|
meta: Dict[str, Any] = {}
|
||||||
@@ -135,7 +131,6 @@ def gen_game(gen_options: dict, meta: Optional[Dict[str, Any]] = None, owner=Non
|
|||||||
erargs.plando_options = PlandoOptions.from_set(meta.setdefault("plando_options",
|
erargs.plando_options = PlandoOptions.from_set(meta.setdefault("plando_options",
|
||||||
{"bosses", "items", "connections", "texts"}))
|
{"bosses", "items", "connections", "texts"}))
|
||||||
erargs.skip_prog_balancing = False
|
erargs.skip_prog_balancing = False
|
||||||
erargs.skip_output = False
|
|
||||||
|
|
||||||
name_counter = Counter()
|
name_counter = Counter()
|
||||||
for player, (playerfile, settings) in enumerate(gen_options.items(), 1):
|
for player, (playerfile, settings) in enumerate(gen_options.items(), 1):
|
||||||
|
|||||||
@@ -1,51 +0,0 @@
|
|||||||
import os
|
|
||||||
import sys
|
|
||||||
|
|
||||||
|
|
||||||
class CommonLocker:
|
|
||||||
"""Uses a file lock to signal that something is already running"""
|
|
||||||
lock_folder = "file_locks"
|
|
||||||
|
|
||||||
def __init__(self, lockname: str, folder=None):
|
|
||||||
if folder:
|
|
||||||
self.lock_folder = folder
|
|
||||||
os.makedirs(self.lock_folder, exist_ok=True)
|
|
||||||
self.lockname = lockname
|
|
||||||
self.lockfile = os.path.join(self.lock_folder, f"{self.lockname}.lck")
|
|
||||||
|
|
||||||
|
|
||||||
class AlreadyRunningException(Exception):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
if sys.platform == 'win32':
|
|
||||||
class Locker(CommonLocker):
|
|
||||||
def __enter__(self):
|
|
||||||
try:
|
|
||||||
if os.path.exists(self.lockfile):
|
|
||||||
os.unlink(self.lockfile)
|
|
||||||
self.fp = os.open(
|
|
||||||
self.lockfile, os.O_CREAT | os.O_EXCL | os.O_RDWR)
|
|
||||||
except OSError as e:
|
|
||||||
raise AlreadyRunningException() from e
|
|
||||||
|
|
||||||
def __exit__(self, _type, value, tb):
|
|
||||||
fp = getattr(self, "fp", None)
|
|
||||||
if fp:
|
|
||||||
os.close(self.fp)
|
|
||||||
os.unlink(self.lockfile)
|
|
||||||
else: # unix
|
|
||||||
import fcntl
|
|
||||||
|
|
||||||
|
|
||||||
class Locker(CommonLocker):
|
|
||||||
def __enter__(self):
|
|
||||||
try:
|
|
||||||
self.fp = open(self.lockfile, "wb")
|
|
||||||
fcntl.flock(self.fp.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
||||||
except OSError as e:
|
|
||||||
raise AlreadyRunningException() from e
|
|
||||||
|
|
||||||
def __exit__(self, _type, value, tb):
|
|
||||||
fcntl.flock(self.fp.fileno(), fcntl.LOCK_UN)
|
|
||||||
self.fp.close()
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
import os
|
|
||||||
import threading
|
|
||||||
import json
|
|
||||||
|
|
||||||
from Utils import local_path, user_path
|
|
||||||
from worlds.alttp.Rom import Sprite
|
|
||||||
|
|
||||||
|
|
||||||
def update_sprites_lttp():
|
|
||||||
from tkinter import Tk
|
|
||||||
from LttPAdjuster import get_image_for_sprite
|
|
||||||
from LttPAdjuster import BackgroundTaskProgress
|
|
||||||
from LttPAdjuster import BackgroundTaskProgressNullWindow
|
|
||||||
from LttPAdjuster import update_sprites
|
|
||||||
|
|
||||||
# Target directories
|
|
||||||
input_dir = user_path("data", "sprites", "alttpr")
|
|
||||||
output_dir = local_path("WebHostLib", "static", "generated") # TODO: move to user_path
|
|
||||||
|
|
||||||
os.makedirs(os.path.join(output_dir, "sprites"), exist_ok=True)
|
|
||||||
# update sprites through gui.py's functions
|
|
||||||
done = threading.Event()
|
|
||||||
try:
|
|
||||||
top = Tk()
|
|
||||||
except:
|
|
||||||
task = BackgroundTaskProgressNullWindow(update_sprites, lambda successful, resultmessage: done.set())
|
|
||||||
else:
|
|
||||||
top.withdraw()
|
|
||||||
task = BackgroundTaskProgress(top, update_sprites, "Updating Sprites", lambda succesful, resultmessage: done.set())
|
|
||||||
while not done.isSet():
|
|
||||||
task.do_events()
|
|
||||||
|
|
||||||
spriteData = []
|
|
||||||
|
|
||||||
for file in (file for file in os.listdir(input_dir) if not file.startswith(".")):
|
|
||||||
sprite = Sprite(os.path.join(input_dir, file))
|
|
||||||
|
|
||||||
if not sprite.name:
|
|
||||||
print("Warning:", file, "has no name.")
|
|
||||||
sprite.name = file.split(".", 1)[0]
|
|
||||||
if sprite.valid:
|
|
||||||
with open(os.path.join(output_dir, "sprites", f"{os.path.splitext(file)[0]}.gif"), 'wb') as image:
|
|
||||||
image.write(get_image_for_sprite(sprite, True))
|
|
||||||
spriteData.append({"file": file, "author": sprite.author_name, "name": sprite.name})
|
|
||||||
else:
|
|
||||||
print(file, "dropped, as it has no valid sprite data.")
|
|
||||||
spriteData.sort(key=lambda entry: entry["name"])
|
|
||||||
with open(f'{output_dir}/spriteData.json', 'w') as file:
|
|
||||||
json.dump({"sprites": spriteData}, file, indent=1)
|
|
||||||
return spriteData
|
|
||||||
@@ -32,21 +32,29 @@ def page_not_found(err):
|
|||||||
|
|
||||||
# Start Playing Page
|
# Start Playing Page
|
||||||
@app.route('/start-playing')
|
@app.route('/start-playing')
|
||||||
@cache.cached()
|
|
||||||
def start_playing():
|
def start_playing():
|
||||||
return render_template(f"startPlaying.html")
|
return render_template(f"startPlaying.html")
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/weighted-settings')
|
||||||
|
def weighted_settings():
|
||||||
|
return render_template(f"weighted-settings.html")
|
||||||
|
|
||||||
|
|
||||||
|
# Player settings pages
|
||||||
|
@app.route('/games/<string:game>/player-settings')
|
||||||
|
def player_settings(game):
|
||||||
|
return render_template(f"player-settings.html", game=game, theme=get_world_theme(game))
|
||||||
|
|
||||||
|
|
||||||
# Game Info Pages
|
# Game Info Pages
|
||||||
@app.route('/games/<string:game>/info/<string:lang>')
|
@app.route('/games/<string:game>/info/<string:lang>')
|
||||||
@cache.cached()
|
|
||||||
def game_info(game, lang):
|
def game_info(game, lang):
|
||||||
return render_template('gameInfo.html', game=game, lang=lang, theme=get_world_theme(game))
|
return render_template('gameInfo.html', game=game, lang=lang, theme=get_world_theme(game))
|
||||||
|
|
||||||
|
|
||||||
# List of supported games
|
# List of supported games
|
||||||
@app.route('/games')
|
@app.route('/games')
|
||||||
@cache.cached()
|
|
||||||
def games():
|
def games():
|
||||||
worlds = {}
|
worlds = {}
|
||||||
for game, world in AutoWorldRegister.world_types.items():
|
for game, world in AutoWorldRegister.world_types.items():
|
||||||
@@ -56,25 +64,21 @@ def games():
|
|||||||
|
|
||||||
|
|
||||||
@app.route('/tutorial/<string:game>/<string:file>/<string:lang>')
|
@app.route('/tutorial/<string:game>/<string:file>/<string:lang>')
|
||||||
@cache.cached()
|
|
||||||
def tutorial(game, file, lang):
|
def tutorial(game, file, lang):
|
||||||
return render_template("tutorial.html", game=game, file=file, lang=lang, theme=get_world_theme(game))
|
return render_template("tutorial.html", game=game, file=file, lang=lang, theme=get_world_theme(game))
|
||||||
|
|
||||||
|
|
||||||
@app.route('/tutorial/')
|
@app.route('/tutorial/')
|
||||||
@cache.cached()
|
|
||||||
def tutorial_landing():
|
def tutorial_landing():
|
||||||
return render_template("tutorialLanding.html")
|
return render_template("tutorialLanding.html")
|
||||||
|
|
||||||
|
|
||||||
@app.route('/faq/<string:lang>/')
|
@app.route('/faq/<string:lang>/')
|
||||||
@cache.cached()
|
|
||||||
def faq(lang):
|
def faq(lang):
|
||||||
return render_template("faq.html", lang=lang)
|
return render_template("faq.html", lang=lang)
|
||||||
|
|
||||||
|
|
||||||
@app.route('/glossary/<string:lang>/')
|
@app.route('/glossary/<string:lang>/')
|
||||||
@cache.cached()
|
|
||||||
def terms(lang):
|
def terms(lang):
|
||||||
return render_template("glossary.html", lang=lang)
|
return render_template("glossary.html", lang=lang)
|
||||||
|
|
||||||
@@ -131,7 +135,6 @@ def host_room(room: UUID):
|
|||||||
if cmd:
|
if cmd:
|
||||||
Command(room=room, commandtext=cmd)
|
Command(room=room, commandtext=cmd)
|
||||||
commit()
|
commit()
|
||||||
return redirect(url_for("host_room", room=room.id))
|
|
||||||
|
|
||||||
now = datetime.datetime.utcnow()
|
now = datetime.datetime.utcnow()
|
||||||
# indicate that the page should reload to get the assigned port
|
# indicate that the page should reload to get the assigned port
|
||||||
@@ -144,7 +147,7 @@ def host_room(room: UUID):
|
|||||||
|
|
||||||
@app.route('/favicon.ico')
|
@app.route('/favicon.ico')
|
||||||
def favicon():
|
def favicon():
|
||||||
return send_from_directory(os.path.join(app.root_path, "static", "static"),
|
return send_from_directory(os.path.join(app.root_path, 'static/static'),
|
||||||
'favicon.ico', mimetype='image/vnd.microsoft.icon')
|
'favicon.ico', mimetype='image/vnd.microsoft.icon')
|
||||||
|
|
||||||
|
|
||||||
@@ -164,11 +167,10 @@ def get_datapackage():
|
|||||||
|
|
||||||
@app.route('/index')
|
@app.route('/index')
|
||||||
@app.route('/sitemap')
|
@app.route('/sitemap')
|
||||||
@cache.cached()
|
|
||||||
def get_sitemap():
|
def get_sitemap():
|
||||||
available_games: List[Dict[str, Union[str, bool]]] = []
|
available_games: List[Dict[str, Union[str, bool]]] = []
|
||||||
for game, world in AutoWorldRegister.world_types.items():
|
for game, world in AutoWorldRegister.world_types.items():
|
||||||
if not world.hidden:
|
if not world.hidden:
|
||||||
has_settings: bool = isinstance(world.web.options_page, bool) and world.web.options_page
|
has_settings: bool = isinstance(world.web.settings_page, bool) and world.web.settings_page
|
||||||
available_games.append({ 'title': game, 'has_settings': has_settings })
|
available_games.append({ 'title': game, 'has_settings': has_settings })
|
||||||
return render_template("siteMap.html", games=available_games)
|
return render_template("siteMap.html", games=available_games)
|
||||||
|
|||||||
@@ -1,226 +1,148 @@
|
|||||||
import collections.abc
|
|
||||||
import json
|
import json
|
||||||
|
import logging
|
||||||
import os
|
import os
|
||||||
from textwrap import dedent
|
import typing
|
||||||
from typing import Dict, Union
|
|
||||||
|
|
||||||
import yaml
|
import yaml
|
||||||
from flask import redirect, render_template, request, Response
|
from jinja2 import Template
|
||||||
|
|
||||||
import Options
|
import Options
|
||||||
from Utils import local_path
|
from Utils import __version__, local_path
|
||||||
from worlds.AutoWorld import AutoWorldRegister
|
from worlds.AutoWorld import AutoWorldRegister
|
||||||
from . import app, cache
|
|
||||||
|
handled_in_js = {"start_inventory", "local_items", "non_local_items", "start_hints", "start_location_hints",
|
||||||
|
"exclude_locations", "priority_locations"}
|
||||||
|
|
||||||
|
|
||||||
def create() -> None:
|
def create():
|
||||||
target_folder = local_path("WebHostLib", "static", "generated")
|
target_folder = local_path("WebHostLib", "static", "generated")
|
||||||
yaml_folder = os.path.join(target_folder, "configs")
|
yaml_folder = os.path.join(target_folder, "configs")
|
||||||
|
|
||||||
Options.generate_yaml_templates(yaml_folder)
|
Options.generate_yaml_templates(yaml_folder)
|
||||||
|
|
||||||
|
def get_html_doc(option_type: type(Options.Option)) -> str:
|
||||||
|
if not option_type.__doc__:
|
||||||
|
return "Please document me!"
|
||||||
|
return "\n".join(line.strip() for line in option_type.__doc__.split("\n")).strip()
|
||||||
|
|
||||||
def get_world_theme(game_name: str) -> str:
|
weighted_settings = {
|
||||||
if game_name in AutoWorldRegister.world_types:
|
"baseOptions": {
|
||||||
return AutoWorldRegister.world_types[game_name].web.theme
|
"description": "Generated by https://archipelago.gg/",
|
||||||
return 'grass'
|
"name": "Player",
|
||||||
|
"game": {},
|
||||||
|
},
|
||||||
|
"games": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
for game_name, world in AutoWorldRegister.world_types.items():
|
||||||
|
|
||||||
def render_options_page(template: str, world_name: str, is_complex: bool = False) -> Union[Response, str]:
|
all_options: typing.Dict[str, Options.AssembleOptions] = {
|
||||||
visibility_flag = Options.Visibility.complex_ui if is_complex else Options.Visibility.simple_ui
|
**Options.per_game_common_options,
|
||||||
world = AutoWorldRegister.world_types[world_name]
|
**world.option_definitions
|
||||||
if world.hidden or world.web.options_page is False:
|
|
||||||
return redirect("games")
|
|
||||||
|
|
||||||
option_groups = {option: option_group.name
|
|
||||||
for option_group in world.web.option_groups
|
|
||||||
for option in option_group.options}
|
|
||||||
ordered_groups = ["Game Options", *[group.name for group in world.web.option_groups]]
|
|
||||||
grouped_options = {group: {} for group in ordered_groups}
|
|
||||||
for option_name, option in world.options_dataclass.type_hints.items():
|
|
||||||
# Exclude settings from options pages if their visibility is disabled
|
|
||||||
if visibility_flag in option.visibility:
|
|
||||||
grouped_options[option_groups.get(option, "Game Options")][option_name] = option
|
|
||||||
|
|
||||||
return render_template(
|
|
||||||
template,
|
|
||||||
world_name=world_name,
|
|
||||||
world=world,
|
|
||||||
option_groups=grouped_options,
|
|
||||||
issubclass=issubclass,
|
|
||||||
Options=Options,
|
|
||||||
theme=get_world_theme(world_name),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def generate_game(options: Dict[str, Union[dict, str]]) -> Union[Response, str]:
|
|
||||||
from .generate import start_generation
|
|
||||||
return start_generation(options, {"plando_options": ["items", "connections", "texts", "bosses"]})
|
|
||||||
|
|
||||||
|
|
||||||
def send_yaml(player_name: str, formatted_options: dict) -> Response:
|
|
||||||
response = Response(yaml.dump(formatted_options, sort_keys=False))
|
|
||||||
response.headers["Content-Type"] = "text/yaml"
|
|
||||||
response.headers["Content-Disposition"] = f"attachment; filename={player_name}.yaml"
|
|
||||||
return response
|
|
||||||
|
|
||||||
|
|
||||||
@app.template_filter("dedent")
|
|
||||||
def filter_dedent(text: str) -> str:
|
|
||||||
return dedent(text).strip("\n ")
|
|
||||||
|
|
||||||
|
|
||||||
@app.template_test("ordered")
|
|
||||||
def test_ordered(obj):
|
|
||||||
return isinstance(obj, collections.abc.Sequence)
|
|
||||||
|
|
||||||
|
|
||||||
@app.route("/games/<string:game>/option-presets", methods=["GET"])
|
|
||||||
@cache.cached()
|
|
||||||
def option_presets(game: str) -> Response:
|
|
||||||
world = AutoWorldRegister.world_types[game]
|
|
||||||
|
|
||||||
class SetEncoder(json.JSONEncoder):
|
|
||||||
def default(self, obj):
|
|
||||||
from collections.abc import Set
|
|
||||||
if isinstance(obj, Set):
|
|
||||||
return list(obj)
|
|
||||||
return json.JSONEncoder.default(self, obj)
|
|
||||||
|
|
||||||
json_data = json.dumps(world.web.options_presets, cls=SetEncoder)
|
|
||||||
response = Response(json_data)
|
|
||||||
response.headers["Content-Type"] = "application/json"
|
|
||||||
return response
|
|
||||||
|
|
||||||
|
|
||||||
@app.route("/weighted-options")
|
|
||||||
def weighted_options_old():
|
|
||||||
return redirect("games", 301)
|
|
||||||
|
|
||||||
|
|
||||||
@app.route("/games/<string:game>/weighted-options")
|
|
||||||
@cache.cached()
|
|
||||||
def weighted_options(game: str):
|
|
||||||
return render_options_page("weightedOptions/weightedOptions.html", game, is_complex=True)
|
|
||||||
|
|
||||||
|
|
||||||
@app.route("/games/<string:game>/generate-weighted-yaml", methods=["POST"])
|
|
||||||
def generate_weighted_yaml(game: str):
|
|
||||||
if request.method == "POST":
|
|
||||||
intent_generate = False
|
|
||||||
options = {}
|
|
||||||
|
|
||||||
for key, val in request.form.items():
|
|
||||||
if "||" not in key:
|
|
||||||
if len(str(val)) == 0:
|
|
||||||
continue
|
|
||||||
|
|
||||||
options[key] = val
|
|
||||||
else:
|
|
||||||
if int(val) == 0:
|
|
||||||
continue
|
|
||||||
|
|
||||||
[option, setting] = key.split("||")
|
|
||||||
options.setdefault(option, {})[setting] = int(val)
|
|
||||||
|
|
||||||
# Error checking
|
|
||||||
if "name" not in options:
|
|
||||||
return "Player name is required."
|
|
||||||
|
|
||||||
# Remove POST data irrelevant to YAML
|
|
||||||
if "intent-generate" in options:
|
|
||||||
intent_generate = True
|
|
||||||
del options["intent-generate"]
|
|
||||||
if "intent-export" in options:
|
|
||||||
del options["intent-export"]
|
|
||||||
|
|
||||||
# Properly format YAML output
|
|
||||||
player_name = options["name"]
|
|
||||||
del options["name"]
|
|
||||||
|
|
||||||
formatted_options = {
|
|
||||||
"name": player_name,
|
|
||||||
"game": game,
|
|
||||||
"description": f"Generated by https://archipelago.gg/ for {game}",
|
|
||||||
game: options,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if intent_generate:
|
# Generate JSON files for player-settings pages
|
||||||
return generate_game({player_name: formatted_options})
|
player_settings = {
|
||||||
|
"baseOptions": {
|
||||||
else:
|
"description": f"Generated by https://archipelago.gg/ for {game_name}",
|
||||||
return send_yaml(player_name, formatted_options)
|
"game": game_name,
|
||||||
|
"name": "Player",
|
||||||
|
},
|
||||||
# Player options pages
|
|
||||||
@app.route("/games/<string:game>/player-options")
|
|
||||||
@cache.cached()
|
|
||||||
def player_options(game: str):
|
|
||||||
return render_options_page("playerOptions/playerOptions.html", game, is_complex=False)
|
|
||||||
|
|
||||||
|
|
||||||
# YAML generator for player-options
|
|
||||||
@app.route("/games/<string:game>/generate-yaml", methods=["POST"])
|
|
||||||
def generate_yaml(game: str):
|
|
||||||
if request.method == "POST":
|
|
||||||
options = {}
|
|
||||||
intent_generate = False
|
|
||||||
for key, val in request.form.items(multi=True):
|
|
||||||
if key in options:
|
|
||||||
if not isinstance(options[key], list):
|
|
||||||
options[key] = [options[key]]
|
|
||||||
options[key].append(val)
|
|
||||||
else:
|
|
||||||
options[key] = val
|
|
||||||
|
|
||||||
# Detect and build ItemDict options from their name pattern
|
|
||||||
for key, val in options.copy().items():
|
|
||||||
key_parts = key.rsplit("||", 2)
|
|
||||||
if key_parts[-1] == "qty":
|
|
||||||
if key_parts[0] not in options:
|
|
||||||
options[key_parts[0]] = {}
|
|
||||||
if val != "0":
|
|
||||||
options[key_parts[0]][key_parts[1]] = int(val)
|
|
||||||
del options[key]
|
|
||||||
|
|
||||||
# Detect random-* keys and set their options accordingly
|
|
||||||
for key, val in options.copy().items():
|
|
||||||
if key.startswith("random-"):
|
|
||||||
options[key.removeprefix("random-")] = "random"
|
|
||||||
del options[key]
|
|
||||||
|
|
||||||
# Error checking
|
|
||||||
if not options["name"]:
|
|
||||||
return "Player name is required."
|
|
||||||
|
|
||||||
# Remove POST data irrelevant to YAML
|
|
||||||
preset_name = 'default'
|
|
||||||
if "intent-generate" in options:
|
|
||||||
intent_generate = True
|
|
||||||
del options["intent-generate"]
|
|
||||||
if "intent-export" in options:
|
|
||||||
del options["intent-export"]
|
|
||||||
if "game-options-preset" in options:
|
|
||||||
preset_name = options["game-options-preset"]
|
|
||||||
del options["game-options-preset"]
|
|
||||||
|
|
||||||
# Properly format YAML output
|
|
||||||
player_name = options["name"]
|
|
||||||
del options["name"]
|
|
||||||
|
|
||||||
description = f"Generated by https://archipelago.gg/ for {game}"
|
|
||||||
if preset_name != 'default' and preset_name != 'custom':
|
|
||||||
description += f" using {preset_name} preset"
|
|
||||||
|
|
||||||
formatted_options = {
|
|
||||||
"name": player_name,
|
|
||||||
"game": game,
|
|
||||||
"description": description,
|
|
||||||
game: options,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if intent_generate:
|
game_options = {}
|
||||||
return generate_game({player_name: formatted_options})
|
for option_name, option in all_options.items():
|
||||||
|
if option_name in handled_in_js:
|
||||||
|
pass
|
||||||
|
|
||||||
else:
|
elif issubclass(option, Options.Choice) or issubclass(option, Options.Toggle):
|
||||||
return send_yaml(player_name, formatted_options)
|
game_options[option_name] = this_option = {
|
||||||
|
"type": "select",
|
||||||
|
"displayName": option.display_name if hasattr(option, "display_name") else option_name,
|
||||||
|
"description": get_html_doc(option),
|
||||||
|
"defaultValue": None,
|
||||||
|
"options": []
|
||||||
|
}
|
||||||
|
|
||||||
|
for sub_option_id, sub_option_name in option.name_lookup.items():
|
||||||
|
if sub_option_name != "random":
|
||||||
|
this_option["options"].append({
|
||||||
|
"name": option.get_option_name(sub_option_id),
|
||||||
|
"value": sub_option_name,
|
||||||
|
})
|
||||||
|
if sub_option_id == option.default:
|
||||||
|
this_option["defaultValue"] = sub_option_name
|
||||||
|
|
||||||
|
if not this_option["defaultValue"]:
|
||||||
|
this_option["defaultValue"] = "random"
|
||||||
|
|
||||||
|
elif issubclass(option, Options.Range):
|
||||||
|
game_options[option_name] = {
|
||||||
|
"type": "range",
|
||||||
|
"displayName": option.display_name if hasattr(option, "display_name") else option_name,
|
||||||
|
"description": get_html_doc(option),
|
||||||
|
"defaultValue": option.default if hasattr(
|
||||||
|
option, "default") and option.default != "random" else option.range_start,
|
||||||
|
"min": option.range_start,
|
||||||
|
"max": option.range_end,
|
||||||
|
}
|
||||||
|
|
||||||
|
if issubclass(option, Options.SpecialRange):
|
||||||
|
game_options[option_name]["type"] = 'special_range'
|
||||||
|
game_options[option_name]["value_names"] = {}
|
||||||
|
for key, val in option.special_range_names.items():
|
||||||
|
game_options[option_name]["value_names"][key] = val
|
||||||
|
|
||||||
|
elif issubclass(option, Options.ItemSet):
|
||||||
|
game_options[option_name] = {
|
||||||
|
"type": "items-list",
|
||||||
|
"displayName": option.display_name if hasattr(option, "display_name") else option_name,
|
||||||
|
"description": get_html_doc(option),
|
||||||
|
"defaultValue": list(option.default)
|
||||||
|
}
|
||||||
|
|
||||||
|
elif issubclass(option, Options.LocationSet):
|
||||||
|
game_options[option_name] = {
|
||||||
|
"type": "locations-list",
|
||||||
|
"displayName": option.display_name if hasattr(option, "display_name") else option_name,
|
||||||
|
"description": get_html_doc(option),
|
||||||
|
"defaultValue": list(option.default)
|
||||||
|
}
|
||||||
|
|
||||||
|
elif issubclass(option, Options.VerifyKeys) and not issubclass(option, Options.OptionDict):
|
||||||
|
if option.valid_keys:
|
||||||
|
game_options[option_name] = {
|
||||||
|
"type": "custom-list",
|
||||||
|
"displayName": option.display_name if hasattr(option, "display_name") else option_name,
|
||||||
|
"description": get_html_doc(option),
|
||||||
|
"options": list(option.valid_keys),
|
||||||
|
"defaultValue": list(option.default) if hasattr(option, "default") else []
|
||||||
|
}
|
||||||
|
|
||||||
|
else:
|
||||||
|
logging.debug(f"{option} not exported to Web Settings.")
|
||||||
|
|
||||||
|
player_settings["gameOptions"] = game_options
|
||||||
|
|
||||||
|
os.makedirs(os.path.join(target_folder, 'player-settings'), exist_ok=True)
|
||||||
|
|
||||||
|
with open(os.path.join(target_folder, 'player-settings', game_name + ".json"), "w") as f:
|
||||||
|
json.dump(player_settings, f, indent=2, separators=(',', ': '))
|
||||||
|
|
||||||
|
if not world.hidden and world.web.settings_page is True:
|
||||||
|
# Add the random option to Choice, TextChoice, and Toggle settings
|
||||||
|
for option in game_options.values():
|
||||||
|
if option["type"] == "select":
|
||||||
|
option["options"].append({"name": "Random", "value": "random"})
|
||||||
|
|
||||||
|
if not option["defaultValue"]:
|
||||||
|
option["defaultValue"] = "random"
|
||||||
|
|
||||||
|
weighted_settings["baseOptions"]["game"][game_name] = 0
|
||||||
|
weighted_settings["games"][game_name] = {}
|
||||||
|
weighted_settings["games"][game_name]["gameSettings"] = game_options
|
||||||
|
weighted_settings["games"][game_name]["gameItems"] = tuple(world.item_names)
|
||||||
|
weighted_settings["games"][game_name]["gameLocations"] = tuple(world.location_names)
|
||||||
|
|
||||||
|
with open(os.path.join(target_folder, 'weighted-settings.json'), "w") as f:
|
||||||
|
json.dump(weighted_settings, f, indent=2, separators=(',', ': '))
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
flask>=3.0.0
|
flask>=2.2.3
|
||||||
pony>=0.7.17
|
pony>=0.7.16; python_version <= '3.10'
|
||||||
|
pony @ https://github.com/Berserker66/pony/releases/download/v0.7.16/pony-0.7.16-py3-none-any.whl#0.7.16 ; python_version >= '3.11'
|
||||||
waitress>=2.1.2
|
waitress>=2.1.2
|
||||||
Flask-Caching>=2.1.0
|
Flask-Caching>=2.0.2
|
||||||
Flask-Compress>=1.14
|
Flask-Compress>=1.13
|
||||||
Flask-Limiter>=3.5.0
|
Flask-Limiter>=3.3.0
|
||||||
bokeh>=3.1.1; python_version <= '3.8'
|
bokeh>=3.1.1
|
||||||
bokeh>=3.3.2; python_version >= '3.9'
|
|
||||||
markupsafe>=2.1.3
|
markupsafe>=2.1.3
|
||||||
|
|||||||
@@ -1,14 +0,0 @@
|
|||||||
from WebHostLib import app
|
|
||||||
from flask import abort
|
|
||||||
from . import cache
|
|
||||||
|
|
||||||
|
|
||||||
@cache.cached()
|
|
||||||
@app.route('/robots.txt')
|
|
||||||
def robots():
|
|
||||||
# If this host is not official, do not allow search engine crawling
|
|
||||||
if not app.config["ASSET_RIGHTS"]:
|
|
||||||
return app.send_static_file('robots.txt')
|
|
||||||
|
|
||||||
# Send 404 if the host has affirmed this to be the official WebHost
|
|
||||||
abort(404)
|
|
||||||
@@ -2,62 +2,13 @@
|
|||||||
|
|
||||||
## What is a randomizer?
|
## What is a randomizer?
|
||||||
|
|
||||||
A randomizer is a modification of a game which reorganizes the items required to progress through that game. A
|
A randomizer is a modification of a video game which reorganizes the items required to progress through the game. A
|
||||||
normal play-through might require you to use item A to unlock item B, then C, and so forth. In a randomized
|
normal play-through of a game might require you to use item A to unlock item B, then C, and so forth. In a randomized
|
||||||
game, you might first find item C, then A, then B.
|
game, you might first find item C, then A, then B.
|
||||||
|
|
||||||
This transforms the game from a linear experience into a puzzle, presenting players with a new challenge each time they
|
This transforms games from a linear experience into a puzzle, presenting players with a new challenge each time they
|
||||||
play. Putting items in non-standard locations can require the player to think about the game world and the items they
|
play a randomized game. Putting items in non-standard locations can require the player to think about the game world and
|
||||||
encounter in new and interesting ways.
|
the items they encounter in new and interesting ways.
|
||||||
|
|
||||||
## What is a multiworld?
|
|
||||||
|
|
||||||
While a randomizer shuffles a game, a multiworld randomizer shuffles that game for multiple players. For example, in a
|
|
||||||
two player multiworld, players A and B each get their own randomized version of a game, called a world. In each
|
|
||||||
player's game, they may find items which belong to the other player. If player A finds an item which belongs to
|
|
||||||
player B, the item will be sent to player B's world over the internet. This creates a cooperative experience, requiring
|
|
||||||
players to rely upon each other to complete their game.
|
|
||||||
|
|
||||||
## What does multi-game mean?
|
|
||||||
|
|
||||||
While a multiworld game traditionally requires all players to be playing the same game, a multi-game multiworld allows
|
|
||||||
players to randomize any of the supported games, and send items between them. This allows players of different
|
|
||||||
games to interact with one another in a single multiplayer environment. Archipelago supports multi-game multiworld.
|
|
||||||
Here is a list of our [Supported Games](https://archipelago.gg/games).
|
|
||||||
|
|
||||||
## Can I generate a single-player game with Archipelago?
|
|
||||||
|
|
||||||
Yes. All of our supported games can be generated as single-player experiences both on the website and by installing
|
|
||||||
the Archipelago generator software. The fastest way to do this is on the website. Find the Supported Game you wish to
|
|
||||||
play, open the Settings Page, pick your settings, and click Generate Game.
|
|
||||||
|
|
||||||
## How do I get started?
|
|
||||||
|
|
||||||
We have a [Getting Started](https://archipelago.gg/tutorial/Archipelago/setup/en) guide that will help you get the
|
|
||||||
software set up. You can use that guide to learn how to generate multiworlds. There are also basic instructions for
|
|
||||||
including multiple games, and hosting multiworlds on the website for ease and convenience.
|
|
||||||
|
|
||||||
If you are ready to start randomizing games, or want to start playing your favorite randomizer with others, please join
|
|
||||||
our discord server at the [Archipelago Discord](https://discord.gg/8Z65BR2). There are always people ready to answer
|
|
||||||
any questions you might have.
|
|
||||||
|
|
||||||
## What are some common terms I should know?
|
|
||||||
|
|
||||||
As randomizers and multiworld randomizers have been around for a while now, there are quite a few common terms used
|
|
||||||
by the communities surrounding them. A list of Archipelago jargon and terms commonly used by the community can be
|
|
||||||
found in the [Glossary](/glossary/en).
|
|
||||||
|
|
||||||
## Does everyone need to be connected at the same time?
|
|
||||||
|
|
||||||
There are two different play-styles that are common for Archipelago multiworld sessions. These sessions can either
|
|
||||||
be considered synchronous (or "sync"), where everyone connects and plays at the same time, or asynchronous (or "async"),
|
|
||||||
where players connect and play at their own pace. The setup for both is identical. The difference in play-style is how
|
|
||||||
you and your friends choose to organize and play your multiworld. Most groups decide on the format before creating
|
|
||||||
their multiworld.
|
|
||||||
|
|
||||||
If a player must leave early, they can use Archipelago's release system. When a player releases their game, all items
|
|
||||||
in that game belonging to other players are sent out automatically. This allows other players to continue to play
|
|
||||||
uninterrupted. Here is a list of all of our [Server Commands](https://archipelago.gg/tutorial/Archipelago/commands/en).
|
|
||||||
|
|
||||||
## What happens if an item is placed somewhere it is impossible to get?
|
## What happens if an item is placed somewhere it is impossible to get?
|
||||||
|
|
||||||
@@ -66,15 +17,53 @@ 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.
|
||||||
|
|
||||||
|
## What is a multi-world?
|
||||||
|
|
||||||
|
While a randomizer shuffles a game, a multi-world randomizer shuffles that game for multiple players. For example, in a
|
||||||
|
two player multi-world, players A and B each get their own randomized version of a game, called a world. In each player's
|
||||||
|
game, they may find items which belong to the other player. If player A finds an item which belongs to player B, the
|
||||||
|
item will be sent to player B's world over the internet.
|
||||||
|
|
||||||
|
This creates a cooperative experience during multi-world games, requiring players to rely upon each other to complete
|
||||||
|
their game.
|
||||||
|
|
||||||
|
## What happens if a person has to leave early?
|
||||||
|
|
||||||
|
If a player must leave early, they can use Archipelago's release system. When a player releases their game, all the
|
||||||
|
items in that game which belong to other players are sent out automatically, so other players can continue to play.
|
||||||
|
|
||||||
|
## What does multi-game mean?
|
||||||
|
|
||||||
|
While a multi-world game traditionally requires all players to be playing the same game, a multi-game multi-world allows
|
||||||
|
players to randomize any of a number of supported games, and send items between them. This allows players of different
|
||||||
|
games to interact with one another in a single multiplayer environment.
|
||||||
|
|
||||||
|
## Can I generate a single-player game with Archipelago?
|
||||||
|
|
||||||
|
Yes. All our supported games can be generated as single-player experiences, and so long as you download the software,
|
||||||
|
the website is not required to generate them.
|
||||||
|
|
||||||
|
## How do I get started?
|
||||||
|
|
||||||
|
If you are ready to start randomizing games, or want to start playing your favorite randomizer with others, please join
|
||||||
|
our discord server at the [Archipelago Discord](https://discord.gg/8Z65BR2). There are always people ready to answer
|
||||||
|
any questions you might have.
|
||||||
|
|
||||||
|
## What are some common terms I should know?
|
||||||
|
|
||||||
|
As randomizers and multiworld randomizers have been around for a while now there are quite a lot of common terms
|
||||||
|
and jargon that is used in conjunction by the communities surrounding them. For a lot of the terms that are more common
|
||||||
|
to Archipelago and its specific systems please see the [Glossary](/glossary/en).
|
||||||
|
|
||||||
## I want to add a game to the Archipelago randomizer. How do I do that?
|
## 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).
|
at [Archipelago GitHub Page](https://github.com/ArchipelagoMW/Archipelago).
|
||||||
|
|
||||||
There, you will find examples of games in the `worlds` folder:
|
There you will find examples of games in the worlds folder
|
||||||
[/worlds Folder in Archipelago Code](https://github.com/ArchipelagoMW/Archipelago/tree/main/worlds).
|
at [/worlds Folder in Archipelago Code](https://github.com/ArchipelagoMW/Archipelago/tree/main/worlds).
|
||||||
|
|
||||||
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).
|
at [/docs Folder in Archipelago Code](https://github.com/ArchipelagoMW/Archipelago/tree/main/docs).
|
||||||
|
|
||||||
If you have more questions, feel free to ask in the **#archipelago-dev** channel on our Discord.
|
If you have more questions, feel free to ask in the **#archipelago-dev** channel on our Discord.
|
||||||
|
|||||||
20
WebHostLib/static/assets/lttp-tracker.js
Normal file
20
WebHostLib/static/assets/lttp-tracker.js
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
window.addEventListener('load', () => {
|
||||||
|
const url = window.location;
|
||||||
|
setInterval(() => {
|
||||||
|
const ajax = new XMLHttpRequest();
|
||||||
|
ajax.onreadystatechange = () => {
|
||||||
|
if (ajax.readyState !== 4) { return; }
|
||||||
|
|
||||||
|
// Create a fake DOM using the returned HTML
|
||||||
|
const domParser = new DOMParser();
|
||||||
|
const fakeDOM = domParser.parseFromString(ajax.responseText, 'text/html');
|
||||||
|
|
||||||
|
// Update item and location trackers
|
||||||
|
document.getElementById('inventory-table').innerHTML = fakeDOM.getElementById('inventory-table').innerHTML;
|
||||||
|
document.getElementById('location-table').innerHTML = fakeDOM.getElementById('location-table').innerHTML;
|
||||||
|
|
||||||
|
};
|
||||||
|
ajax.open('GET', url);
|
||||||
|
ajax.send();
|
||||||
|
}, 15000)
|
||||||
|
});
|
||||||
398
WebHostLib/static/assets/player-settings.js
Normal file
398
WebHostLib/static/assets/player-settings.js
Normal file
@@ -0,0 +1,398 @@
|
|||||||
|
let gameName = null;
|
||||||
|
|
||||||
|
window.addEventListener('load', () => {
|
||||||
|
gameName = document.getElementById('player-settings').getAttribute('data-game');
|
||||||
|
|
||||||
|
// Update game name on page
|
||||||
|
document.getElementById('game-name').innerText = gameName;
|
||||||
|
|
||||||
|
fetchSettingData().then((results) => {
|
||||||
|
let settingHash = localStorage.getItem(`${gameName}-hash`);
|
||||||
|
if (!settingHash) {
|
||||||
|
// If no hash data has been set before, set it now
|
||||||
|
settingHash = md5(JSON.stringify(results));
|
||||||
|
localStorage.setItem(`${gameName}-hash`, settingHash);
|
||||||
|
localStorage.removeItem(gameName);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (settingHash !== md5(JSON.stringify(results))) {
|
||||||
|
showUserMessage("Your settings are out of date! Click here to update them! Be aware this will reset " +
|
||||||
|
"them all to default.");
|
||||||
|
document.getElementById('user-message').addEventListener('click', resetSettings);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Page setup
|
||||||
|
createDefaultSettings(results);
|
||||||
|
buildUI(results);
|
||||||
|
adjustHeaderWidth();
|
||||||
|
|
||||||
|
// Event listeners
|
||||||
|
document.getElementById('export-settings').addEventListener('click', () => exportSettings());
|
||||||
|
document.getElementById('generate-race').addEventListener('click', () => generateGame(true));
|
||||||
|
document.getElementById('generate-game').addEventListener('click', () => generateGame());
|
||||||
|
|
||||||
|
// Name input field
|
||||||
|
const playerSettings = JSON.parse(localStorage.getItem(gameName));
|
||||||
|
const nameInput = document.getElementById('player-name');
|
||||||
|
nameInput.addEventListener('keyup', (event) => updateBaseSetting(event));
|
||||||
|
nameInput.value = playerSettings.name;
|
||||||
|
}).catch((e) => {
|
||||||
|
console.error(e);
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
window.location.replace(`${url.protocol}//${url.hostname}/page-not-found`);
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
const resetSettings = () => {
|
||||||
|
localStorage.removeItem(gameName);
|
||||||
|
localStorage.removeItem(`${gameName}-hash`)
|
||||||
|
window.location.reload();
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchSettingData = () => new Promise((resolve, reject) => {
|
||||||
|
const ajax = new XMLHttpRequest();
|
||||||
|
ajax.onreadystatechange = () => {
|
||||||
|
if (ajax.readyState !== 4) { return; }
|
||||||
|
if (ajax.status !== 200) {
|
||||||
|
reject(ajax.responseText);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try{ resolve(JSON.parse(ajax.responseText)); }
|
||||||
|
catch(error){ reject(error); }
|
||||||
|
};
|
||||||
|
ajax.open('GET', `${window.location.origin}/static/generated/player-settings/${gameName}.json`, true);
|
||||||
|
ajax.send();
|
||||||
|
});
|
||||||
|
|
||||||
|
const createDefaultSettings = (settingData) => {
|
||||||
|
if (!localStorage.getItem(gameName)) {
|
||||||
|
const newSettings = {
|
||||||
|
[gameName]: {},
|
||||||
|
};
|
||||||
|
for (let baseOption of Object.keys(settingData.baseOptions)){
|
||||||
|
newSettings[baseOption] = settingData.baseOptions[baseOption];
|
||||||
|
}
|
||||||
|
for (let gameOption of Object.keys(settingData.gameOptions)){
|
||||||
|
newSettings[gameName][gameOption] = settingData.gameOptions[gameOption].defaultValue;
|
||||||
|
}
|
||||||
|
localStorage.setItem(gameName, JSON.stringify(newSettings));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildUI = (settingData) => {
|
||||||
|
// Game Options
|
||||||
|
const leftGameOpts = {};
|
||||||
|
const rightGameOpts = {};
|
||||||
|
Object.keys(settingData.gameOptions).forEach((key, index) => {
|
||||||
|
if (index < Object.keys(settingData.gameOptions).length / 2) { leftGameOpts[key] = settingData.gameOptions[key]; }
|
||||||
|
else { rightGameOpts[key] = settingData.gameOptions[key]; }
|
||||||
|
});
|
||||||
|
document.getElementById('game-options-left').appendChild(buildOptionsTable(leftGameOpts));
|
||||||
|
document.getElementById('game-options-right').appendChild(buildOptionsTable(rightGameOpts));
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildOptionsTable = (settings, romOpts = false) => {
|
||||||
|
const currentSettings = JSON.parse(localStorage.getItem(gameName));
|
||||||
|
const table = document.createElement('table');
|
||||||
|
const tbody = document.createElement('tbody');
|
||||||
|
|
||||||
|
Object.keys(settings).forEach((setting) => {
|
||||||
|
const tr = document.createElement('tr');
|
||||||
|
|
||||||
|
// td Left
|
||||||
|
const tdl = document.createElement('td');
|
||||||
|
const label = document.createElement('label');
|
||||||
|
label.textContent = `${settings[setting].displayName}: `;
|
||||||
|
label.setAttribute('for', setting);
|
||||||
|
|
||||||
|
const questionSpan = document.createElement('span');
|
||||||
|
questionSpan.classList.add('interactive');
|
||||||
|
questionSpan.setAttribute('data-tooltip', settings[setting].description);
|
||||||
|
questionSpan.innerText = '(?)';
|
||||||
|
|
||||||
|
label.appendChild(questionSpan);
|
||||||
|
tdl.appendChild(label);
|
||||||
|
tr.appendChild(tdl);
|
||||||
|
|
||||||
|
// td Right
|
||||||
|
const tdr = document.createElement('td');
|
||||||
|
let element = null;
|
||||||
|
|
||||||
|
const randomButton = document.createElement('button');
|
||||||
|
|
||||||
|
switch(settings[setting].type){
|
||||||
|
case 'select':
|
||||||
|
element = document.createElement('div');
|
||||||
|
element.classList.add('select-container');
|
||||||
|
let select = document.createElement('select');
|
||||||
|
select.setAttribute('id', setting);
|
||||||
|
select.setAttribute('data-key', setting);
|
||||||
|
if (romOpts) { select.setAttribute('data-romOpt', '1'); }
|
||||||
|
settings[setting].options.forEach((opt) => {
|
||||||
|
const option = document.createElement('option');
|
||||||
|
option.setAttribute('value', opt.value);
|
||||||
|
option.innerText = opt.name;
|
||||||
|
if ((isNaN(currentSettings[gameName][setting]) &&
|
||||||
|
(parseInt(opt.value, 10) === parseInt(currentSettings[gameName][setting]))) ||
|
||||||
|
(opt.value === currentSettings[gameName][setting]))
|
||||||
|
{
|
||||||
|
option.selected = true;
|
||||||
|
}
|
||||||
|
select.appendChild(option);
|
||||||
|
});
|
||||||
|
select.addEventListener('change', (event) => updateGameSetting(event.target));
|
||||||
|
element.appendChild(select);
|
||||||
|
|
||||||
|
// Randomize button
|
||||||
|
randomButton.innerText = '🎲';
|
||||||
|
randomButton.classList.add('randomize-button');
|
||||||
|
randomButton.setAttribute('data-key', setting);
|
||||||
|
randomButton.setAttribute('data-tooltip', 'Toggle randomization for this option!');
|
||||||
|
randomButton.addEventListener('click', (event) => toggleRandomize(event, select));
|
||||||
|
if (currentSettings[gameName][setting] === 'random') {
|
||||||
|
randomButton.classList.add('active');
|
||||||
|
select.disabled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
element.appendChild(randomButton);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'range':
|
||||||
|
element = document.createElement('div');
|
||||||
|
element.classList.add('range-container');
|
||||||
|
|
||||||
|
let range = document.createElement('input');
|
||||||
|
range.setAttribute('type', 'range');
|
||||||
|
range.setAttribute('data-key', setting);
|
||||||
|
range.setAttribute('min', settings[setting].min);
|
||||||
|
range.setAttribute('max', settings[setting].max);
|
||||||
|
range.value = currentSettings[gameName][setting];
|
||||||
|
range.addEventListener('change', (event) => {
|
||||||
|
document.getElementById(`${setting}-value`).innerText = event.target.value;
|
||||||
|
updateGameSetting(event.target);
|
||||||
|
});
|
||||||
|
element.appendChild(range);
|
||||||
|
|
||||||
|
let rangeVal = document.createElement('span');
|
||||||
|
rangeVal.classList.add('range-value');
|
||||||
|
rangeVal.setAttribute('id', `${setting}-value`);
|
||||||
|
rangeVal.innerText = currentSettings[gameName][setting] !== 'random' ?
|
||||||
|
currentSettings[gameName][setting] : settings[setting].defaultValue;
|
||||||
|
element.appendChild(rangeVal);
|
||||||
|
|
||||||
|
// Randomize button
|
||||||
|
randomButton.innerText = '🎲';
|
||||||
|
randomButton.classList.add('randomize-button');
|
||||||
|
randomButton.setAttribute('data-key', setting);
|
||||||
|
randomButton.setAttribute('data-tooltip', 'Toggle randomization for this option!');
|
||||||
|
randomButton.addEventListener('click', (event) => toggleRandomize(event, range));
|
||||||
|
if (currentSettings[gameName][setting] === 'random') {
|
||||||
|
randomButton.classList.add('active');
|
||||||
|
range.disabled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
element.appendChild(randomButton);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'special_range':
|
||||||
|
element = document.createElement('div');
|
||||||
|
element.classList.add('special-range-container');
|
||||||
|
|
||||||
|
// Build the select element
|
||||||
|
let specialRangeSelect = document.createElement('select');
|
||||||
|
specialRangeSelect.setAttribute('data-key', setting);
|
||||||
|
Object.keys(settings[setting].value_names).forEach((presetName) => {
|
||||||
|
let presetOption = document.createElement('option');
|
||||||
|
presetOption.innerText = presetName;
|
||||||
|
presetOption.value = settings[setting].value_names[presetName];
|
||||||
|
const words = presetOption.innerText.split("_");
|
||||||
|
for (let i = 0; i < words.length; i++) {
|
||||||
|
words[i] = words[i][0].toUpperCase() + words[i].substring(1);
|
||||||
|
}
|
||||||
|
presetOption.innerText = words.join(" ");
|
||||||
|
specialRangeSelect.appendChild(presetOption);
|
||||||
|
});
|
||||||
|
let customOption = document.createElement('option');
|
||||||
|
customOption.innerText = 'Custom';
|
||||||
|
customOption.value = 'custom';
|
||||||
|
customOption.selected = true;
|
||||||
|
specialRangeSelect.appendChild(customOption);
|
||||||
|
if (Object.values(settings[setting].value_names).includes(Number(currentSettings[gameName][setting]))) {
|
||||||
|
specialRangeSelect.value = Number(currentSettings[gameName][setting]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build range element
|
||||||
|
let specialRangeWrapper = document.createElement('div');
|
||||||
|
specialRangeWrapper.classList.add('special-range-wrapper');
|
||||||
|
let specialRange = document.createElement('input');
|
||||||
|
specialRange.setAttribute('type', 'range');
|
||||||
|
specialRange.setAttribute('data-key', setting);
|
||||||
|
specialRange.setAttribute('min', settings[setting].min);
|
||||||
|
specialRange.setAttribute('max', settings[setting].max);
|
||||||
|
specialRange.value = currentSettings[gameName][setting];
|
||||||
|
|
||||||
|
// Build rage value element
|
||||||
|
let specialRangeVal = document.createElement('span');
|
||||||
|
specialRangeVal.classList.add('range-value');
|
||||||
|
specialRangeVal.setAttribute('id', `${setting}-value`);
|
||||||
|
specialRangeVal.innerText = currentSettings[gameName][setting] !== 'random' ?
|
||||||
|
currentSettings[gameName][setting] : settings[setting].defaultValue;
|
||||||
|
|
||||||
|
// Configure select event listener
|
||||||
|
specialRangeSelect.addEventListener('change', (event) => {
|
||||||
|
if (event.target.value === 'custom') { return; }
|
||||||
|
|
||||||
|
// Update range slider
|
||||||
|
specialRange.value = event.target.value;
|
||||||
|
document.getElementById(`${setting}-value`).innerText = event.target.value;
|
||||||
|
updateGameSetting(event.target);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Configure range event handler
|
||||||
|
specialRange.addEventListener('change', (event) => {
|
||||||
|
// Update select element
|
||||||
|
specialRangeSelect.value =
|
||||||
|
(Object.values(settings[setting].value_names).includes(parseInt(event.target.value))) ?
|
||||||
|
parseInt(event.target.value) : 'custom';
|
||||||
|
document.getElementById(`${setting}-value`).innerText = event.target.value;
|
||||||
|
updateGameSetting(event.target);
|
||||||
|
});
|
||||||
|
|
||||||
|
element.appendChild(specialRangeSelect);
|
||||||
|
specialRangeWrapper.appendChild(specialRange);
|
||||||
|
specialRangeWrapper.appendChild(specialRangeVal);
|
||||||
|
element.appendChild(specialRangeWrapper);
|
||||||
|
|
||||||
|
// Randomize button
|
||||||
|
randomButton.innerText = '🎲';
|
||||||
|
randomButton.classList.add('randomize-button');
|
||||||
|
randomButton.setAttribute('data-key', setting);
|
||||||
|
randomButton.setAttribute('data-tooltip', 'Toggle randomization for this option!');
|
||||||
|
randomButton.addEventListener('click', (event) => toggleRandomize(
|
||||||
|
event, specialRange, specialRangeSelect)
|
||||||
|
);
|
||||||
|
if (currentSettings[gameName][setting] === 'random') {
|
||||||
|
randomButton.classList.add('active');
|
||||||
|
specialRange.disabled = true;
|
||||||
|
specialRangeSelect.disabled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
specialRangeWrapper.appendChild(randomButton);
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
console.error(`Ignoring unknown setting type: ${settings[setting].type} with name ${setting}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
tdr.appendChild(element);
|
||||||
|
tr.appendChild(tdr);
|
||||||
|
tbody.appendChild(tr);
|
||||||
|
});
|
||||||
|
|
||||||
|
table.appendChild(tbody);
|
||||||
|
return table;
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleRandomize = (event, inputElement, optionalSelectElement = null) => {
|
||||||
|
const active = event.target.classList.contains('active');
|
||||||
|
const randomButton = event.target;
|
||||||
|
|
||||||
|
if (active) {
|
||||||
|
randomButton.classList.remove('active');
|
||||||
|
inputElement.disabled = undefined;
|
||||||
|
if (optionalSelectElement) {
|
||||||
|
optionalSelectElement.disabled = undefined;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
randomButton.classList.add('active');
|
||||||
|
inputElement.disabled = true;
|
||||||
|
if (optionalSelectElement) {
|
||||||
|
optionalSelectElement.disabled = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateGameSetting(randomButton);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateBaseSetting = (event) => {
|
||||||
|
const options = JSON.parse(localStorage.getItem(gameName));
|
||||||
|
options[event.target.getAttribute('data-key')] = isNaN(event.target.value) ?
|
||||||
|
event.target.value : parseInt(event.target.value);
|
||||||
|
localStorage.setItem(gameName, JSON.stringify(options));
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateGameSetting = (settingElement) => {
|
||||||
|
const options = JSON.parse(localStorage.getItem(gameName));
|
||||||
|
|
||||||
|
if (settingElement.classList.contains('randomize-button')) {
|
||||||
|
// If the event passed in is the randomize button, then we know what we must do.
|
||||||
|
options[gameName][settingElement.getAttribute('data-key')] = 'random';
|
||||||
|
} else {
|
||||||
|
options[gameName][settingElement.getAttribute('data-key')] = isNaN(settingElement.value) ?
|
||||||
|
settingElement.value : parseInt(settingElement.value, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
localStorage.setItem(gameName, JSON.stringify(options));
|
||||||
|
};
|
||||||
|
|
||||||
|
const exportSettings = () => {
|
||||||
|
const settings = JSON.parse(localStorage.getItem(gameName));
|
||||||
|
if (!settings.name || settings.name.toLowerCase() === 'player' || settings.name.trim().length === 0) {
|
||||||
|
return showUserMessage('You must enter a player name!');
|
||||||
|
}
|
||||||
|
const yamlText = jsyaml.safeDump(settings, { noCompatMode: true }).replaceAll(/'(\d+)':/g, (x, y) => `${y}:`);
|
||||||
|
download(`${document.getElementById('player-name').value}.yaml`, yamlText);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Create an anchor and trigger a download of a text file. */
|
||||||
|
const download = (filename, text) => {
|
||||||
|
const downloadLink = document.createElement('a');
|
||||||
|
downloadLink.setAttribute('href','data:text/yaml;charset=utf-8,'+ encodeURIComponent(text))
|
||||||
|
downloadLink.setAttribute('download', filename);
|
||||||
|
downloadLink.style.display = 'none';
|
||||||
|
document.body.appendChild(downloadLink);
|
||||||
|
downloadLink.click();
|
||||||
|
document.body.removeChild(downloadLink);
|
||||||
|
};
|
||||||
|
|
||||||
|
const generateGame = (raceMode = false) => {
|
||||||
|
const settings = JSON.parse(localStorage.getItem(gameName));
|
||||||
|
if (!settings.name || settings.name.toLowerCase() === 'player' || settings.name.trim().length === 0) {
|
||||||
|
return showUserMessage('You must enter a player name!');
|
||||||
|
}
|
||||||
|
|
||||||
|
axios.post('/api/generate', {
|
||||||
|
weights: { player: settings },
|
||||||
|
presetData: { player: settings },
|
||||||
|
playerCount: 1,
|
||||||
|
spoiler: 3,
|
||||||
|
race: raceMode ? '1' : '0',
|
||||||
|
}).then((response) => {
|
||||||
|
window.location.href = response.data.url;
|
||||||
|
}).catch((error) => {
|
||||||
|
let userMessage = 'Something went wrong and your game could not be generated.';
|
||||||
|
if (error.response.data.text) {
|
||||||
|
userMessage += ' ' + error.response.data.text;
|
||||||
|
}
|
||||||
|
showUserMessage(userMessage);
|
||||||
|
console.error(error);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const showUserMessage = (message) => {
|
||||||
|
const userMessage = document.getElementById('user-message');
|
||||||
|
userMessage.innerText = message;
|
||||||
|
userMessage.classList.add('visible');
|
||||||
|
window.scrollTo(0, 0);
|
||||||
|
userMessage.addEventListener('click', () => {
|
||||||
|
userMessage.classList.remove('visible');
|
||||||
|
userMessage.addEventListener('click', hideUserMessage);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const hideUserMessage = () => {
|
||||||
|
const userMessage = document.getElementById('user-message');
|
||||||
|
userMessage.classList.remove('visible');
|
||||||
|
userMessage.removeEventListener('click', hideUserMessage);
|
||||||
|
};
|
||||||
@@ -1,335 +0,0 @@
|
|||||||
let presets = {};
|
|
||||||
|
|
||||||
window.addEventListener('load', async () => {
|
|
||||||
// Load settings from localStorage, if available
|
|
||||||
loadSettings();
|
|
||||||
|
|
||||||
// Fetch presets if available
|
|
||||||
await fetchPresets();
|
|
||||||
|
|
||||||
// Handle changes to range inputs
|
|
||||||
document.querySelectorAll('input[type=range]').forEach((range) => {
|
|
||||||
const optionName = range.getAttribute('id');
|
|
||||||
range.addEventListener('change', () => {
|
|
||||||
document.getElementById(`${optionName}-value`).innerText = range.value;
|
|
||||||
|
|
||||||
// Handle updating named range selects to "custom" if appropriate
|
|
||||||
const select = document.querySelector(`select[data-option-name=${optionName}]`);
|
|
||||||
if (select) {
|
|
||||||
let updated = false;
|
|
||||||
select?.childNodes.forEach((option) => {
|
|
||||||
if (option.value === range.value) {
|
|
||||||
select.value = range.value;
|
|
||||||
updated = true;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
if (!updated) {
|
|
||||||
select.value = 'custom';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Handle changes to named range selects
|
|
||||||
document.querySelectorAll('.named-range-container select').forEach((select) => {
|
|
||||||
const optionName = select.getAttribute('data-option-name');
|
|
||||||
select.addEventListener('change', (evt) => {
|
|
||||||
document.getElementById(optionName).value = evt.target.value;
|
|
||||||
document.getElementById(`${optionName}-value`).innerText = evt.target.value;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Handle changes to randomize checkboxes
|
|
||||||
document.querySelectorAll('.randomize-checkbox').forEach((checkbox) => {
|
|
||||||
const optionName = checkbox.getAttribute('data-option-name');
|
|
||||||
checkbox.addEventListener('change', () => {
|
|
||||||
const optionInput = document.getElementById(optionName);
|
|
||||||
const namedRangeSelect = document.querySelector(`select[data-option-name=${optionName}]`);
|
|
||||||
const customInput = document.getElementById(`${optionName}-custom`);
|
|
||||||
if (checkbox.checked) {
|
|
||||||
optionInput.setAttribute('disabled', '1');
|
|
||||||
namedRangeSelect?.setAttribute('disabled', '1');
|
|
||||||
if (customInput) {
|
|
||||||
customInput.setAttribute('disabled', '1');
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
optionInput.removeAttribute('disabled');
|
|
||||||
namedRangeSelect?.removeAttribute('disabled');
|
|
||||||
if (customInput) {
|
|
||||||
customInput.removeAttribute('disabled');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Handle changes to TextChoice input[type=text]
|
|
||||||
document.querySelectorAll('.text-choice-container input[type=text]').forEach((input) => {
|
|
||||||
const optionName = input.getAttribute('data-option-name');
|
|
||||||
input.addEventListener('input', () => {
|
|
||||||
const select = document.getElementById(optionName);
|
|
||||||
const optionValues = [];
|
|
||||||
select.childNodes.forEach((option) => optionValues.push(option.value));
|
|
||||||
select.value = (optionValues.includes(input.value)) ? input.value : 'custom';
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Handle changes to TextChoice select
|
|
||||||
document.querySelectorAll('.text-choice-container select').forEach((select) => {
|
|
||||||
const optionName = select.getAttribute('id');
|
|
||||||
select.addEventListener('change', () => {
|
|
||||||
document.getElementById(`${optionName}-custom`).value = '';
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Update the "Option Preset" select to read "custom" when changes are made to relevant inputs
|
|
||||||
const presetSelect = document.getElementById('game-options-preset');
|
|
||||||
document.querySelectorAll('input, select').forEach((input) => {
|
|
||||||
if ( // Ignore inputs which have no effect on yaml generation
|
|
||||||
(input.id === 'player-name') ||
|
|
||||||
(input.id === 'game-options-preset') ||
|
|
||||||
(input.classList.contains('group-toggle')) ||
|
|
||||||
(input.type === 'submit')
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
input.addEventListener('change', () => {
|
|
||||||
presetSelect.value = 'custom';
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Handle changes to presets select
|
|
||||||
document.getElementById('game-options-preset').addEventListener('change', choosePreset);
|
|
||||||
|
|
||||||
// Save settings to localStorage when form is submitted
|
|
||||||
document.getElementById('options-form').addEventListener('submit', (evt) => {
|
|
||||||
const playerName = document.getElementById('player-name');
|
|
||||||
if (!playerName.value.trim()) {
|
|
||||||
evt.preventDefault();
|
|
||||||
window.scrollTo(0, 0);
|
|
||||||
showUserMessage('You must enter a player name!');
|
|
||||||
}
|
|
||||||
|
|
||||||
saveSettings();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Save all settings to localStorage
|
|
||||||
const saveSettings = () => {
|
|
||||||
const options = {
|
|
||||||
inputs: {},
|
|
||||||
checkboxes: {},
|
|
||||||
};
|
|
||||||
document.querySelectorAll('input, select').forEach((input) => {
|
|
||||||
if (input.type === 'submit') {
|
|
||||||
// Ignore submit inputs
|
|
||||||
}
|
|
||||||
else if (input.type === 'checkbox') {
|
|
||||||
options.checkboxes[input.id] = input.checked;
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
options.inputs[input.id] = input.value
|
|
||||||
}
|
|
||||||
});
|
|
||||||
const game = document.getElementById('player-options').getAttribute('data-game');
|
|
||||||
localStorage.setItem(game, JSON.stringify(options));
|
|
||||||
};
|
|
||||||
|
|
||||||
// Load all options from localStorage
|
|
||||||
const loadSettings = () => {
|
|
||||||
const game = document.getElementById('player-options').getAttribute('data-game');
|
|
||||||
|
|
||||||
const options = JSON.parse(localStorage.getItem(game));
|
|
||||||
if (options) {
|
|
||||||
if (!options.inputs || !options.checkboxes) {
|
|
||||||
localStorage.removeItem(game);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Restore value-based inputs and selects
|
|
||||||
Object.keys(options.inputs).forEach((key) => {
|
|
||||||
try{
|
|
||||||
document.getElementById(key).value = options.inputs[key];
|
|
||||||
const rangeValue = document.getElementById(`${key}-value`);
|
|
||||||
if (rangeValue) {
|
|
||||||
rangeValue.innerText = options.inputs[key];
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`Unable to restore value to input with id ${key}`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Restore checkboxes
|
|
||||||
Object.keys(options.checkboxes).forEach((key) => {
|
|
||||||
try{
|
|
||||||
if (options.checkboxes[key]) {
|
|
||||||
document.getElementById(key).setAttribute('checked', '1');
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`Unable to restore value to input with id ${key}`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure any input for which the randomize checkbox is checked by default, the relevant inputs are disabled
|
|
||||||
document.querySelectorAll('.randomize-checkbox').forEach((checkbox) => {
|
|
||||||
const optionName = checkbox.getAttribute('data-option-name');
|
|
||||||
if (checkbox.checked) {
|
|
||||||
const input = document.getElementById(optionName);
|
|
||||||
if (input) {
|
|
||||||
input.setAttribute('disabled', '1');
|
|
||||||
}
|
|
||||||
const customInput = document.getElementById(`${optionName}-custom`);
|
|
||||||
if (customInput) {
|
|
||||||
customInput.setAttribute('disabled', '1');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch the preset data for this game and apply the presets if localStorage indicates one was previously chosen
|
|
||||||
* @returns {Promise<void>}
|
|
||||||
*/
|
|
||||||
const fetchPresets = async () => {
|
|
||||||
const response = await fetch('option-presets');
|
|
||||||
presets = await response.json();
|
|
||||||
const presetSelect = document.getElementById('game-options-preset');
|
|
||||||
presetSelect.removeAttribute('disabled');
|
|
||||||
|
|
||||||
const game = document.getElementById('player-options').getAttribute('data-game');
|
|
||||||
const presetToApply = localStorage.getItem(`${game}-preset`);
|
|
||||||
const playerName = localStorage.getItem(`${game}-player`);
|
|
||||||
if (presetToApply) {
|
|
||||||
localStorage.removeItem(`${game}-preset`);
|
|
||||||
presetSelect.value = presetToApply;
|
|
||||||
applyPresets(presetToApply);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (playerName) {
|
|
||||||
document.getElementById('player-name').value = playerName;
|
|
||||||
localStorage.removeItem(`${game}-player`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Clear the localStorage for this game and set a preset to be loaded upon page reload
|
|
||||||
* @param evt
|
|
||||||
*/
|
|
||||||
const choosePreset = (evt) => {
|
|
||||||
if (evt.target.value === 'custom') { return; }
|
|
||||||
|
|
||||||
const game = document.getElementById('player-options').getAttribute('data-game');
|
|
||||||
localStorage.removeItem(game);
|
|
||||||
|
|
||||||
localStorage.setItem(`${game}-player`, document.getElementById('player-name').value);
|
|
||||||
if (evt.target.value !== 'default') {
|
|
||||||
localStorage.setItem(`${game}-preset`, evt.target.value);
|
|
||||||
}
|
|
||||||
|
|
||||||
document.querySelectorAll('#options-form input, #options-form select').forEach((input) => {
|
|
||||||
if (input.id === 'player-name') { return; }
|
|
||||||
input.removeAttribute('value');
|
|
||||||
});
|
|
||||||
|
|
||||||
window.location.replace(window.location.href);
|
|
||||||
};
|
|
||||||
|
|
||||||
const applyPresets = (presetName) => {
|
|
||||||
// Ignore the "default" preset, because it gets set automatically by Jinja
|
|
||||||
if (presetName === 'default') {
|
|
||||||
saveSettings();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!presets[presetName]) {
|
|
||||||
console.error(`Unknown preset ${presetName} chosen`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const preset = presets[presetName];
|
|
||||||
Object.keys(preset).forEach((optionName) => {
|
|
||||||
const optionValue = preset[optionName];
|
|
||||||
|
|
||||||
// Handle List and Set options
|
|
||||||
if (Array.isArray(optionValue)) {
|
|
||||||
document.querySelectorAll(`input[type=checkbox][name=${optionName}]`).forEach((checkbox) => {
|
|
||||||
if (optionValue.includes(checkbox.value)) {
|
|
||||||
checkbox.setAttribute('checked', '1');
|
|
||||||
} else {
|
|
||||||
checkbox.removeAttribute('checked');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle Dict options
|
|
||||||
if (typeof(optionValue) === 'object' && optionValue !== null) {
|
|
||||||
const itemNames = Object.keys(optionValue);
|
|
||||||
document.querySelectorAll(`input[type=number][data-option-name=${optionName}]`).forEach((input) => {
|
|
||||||
const itemName = input.getAttribute('data-item-name');
|
|
||||||
input.value = (itemNames.includes(itemName)) ? optionValue[itemName] : 0
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Identify all possible elements
|
|
||||||
const normalInput = document.getElementById(optionName);
|
|
||||||
const customInput = document.getElementById(`${optionName}-custom`);
|
|
||||||
const rangeValue = document.getElementById(`${optionName}-value`);
|
|
||||||
const randomizeInput = document.getElementById(`random-${optionName}`);
|
|
||||||
const namedRangeSelect = document.getElementById(`${optionName}-select`);
|
|
||||||
|
|
||||||
// It is possible for named ranges to use name of a value rather than the value itself. This is accounted for here
|
|
||||||
let trueValue = optionValue;
|
|
||||||
if (namedRangeSelect) {
|
|
||||||
namedRangeSelect.querySelectorAll('option').forEach((opt) => {
|
|
||||||
if (opt.innerText.startsWith(optionValue)) {
|
|
||||||
trueValue = opt.value;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
namedRangeSelect.value = trueValue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle options whose presets are "random"
|
|
||||||
if (optionValue === 'random') {
|
|
||||||
normalInput.setAttribute('disabled', '1');
|
|
||||||
randomizeInput.setAttribute('checked', '1');
|
|
||||||
if (customInput) {
|
|
||||||
customInput.setAttribute('disabled', '1');
|
|
||||||
}
|
|
||||||
if (rangeValue) {
|
|
||||||
rangeValue.innerText = normalInput.value;
|
|
||||||
}
|
|
||||||
if (namedRangeSelect) {
|
|
||||||
namedRangeSelect.setAttribute('disabled', '1');
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle normal (text, number, select, etc.) and custom inputs (custom inputs exist with TextChoice only)
|
|
||||||
normalInput.value = trueValue;
|
|
||||||
normalInput.removeAttribute('disabled');
|
|
||||||
randomizeInput.removeAttribute('checked');
|
|
||||||
if (customInput) {
|
|
||||||
document.getElementById(`${optionName}-custom`).removeAttribute('disabled');
|
|
||||||
}
|
|
||||||
if (rangeValue) {
|
|
||||||
rangeValue.innerText = trueValue;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
saveSettings();
|
|
||||||
};
|
|
||||||
|
|
||||||
const showUserMessage = (text) => {
|
|
||||||
const userMessage = document.getElementById('user-message');
|
|
||||||
userMessage.innerText = text;
|
|
||||||
userMessage.addEventListener('click', hideUserMessage);
|
|
||||||
userMessage.style.display = 'block';
|
|
||||||
};
|
|
||||||
|
|
||||||
const hideUserMessage = () => {
|
|
||||||
const userMessage = document.getElementById('user-message');
|
|
||||||
userMessage.removeEventListener('click', hideUserMessage);
|
|
||||||
userMessage.style.display = 'none';
|
|
||||||
};
|
|
||||||
@@ -25,16 +25,16 @@ window.addEventListener('load', () => {
|
|||||||
|
|
||||||
// Collapsible advancement sections
|
// Collapsible advancement sections
|
||||||
const categories = document.getElementsByClassName("location-category");
|
const categories = document.getElementsByClassName("location-category");
|
||||||
for (let category of categories) {
|
for (let i = 0; i < categories.length; i++) {
|
||||||
let hide_id = category.id.split('_')[0];
|
let hide_id = categories[i].id.split('-')[0];
|
||||||
if (hide_id === 'Total') {
|
if (hide_id == 'Total') {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
category.addEventListener('click', function() {
|
categories[i].addEventListener('click', function() {
|
||||||
// Toggle the advancement list
|
// Toggle the advancement list
|
||||||
document.getElementById(hide_id).classList.toggle("hide");
|
document.getElementById(hide_id).classList.toggle("hide");
|
||||||
// Change text of the header
|
// Change text of the header
|
||||||
const tab_header = document.getElementById(hide_id+'_header').children[0];
|
const tab_header = document.getElementById(hide_id+'-header').children[0];
|
||||||
const orig_text = tab_header.innerHTML;
|
const orig_text = tab_header.innerHTML;
|
||||||
let new_text;
|
let new_text;
|
||||||
if (orig_text.includes("▼")) {
|
if (orig_text.includes("▼")) {
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
window.addEventListener('load', () => {
|
|
||||||
// Add toggle listener to all elements with .collapse-toggle
|
|
||||||
const toggleButtons = document.querySelectorAll('details');
|
|
||||||
|
|
||||||
// Handle game filter input
|
|
||||||
const gameSearch = document.getElementById('game-search');
|
|
||||||
gameSearch.value = '';
|
|
||||||
gameSearch.addEventListener('input', (evt) => {
|
|
||||||
if (!evt.target.value.trim()) {
|
|
||||||
// If input is empty, display all games as collapsed
|
|
||||||
return toggleButtons.forEach((header) => {
|
|
||||||
header.style.display = null;
|
|
||||||
header.removeAttribute('open');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Loop over all the games
|
|
||||||
toggleButtons.forEach((header) => {
|
|
||||||
// If the game name includes the search string, display the game. If not, hide it
|
|
||||||
if (header.getAttribute('data-game').toLowerCase().includes(evt.target.value.toLowerCase())) {
|
|
||||||
header.style.display = null;
|
|
||||||
header.setAttribute('open', '1');
|
|
||||||
} else {
|
|
||||||
header.style.display = 'none';
|
|
||||||
header.removeAttribute('open');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById('expand-all').addEventListener('click', expandAll);
|
|
||||||
document.getElementById('collapse-all').addEventListener('click', collapseAll);
|
|
||||||
});
|
|
||||||
|
|
||||||
const expandAll = () => {
|
|
||||||
document.querySelectorAll('details').forEach((detail) => {
|
|
||||||
detail.setAttribute('open', '1');
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const collapseAll = () => {
|
|
||||||
document.querySelectorAll('details').forEach((detail) => {
|
|
||||||
detail.removeAttribute('open');
|
|
||||||
});
|
|
||||||
};
|
|
||||||
@@ -4,34 +4,16 @@ const adjustTableHeight = () => {
|
|||||||
return;
|
return;
|
||||||
const upperDistance = tablesContainer.getBoundingClientRect().top;
|
const upperDistance = tablesContainer.getBoundingClientRect().top;
|
||||||
|
|
||||||
|
const containerHeight = window.innerHeight - upperDistance;
|
||||||
|
tablesContainer.style.maxHeight = `calc(${containerHeight}px - 1rem)`;
|
||||||
|
|
||||||
const tableWrappers = document.getElementsByClassName('table-wrapper');
|
const tableWrappers = document.getElementsByClassName('table-wrapper');
|
||||||
for (let i = 0; i < tableWrappers.length; i++) {
|
for(let i=0; i < tableWrappers.length; i++){
|
||||||
// Ensure we are starting from maximum size prior to calculation.
|
const maxHeight = (window.innerHeight - upperDistance) / 2;
|
||||||
tableWrappers[i].style.height = null;
|
tableWrappers[i].style.maxHeight = `calc(${maxHeight}px - 1rem)`;
|
||||||
tableWrappers[i].style.maxHeight = null;
|
|
||||||
|
|
||||||
// Set as a reasonable height, but still allows the user to resize element if they desire.
|
|
||||||
const currentHeight = tableWrappers[i].offsetHeight;
|
|
||||||
const maxHeight = (window.innerHeight - upperDistance) / Math.min(tableWrappers.length, 4);
|
|
||||||
if (currentHeight > maxHeight) {
|
|
||||||
tableWrappers[i].style.height = `calc(${maxHeight}px - 1rem)`;
|
|
||||||
}
|
|
||||||
|
|
||||||
tableWrappers[i].style.maxHeight = `${currentHeight}px`;
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Convert an integer number of seconds into a human readable HH:MM format
|
|
||||||
* @param {Number} seconds
|
|
||||||
* @returns {string}
|
|
||||||
*/
|
|
||||||
const secondsToHours = (seconds) => {
|
|
||||||
let hours = Math.floor(seconds / 3600);
|
|
||||||
let minutes = Math.floor((seconds - (hours * 3600)) / 60).toString().padStart(2, '0');
|
|
||||||
return `${hours}:${minutes}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener('load', () => {
|
window.addEventListener('load', () => {
|
||||||
const tables = $(".table").DataTable({
|
const tables = $(".table").DataTable({
|
||||||
paging: false,
|
paging: false,
|
||||||
@@ -45,31 +27,24 @@ window.addEventListener('load', () => {
|
|||||||
stateLoadCallback: function(settings) {
|
stateLoadCallback: function(settings) {
|
||||||
return JSON.parse(localStorage.getItem(`DataTables_${settings.sInstance}_/tracker`));
|
return JSON.parse(localStorage.getItem(`DataTables_${settings.sInstance}_/tracker`));
|
||||||
},
|
},
|
||||||
footerCallback: function(tfoot, data, start, end, display) {
|
|
||||||
if (tfoot) {
|
|
||||||
const activityData = this.api().column('lastActivity:name').data().toArray().filter(x => !isNaN(x));
|
|
||||||
Array.from(tfoot?.children).find(td => td.classList.contains('last-activity')).innerText =
|
|
||||||
(activityData.length) ? secondsToHours(Math.min(...activityData)) : 'None';
|
|
||||||
}
|
|
||||||
},
|
|
||||||
columnDefs: [
|
columnDefs: [
|
||||||
{
|
|
||||||
targets: 'last-activity',
|
|
||||||
name: 'lastActivity'
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
targets: 'hours',
|
targets: 'hours',
|
||||||
render: function (data, type, row) {
|
render: function (data, type, row) {
|
||||||
if (type === "sort" || type === 'type') {
|
if (type === "sort" || type === 'type') {
|
||||||
if (data === "None")
|
if (data === "None")
|
||||||
return Number.MAX_VALUE;
|
return -1;
|
||||||
|
|
||||||
return parseInt(data);
|
return parseInt(data);
|
||||||
}
|
}
|
||||||
if (data === "None")
|
if (data === "None")
|
||||||
return data;
|
return data;
|
||||||
|
|
||||||
return secondsToHours(data);
|
let hours = Math.floor(data / 3600);
|
||||||
|
let minutes = Math.floor((data - (hours * 3600)) / 60);
|
||||||
|
|
||||||
|
if (minutes < 10) {minutes = "0"+minutes;}
|
||||||
|
return hours+':'+minutes;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -139,16 +114,11 @@ window.addEventListener('load', () => {
|
|||||||
if (status === "success") {
|
if (status === "success") {
|
||||||
target.find(".table").each(function (i, new_table) {
|
target.find(".table").each(function (i, new_table) {
|
||||||
const new_trs = $(new_table).find("tbody>tr");
|
const new_trs = $(new_table).find("tbody>tr");
|
||||||
const footer_tr = $(new_table).find("tfoot>tr");
|
|
||||||
const old_table = tables.eq(i);
|
const old_table = tables.eq(i);
|
||||||
const topscroll = $(old_table.settings()[0].nScrollBody).scrollTop();
|
const topscroll = $(old_table.settings()[0].nScrollBody).scrollTop();
|
||||||
const leftscroll = $(old_table.settings()[0].nScrollBody).scrollLeft();
|
const leftscroll = $(old_table.settings()[0].nScrollBody).scrollLeft();
|
||||||
old_table.clear();
|
old_table.clear();
|
||||||
if (footer_tr.length) {
|
old_table.rows.add(new_trs).draw();
|
||||||
$(old_table.table).find("tfoot").html(footer_tr);
|
|
||||||
}
|
|
||||||
old_table.rows.add(new_trs);
|
|
||||||
old_table.draw();
|
|
||||||
$(old_table.settings()[0].nScrollBody).scrollTop(topscroll);
|
$(old_table.settings()[0].nScrollBody).scrollTop(topscroll);
|
||||||
$(old_table.settings()[0].nScrollBody).scrollLeft(leftscroll);
|
$(old_table.settings()[0].nScrollBody).scrollLeft(leftscroll);
|
||||||
});
|
});
|
||||||
|
|||||||
1219
WebHostLib/static/assets/weighted-settings.js
Normal file
1219
WebHostLib/static/assets/weighted-settings.js
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,223 +0,0 @@
|
|||||||
let deletedOptions = {};
|
|
||||||
|
|
||||||
window.addEventListener('load', () => {
|
|
||||||
const worldName = document.querySelector('#weighted-options').getAttribute('data-game');
|
|
||||||
|
|
||||||
// Generic change listener. Detecting unique qualities and acting on them here reduces initial JS initialisation time
|
|
||||||
// and handles dynamically created elements
|
|
||||||
document.addEventListener('change', (evt) => {
|
|
||||||
// Handle updates to range inputs
|
|
||||||
if (evt.target.type === 'range') {
|
|
||||||
// Update span containing range value. All ranges have a corresponding `{rangeId}-value` span
|
|
||||||
document.getElementById(`${evt.target.id}-value`).innerText = evt.target.value;
|
|
||||||
|
|
||||||
// If the changed option was the name of a game, determine whether to show or hide that game's div
|
|
||||||
if (evt.target.id.startsWith('game||')) {
|
|
||||||
const gameName = evt.target.id.split('||')[1];
|
|
||||||
const gameDiv = document.getElementById(`${gameName}-container`);
|
|
||||||
if (evt.target.value > 0) {
|
|
||||||
gameDiv.classList.remove('hidden');
|
|
||||||
} else {
|
|
||||||
gameDiv.classList.add('hidden');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Generic click listener
|
|
||||||
document.addEventListener('click', (evt) => {
|
|
||||||
// Handle creating new rows for Range options
|
|
||||||
if (evt.target.classList.contains('add-range-option-button')) {
|
|
||||||
const optionName = evt.target.getAttribute('data-option');
|
|
||||||
addRangeRow(optionName);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle deleting range rows
|
|
||||||
if (evt.target.classList.contains('range-option-delete')) {
|
|
||||||
const targetRow = document.querySelector(`tr[data-row="${evt.target.getAttribute('data-target')}"]`);
|
|
||||||
setDeletedOption(
|
|
||||||
targetRow.getAttribute('data-option-name'),
|
|
||||||
targetRow.getAttribute('data-value'),
|
|
||||||
);
|
|
||||||
targetRow.parentElement.removeChild(targetRow);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Listen for enter presses on inputs intended to add range rows
|
|
||||||
document.addEventListener('keydown', (evt) => {
|
|
||||||
if (evt.key === 'Enter') {
|
|
||||||
evt.preventDefault();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (evt.key === 'Enter' && evt.target.classList.contains('range-option-value')) {
|
|
||||||
const optionName = evt.target.getAttribute('data-option');
|
|
||||||
addRangeRow(optionName);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Detect form submission
|
|
||||||
document.getElementById('weighted-options-form').addEventListener('submit', (evt) => {
|
|
||||||
// Save data to localStorage
|
|
||||||
const weightedOptions = {};
|
|
||||||
document.querySelectorAll('input[name]').forEach((input) => {
|
|
||||||
const keys = input.getAttribute('name').split('||');
|
|
||||||
|
|
||||||
// Determine keys
|
|
||||||
const optionName = keys[0] ?? null;
|
|
||||||
const subOption = keys[1] ?? null;
|
|
||||||
|
|
||||||
// Ensure keys exist
|
|
||||||
if (!weightedOptions[optionName]) { weightedOptions[optionName] = {}; }
|
|
||||||
if (subOption && !weightedOptions[optionName][subOption]) {
|
|
||||||
weightedOptions[optionName][subOption] = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (subOption) { return weightedOptions[optionName][subOption] = determineValue(input); }
|
|
||||||
if (optionName) { return weightedOptions[optionName] = determineValue(input); }
|
|
||||||
});
|
|
||||||
|
|
||||||
localStorage.setItem(`${worldName}-weights`, JSON.stringify(weightedOptions));
|
|
||||||
localStorage.setItem(`${worldName}-deletedOptions`, JSON.stringify(deletedOptions));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Remove all deleted values as specified by localStorage
|
|
||||||
deletedOptions = JSON.parse(localStorage.getItem(`${worldName}-deletedOptions`) || '{}');
|
|
||||||
Object.keys(deletedOptions).forEach((optionName) => {
|
|
||||||
deletedOptions[optionName].forEach((value) => {
|
|
||||||
const targetRow = document.querySelector(`tr[data-row="${value}-row"]`);
|
|
||||||
targetRow.parentElement.removeChild(targetRow);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Populate all settings from localStorage on page initialisation
|
|
||||||
const previousSettingsJson = localStorage.getItem(`${worldName}-weights`);
|
|
||||||
if (previousSettingsJson) {
|
|
||||||
const previousSettings = JSON.parse(previousSettingsJson);
|
|
||||||
Object.keys(previousSettings).forEach((option) => {
|
|
||||||
if (typeof previousSettings[option] === 'string') {
|
|
||||||
return document.querySelector(`input[name="${option}"]`).value = previousSettings[option];
|
|
||||||
}
|
|
||||||
|
|
||||||
Object.keys(previousSettings[option]).forEach((value) => {
|
|
||||||
const input = document.querySelector(`input[name="${option}||${value}"]`);
|
|
||||||
if (!input?.type) {
|
|
||||||
return console.error(`Unable to populate option with name ${option}||${value}.`);
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (input.type) {
|
|
||||||
case 'checkbox':
|
|
||||||
input.checked = (parseInt(previousSettings[option][value], 10) === 1);
|
|
||||||
break;
|
|
||||||
case 'range':
|
|
||||||
input.value = parseInt(previousSettings[option][value], 10);
|
|
||||||
break;
|
|
||||||
case 'number':
|
|
||||||
input.value = previousSettings[option][value].toString();
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
console.error(`Found unsupported input type: ${input.type}`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const addRangeRow = (optionName) => {
|
|
||||||
const inputQuery = `input[type=number][data-option="${optionName}"].range-option-value`;
|
|
||||||
const inputTarget = document.querySelector(inputQuery);
|
|
||||||
const newValue = inputTarget.value;
|
|
||||||
if (!/^-?\d+$/.test(newValue)) {
|
|
||||||
alert('Range values must be a positive or negative integer!');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
inputTarget.value = '';
|
|
||||||
const tBody = document.querySelector(`table[data-option="${optionName}"].range-rows tbody`);
|
|
||||||
const tr = document.createElement('tr');
|
|
||||||
tr.setAttribute('data-row', `${optionName}-${newValue}-row`);
|
|
||||||
tr.setAttribute('data-option-name', optionName);
|
|
||||||
tr.setAttribute('data-value', newValue);
|
|
||||||
const tdLeft = document.createElement('td');
|
|
||||||
tdLeft.classList.add('td-left');
|
|
||||||
const label = document.createElement('label');
|
|
||||||
label.setAttribute('for', `${optionName}||${newValue}`);
|
|
||||||
label.innerText = newValue.toString();
|
|
||||||
tdLeft.appendChild(label);
|
|
||||||
tr.appendChild(tdLeft);
|
|
||||||
const tdMiddle = document.createElement('td');
|
|
||||||
tdMiddle.classList.add('td-middle');
|
|
||||||
const range = document.createElement('input');
|
|
||||||
range.setAttribute('type', 'range');
|
|
||||||
range.setAttribute('min', '0');
|
|
||||||
range.setAttribute('max', '50');
|
|
||||||
range.setAttribute('value', '0');
|
|
||||||
range.setAttribute('id', `${optionName}||${newValue}`);
|
|
||||||
range.setAttribute('name', `${optionName}||${newValue}`);
|
|
||||||
tdMiddle.appendChild(range);
|
|
||||||
tr.appendChild(tdMiddle);
|
|
||||||
const tdRight = document.createElement('td');
|
|
||||||
tdRight.classList.add('td-right');
|
|
||||||
const valueSpan = document.createElement('span');
|
|
||||||
valueSpan.setAttribute('id', `${optionName}||${newValue}-value`);
|
|
||||||
valueSpan.innerText = '0';
|
|
||||||
tdRight.appendChild(valueSpan);
|
|
||||||
tr.appendChild(tdRight);
|
|
||||||
const tdDelete = document.createElement('td');
|
|
||||||
const deleteSpan = document.createElement('span');
|
|
||||||
deleteSpan.classList.add('range-option-delete');
|
|
||||||
deleteSpan.classList.add('js-required');
|
|
||||||
deleteSpan.setAttribute('data-target', `${optionName}-${newValue}-row`);
|
|
||||||
deleteSpan.innerText = '❌';
|
|
||||||
tdDelete.appendChild(deleteSpan);
|
|
||||||
tr.appendChild(tdDelete);
|
|
||||||
tBody.appendChild(tr);
|
|
||||||
|
|
||||||
// Remove this option from the set of deleted options if it exists
|
|
||||||
unsetDeletedOption(optionName, newValue);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Determines the value of an input element, or returns a 1 or 0 if the element is a checkbox
|
|
||||||
*
|
|
||||||
* @param {object} input - The input element.
|
|
||||||
* @returns {number} The value of the input element.
|
|
||||||
*/
|
|
||||||
const determineValue = (input) => {
|
|
||||||
switch (input.type) {
|
|
||||||
case 'checkbox':
|
|
||||||
return (input.checked ? 1 : 0);
|
|
||||||
case 'range':
|
|
||||||
return parseInt(input.value, 10);
|
|
||||||
default:
|
|
||||||
return input.value;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sets the deleted option value for a given world and option name.
|
|
||||||
* If the world or option does not exist, it creates the necessary entries.
|
|
||||||
*
|
|
||||||
* @param {string} optionName - The name of the option.
|
|
||||||
* @param {*} value - The value to be set for the deleted option.
|
|
||||||
* @returns {void}
|
|
||||||
*/
|
|
||||||
const setDeletedOption = (optionName, value) => {
|
|
||||||
deletedOptions[optionName] = deletedOptions[optionName] || [];
|
|
||||||
deletedOptions[optionName].push(`${optionName}-${value}`);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Removes a specific value from the deletedOptions object.
|
|
||||||
*
|
|
||||||
* @param {string} optionName - The name of the option.
|
|
||||||
* @param {*} value - The value to be removed
|
|
||||||
* @returns {void}
|
|
||||||
*/
|
|
||||||
const unsetDeletedOption = (optionName, value) => {
|
|
||||||
if (!deletedOptions.hasOwnProperty(optionName)) { return; }
|
|
||||||
if (deletedOptions[optionName].includes(`${optionName}-${value}`)) {
|
|
||||||
deletedOptions[optionName].splice(deletedOptions[optionName].indexOf(`${optionName}-${value}`), 1);
|
|
||||||
}
|
|
||||||
if (deletedOptions[optionName].length === 0) {
|
|
||||||
delete deletedOptions[optionName];
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
User-agent: Googlebot
|
|
||||||
Disallow: /
|
|
||||||
|
|
||||||
User-agent: APIs-Google
|
|
||||||
Disallow: /
|
|
||||||
|
|
||||||
User-agent: AdsBot-Google-Mobile
|
|
||||||
Disallow: /
|
|
||||||
|
|
||||||
User-agent: AdsBot-Google-Mobile
|
|
||||||
Disallow: /
|
|
||||||
|
|
||||||
User-agent: Mediapartners-Google
|
|
||||||
Disallow: /
|
|
||||||
|
|
||||||
User-agent: Google-Safety
|
|
||||||
Disallow: /
|
|
||||||
|
|
||||||
User-agent: *
|
|
||||||
Disallow: /
|
|
||||||
@@ -44,7 +44,7 @@ a{
|
|||||||
font-family: LexendDeca-Regular, sans-serif;
|
font-family: LexendDeca-Regular, sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
button, input[type=submit]{
|
button{
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
padding: 10px 17px 11px 16px; /* top right bottom left */
|
padding: 10px 17px 11px 16px; /* top right bottom left */
|
||||||
@@ -57,7 +57,7 @@ button, input[type=submit]{
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
button:active, input[type=submit]:active{
|
button:active{
|
||||||
border-right: 1px solid rgba(0, 0, 0, 0.5);
|
border-right: 1px solid rgba(0, 0, 0, 0.5);
|
||||||
border-bottom: 1px solid rgba(0, 0, 0, 0.5);
|
border-bottom: 1px solid rgba(0, 0, 0, 0.5);
|
||||||
padding-right: 16px;
|
padding-right: 16px;
|
||||||
@@ -66,11 +66,11 @@ button:active, input[type=submit]:active{
|
|||||||
margin-bottom: 2px;
|
margin-bottom: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
button.button-grass, input[type=submit].button-grass{
|
button.button-grass{
|
||||||
border: 1px solid black;
|
border: 1px solid black;
|
||||||
}
|
}
|
||||||
|
|
||||||
button.button-dirt, input[type=submit].button-dirt{
|
button.button-dirt{
|
||||||
border: 1px solid black;
|
border: 1px solid black;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,4 +111,4 @@ h5, h6{
|
|||||||
|
|
||||||
.interactive{
|
.interactive{
|
||||||
color: #ffef00;
|
color: #ffef00;
|
||||||
}
|
}
|
||||||
@@ -235,6 +235,9 @@ html{
|
|||||||
line-height: 30px;
|
line-height: 30px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#landing .variable{
|
||||||
|
color: #ffff00;
|
||||||
|
}
|
||||||
|
|
||||||
.landing-deco{
|
.landing-deco{
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
|||||||
75
WebHostLib/static/styles/lttp-tracker.css
Normal file
75
WebHostLib/static/styles/lttp-tracker.css
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
#player-tracker-wrapper{
|
||||||
|
margin: 0;
|
||||||
|
font-family: LexendDeca-Light, sans-serif;
|
||||||
|
color: white;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#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: 284px;
|
||||||
|
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(75%);
|
||||||
|
}
|
||||||
|
|
||||||
|
#inventory-table img.acquired{
|
||||||
|
filter: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#inventory-table img.powder-fix{
|
||||||
|
width: 35px;
|
||||||
|
height: 35px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#location-table{
|
||||||
|
width: 284px;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
#location-table th{
|
||||||
|
vertical-align: middle;
|
||||||
|
text-align: center;
|
||||||
|
padding-right: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#location-table td{
|
||||||
|
padding-top: 2px;
|
||||||
|
padding-bottom: 2px;
|
||||||
|
padding-right: 5px;
|
||||||
|
line-height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#location-table td.counter{
|
||||||
|
padding-right: 8px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
#location-table img{
|
||||||
|
height: 100%;
|
||||||
|
max-width: 30px;
|
||||||
|
max-height: 30px;
|
||||||
|
}
|
||||||
@@ -23,7 +23,7 @@
|
|||||||
|
|
||||||
.markdown a{}
|
.markdown a{}
|
||||||
|
|
||||||
.markdown h1, .markdown details summary.h1{
|
.markdown h1{
|
||||||
font-size: 52px;
|
font-size: 52px;
|
||||||
font-weight: normal;
|
font-weight: normal;
|
||||||
font-family: LondrinaSolid-Regular, sans-serif;
|
font-family: LondrinaSolid-Regular, sans-serif;
|
||||||
@@ -33,7 +33,7 @@
|
|||||||
text-shadow: 1px 1px 4px #000000;
|
text-shadow: 1px 1px 4px #000000;
|
||||||
}
|
}
|
||||||
|
|
||||||
.markdown h2, .markdown details summary.h2{
|
.markdown h2{
|
||||||
font-size: 38px;
|
font-size: 38px;
|
||||||
font-weight: normal;
|
font-weight: normal;
|
||||||
font-family: LondrinaSolid-Light, sans-serif;
|
font-family: LondrinaSolid-Light, sans-serif;
|
||||||
@@ -45,7 +45,7 @@
|
|||||||
text-shadow: 1px 1px 2px #000000;
|
text-shadow: 1px 1px 2px #000000;
|
||||||
}
|
}
|
||||||
|
|
||||||
.markdown h3, .markdown details summary.h3{
|
.markdown h3{
|
||||||
font-size: 26px;
|
font-size: 26px;
|
||||||
font-family: LexendDeca-Regular, sans-serif;
|
font-family: LexendDeca-Regular, sans-serif;
|
||||||
text-transform: none;
|
text-transform: none;
|
||||||
@@ -55,7 +55,7 @@
|
|||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.markdown h4, .markdown details summary.h4{
|
.markdown h4{
|
||||||
font-family: LexendDeca-Regular, sans-serif;
|
font-family: LexendDeca-Regular, sans-serif;
|
||||||
text-transform: none;
|
text-transform: none;
|
||||||
font-size: 24px;
|
font-size: 24px;
|
||||||
@@ -63,21 +63,21 @@
|
|||||||
margin-bottom: 24px;
|
margin-bottom: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.markdown h5, .markdown details summary.h5{
|
.markdown h5{
|
||||||
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;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.markdown h6, .markdown details summary.h6{
|
.markdown 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;;
|
cursor: pointer;;
|
||||||
}
|
}
|
||||||
|
|
||||||
.markdown h4, .markdown h5, .markdown h6{
|
.markdown h4, .markdown h5,.markdown h6{
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
213
WebHostLib/static/styles/player-settings.css
Normal file
213
WebHostLib/static/styles/player-settings.css
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
html{
|
||||||
|
background-image: url('../static/backgrounds/grass.png');
|
||||||
|
background-repeat: repeat;
|
||||||
|
background-size: 650px 650px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#player-settings{
|
||||||
|
box-sizing: border-box;
|
||||||
|
max-width: 1024px;
|
||||||
|
margin-left: auto;
|
||||||
|
margin-right: auto;
|
||||||
|
background-color: rgba(0, 0, 0, 0.15);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1rem;
|
||||||
|
color: #eeffeb;
|
||||||
|
}
|
||||||
|
|
||||||
|
#player-settings #player-settings-button-row{
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-top: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#player-settings code{
|
||||||
|
background-color: #d9cd8e;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding-left: 0.25rem;
|
||||||
|
padding-right: 0.25rem;
|
||||||
|
color: #000000;
|
||||||
|
}
|
||||||
|
|
||||||
|
#player-settings #user-message{
|
||||||
|
display: none;
|
||||||
|
width: calc(100% - 8px);
|
||||||
|
background-color: #ffe86b;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: #000000;
|
||||||
|
padding: 4px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
#player-settings #user-message.visible{
|
||||||
|
display: block;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
#player-settings h1{
|
||||||
|
font-size: 2.5rem;
|
||||||
|
font-weight: normal;
|
||||||
|
width: 100%;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
text-shadow: 1px 1px 4px #000000;
|
||||||
|
}
|
||||||
|
|
||||||
|
#player-settings h2{
|
||||||
|
font-size: 40px;
|
||||||
|
font-weight: normal;
|
||||||
|
width: 100%;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
text-transform: lowercase;
|
||||||
|
text-shadow: 1px 1px 2px #000000;
|
||||||
|
}
|
||||||
|
|
||||||
|
#player-settings h3, #player-settings h4, #player-settings h5, #player-settings h6{
|
||||||
|
text-shadow: 1px 1px 1px rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
#player-settings input:not([type]){
|
||||||
|
border: 1px solid #000000;
|
||||||
|
padding: 3px;
|
||||||
|
border-radius: 3px;
|
||||||
|
min-width: 150px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#player-settings input:not([type]):focus{
|
||||||
|
border: 1px solid #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
#player-settings select{
|
||||||
|
border: 1px solid #000000;
|
||||||
|
padding: 3px;
|
||||||
|
border-radius: 3px;
|
||||||
|
min-width: 150px;
|
||||||
|
background-color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
#player-settings #game-options, #player-settings #rom-options{
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
}
|
||||||
|
|
||||||
|
#player-settings .left, #player-settings .right{
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
#player-settings .left{
|
||||||
|
margin-right: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#player-settings .right{
|
||||||
|
margin-left: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#player-settings table{
|
||||||
|
margin-bottom: 30px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
#player-settings table .select-container{
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
}
|
||||||
|
|
||||||
|
#player-settings table .select-container select{
|
||||||
|
min-width: 200px;
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
#player-settings table select:disabled{
|
||||||
|
background-color: lightgray;
|
||||||
|
}
|
||||||
|
|
||||||
|
#player-settings table .range-container{
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
}
|
||||||
|
|
||||||
|
#player-settings table .range-container input[type=range]{
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
#player-settings table .range-value{
|
||||||
|
min-width: 20px;
|
||||||
|
margin-left: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#player-settings table .special-range-container{
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
#player-settings table .special-range-wrapper{
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#player-settings table .special-range-wrapper input[type=range]{
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
#player-settings table .randomize-button {
|
||||||
|
max-height: 24px;
|
||||||
|
line-height: 16px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
margin: 0 0 0 0.25rem;
|
||||||
|
font-size: 12px;
|
||||||
|
border: 1px solid black;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#player-settings table .randomize-button.active {
|
||||||
|
background-color: #ffef00; /* Same as .interactive in globalStyles.css */
|
||||||
|
}
|
||||||
|
|
||||||
|
#player-settings table .randomize-button[data-tooltip]::after {
|
||||||
|
left: unset;
|
||||||
|
right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#player-settings table label{
|
||||||
|
display: block;
|
||||||
|
min-width: 200px;
|
||||||
|
margin-right: 4px;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
#player-settings th, #player-settings td{
|
||||||
|
border: none;
|
||||||
|
padding: 3px;
|
||||||
|
font-size: 17px;
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media all and (max-width: 1024px) {
|
||||||
|
#player-settings {
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#player-settings #game-options{
|
||||||
|
justify-content: flex-start;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
#player-settings .left,
|
||||||
|
#player-settings .right {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#game-options table {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#game-options table label{
|
||||||
|
display: block;
|
||||||
|
min-width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#game-options table tr td {
|
||||||
|
width: 50%;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,310 +0,0 @@
|
|||||||
@import "../markdown.css";
|
|
||||||
html {
|
|
||||||
background-image: url("../../static/backgrounds/grass.png");
|
|
||||||
background-repeat: repeat;
|
|
||||||
background-size: 650px 650px;
|
|
||||||
overflow-x: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
#player-options {
|
|
||||||
box-sizing: border-box;
|
|
||||||
max-width: 1024px;
|
|
||||||
margin-left: auto;
|
|
||||||
margin-right: auto;
|
|
||||||
background-color: rgba(0, 0, 0, 0.15);
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 1rem;
|
|
||||||
color: #eeffeb;
|
|
||||||
word-break: break-all;
|
|
||||||
}
|
|
||||||
#player-options #player-options-header h1 {
|
|
||||||
margin-bottom: 0;
|
|
||||||
padding-bottom: 0;
|
|
||||||
}
|
|
||||||
#player-options #player-options-header h1:nth-child(2) {
|
|
||||||
font-size: 1.4rem;
|
|
||||||
margin-top: -8px;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
}
|
|
||||||
#player-options .js-warning-banner {
|
|
||||||
width: calc(100% - 1rem);
|
|
||||||
padding: 0.5rem;
|
|
||||||
border-radius: 4px;
|
|
||||||
background-color: #f3f309;
|
|
||||||
color: #000000;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
#player-options .group-container {
|
|
||||||
padding: 0;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
#player-options .group-container h2 {
|
|
||||||
user-select: none;
|
|
||||||
cursor: unset;
|
|
||||||
}
|
|
||||||
#player-options .group-container h2 label {
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
#player-options #player-options-button-row {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
justify-content: space-between;
|
|
||||||
margin-top: 15px;
|
|
||||||
}
|
|
||||||
#player-options #user-message {
|
|
||||||
display: none;
|
|
||||||
width: calc(100% - 8px);
|
|
||||||
background-color: #ffe86b;
|
|
||||||
border-radius: 4px;
|
|
||||||
color: #000000;
|
|
||||||
padding: 4px;
|
|
||||||
text-align: center;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
#player-options h1 {
|
|
||||||
font-size: 2.5rem;
|
|
||||||
font-weight: normal;
|
|
||||||
width: 100%;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
text-shadow: 1px 1px 4px #000000;
|
|
||||||
}
|
|
||||||
#player-options h2 {
|
|
||||||
font-size: 40px;
|
|
||||||
font-weight: normal;
|
|
||||||
width: 100%;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
text-transform: lowercase;
|
|
||||||
text-shadow: 1px 1px 2px #000000;
|
|
||||||
}
|
|
||||||
#player-options h3, #player-options h4, #player-options h5, #player-options h6 {
|
|
||||||
text-shadow: 1px 1px 1px rgba(0, 0, 0, 0.5);
|
|
||||||
}
|
|
||||||
#player-options input:not([type]) {
|
|
||||||
border: 1px solid #000000;
|
|
||||||
padding: 3px;
|
|
||||||
border-radius: 3px;
|
|
||||||
min-width: 150px;
|
|
||||||
}
|
|
||||||
#player-options input:not([type]):focus {
|
|
||||||
border: 1px solid #ffffff;
|
|
||||||
}
|
|
||||||
#player-options select {
|
|
||||||
border: 1px solid #000000;
|
|
||||||
padding: 3px;
|
|
||||||
border-radius: 3px;
|
|
||||||
min-width: 150px;
|
|
||||||
background-color: #ffffff;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
#player-options .game-options {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
}
|
|
||||||
#player-options .game-options .left, #player-options .game-options .right {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 12rem auto;
|
|
||||||
grid-row-gap: 0.5rem;
|
|
||||||
grid-auto-rows: min-content;
|
|
||||||
align-items: start;
|
|
||||||
min-width: 480px;
|
|
||||||
width: 50%;
|
|
||||||
}
|
|
||||||
#player-options #meta-options {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 20px;
|
|
||||||
padding: 3px;
|
|
||||||
}
|
|
||||||
#player-options #meta-options input, #player-options #meta-options select {
|
|
||||||
box-sizing: border-box;
|
|
||||||
width: 200px;
|
|
||||||
}
|
|
||||||
#player-options .left, #player-options .right {
|
|
||||||
flex-grow: 1;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
}
|
|
||||||
#player-options .left {
|
|
||||||
margin-right: 20px;
|
|
||||||
}
|
|
||||||
#player-options .select-container {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
max-width: 270px;
|
|
||||||
}
|
|
||||||
#player-options .select-container select {
|
|
||||||
min-width: 200px;
|
|
||||||
flex-grow: 1;
|
|
||||||
}
|
|
||||||
#player-options .select-container select:disabled {
|
|
||||||
background-color: lightgray;
|
|
||||||
}
|
|
||||||
#player-options .range-container {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
max-width: 270px;
|
|
||||||
}
|
|
||||||
#player-options .range-container input[type=range] {
|
|
||||||
flex-grow: 1;
|
|
||||||
}
|
|
||||||
#player-options .range-container .range-value {
|
|
||||||
min-width: 20px;
|
|
||||||
margin-left: 0.25rem;
|
|
||||||
}
|
|
||||||
#player-options .named-range-container {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
max-width: 270px;
|
|
||||||
}
|
|
||||||
#player-options .named-range-container .named-range-wrapper {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
margin-top: 0.25rem;
|
|
||||||
}
|
|
||||||
#player-options .named-range-container .named-range-wrapper input[type=range] {
|
|
||||||
flex-grow: 1;
|
|
||||||
}
|
|
||||||
#player-options .free-text-container {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
max-width: 270px;
|
|
||||||
}
|
|
||||||
#player-options .free-text-container input[type=text] {
|
|
||||||
flex-grow: 1;
|
|
||||||
}
|
|
||||||
#player-options .text-choice-container {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
max-width: 270px;
|
|
||||||
}
|
|
||||||
#player-options .text-choice-container .text-choice-wrapper {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
margin-bottom: 0.25rem;
|
|
||||||
}
|
|
||||||
#player-options .text-choice-container .text-choice-wrapper select {
|
|
||||||
flex-grow: 1;
|
|
||||||
}
|
|
||||||
#player-options .option-container {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
background-color: rgba(0, 0, 0, 0.25);
|
|
||||||
border: 1px solid rgba(20, 20, 20, 0.25);
|
|
||||||
border-radius: 3px;
|
|
||||||
color: #ffffff;
|
|
||||||
max-height: 10rem;
|
|
||||||
min-width: 14.5rem;
|
|
||||||
overflow-y: auto;
|
|
||||||
padding-right: 0.25rem;
|
|
||||||
padding-left: 0.25rem;
|
|
||||||
}
|
|
||||||
#player-options .option-container .option-divider {
|
|
||||||
width: 100%;
|
|
||||||
height: 2px;
|
|
||||||
background-color: rgba(20, 20, 20, 0.25);
|
|
||||||
margin-top: 0.125rem;
|
|
||||||
margin-bottom: 0.125rem;
|
|
||||||
}
|
|
||||||
#player-options .option-container .option-entry {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
align-items: flex-start;
|
|
||||||
margin-bottom: 0.125rem;
|
|
||||||
margin-top: 0.125rem;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
#player-options .option-container .option-entry:hover {
|
|
||||||
background-color: rgba(20, 20, 20, 0.25);
|
|
||||||
}
|
|
||||||
#player-options .option-container .option-entry input[type=checkbox] {
|
|
||||||
margin-right: 0.25rem;
|
|
||||||
}
|
|
||||||
#player-options .option-container .option-entry input[type=number] {
|
|
||||||
max-width: 1.5rem;
|
|
||||||
max-height: 1rem;
|
|
||||||
margin-left: 0.125rem;
|
|
||||||
text-align: center;
|
|
||||||
/* Hide arrows on input[type=number] fields */
|
|
||||||
-moz-appearance: textfield;
|
|
||||||
}
|
|
||||||
#player-options .option-container .option-entry input[type=number]::-webkit-outer-spin-button, #player-options .option-container .option-entry input[type=number]::-webkit-inner-spin-button {
|
|
||||||
-webkit-appearance: none;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
#player-options .option-container .option-entry label {
|
|
||||||
flex-grow: 1;
|
|
||||||
margin-right: 0;
|
|
||||||
min-width: unset;
|
|
||||||
display: unset;
|
|
||||||
}
|
|
||||||
#player-options .randomize-button {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
justify-content: center;
|
|
||||||
height: 22px;
|
|
||||||
max-width: 30px;
|
|
||||||
margin: 0 0 0 0.25rem;
|
|
||||||
font-size: 14px;
|
|
||||||
border: 1px solid black;
|
|
||||||
border-radius: 3px;
|
|
||||||
background-color: #d3d3d3;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
#player-options .randomize-button:hover {
|
|
||||||
background-color: #c0c0c0;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
#player-options .randomize-button label {
|
|
||||||
line-height: 22px;
|
|
||||||
padding-left: 5px;
|
|
||||||
padding-right: 2px;
|
|
||||||
margin-right: 4px;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
min-width: unset;
|
|
||||||
}
|
|
||||||
#player-options .randomize-button label:hover {
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
#player-options .randomize-button input[type=checkbox] {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
#player-options .randomize-button:has(input[type=checkbox]:checked) {
|
|
||||||
background-color: #ffef00; /* Same as .interactive in globalStyles.css */
|
|
||||||
}
|
|
||||||
#player-options .randomize-button:has(input[type=checkbox]:checked):hover {
|
|
||||||
background-color: #eedd27;
|
|
||||||
}
|
|
||||||
#player-options .randomize-button[data-tooltip]::after {
|
|
||||||
left: unset;
|
|
||||||
right: 0;
|
|
||||||
}
|
|
||||||
#player-options label {
|
|
||||||
display: block;
|
|
||||||
margin-right: 4px;
|
|
||||||
cursor: default;
|
|
||||||
word-break: break-word;
|
|
||||||
}
|
|
||||||
#player-options th, #player-options td {
|
|
||||||
border: none;
|
|
||||||
padding: 3px;
|
|
||||||
font-size: 17px;
|
|
||||||
vertical-align: top;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media all and (max-width: 1024px) {
|
|
||||||
#player-options {
|
|
||||||
border-radius: 0;
|
|
||||||
}
|
|
||||||
#player-options #meta-options {
|
|
||||||
flex-direction: column;
|
|
||||||
justify-content: flex-start;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
#player-options .game-options {
|
|
||||||
justify-content: flex-start;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/*# sourceMappingURL=playerOptions.css.map */
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
{"version":3,"sourceRoot":"","sources":["playerOptions.scss"],"names":[],"mappings":"AAAQ;AAER;EACI;EACA;EACA;EACA;;;AAGJ;EACI;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;AAGI;EACI;EACA;;AAGJ;EACI;EACA;EACA;;AAIR;EACI;EACA;EACA;EACA;EACA;EACA;EACA;;AAGJ;EACI;EACA;;AAEA;EACI;EACA;;AAEA;EACI;;AAKZ;EACI;EACA;EACA;EACA;;AAGJ;EACI;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;AAGJ;EACI;EACA;EACA;EACA;EACA;;AAGJ;EACI;EACA;EACA;EACA;EACA;EACA;;AAGJ;EACI;;AAGJ;EACI;EACA;EACA;EACA;;AAEA;EACI;;AAIR;EACI;EACA;EACA;EACA;EACA;EACA;;AAGJ;EACI;EACA;;AAEA;EACI;EACA;EACA;EACA;EACA;EACA;EACA;;AAIR;EACI;EACA;EACA;EACA;;AAEA;EACI;EACA;;AAIR;EACI;EACA;;AAGJ;EACI;;AAGJ;EACI;EACA;EACA;;AAEA;EACI;EACA;;AAEA;EACI;;AAKZ;EACI;EACA;EACA;;AAEA;EACI;;AAGJ;EACI;EACA;;AAIR;EACI;EACA;EACA;;AAEA;EACI;EACA;EACA;;AAEA;EACI;;AAKZ;EACI;EACA;EACA;;AAEA;EACI;;AAIR;EACI;EACA;EACA;;AAEA;EACI;EACA;EACA;;AAEA;EACI;;AAKZ;EACI;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;AAEA;EACI;EACA;EACA;EACA;EACA;;AAGJ;EACI;EACA;EACA;EACA;EACA;EACA;;AAEA;EACI;;AAGJ;EACI;;AAGJ;EACI;EACA;EACA;EACA;AAEA;EACA;;AACA;EACI;EACA;;AAIR;EACI;EACA;EACA;EACA;;AAKZ;EACI;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;AAEA;EACI;EACA;;AAGJ;EACI;EACA;EACA;EACA;EACA;EACA;EACA;;AACA;EACI;;AAIR;EACI;;AAGJ;EACI;;AAEA;EACI;;AAIR;EACI;EACA;;AAIR;EACI;EACA;EACA;EACA;;AAGJ;EACI;EACA;EACA;EACA;;;AAIR;EACI;IACI;;EAEA;IACI;IACA;IACA;;EAGJ;IACI;IACA","file":"playerOptions.css"}
|
|
||||||
@@ -1,364 +0,0 @@
|
|||||||
@import "../markdown.css";
|
|
||||||
|
|
||||||
html{
|
|
||||||
background-image: url('../../static/backgrounds/grass.png');
|
|
||||||
background-repeat: repeat;
|
|
||||||
background-size: 650px 650px;
|
|
||||||
overflow-x: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
#player-options{
|
|
||||||
box-sizing: border-box;
|
|
||||||
max-width: 1024px;
|
|
||||||
margin-left: auto;
|
|
||||||
margin-right: auto;
|
|
||||||
background-color: rgba(0, 0, 0, 0.15);
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 1rem;
|
|
||||||
color: #eeffeb;
|
|
||||||
word-break: break-all;
|
|
||||||
|
|
||||||
#player-options-header{
|
|
||||||
h1{
|
|
||||||
margin-bottom: 0;
|
|
||||||
padding-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
h1:nth-child(2){
|
|
||||||
font-size: 1.4rem;
|
|
||||||
margin-top: -8px;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.js-warning-banner{
|
|
||||||
width: calc(100% - 1rem);
|
|
||||||
padding: 0.5rem;
|
|
||||||
border-radius: 4px;
|
|
||||||
background-color: #f3f309;
|
|
||||||
color: #000000;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.group-container{
|
|
||||||
padding: 0;
|
|
||||||
margin: 0;
|
|
||||||
|
|
||||||
h2{
|
|
||||||
user-select: none;
|
|
||||||
cursor: unset;
|
|
||||||
|
|
||||||
label{
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#player-options-button-row{
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
justify-content: space-between;
|
|
||||||
margin-top: 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#user-message{
|
|
||||||
display: none;
|
|
||||||
width: calc(100% - 8px);
|
|
||||||
background-color: #ffe86b;
|
|
||||||
border-radius: 4px;
|
|
||||||
color: #000000;
|
|
||||||
padding: 4px;
|
|
||||||
text-align: center;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
h1{
|
|
||||||
font-size: 2.5rem;
|
|
||||||
font-weight: normal;
|
|
||||||
width: 100%;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
text-shadow: 1px 1px 4px #000000;
|
|
||||||
}
|
|
||||||
|
|
||||||
h2{
|
|
||||||
font-size: 40px;
|
|
||||||
font-weight: normal;
|
|
||||||
width: 100%;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
text-transform: lowercase;
|
|
||||||
text-shadow: 1px 1px 2px #000000;
|
|
||||||
}
|
|
||||||
|
|
||||||
h3, h4, h5, h6{
|
|
||||||
text-shadow: 1px 1px 1px rgba(0, 0, 0, 0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
input:not([type]){
|
|
||||||
border: 1px solid #000000;
|
|
||||||
padding: 3px;
|
|
||||||
border-radius: 3px;
|
|
||||||
min-width: 150px;
|
|
||||||
|
|
||||||
&:focus{
|
|
||||||
border: 1px solid #ffffff;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
select{
|
|
||||||
border: 1px solid #000000;
|
|
||||||
padding: 3px;
|
|
||||||
border-radius: 3px;
|
|
||||||
min-width: 150px;
|
|
||||||
background-color: #ffffff;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
|
|
||||||
.game-options{
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
|
|
||||||
.left, .right{
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 12rem auto;
|
|
||||||
grid-row-gap: 0.5rem;
|
|
||||||
grid-auto-rows: min-content;
|
|
||||||
align-items: start;
|
|
||||||
min-width: 480px;
|
|
||||||
width: 50%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#meta-options{
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 20px;
|
|
||||||
padding: 3px;
|
|
||||||
|
|
||||||
input, select{
|
|
||||||
box-sizing: border-box;
|
|
||||||
width: 200px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.left, .right{
|
|
||||||
flex-grow: 1;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.left{
|
|
||||||
margin-right: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.select-container{
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
max-width: 270px;
|
|
||||||
|
|
||||||
select{
|
|
||||||
min-width: 200px;
|
|
||||||
flex-grow: 1;
|
|
||||||
|
|
||||||
&:disabled{
|
|
||||||
background-color: lightgray;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.range-container{
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
max-width: 270px;
|
|
||||||
|
|
||||||
input[type=range]{
|
|
||||||
flex-grow: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.range-value{
|
|
||||||
min-width: 20px;
|
|
||||||
margin-left: 0.25rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.named-range-container{
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
max-width: 270px;
|
|
||||||
|
|
||||||
.named-range-wrapper{
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
margin-top: 0.25rem;
|
|
||||||
|
|
||||||
input[type=range]{
|
|
||||||
flex-grow: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.free-text-container{
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
max-width: 270px;
|
|
||||||
|
|
||||||
input[type=text]{
|
|
||||||
flex-grow: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.text-choice-container{
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
max-width: 270px;
|
|
||||||
|
|
||||||
.text-choice-wrapper{
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
margin-bottom: 0.25rem;
|
|
||||||
|
|
||||||
select{
|
|
||||||
flex-grow: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.option-container{
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
background-color: rgba(0, 0, 0, 0.25);
|
|
||||||
border: 1px solid rgba(20, 20, 20, 0.25);
|
|
||||||
border-radius: 3px;
|
|
||||||
color: #ffffff;
|
|
||||||
max-height: 10rem;
|
|
||||||
min-width: 14.5rem;
|
|
||||||
overflow-y: auto;
|
|
||||||
padding-right: 0.25rem;
|
|
||||||
padding-left: 0.25rem;
|
|
||||||
|
|
||||||
.option-divider{
|
|
||||||
width: 100%;
|
|
||||||
height: 2px;
|
|
||||||
background-color: rgba(20, 20, 20, 0.25);
|
|
||||||
margin-top: 0.125rem;
|
|
||||||
margin-bottom: 0.125rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.option-entry{
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
align-items: flex-start;
|
|
||||||
margin-bottom: 0.125rem;
|
|
||||||
margin-top: 0.125rem;
|
|
||||||
user-select: none;
|
|
||||||
|
|
||||||
&:hover{
|
|
||||||
background-color: rgba(20, 20, 20, 0.25);
|
|
||||||
}
|
|
||||||
|
|
||||||
input[type=checkbox]{
|
|
||||||
margin-right: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
input[type=number]{
|
|
||||||
max-width: 1.5rem;
|
|
||||||
max-height: 1rem;
|
|
||||||
margin-left: 0.125rem;
|
|
||||||
text-align: center;
|
|
||||||
|
|
||||||
/* Hide arrows on input[type=number] fields */
|
|
||||||
-moz-appearance: textfield;
|
|
||||||
&::-webkit-outer-spin-button, &::-webkit-inner-spin-button{
|
|
||||||
-webkit-appearance: none;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
label{
|
|
||||||
flex-grow: 1;
|
|
||||||
margin-right: 0;
|
|
||||||
min-width: unset;
|
|
||||||
display: unset;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.randomize-button{
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
justify-content: center;
|
|
||||||
height: 22px;
|
|
||||||
max-width: 30px;
|
|
||||||
margin: 0 0 0 0.25rem;
|
|
||||||
font-size: 14px;
|
|
||||||
border: 1px solid black;
|
|
||||||
border-radius: 3px;
|
|
||||||
background-color: #d3d3d3;
|
|
||||||
user-select: none;
|
|
||||||
|
|
||||||
&:hover{
|
|
||||||
background-color: #c0c0c0;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
label{
|
|
||||||
line-height: 22px;
|
|
||||||
padding-left: 5px;
|
|
||||||
padding-right: 2px;
|
|
||||||
margin-right: 4px;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
min-width: unset;
|
|
||||||
&:hover{
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
input[type=checkbox]{
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:has(input[type=checkbox]:checked){
|
|
||||||
background-color: #ffef00; /* Same as .interactive in globalStyles.css */
|
|
||||||
|
|
||||||
&:hover{
|
|
||||||
background-color: #eedd27;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&[data-tooltip]::after{
|
|
||||||
left: unset;
|
|
||||||
right: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
label{
|
|
||||||
display: block;
|
|
||||||
margin-right: 4px;
|
|
||||||
cursor: default;
|
|
||||||
word-break: break-word;
|
|
||||||
}
|
|
||||||
|
|
||||||
th, td{
|
|
||||||
border: none;
|
|
||||||
padding: 3px;
|
|
||||||
font-size: 17px;
|
|
||||||
vertical-align: top;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media all and (max-width: 1024px) {
|
|
||||||
#player-options {
|
|
||||||
border-radius: 0;
|
|
||||||
|
|
||||||
#meta-options {
|
|
||||||
flex-direction: column;
|
|
||||||
justify-content: flex-start;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.game-options{
|
|
||||||
justify-content: flex-start;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,160 +0,0 @@
|
|||||||
#player-tracker-wrapper{
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
#tracker-table td {
|
|
||||||
vertical-align: top;
|
|
||||||
}
|
|
||||||
|
|
||||||
.inventory-table-area{
|
|
||||||
border: 2px solid #000000;
|
|
||||||
border-radius: 4px;
|
|
||||||
padding: 3px 10px 3px 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.inventory-table-area:has(.inventory-table-terran) {
|
|
||||||
width: 690px;
|
|
||||||
background-color: #525494;
|
|
||||||
}
|
|
||||||
|
|
||||||
.inventory-table-area:has(.inventory-table-zerg) {
|
|
||||||
width: 360px;
|
|
||||||
background-color: #9d60d2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.inventory-table-area:has(.inventory-table-protoss) {
|
|
||||||
width: 400px;
|
|
||||||
background-color: #d2b260;
|
|
||||||
}
|
|
||||||
|
|
||||||
#tracker-table .inventory-table td{
|
|
||||||
width: 40px;
|
|
||||||
height: 40px;
|
|
||||||
text-align: center;
|
|
||||||
vertical-align: middle;
|
|
||||||
}
|
|
||||||
|
|
||||||
.inventory-table td.title{
|
|
||||||
padding-top: 10px;
|
|
||||||
height: 20px;
|
|
||||||
font-family: "JuraBook", monospace;
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
.inventory-table img{
|
|
||||||
height: 100%;
|
|
||||||
max-width: 40px;
|
|
||||||
max-height: 40px;
|
|
||||||
border: 1px solid #000000;
|
|
||||||
filter: grayscale(100%) contrast(75%) brightness(20%);
|
|
||||||
background-color: black;
|
|
||||||
}
|
|
||||||
|
|
||||||
.inventory-table img.acquired{
|
|
||||||
filter: none;
|
|
||||||
background-color: black;
|
|
||||||
}
|
|
||||||
|
|
||||||
.inventory-table .tint-terran img.acquired {
|
|
||||||
filter: sepia(100%) saturate(300%) brightness(130%) hue-rotate(120deg)
|
|
||||||
}
|
|
||||||
|
|
||||||
.inventory-table .tint-protoss img.acquired {
|
|
||||||
filter: sepia(100%) saturate(1000%) brightness(110%) hue-rotate(180deg)
|
|
||||||
}
|
|
||||||
|
|
||||||
.inventory-table .tint-level-1 img.acquired {
|
|
||||||
filter: sepia(100%) saturate(1000%) brightness(110%) hue-rotate(60deg)
|
|
||||||
}
|
|
||||||
|
|
||||||
.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;
|
|
||||||
}
|
|
||||||
110
WebHostLib/static/styles/sc2wolTracker.css
Normal file
110
WebHostLib/static/styles/sc2wolTracker.css
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
#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: 500px;
|
||||||
|
background-color: #525494;
|
||||||
|
}
|
||||||
|
|
||||||
|
#inventory-table td{
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
text-align: center;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
#inventory-table td.title{
|
||||||
|
padding-top: 10px;
|
||||||
|
height: 20px;
|
||||||
|
font-family: "JuraBook", monospace;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
#inventory-table img{
|
||||||
|
height: 100%;
|
||||||
|
max-width: 40px;
|
||||||
|
max-height: 40px;
|
||||||
|
border: 1px solid #000000;
|
||||||
|
filter: grayscale(100%) contrast(75%) brightness(20%);
|
||||||
|
}
|
||||||
|
|
||||||
|
#inventory-table img.acquired{
|
||||||
|
filter: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#inventory-table div.counted-item {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
#inventory-table div.item-count {
|
||||||
|
text-align: left;
|
||||||
|
color: black;
|
||||||
|
font-family: "JuraBook", monospace;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
#location-table{
|
||||||
|
width: 500px;
|
||||||
|
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: #525494;
|
||||||
|
padding: 10px 3px 3px;
|
||||||
|
font-family: "JuraBook", monospace;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: bold;
|
||||||
|
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: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#location-table td.location-name {
|
||||||
|
padding-left: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hide {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
@@ -8,15 +8,14 @@
|
|||||||
cursor: unset;
|
cursor: unset;
|
||||||
}
|
}
|
||||||
|
|
||||||
#games h1, #games details summary.h1{
|
#games h1{
|
||||||
font-size: 60px;
|
font-size: 60px;
|
||||||
cursor: unset;
|
cursor: unset;
|
||||||
}
|
}
|
||||||
|
|
||||||
#games h2, #games details summary.h2{
|
#games h2{
|
||||||
color: #93dcff;
|
color: #93dcff;
|
||||||
margin-bottom: 2px;
|
margin-bottom: 2px;
|
||||||
text-transform: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#games a{
|
#games a{
|
||||||
@@ -32,13 +31,3 @@
|
|||||||
line-height: 25px;
|
line-height: 25px;
|
||||||
margin-bottom: 7px;
|
margin-bottom: 7px;
|
||||||
}
|
}
|
||||||
|
|
||||||
#games .page-controls{
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
margin-top: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
#games .page-controls button{
|
|
||||||
margin-left: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -42,7 +42,6 @@ give it one of the following classes: tooltip-left, tooltip-right, tooltip-top,
|
|||||||
[data-tooltip]:hover:before, [data-tooltip]:hover:after, .tooltip:hover:before, .tooltip:hover:after{
|
[data-tooltip]:hover:before, [data-tooltip]:hover:after, .tooltip:hover:before, .tooltip:hover:after{
|
||||||
visibility: visible;
|
visibility: visible;
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
word-break: break-word;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Directional arrow styles */
|
/** Directional arrow styles */
|
||||||
|
|||||||
@@ -7,55 +7,134 @@
|
|||||||
width: calc(100% - 1rem);
|
width: calc(100% - 1rem);
|
||||||
}
|
}
|
||||||
|
|
||||||
#tracker-wrapper a {
|
#tracker-wrapper a{
|
||||||
color: #234ae4;
|
color: #234ae4;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
#tracker-header-bar {
|
.table-wrapper{
|
||||||
display: flex;
|
overflow-y: auto;
|
||||||
flex-direction: row;
|
overflow-x: auto;
|
||||||
justify-content: flex-start;
|
|
||||||
align-content: center;
|
|
||||||
line-height: 20px;
|
|
||||||
gap: 0.5rem;
|
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
#tracker-header-bar .info {
|
#tracker-header-bar{
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: flex-start;
|
||||||
|
line-height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#tracker-header-bar .info{
|
||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
padding: 2px;
|
}
|
||||||
flex-grow: 1;
|
|
||||||
align-self: center;
|
#search{
|
||||||
text-align: justify;
|
border: 1px solid #000000;
|
||||||
|
border-radius: 3px;
|
||||||
|
padding: 3px;
|
||||||
|
width: 200px;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
margin-right: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#multi-stream-link{
|
||||||
|
margin-right: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.dataTables_wrapper.no-footer .dataTables_scrollBody{
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
table.dataTable{
|
||||||
|
color: #000000;
|
||||||
|
}
|
||||||
|
|
||||||
|
table.dataTable thead{
|
||||||
|
font-family: LexendDeca-Regular, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
table.dataTable tbody{
|
||||||
|
background-color: #dce2bd;
|
||||||
|
font-family: LexendDeca-Light, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
table.dataTable tbody tr:hover{
|
||||||
|
background-color: #e2eabb;
|
||||||
|
}
|
||||||
|
|
||||||
|
table.dataTable tbody td{
|
||||||
|
padding: 4px 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
table.dataTable, table.dataTable.no-footer{
|
||||||
|
border-left: 1px solid #bba967;
|
||||||
|
width: calc(100% - 2px) !important;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
table.dataTable thead th{
|
||||||
|
position: -webkit-sticky;
|
||||||
|
position: sticky;
|
||||||
|
background-color: #b0a77d;
|
||||||
|
top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
table.dataTable thead th.upper-row{
|
||||||
|
position: -webkit-sticky;
|
||||||
|
position: sticky;
|
||||||
|
background-color: #b0a77d;
|
||||||
|
height: 36px;
|
||||||
|
top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
table.dataTable thead th.lower-row{
|
||||||
|
position: -webkit-sticky;
|
||||||
|
position: sticky;
|
||||||
|
background-color: #b0a77d;
|
||||||
|
height: 22px;
|
||||||
|
top: 46px;
|
||||||
|
}
|
||||||
|
|
||||||
|
table.dataTable tbody td{
|
||||||
|
border: 1px solid #bba967;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.dataTables_scrollBody{
|
||||||
|
background-color: inherit !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
table.dataTable .center-column{
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
img.alttp-sprite {
|
||||||
|
height: auto;
|
||||||
|
max-height: 32px;
|
||||||
|
min-height: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-acquired{
|
||||||
|
background-color: #d3c97d;
|
||||||
}
|
}
|
||||||
|
|
||||||
#tracker-navigation {
|
#tracker-navigation {
|
||||||
display: flex;
|
display: inline-flex;
|
||||||
flex-wrap: wrap;
|
|
||||||
margin: 0 0.5rem 0.5rem 0.5rem;
|
|
||||||
user-select: none;
|
|
||||||
height: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tracker-navigation-bar {
|
|
||||||
display: flex;
|
|
||||||
background-color: #b0a77d;
|
background-color: #b0a77d;
|
||||||
|
margin: 0.5rem;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tracker-navigation-button {
|
.tracker-navigation-button {
|
||||||
display: flex;
|
display: block;
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
margin: 4px;
|
margin: 4px;
|
||||||
padding-left: 12px;
|
padding-left: 12px;
|
||||||
padding-right: 12px;
|
padding-right: 12px;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
color: black !important;
|
color: #000;
|
||||||
font-weight: lighter;
|
font-weight: lighter;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,100 +146,6 @@
|
|||||||
background-color: rgb(220, 226, 189);
|
background-color: rgb(220, 226, 189);
|
||||||
}
|
}
|
||||||
|
|
||||||
.table-wrapper {
|
|
||||||
overflow-y: auto;
|
|
||||||
overflow-x: auto;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
resize: vertical;
|
|
||||||
}
|
|
||||||
|
|
||||||
#search {
|
|
||||||
border: 1px solid #000000;
|
|
||||||
border-radius: 3px;
|
|
||||||
padding: 3px;
|
|
||||||
width: 200px;
|
|
||||||
}
|
|
||||||
|
|
||||||
div.dataTables_wrapper.no-footer .dataTables_scrollBody {
|
|
||||||
border: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
table.dataTable {
|
|
||||||
color: #000000;
|
|
||||||
}
|
|
||||||
|
|
||||||
table.dataTable thead {
|
|
||||||
font-family: LexendDeca-Regular, sans-serif;
|
|
||||||
}
|
|
||||||
|
|
||||||
table.dataTable tbody, table.dataTable tfoot {
|
|
||||||
background-color: #dce2bd;
|
|
||||||
font-family: LexendDeca-Light, sans-serif;
|
|
||||||
}
|
|
||||||
|
|
||||||
table.dataTable tbody tr:hover, table.dataTable tfoot tr:hover {
|
|
||||||
background-color: #e2eabb;
|
|
||||||
}
|
|
||||||
|
|
||||||
table.dataTable tbody td, table.dataTable tfoot td {
|
|
||||||
padding: 4px 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
table.dataTable, table.dataTable.no-footer {
|
|
||||||
border-left: 1px solid #bba967;
|
|
||||||
width: calc(100% - 2px) !important;
|
|
||||||
font-size: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
table.dataTable thead th {
|
|
||||||
position: -webkit-sticky;
|
|
||||||
position: sticky;
|
|
||||||
background-color: #b0a77d;
|
|
||||||
top: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
table.dataTable thead th.upper-row {
|
|
||||||
position: -webkit-sticky;
|
|
||||||
position: sticky;
|
|
||||||
background-color: #b0a77d;
|
|
||||||
height: 36px;
|
|
||||||
top: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
table.dataTable thead th.lower-row {
|
|
||||||
position: -webkit-sticky;
|
|
||||||
position: sticky;
|
|
||||||
background-color: #b0a77d;
|
|
||||||
height: 22px;
|
|
||||||
top: 46px;
|
|
||||||
}
|
|
||||||
|
|
||||||
table.dataTable tbody td, table.dataTable tfoot td {
|
|
||||||
border: 1px solid #bba967;
|
|
||||||
}
|
|
||||||
|
|
||||||
table.dataTable tfoot td {
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
div.dataTables_scrollBody {
|
|
||||||
background-color: inherit !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
table.dataTable .center-column {
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
img.icon-sprite {
|
|
||||||
height: auto;
|
|
||||||
max-height: 32px;
|
|
||||||
min-height: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.item-acquired {
|
|
||||||
background-color: #d3c97d;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media all and (max-width: 1700px) {
|
@media all and (max-width: 1700px) {
|
||||||
table.dataTable thead th.upper-row{
|
table.dataTable thead th.upper-row{
|
||||||
position: -webkit-sticky;
|
position: -webkit-sticky;
|
||||||
@@ -170,7 +155,7 @@ img.icon-sprite {
|
|||||||
top: 0;
|
top: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
table.dataTable thead th.lower-row {
|
table.dataTable thead th.lower-row{
|
||||||
position: -webkit-sticky;
|
position: -webkit-sticky;
|
||||||
position: sticky;
|
position: sticky;
|
||||||
background-color: #b0a77d;
|
background-color: #b0a77d;
|
||||||
@@ -178,11 +163,11 @@ img.icon-sprite {
|
|||||||
top: 37px;
|
top: 37px;
|
||||||
}
|
}
|
||||||
|
|
||||||
table.dataTable, table.dataTable.no-footer {
|
table.dataTable, table.dataTable.no-footer{
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
img.icon-sprite {
|
img.alttp-sprite {
|
||||||
height: auto;
|
height: auto;
|
||||||
max-height: 24px;
|
max-height: 24px;
|
||||||
min-height: 10px;
|
min-height: 10px;
|
||||||
@@ -198,7 +183,7 @@ img.icon-sprite {
|
|||||||
top: 0;
|
top: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
table.dataTable thead th.lower-row {
|
table.dataTable thead th.lower-row{
|
||||||
position: -webkit-sticky;
|
position: -webkit-sticky;
|
||||||
position: sticky;
|
position: sticky;
|
||||||
background-color: #b0a77d;
|
background-color: #b0a77d;
|
||||||
@@ -206,11 +191,11 @@ img.icon-sprite {
|
|||||||
top: 32px;
|
top: 32px;
|
||||||
}
|
}
|
||||||
|
|
||||||
table.dataTable, table.dataTable.no-footer {
|
table.dataTable, table.dataTable.no-footer{
|
||||||
font-size: 0.6rem;
|
font-size: 0.6rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
img.icon-sprite {
|
img.alttp-sprite {
|
||||||
height: auto;
|
height: auto;
|
||||||
max-height: 20px;
|
max-height: 20px;
|
||||||
min-height: 10px;
|
min-height: 10px;
|
||||||
|
|||||||
@@ -1,142 +0,0 @@
|
|||||||
@import url('https://fonts.googleapis.com/css2?family=Lexend+Deca:wght@100..900&display=swap');
|
|
||||||
|
|
||||||
.tracker-container {
|
|
||||||
width: 440px;
|
|
||||||
box-sizing: border-box;
|
|
||||||
font-family: "Lexend Deca", Arial, Helvetica, sans-serif;
|
|
||||||
border: 2px solid black;
|
|
||||||
border-radius: 4px;
|
|
||||||
resize: both;
|
|
||||||
|
|
||||||
background-color: #42b149;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hidden {
|
|
||||||
visibility: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Inventory Grid ****************************************************************************************************/
|
|
||||||
.inventory-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(6, minmax(0, 1fr));
|
|
||||||
padding: 1rem;
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.inventory-grid .item {
|
|
||||||
position: relative;
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
height: 48px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.inventory-grid .dual-item {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.inventory-grid .missing {
|
|
||||||
/* Missing items will be in full grayscale to signify "uncollected". */
|
|
||||||
filter: grayscale(100%) contrast(75%) brightness(75%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.inventory-grid .item img,
|
|
||||||
.inventory-grid .dual-item img {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
text-align: center;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
text-shadow: 0 1px 2px black;
|
|
||||||
font-weight: bold;
|
|
||||||
image-rendering: crisp-edges;
|
|
||||||
background-size: contain;
|
|
||||||
background-repeat: no-repeat;
|
|
||||||
}
|
|
||||||
|
|
||||||
.inventory-grid .dual-item img {
|
|
||||||
height: 48px;
|
|
||||||
margin: 0 -4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.inventory-grid .dual-item img:first-child {
|
|
||||||
align-self: flex-end;
|
|
||||||
}
|
|
||||||
|
|
||||||
.inventory-grid .item .quantity {
|
|
||||||
position: absolute;
|
|
||||||
bottom: 0;
|
|
||||||
right: 0;
|
|
||||||
text-align: right;
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 1.75rem;
|
|
||||||
line-height: 1.75rem;
|
|
||||||
text-shadow:
|
|
||||||
-1px -1px 0 #000,
|
|
||||||
1px -1px 0 #000,
|
|
||||||
-1px 1px 0 #000,
|
|
||||||
1px 1px 0 #000;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Regions List ******************************************************************************************************/
|
|
||||||
.regions-list {
|
|
||||||
padding: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.regions-list summary {
|
|
||||||
list-style: none;
|
|
||||||
display: flex;
|
|
||||||
gap: 0.5rem;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.regions-list summary::before {
|
|
||||||
content: "⯈";
|
|
||||||
width: 1em;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.regions-list details {
|
|
||||||
font-weight: 300;
|
|
||||||
}
|
|
||||||
|
|
||||||
.regions-list details[open] > summary::before {
|
|
||||||
content: "⯆";
|
|
||||||
}
|
|
||||||
|
|
||||||
.regions-list .region {
|
|
||||||
width: 100%;
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 20fr 8fr 2fr 2fr;
|
|
||||||
align-items: center;
|
|
||||||
gap: 4px;
|
|
||||||
text-align: center;
|
|
||||||
font-weight: 300;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
.regions-list .region :first-child {
|
|
||||||
text-align: left;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.regions-list .region.region-header {
|
|
||||||
margin-left: 24px;
|
|
||||||
width: calc(100% - 24px);
|
|
||||||
padding: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.regions-list .location-rows {
|
|
||||||
border-top: 1px solid white;
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: auto 32px;
|
|
||||||
font-weight: 300;
|
|
||||||
padding: 2px 8px;
|
|
||||||
margin-top: 4px;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.regions-list .location-rows :nth-child(even) {
|
|
||||||
text-align: right;
|
|
||||||
}
|
|
||||||
309
WebHostLib/static/styles/weighted-settings.css
Normal file
309
WebHostLib/static/styles/weighted-settings.css
Normal file
@@ -0,0 +1,309 @@
|
|||||||
|
html{
|
||||||
|
background-image: url('../static/backgrounds/grass.png');
|
||||||
|
background-repeat: repeat;
|
||||||
|
background-size: 650px 650px;
|
||||||
|
scroll-padding-top: 90px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#weighted-settings{
|
||||||
|
max-width: 1000px;
|
||||||
|
margin-left: auto;
|
||||||
|
margin-right: auto;
|
||||||
|
background-color: rgba(0, 0, 0, 0.15);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1rem;
|
||||||
|
color: #eeffeb;
|
||||||
|
}
|
||||||
|
|
||||||
|
#weighted-settings #games-wrapper{
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
#weighted-settings .setting-wrapper{
|
||||||
|
width: 100%;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#weighted-settings .setting-wrapper .add-option-div{
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: flex-start;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#weighted-settings .setting-wrapper .add-option-div button{
|
||||||
|
width: auto;
|
||||||
|
height: auto;
|
||||||
|
margin: 0 0 0 0.15rem;
|
||||||
|
padding: 0 0.25rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
#weighted-settings .setting-wrapper .add-option-div button:active{
|
||||||
|
margin-bottom: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#weighted-settings p.setting-description{
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#weighted-settings p.hint-text{
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
#weighted-settings .jump-link{
|
||||||
|
color: #ffef00;
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
#weighted-settings table{
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
#weighted-settings table th, #weighted-settings table td{
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#weighted-settings table td{
|
||||||
|
padding: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#weighted-settings table .td-left{
|
||||||
|
font-family: LexendDeca-Regular, sans-serif;
|
||||||
|
padding-right: 1rem;
|
||||||
|
width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#weighted-settings table .td-middle{
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: space-evenly;
|
||||||
|
padding-right: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#weighted-settings table .td-right{
|
||||||
|
width: 4rem;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
#weighted-settings table .td-delete{
|
||||||
|
width: 50px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
#weighted-settings table .range-option-delete{
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
#weighted-settings .items-wrapper{
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
#weighted-settings .items-div h3{
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#weighted-settings .items-wrapper .item-set-wrapper{
|
||||||
|
width: 24%;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
#weighted-settings .item-container{
|
||||||
|
border: 1px solid #ffffff;
|
||||||
|
border-radius: 2px;
|
||||||
|
width: 100%;
|
||||||
|
height: 300px;
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
|
margin-top: 0.125rem;
|
||||||
|
font-weight: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
#weighted-settings .item-container .item-div{
|
||||||
|
padding: 0.125rem 0.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
#weighted-settings .item-container .item-div:hover{
|
||||||
|
background-color: rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#weighted-settings .item-container .item-qty-div{
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0.125rem 0.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
#weighted-settings .item-container .item-qty-div .item-qty-input-wrapper{
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: space-around;
|
||||||
|
}
|
||||||
|
|
||||||
|
#weighted-settings .item-container .item-qty-div input{
|
||||||
|
min-width: unset;
|
||||||
|
width: 1.5rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
#weighted-settings .item-container .item-qty-div:hover{
|
||||||
|
background-color: rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#weighted-settings .hints-div, #weighted-settings .locations-div{
|
||||||
|
margin-top: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#weighted-settings .hints-div h3, #weighted-settings .locations-div h3{
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#weighted-settings .hints-container, #weighted-settings .locations-container{
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
#weighted-settings .hints-wrapper, #weighted-settings .locations-wrapper{
|
||||||
|
width: calc(50% - 0.5rem);
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
#weighted-settings .hints-wrapper .simple-list, #weighted-settings .locations-wrapper .simple-list{
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
height: 300px;
|
||||||
|
font-weight: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
#weighted-settings #weighted-settings-button-row{
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-top: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#weighted-settings code{
|
||||||
|
background-color: #d9cd8e;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding-left: 0.25rem;
|
||||||
|
padding-right: 0.25rem;
|
||||||
|
color: #000000;
|
||||||
|
}
|
||||||
|
|
||||||
|
#weighted-settings #user-message{
|
||||||
|
display: none;
|
||||||
|
width: calc(100% - 8px);
|
||||||
|
background-color: #ffe86b;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: #000000;
|
||||||
|
padding: 4px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
#weighted-settings #user-message.visible{
|
||||||
|
display: block;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
#weighted-settings h1{
|
||||||
|
font-size: 2.5rem;
|
||||||
|
font-weight: normal;
|
||||||
|
border-bottom: 1px solid #ffffff;
|
||||||
|
width: 100%;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
color: #ffffff;
|
||||||
|
text-shadow: 1px 1px 4px #000000;
|
||||||
|
}
|
||||||
|
|
||||||
|
#weighted-settings h2{
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: normal;
|
||||||
|
border-bottom: 1px solid #ffffff;
|
||||||
|
width: 100%;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
color: #ffe993;
|
||||||
|
text-transform: none;
|
||||||
|
text-shadow: 1px 1px 2px #000000;
|
||||||
|
}
|
||||||
|
|
||||||
|
#weighted-settings h3, #weighted-settings h4, #weighted-settings h5, #weighted-settings h6{
|
||||||
|
color: #ffffff;
|
||||||
|
text-shadow: 1px 1px 1px rgba(0, 0, 0, 0.5);
|
||||||
|
text-transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#weighted-settings a{
|
||||||
|
color: #ffef00;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
#weighted-settings input:not([type]){
|
||||||
|
border: 1px solid #000000;
|
||||||
|
padding: 3px;
|
||||||
|
border-radius: 3px;
|
||||||
|
min-width: 150px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#weighted-settings input:not([type]):focus{
|
||||||
|
border: 1px solid #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
#weighted-settings select{
|
||||||
|
border: 1px solid #000000;
|
||||||
|
padding: 3px;
|
||||||
|
border-radius: 3px;
|
||||||
|
min-width: 150px;
|
||||||
|
background-color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
#weighted-settings .game-options, #weighted-settings .rom-options{
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
#weighted-settings .simple-list{
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
max-height: 300px;
|
||||||
|
overflow-y: auto;
|
||||||
|
border: 1px solid #ffffff;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#weighted-settings .simple-list .list-row label{
|
||||||
|
display: block;
|
||||||
|
width: calc(100% - 0.5rem);
|
||||||
|
padding: 0.0625rem 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#weighted-settings .simple-list .list-row label:hover{
|
||||||
|
background-color: rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#weighted-settings .simple-list .list-row label input[type=checkbox]{
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#weighted-settings .invisible{
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media all and (max-width: 1000px), all and (orientation: portrait){
|
||||||
|
#weighted-settings .game-options{
|
||||||
|
justify-content: flex-start;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
#game-options table label{
|
||||||
|
display: block;
|
||||||
|
min-width: 200px;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,232 +0,0 @@
|
|||||||
html {
|
|
||||||
background-image: url("../../static/backgrounds/grass.png");
|
|
||||||
background-repeat: repeat;
|
|
||||||
background-size: 650px 650px;
|
|
||||||
scroll-padding-top: 90px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#weighted-options {
|
|
||||||
max-width: 1000px;
|
|
||||||
margin-left: auto;
|
|
||||||
margin-right: auto;
|
|
||||||
background-color: rgba(0, 0, 0, 0.15);
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 1rem;
|
|
||||||
color: #eeffeb;
|
|
||||||
}
|
|
||||||
#weighted-options #weighted-options-header h1 {
|
|
||||||
margin-bottom: 0;
|
|
||||||
padding-bottom: 0;
|
|
||||||
}
|
|
||||||
#weighted-options #weighted-options-header h1:nth-child(2) {
|
|
||||||
font-size: 1.4rem;
|
|
||||||
margin-top: -8px;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
}
|
|
||||||
#weighted-options .js-warning-banner {
|
|
||||||
width: calc(100% - 1rem);
|
|
||||||
padding: 0.5rem;
|
|
||||||
border-radius: 4px;
|
|
||||||
background-color: #f3f309;
|
|
||||||
color: #000000;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
#weighted-options .option-wrapper {
|
|
||||||
width: 100%;
|
|
||||||
margin-bottom: 2rem;
|
|
||||||
}
|
|
||||||
#weighted-options .option-wrapper .add-option-div {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
justify-content: flex-start;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
#weighted-options .option-wrapper .add-option-div button {
|
|
||||||
width: auto;
|
|
||||||
height: auto;
|
|
||||||
margin: 0 0 0 0.15rem;
|
|
||||||
padding: 0 0.25rem;
|
|
||||||
border-radius: 4px;
|
|
||||||
cursor: default;
|
|
||||||
}
|
|
||||||
#weighted-options .option-wrapper .add-option-div button:active {
|
|
||||||
margin-bottom: 1px;
|
|
||||||
}
|
|
||||||
#weighted-options p.option-description {
|
|
||||||
margin: 0 0 1rem;
|
|
||||||
}
|
|
||||||
#weighted-options p.hint-text {
|
|
||||||
margin: 0 0 1rem;
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
#weighted-options table {
|
|
||||||
width: 100%;
|
|
||||||
margin-top: 0.5rem;
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
}
|
|
||||||
#weighted-options table th, #weighted-options table td {
|
|
||||||
border: none;
|
|
||||||
}
|
|
||||||
#weighted-options table td {
|
|
||||||
padding: 5px;
|
|
||||||
}
|
|
||||||
#weighted-options table .td-left {
|
|
||||||
font-family: LexendDeca-Regular, sans-serif;
|
|
||||||
padding-right: 1rem;
|
|
||||||
width: 200px;
|
|
||||||
}
|
|
||||||
#weighted-options table .td-middle {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
justify-content: space-evenly;
|
|
||||||
padding-right: 1rem;
|
|
||||||
}
|
|
||||||
#weighted-options table .td-right {
|
|
||||||
width: 4rem;
|
|
||||||
text-align: right;
|
|
||||||
}
|
|
||||||
#weighted-options table .td-delete {
|
|
||||||
width: 50px;
|
|
||||||
text-align: right;
|
|
||||||
}
|
|
||||||
#weighted-options table .range-option-delete {
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
#weighted-options #weighted-options-button-row {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
justify-content: space-between;
|
|
||||||
margin-top: 15px;
|
|
||||||
}
|
|
||||||
#weighted-options #user-message {
|
|
||||||
display: none;
|
|
||||||
width: calc(100% - 8px);
|
|
||||||
background-color: #ffe86b;
|
|
||||||
border-radius: 4px;
|
|
||||||
color: #000000;
|
|
||||||
padding: 4px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
#weighted-options #user-message.visible {
|
|
||||||
display: block;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
#weighted-options h1 {
|
|
||||||
font-size: 2.5rem;
|
|
||||||
font-weight: normal;
|
|
||||||
width: 100%;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
color: #ffffff;
|
|
||||||
text-shadow: 1px 1px 4px #000000;
|
|
||||||
}
|
|
||||||
#weighted-options h2, #weighted-options details summary.h2 {
|
|
||||||
font-size: 2rem;
|
|
||||||
font-weight: normal;
|
|
||||||
border-bottom: 1px solid #ffffff;
|
|
||||||
width: 100%;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
color: #ffe993;
|
|
||||||
text-transform: none;
|
|
||||||
text-shadow: 1px 1px 2px #000000;
|
|
||||||
}
|
|
||||||
#weighted-options h3, #weighted-options h4, #weighted-options h5, #weighted-options h6 {
|
|
||||||
color: #ffffff;
|
|
||||||
text-shadow: 1px 1px 1px rgba(0, 0, 0, 0.5);
|
|
||||||
text-transform: none;
|
|
||||||
cursor: unset;
|
|
||||||
}
|
|
||||||
#weighted-options h3.option-group-header {
|
|
||||||
margin-top: 0.75rem;
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
#weighted-options a {
|
|
||||||
color: #ffef00;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
#weighted-options input:not([type]) {
|
|
||||||
border: 1px solid #000000;
|
|
||||||
padding: 3px;
|
|
||||||
border-radius: 3px;
|
|
||||||
min-width: 150px;
|
|
||||||
}
|
|
||||||
#weighted-options input:not([type]):focus {
|
|
||||||
border: 1px solid #ffffff;
|
|
||||||
}
|
|
||||||
#weighted-options .invisible {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
#weighted-options .unsupported-option {
|
|
||||||
margin-top: 0.5rem;
|
|
||||||
}
|
|
||||||
#weighted-options .set-container, #weighted-options .dict-container, #weighted-options .list-container {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
background-color: rgba(0, 0, 0, 0.25);
|
|
||||||
border: 1px solid rgba(20, 20, 20, 0.25);
|
|
||||||
border-radius: 3px;
|
|
||||||
color: #ffffff;
|
|
||||||
max-height: 15rem;
|
|
||||||
min-width: 14.5rem;
|
|
||||||
overflow-y: auto;
|
|
||||||
padding-right: 0.25rem;
|
|
||||||
padding-left: 0.25rem;
|
|
||||||
margin-top: 0.5rem;
|
|
||||||
}
|
|
||||||
#weighted-options .set-container .divider, #weighted-options .dict-container .divider, #weighted-options .list-container .divider {
|
|
||||||
width: 100%;
|
|
||||||
height: 2px;
|
|
||||||
background-color: rgba(20, 20, 20, 0.25);
|
|
||||||
margin-top: 0.125rem;
|
|
||||||
margin-bottom: 0.125rem;
|
|
||||||
}
|
|
||||||
#weighted-options .set-container .set-entry, #weighted-options .set-container .dict-entry, #weighted-options .set-container .list-entry, #weighted-options .dict-container .set-entry, #weighted-options .dict-container .dict-entry, #weighted-options .dict-container .list-entry, #weighted-options .list-container .set-entry, #weighted-options .list-container .dict-entry, #weighted-options .list-container .list-entry {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
align-items: flex-start;
|
|
||||||
padding-bottom: 0.25rem;
|
|
||||||
padding-top: 0.25rem;
|
|
||||||
user-select: none;
|
|
||||||
line-height: 1rem;
|
|
||||||
}
|
|
||||||
#weighted-options .set-container .set-entry:hover, #weighted-options .set-container .dict-entry:hover, #weighted-options .set-container .list-entry:hover, #weighted-options .dict-container .set-entry:hover, #weighted-options .dict-container .dict-entry:hover, #weighted-options .dict-container .list-entry:hover, #weighted-options .list-container .set-entry:hover, #weighted-options .list-container .dict-entry:hover, #weighted-options .list-container .list-entry:hover {
|
|
||||||
background-color: rgba(20, 20, 20, 0.25);
|
|
||||||
}
|
|
||||||
#weighted-options .set-container .set-entry input[type=checkbox], #weighted-options .set-container .dict-entry input[type=checkbox], #weighted-options .set-container .list-entry input[type=checkbox], #weighted-options .dict-container .set-entry input[type=checkbox], #weighted-options .dict-container .dict-entry input[type=checkbox], #weighted-options .dict-container .list-entry input[type=checkbox], #weighted-options .list-container .set-entry input[type=checkbox], #weighted-options .list-container .dict-entry input[type=checkbox], #weighted-options .list-container .list-entry input[type=checkbox] {
|
|
||||||
margin-right: 0.25rem;
|
|
||||||
}
|
|
||||||
#weighted-options .set-container .set-entry input[type=number], #weighted-options .set-container .dict-entry input[type=number], #weighted-options .set-container .list-entry input[type=number], #weighted-options .dict-container .set-entry input[type=number], #weighted-options .dict-container .dict-entry input[type=number], #weighted-options .dict-container .list-entry input[type=number], #weighted-options .list-container .set-entry input[type=number], #weighted-options .list-container .dict-entry input[type=number], #weighted-options .list-container .list-entry input[type=number] {
|
|
||||||
max-width: 1.5rem;
|
|
||||||
max-height: 1rem;
|
|
||||||
margin-left: 0.125rem;
|
|
||||||
text-align: center;
|
|
||||||
/* Hide arrows on input[type=number] fields */
|
|
||||||
-moz-appearance: textfield;
|
|
||||||
}
|
|
||||||
#weighted-options .set-container .set-entry input[type=number]::-webkit-outer-spin-button, #weighted-options .set-container .set-entry input[type=number]::-webkit-inner-spin-button, #weighted-options .set-container .dict-entry input[type=number]::-webkit-outer-spin-button, #weighted-options .set-container .dict-entry input[type=number]::-webkit-inner-spin-button, #weighted-options .set-container .list-entry input[type=number]::-webkit-outer-spin-button, #weighted-options .set-container .list-entry input[type=number]::-webkit-inner-spin-button, #weighted-options .dict-container .set-entry input[type=number]::-webkit-outer-spin-button, #weighted-options .dict-container .set-entry input[type=number]::-webkit-inner-spin-button, #weighted-options .dict-container .dict-entry input[type=number]::-webkit-outer-spin-button, #weighted-options .dict-container .dict-entry input[type=number]::-webkit-inner-spin-button, #weighted-options .dict-container .list-entry input[type=number]::-webkit-outer-spin-button, #weighted-options .dict-container .list-entry input[type=number]::-webkit-inner-spin-button, #weighted-options .list-container .set-entry input[type=number]::-webkit-outer-spin-button, #weighted-options .list-container .set-entry input[type=number]::-webkit-inner-spin-button, #weighted-options .list-container .dict-entry input[type=number]::-webkit-outer-spin-button, #weighted-options .list-container .dict-entry input[type=number]::-webkit-inner-spin-button, #weighted-options .list-container .list-entry input[type=number]::-webkit-outer-spin-button, #weighted-options .list-container .list-entry input[type=number]::-webkit-inner-spin-button {
|
|
||||||
-webkit-appearance: none;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
#weighted-options .set-container .set-entry label, #weighted-options .set-container .dict-entry label, #weighted-options .set-container .list-entry label, #weighted-options .dict-container .set-entry label, #weighted-options .dict-container .dict-entry label, #weighted-options .dict-container .list-entry label, #weighted-options .list-container .set-entry label, #weighted-options .list-container .dict-entry label, #weighted-options .list-container .list-entry label {
|
|
||||||
flex-grow: 1;
|
|
||||||
margin-right: 0;
|
|
||||||
min-width: unset;
|
|
||||||
display: unset;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hidden {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media all and (max-width: 1000px), all and (orientation: portrait) {
|
|
||||||
#weighted-options .game-options {
|
|
||||||
justify-content: flex-start;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
#game-options table label {
|
|
||||||
display: block;
|
|
||||||
min-width: 200px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/*# sourceMappingURL=weightedOptions.css.map */
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
{"version":3,"sourceRoot":"","sources":["weightedOptions.scss"],"names":[],"mappings":"AAAA;EACI;EACA;EACA;EACA;;;AAGJ;EACI;EACA;EACA;EACA;EACA;EACA;EACA;;AAGI;EACI;EACA;;AAGJ;EACI;EACA;EACA;;AAIR;EACI;EACA;EACA;EACA;EACA;EACA;EACA;;AAGJ;EACI;EACA;;AAEA;EACI;EACA;EACA;EACA;;AAEA;EACI;EACA;EACA;EACA;EACA;EACA;;AAEA;EACI;;AAOZ;EACI;;AAGJ;EACI;EACA;;AAIR;EACI;EACA;EACA;;AAEA;EACI;;AAGJ;EACI;;AAGJ;EACI;EACA;EACA;;AAGJ;EACI;EACA;EACA;EACA;;AAGJ;EACI;EACA;;AAGJ;EACI;EACA;;AAGJ;EACI;;AAIR;EACI;EACA;EACA;EACA;;AAGJ;EACI;EACA;EACA;EACA;EACA;EACA;EACA;;AAEA;EACI;EACA;;AAIR;EACI;EACA;EACA;EACA;EACA;EACA;;AAGJ;EACI;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;AAGJ;EACI;EACA;EACA;EACA;;AAIA;EACI;EACA;;AAIR;EACI;EACA;;AAGJ;EACI;EACA;EACA;EACA;;AAEA;EACI;;AAIR;EACI;;AAGJ;EACI;;AAGJ;EACI;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;AAEA;EACI;EACA;EACA;EACA;EACA;;AAGJ;EACI;EACA;EACA;EACA;EACA;EACA;EACA;;AAEA;EACI;;AAGJ;EACI;;AAGJ;EACI;EACA;EACA;EACA;AAEA;EACA;;AACA;EACI;EACA;;AAIR;EACI;EACA;EACA;EACA;;;AAMhB;EACI;;;AAGJ;EACI;IACI;IACA;;EAGJ;IACI;IACA","file":"weightedOptions.css"}
|
|
||||||
@@ -1,274 +0,0 @@
|
|||||||
html{
|
|
||||||
background-image: url('../../static/backgrounds/grass.png');
|
|
||||||
background-repeat: repeat;
|
|
||||||
background-size: 650px 650px;
|
|
||||||
scroll-padding-top: 90px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#weighted-options{
|
|
||||||
max-width: 1000px;
|
|
||||||
margin-left: auto;
|
|
||||||
margin-right: auto;
|
|
||||||
background-color: rgba(0, 0, 0, 0.15);
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 1rem;
|
|
||||||
color: #eeffeb;
|
|
||||||
|
|
||||||
#weighted-options-header{
|
|
||||||
h1{
|
|
||||||
margin-bottom: 0;
|
|
||||||
padding-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
h1:nth-child(2){
|
|
||||||
font-size: 1.4rem;
|
|
||||||
margin-top: -8px;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.js-warning-banner{
|
|
||||||
width: calc(100% - 1rem);
|
|
||||||
padding: 0.5rem;
|
|
||||||
border-radius: 4px;
|
|
||||||
background-color: #f3f309;
|
|
||||||
color: #000000;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.option-wrapper{
|
|
||||||
width: 100%;
|
|
||||||
margin-bottom: 2rem;
|
|
||||||
|
|
||||||
.add-option-div{
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
justify-content: flex-start;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
|
|
||||||
button{
|
|
||||||
width: auto;
|
|
||||||
height: auto;
|
|
||||||
margin: 0 0 0 0.15rem;
|
|
||||||
padding: 0 0.25rem;
|
|
||||||
border-radius: 4px;
|
|
||||||
cursor: default;
|
|
||||||
|
|
||||||
&:active{
|
|
||||||
margin-bottom: 1px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
p{
|
|
||||||
&.option-description{
|
|
||||||
margin: 0 0 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.hint-text{
|
|
||||||
margin: 0 0 1rem;
|
|
||||||
font-style: italic;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
table{
|
|
||||||
width: 100%;
|
|
||||||
margin-top: 0.5rem;
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
|
|
||||||
th, td{
|
|
||||||
border: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
td{
|
|
||||||
padding: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.td-left{
|
|
||||||
font-family: LexendDeca-Regular, sans-serif;
|
|
||||||
padding-right: 1rem;
|
|
||||||
width: 200px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.td-middle{
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
justify-content: space-evenly;
|
|
||||||
padding-right: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.td-right{
|
|
||||||
width: 4rem;
|
|
||||||
text-align: right;
|
|
||||||
}
|
|
||||||
|
|
||||||
.td-delete{
|
|
||||||
width: 50px;
|
|
||||||
text-align: right;
|
|
||||||
}
|
|
||||||
|
|
||||||
.range-option-delete{
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#weighted-options-button-row{
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
justify-content: space-between;
|
|
||||||
margin-top: 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#user-message{
|
|
||||||
display: none;
|
|
||||||
width: calc(100% - 8px);
|
|
||||||
background-color: #ffe86b;
|
|
||||||
border-radius: 4px;
|
|
||||||
color: #000000;
|
|
||||||
padding: 4px;
|
|
||||||
text-align: center;
|
|
||||||
|
|
||||||
&.visible{
|
|
||||||
display: block;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
h1{
|
|
||||||
font-size: 2.5rem;
|
|
||||||
font-weight: normal;
|
|
||||||
width: 100%;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
color: #ffffff;
|
|
||||||
text-shadow: 1px 1px 4px #000000;
|
|
||||||
}
|
|
||||||
|
|
||||||
h2, details summary.h2{
|
|
||||||
font-size: 2rem;
|
|
||||||
font-weight: normal;
|
|
||||||
border-bottom: 1px solid #ffffff;
|
|
||||||
width: 100%;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
color: #ffe993;
|
|
||||||
text-transform: none;
|
|
||||||
text-shadow: 1px 1px 2px #000000;
|
|
||||||
}
|
|
||||||
|
|
||||||
h3, h4, h5, h6{
|
|
||||||
color: #ffffff;
|
|
||||||
text-shadow: 1px 1px 1px rgba(0, 0, 0, 0.5);
|
|
||||||
text-transform: none;
|
|
||||||
cursor: unset;
|
|
||||||
}
|
|
||||||
|
|
||||||
h3{
|
|
||||||
&.option-group-header{
|
|
||||||
margin-top: 0.75rem;
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
a{
|
|
||||||
color: #ffef00;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
input:not([type]){
|
|
||||||
border: 1px solid #000000;
|
|
||||||
padding: 3px;
|
|
||||||
border-radius: 3px;
|
|
||||||
min-width: 150px;
|
|
||||||
|
|
||||||
&:focus{
|
|
||||||
border: 1px solid #ffffff;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.invisible{
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.unsupported-option{
|
|
||||||
margin-top: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.set-container, .dict-container, .list-container{
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
background-color: rgba(0, 0, 0, 0.25);
|
|
||||||
border: 1px solid rgba(20, 20, 20, 0.25);
|
|
||||||
border-radius: 3px;
|
|
||||||
color: #ffffff;
|
|
||||||
max-height: 15rem;
|
|
||||||
min-width: 14.5rem;
|
|
||||||
overflow-y: auto;
|
|
||||||
padding-right: 0.25rem;
|
|
||||||
padding-left: 0.25rem;
|
|
||||||
margin-top: 0.5rem;
|
|
||||||
|
|
||||||
.divider{
|
|
||||||
width: 100%;
|
|
||||||
height: 2px;
|
|
||||||
background-color: rgba(20, 20, 20, 0.25);
|
|
||||||
margin-top: 0.125rem;
|
|
||||||
margin-bottom: 0.125rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.set-entry, .dict-entry, .list-entry{
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
align-items: flex-start;
|
|
||||||
padding-bottom: 0.25rem;
|
|
||||||
padding-top: 0.25rem;
|
|
||||||
user-select: none;
|
|
||||||
line-height: 1rem;
|
|
||||||
|
|
||||||
&:hover{
|
|
||||||
background-color: rgba(20, 20, 20, 0.25);
|
|
||||||
}
|
|
||||||
|
|
||||||
input[type=checkbox]{
|
|
||||||
margin-right: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
input[type=number]{
|
|
||||||
max-width: 1.5rem;
|
|
||||||
max-height: 1rem;
|
|
||||||
margin-left: 0.125rem;
|
|
||||||
text-align: center;
|
|
||||||
|
|
||||||
/* Hide arrows on input[type=number] fields */
|
|
||||||
-moz-appearance: textfield;
|
|
||||||
&::-webkit-outer-spin-button, &::-webkit-inner-spin-button{
|
|
||||||
-webkit-appearance: none;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
label{
|
|
||||||
flex-grow: 1;
|
|
||||||
margin-right: 0;
|
|
||||||
min-width: unset;
|
|
||||||
display: unset;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.hidden{
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media all and (max-width: 1000px), all and (orientation: portrait){
|
|
||||||
#weighted-options .game-options{
|
|
||||||
justify-content: flex-start;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
#game-options table label{
|
|
||||||
display: block;
|
|
||||||
min-width: 200px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -17,9 +17,9 @@
|
|||||||
</p>
|
</p>
|
||||||
<div id="check-form-wrapper">
|
<div id="check-form-wrapper">
|
||||||
<form id="check-form" method="post" enctype="multipart/form-data">
|
<form id="check-form" method="post" enctype="multipart/form-data">
|
||||||
<input id="file-input" type="file" name="file" multiple>
|
<input id="file-input" type="file" name="file">
|
||||||
</form>
|
</form>
|
||||||
<button id="check-button">Upload File(s)</button>
|
<button id="check-button">Upload</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -28,10 +28,6 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
{% if combined_yaml %}
|
|
||||||
<h1>Combined File Download</h1>
|
|
||||||
<p><a href="data:text/yaml;base64,{{ combined_yaml }}" download="combined.yaml">Download</a></p>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -7,11 +7,6 @@
|
|||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<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 }}">
|
<div id="player-tracker-wrapper" data-tracker="{{ room.tracker|suuid }}">
|
||||||
<table id="inventory-table">
|
<table id="inventory-table">
|
||||||
<tr class="column-headers">
|
<tr class="column-headers">
|
||||||
@@ -69,8 +69,8 @@
|
|||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<select name="collect_mode" id="collect_mode">
|
<select name="collect_mode" id="collect_mode">
|
||||||
<option value="auto">Automatic on goal completion</option>
|
|
||||||
<option value="goal">Allow !collect after goal completion</option>
|
<option value="goal">Allow !collect after goal completion</option>
|
||||||
|
<option value="auto">Automatic on goal completion</option>
|
||||||
<option value="auto-enabled">
|
<option value="auto-enabled">
|
||||||
Automatic on goal completion and manual !collect
|
Automatic on goal completion and manual !collect
|
||||||
</option>
|
</option>
|
||||||
@@ -93,9 +93,9 @@
|
|||||||
{% if race -%}
|
{% if race -%}
|
||||||
<option value="disabled">Disabled in Race mode</option>
|
<option value="disabled">Disabled in Race mode</option>
|
||||||
{%- else -%}
|
{%- else -%}
|
||||||
|
<option value="disabled">Disabled</option>
|
||||||
<option value="goal">Allow !remaining after goal completion</option>
|
<option value="goal">Allow !remaining after goal completion</option>
|
||||||
<option value="enabled">Manual !remaining</option>
|
<option value="enabled">Manual !remaining</option>
|
||||||
<option value="disabled">Disabled</option>
|
|
||||||
{%- endif -%}
|
{%- endif -%}
|
||||||
</select>
|
</select>
|
||||||
</td>
|
</td>
|
||||||
@@ -185,12 +185,12 @@ Warning: playthrough can take a significant amount of time for larger multiworld
|
|||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<input type="checkbox" id="plando_items" name="plando_items" value="items">
|
|
||||||
<label for="plando_items">Items</label><br>
|
|
||||||
|
|
||||||
<input type="checkbox" id="plando_bosses" name="plando_bosses" value="bosses" checked>
|
<input type="checkbox" id="plando_bosses" name="plando_bosses" value="bosses" checked>
|
||||||
<label for="plando_bosses">Bosses</label><br>
|
<label for="plando_bosses">Bosses</label><br>
|
||||||
|
|
||||||
|
<input type="checkbox" id="plando_items" name="plando_items" value="items" checked>
|
||||||
|
<label for="plando_items">Items</label><br>
|
||||||
|
|
||||||
<input type="checkbox" id="plando_connections" name="plando_connections" value="connections" checked>
|
<input type="checkbox" id="plando_connections" name="plando_connections" value="connections" checked>
|
||||||
<label for="plando_connections">Connections</label><br>
|
<label for="plando_connections">Connections</label><br>
|
||||||
|
|
||||||
@@ -203,10 +203,10 @@ Warning: playthrough can take a significant amount of time for larger multiworld
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="generate-form-button-row">
|
<div id="generate-form-button-row">
|
||||||
<input id="file-input" type="file" name="file" multiple>
|
<input id="file-input" type="file" name="file">
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
<button id="generate-game-button">Upload File(s)</button>
|
<button id="generate-game-button">Upload File</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,57 +1,36 @@
|
|||||||
{% extends "tablepage.html" %}
|
{% extends 'tablepage.html' %}
|
||||||
{% block head %}
|
{% block head %}
|
||||||
{{ super() }}
|
{{ super() }}
|
||||||
<title>{{ player_name }}'s Tracker</title>
|
<title>{{ player_name }}'s Tracker</title>
|
||||||
<link rel="stylesheet" type="text/css" href="{{ url_for("static", filename="styles/tracker.css") }}"/>
|
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/tracker.css") }}"/>
|
||||||
<script type="application/ecmascript" src="{{ url_for("static", filename="assets/jquery.scrollsync.js") }}"></script>
|
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/jquery.scrollsync.js") }}"></script>
|
||||||
<script type="application/ecmascript" src="{{ url_for("static", filename="assets/trackerCommon.js") }}"></script>
|
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/trackerCommon.js") }}"></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block body %}
|
{% block body %}
|
||||||
{% include "header/dirtHeader.html" %}
|
{% include 'header/dirtHeader.html' %}
|
||||||
|
<div id="tracker-wrapper" data-tracker="{{ room.tracker|suuid }}/{{ team }}/{{ player }}" data-second="{{ saving_second }}">
|
||||||
<div id="tracker-navigation">
|
|
||||||
<div class="tracker-navigation-bar">
|
|
||||||
<a
|
|
||||||
class="tracker-navigation-button"
|
|
||||||
href="{{ url_for("get_multiworld_tracker", tracker=room.tracker) }}"
|
|
||||||
>
|
|
||||||
🡸 Return to Multiworld Tracker
|
|
||||||
</a>
|
|
||||||
{% if game_specific_tracker %}
|
|
||||||
<a
|
|
||||||
class="tracker-navigation-button"
|
|
||||||
href="{{ url_for("get_player_tracker", tracker=room.tracker, tracked_team=team, tracked_player=player) }}"
|
|
||||||
>
|
|
||||||
Game-Specific Tracker
|
|
||||||
</a>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="tracker-wrapper" data-tracker="{{ room.tracker | suuid }}/{{ team }}/{{ player }}" data-second="{{ saving_second }}">
|
|
||||||
<div id="tracker-header-bar">
|
<div id="tracker-header-bar">
|
||||||
<input placeholder="Search" id="search" />
|
<input placeholder="Search" id="search"/>
|
||||||
<div class="info">This tracker will automatically update itself periodically.</div>
|
<span class="info">This tracker will automatically update itself periodically.</span>
|
||||||
</div>
|
</div>
|
||||||
<div id="tables-container">
|
|
||||||
<div class="table-wrapper">
|
<div class="table-wrapper">
|
||||||
<table id="received-table" class="table non-unique-item-table">
|
<table id="received-table" class="table non-unique-item-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Item</th>
|
<th>Item</th>
|
||||||
<th>Amount</th>
|
<th>Amount</th>
|
||||||
<th>Last Order Received</th>
|
<th>Order Received</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
|
|
||||||
{% for id, count in inventory.items() if count > 0 %}
|
{% for id, count in inventory.items() %}
|
||||||
<tr>
|
<tr>
|
||||||
<td>{{ item_id_to_name[game][id] }}</td>
|
<td>{{ id | item_name }}</td>
|
||||||
<td>{{ count }}</td>
|
<td>{{ count }}</td>
|
||||||
<td>{{ received_items[id] }}</td>
|
<td>{{received_items[id]}}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{%- endfor -%}
|
{%- endfor -%}
|
||||||
|
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -60,62 +39,24 @@
|
|||||||
<div class="table-wrapper">
|
<div class="table-wrapper">
|
||||||
<table id="locations-table" class="table non-unique-item-table">
|
<table id="locations-table" class="table non-unique-item-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Location</th>
|
<th>Location</th>
|
||||||
<th class="center-column">Checked</th>
|
<th>Checked</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
|
{% for name in checked_locations %}
|
||||||
{%- for location in locations -%}
|
|
||||||
<tr>
|
<tr>
|
||||||
<td>{{ location_id_to_name[game][location] }}</td>
|
<td>{{ name | location_name}}</td>
|
||||||
<td class="center-column">
|
<td>✔</td>
|
||||||
{% if location in checked_locations %}✔{% endif %}
|
|
||||||
</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
{%- endfor -%}
|
{%- endfor -%}
|
||||||
|
{% for name in not_checked_locations %}
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<div class="table-wrapper">
|
|
||||||
<table id="hints-table" class="table non-unique-item-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
<tr>
|
||||||
<th>Finder</th>
|
<td>{{ name | location_name}}</td>
|
||||||
<th>Receiver</th>
|
<td></td>
|
||||||
<th>Item</th>
|
|
||||||
<th>Location</th>
|
|
||||||
<th>Game</th>
|
|
||||||
<th>Entrance</th>
|
|
||||||
<th class="center-column">Found</th>
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
{%- endfor -%}
|
||||||
<tbody>
|
|
||||||
{%- for hint in hints -%}
|
|
||||||
<tr>
|
|
||||||
<td>
|
|
||||||
{% if hint.finding_player == player %}
|
|
||||||
<b>{{ player_names_with_alias[(team, hint.finding_player)] }}</b>
|
|
||||||
{% else %}
|
|
||||||
{{ player_names_with_alias[(team, hint.finding_player)] }}
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
{% if hint.receiving_player == player %}
|
|
||||||
<b>{{ player_names_with_alias[(team, hint.receiving_player)] }}</b>
|
|
||||||
{% else %}
|
|
||||||
{{ player_names_with_alias[(team, hint.receiving_player)] }}
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
|
||||||
<td>{{ item_id_to_name[games[(team, hint.receiving_player)]][hint.item] }}</td>
|
|
||||||
<td>{{ location_id_to_name[games[(team, hint.finding_player)]][hint.location] }}</td>
|
|
||||||
<td>{{ games[(team, hint.finding_player)] }}</td>
|
|
||||||
<td>{% if hint.entrance %}{{ hint.entrance }}{% else %}Vanilla{% endif %}</td>
|
|
||||||
<td class="center-column">{% if hint.found %}✔{% endif %}</td>
|
|
||||||
</tr>
|
|
||||||
{%- endfor -%}
|
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
28
WebHostLib/templates/hintTable.html
Normal file
28
WebHostLib/templates/hintTable.html
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
{% for team, hints in hints.items() %}
|
||||||
|
<div class="table-wrapper">
|
||||||
|
<table id="hints-table" class="table non-unique-item-table" data-order='[[5, "asc"], [0, "asc"]]'>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Finder</th>
|
||||||
|
<th>Receiver</th>
|
||||||
|
<th>Item</th>
|
||||||
|
<th>Location</th>
|
||||||
|
<th>Entrance</th>
|
||||||
|
<th>Found</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{%- for hint in hints -%}
|
||||||
|
<tr>
|
||||||
|
<td>{{ long_player_names[team, hint.finding_player] }}</td>
|
||||||
|
<td>{{ long_player_names[team, hint.receiving_player] }}</td>
|
||||||
|
<td>{{ hint.item|item_name }}</td>
|
||||||
|
<td>{{ hint.location|location_name }}</td>
|
||||||
|
<td>{% if hint.entrance %}{{ hint.entrance }}{% else %}Vanilla{% endif %}</td>
|
||||||
|
<td>{% if hint.found %}✔{% endif %}</td>
|
||||||
|
</tr>
|
||||||
|
{%- endfor -%}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
@@ -3,16 +3,6 @@
|
|||||||
{% block head %}
|
{% block head %}
|
||||||
<title>Multiworld {{ room.id|suuid }}</title>
|
<title>Multiworld {{ room.id|suuid }}</title>
|
||||||
{% if should_refresh %}<meta http-equiv="refresh" content="2">{% endif %}
|
{% if should_refresh %}<meta http-equiv="refresh" content="2">{% endif %}
|
||||||
<meta name="og:site_name" content="Archipelago">
|
|
||||||
<meta property="og:title" content="Multiworld {{ room.id|suuid }}">
|
|
||||||
<meta property="og:type" content="website" />
|
|
||||||
{% if room.seed.slots|length < 2 %}
|
|
||||||
<meta property="og:description" content="{{ room.seed.slots|length }} Player World
|
|
||||||
{% if room.last_port != -1 %}running on {{ config['HOST_ADDRESS'] }} with port {{ room.last_port }}{% endif %}">
|
|
||||||
{% else %}
|
|
||||||
<meta property="og:description" content="{{ room.seed.slots|length }} Players Multiworld
|
|
||||||
{% if room.last_port != -1 %}running on {{ config['HOST_ADDRESS'] }} with port {{ room.last_port }}{% endif %}">
|
|
||||||
{% endif %}
|
|
||||||
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/hostRoom.css") }}"/>
|
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/hostRoom.css") }}"/>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{% block footer %}
|
{% block footer %}
|
||||||
<footer id="island-footer">
|
<footer id="island-footer">
|
||||||
<div id="copyright-notice">Copyright 2024 Archipelago</div>
|
<div id="copyright-notice">Copyright 2023 Archipelago</div>
|
||||||
<div id="links">
|
<div id="links">
|
||||||
<a href="/sitemap">Site Map</a>
|
<a href="/sitemap">Site Map</a>
|
||||||
-
|
-
|
||||||
|
|||||||
@@ -49,9 +49,9 @@
|
|||||||
our crazy idea into a reality.
|
our crazy idea into a reality.
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
<a href="{{ url_for("stats") }}">{{ seeds }}</a>
|
<span class="variable">{{ seeds }}</span>
|
||||||
games were generated and
|
games were generated and
|
||||||
<a href="{{ url_for("stats") }}">{{ rooms }}</a>
|
<span class="variable">{{ rooms }}</span>
|
||||||
were hosted in the last 7 days.
|
were hosted in the last 7 days.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
171
WebHostLib/templates/lttpMultiTracker.html
Normal file
171
WebHostLib/templates/lttpMultiTracker.html
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
{% extends 'tablepage.html' %}
|
||||||
|
{% block head %}
|
||||||
|
{{ super() }}
|
||||||
|
<title>ALttP Multiworld Tracker</title>
|
||||||
|
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/tracker.css") }}"/>
|
||||||
|
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/jquery.scrollsync.js") }}"></script>
|
||||||
|
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/lttpMultiTracker.js") }}"></script>
|
||||||
|
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/trackerCommon.js") }}"></script>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block body %}
|
||||||
|
{% include 'header/dirtHeader.html' %}
|
||||||
|
{% include 'multiTrackerNavigation.html' %}
|
||||||
|
<div id="tracker-wrapper" data-tracker="{{ room.tracker|suuid }}">
|
||||||
|
<div id="tracker-header-bar">
|
||||||
|
<input placeholder="Search" id="search"/>
|
||||||
|
<span{% if not video %} hidden{% endif %} id="multi-stream-link">
|
||||||
|
<a target="_blank" href="https://multistream.me/
|
||||||
|
{%- for platform, link in video.values()|unique(False, 1)-%}
|
||||||
|
{%- if platform == "Twitch" -%}t{%- else -%}yt{%- endif -%}:{{- link -}}/
|
||||||
|
{%- endfor -%}">
|
||||||
|
Multistream
|
||||||
|
</a>
|
||||||
|
</span>
|
||||||
|
<span class="info">Clicking on a slot's number will bring up a slot-specific auto-tracker. This tracker will automatically update itself periodically.</span>
|
||||||
|
</div>
|
||||||
|
<div id="tables-container">
|
||||||
|
{% for team, players in inventory.items() %}
|
||||||
|
<div class="table-wrapper">
|
||||||
|
<table id="inventory-table" class="table unique-item-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>#</th>
|
||||||
|
<th>Name</th>
|
||||||
|
{%- for name in tracking_names -%}
|
||||||
|
{%- if name in icons -%}
|
||||||
|
<th class="center-column">
|
||||||
|
<img class="alttp-sprite" src="{{ icons[name] }}" alt="{{ name|e }}">
|
||||||
|
</th>
|
||||||
|
{%- else -%}
|
||||||
|
<th class="center-column">{{ name|e }}</th>
|
||||||
|
{%- endif -%}
|
||||||
|
{%- endfor -%}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{%- for player, items in players.items() -%}
|
||||||
|
<tr>
|
||||||
|
<td><a href="{{ url_for("get_player_tracker", tracker=room.tracker,
|
||||||
|
tracked_team=team, tracked_player=player)}}">{{ loop.index }}</a></td>
|
||||||
|
{%- if (team, loop.index) in video -%}
|
||||||
|
{%- if video[(team, loop.index)][0] == "Twitch" -%}
|
||||||
|
<td>
|
||||||
|
<a target="_blank" href="https://www.twitch.tv/{{ video[(team, loop.index)][1] }}">
|
||||||
|
{{ player_names[(team, loop.index)] }}
|
||||||
|
▶️</a></td>
|
||||||
|
{%- elif video[(team, loop.index)][0] == "Youtube" -%}
|
||||||
|
<td>
|
||||||
|
<a target="_blank" href="youtube.com/c/{{ video[(team, loop.index)][1] }}/live">
|
||||||
|
{{ player_names[(team, loop.index)] }}
|
||||||
|
▶️</a></td>
|
||||||
|
{%- endif -%}
|
||||||
|
{%- else -%}
|
||||||
|
<td>{{ player_names[(team, loop.index)] }}</td>
|
||||||
|
{%- endif -%}
|
||||||
|
{%- for id in tracking_ids -%}
|
||||||
|
{%- if items[id] -%}
|
||||||
|
<td class="center-column item-acquired">
|
||||||
|
{% if id in multi_items %}{{ items[id] }}{% else %}✔️{% endif %}</td>
|
||||||
|
{%- else -%}
|
||||||
|
<td></td>
|
||||||
|
{%- endif -%}
|
||||||
|
{% endfor %}
|
||||||
|
</tr>
|
||||||
|
{%- endfor -%}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
{% for team, players in checks_done.items() %}
|
||||||
|
<div class="table-wrapper">
|
||||||
|
<table id="checks-table" class="table non-unique-item-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th rowspan="2">#</th>
|
||||||
|
<th rowspan="2">Name</th>
|
||||||
|
{% for area in ordered_areas %}
|
||||||
|
{% set colspan = 1 %}
|
||||||
|
{% if area in key_locations %}
|
||||||
|
{% set colspan = colspan + 1 %}
|
||||||
|
{% endif %}
|
||||||
|
{% if area in big_key_locations %}
|
||||||
|
{% set colspan = colspan + 1 %}
|
||||||
|
{% endif %}
|
||||||
|
{% if area in icons %}
|
||||||
|
<th colspan="{{ colspan }}" class="center-column upper-row">
|
||||||
|
<img class="alttp-sprite" src="{{ icons[area] }}" alt="{{ area }}"></th>
|
||||||
|
{%- else -%}
|
||||||
|
<th colspan="{{ colspan }}" class="center-column">{{ area }}</th>
|
||||||
|
{%- endif -%}
|
||||||
|
{%- endfor -%}
|
||||||
|
<th rowspan="2" class="center-column">%</th>
|
||||||
|
<th rowspan="2" class="center-column hours">Last<br>Activity</th>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
{% for area in ordered_areas %}
|
||||||
|
<th class="center-column lower-row fraction">
|
||||||
|
<img class="alttp-sprite" src="{{ icons["Chest"] }}" alt="Checks">
|
||||||
|
</th>
|
||||||
|
{% if area in key_locations %}
|
||||||
|
<th class="center-column lower-row number">
|
||||||
|
<img class="alttp-sprite" src="{{ icons["Small Key"] }}" alt="Small Key">
|
||||||
|
</th>
|
||||||
|
{% endif %}
|
||||||
|
{% if area in big_key_locations %}
|
||||||
|
<th class="center-column lower-row number">
|
||||||
|
<img class="alttp-sprite" src="{{ icons["Big Key"] }}" alt="Big Key">
|
||||||
|
</th>
|
||||||
|
{%- endif -%}
|
||||||
|
{%- endfor -%}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{%- for player, checks in players.items() -%}
|
||||||
|
<tr>
|
||||||
|
<td><a href="{{ url_for("get_player_tracker", tracker=room.tracker,
|
||||||
|
tracked_team=team, tracked_player=player)}}">{{ loop.index }}</a></td>
|
||||||
|
<td>{{ player_names[(team, loop.index)]|e }}</td>
|
||||||
|
{%- for area in ordered_areas -%}
|
||||||
|
{% if player in checks_in_area and area in checks_in_area[player] %}
|
||||||
|
{%- set checks_done = checks[area] -%}
|
||||||
|
{%- set checks_total = checks_in_area[player][area] -%}
|
||||||
|
{%- if checks_done == checks_total -%}
|
||||||
|
<td class="item-acquired center-column">
|
||||||
|
{{ checks_done }}/{{ checks_total }}</td>
|
||||||
|
{%- else -%}
|
||||||
|
<td class="center-column">{{ checks_done }}/{{ checks_total }}</td>
|
||||||
|
{%- endif -%}
|
||||||
|
{%- if area in key_locations -%}
|
||||||
|
<td class="center-column">{{ inventory[team][player][small_key_ids[area]] }}</td>
|
||||||
|
{%- endif -%}
|
||||||
|
{%- if area in big_key_locations -%}
|
||||||
|
<td class="center-column">{% if inventory[team][player][big_key_ids[area]] %}✔️{% endif %}</td>
|
||||||
|
{%- endif -%}
|
||||||
|
{% else %}
|
||||||
|
<td class="center-column"></td>
|
||||||
|
{%- if area in key_locations -%}
|
||||||
|
<td class="center-column"></td>
|
||||||
|
{%- endif -%}
|
||||||
|
{%- if area in big_key_locations -%}
|
||||||
|
<td class="center-column"></td>
|
||||||
|
{%- endif -%}
|
||||||
|
{% endif %}
|
||||||
|
{%- endfor -%}
|
||||||
|
<td class="center-column">{{ percent_total_checks_done[team][player] }}</td>
|
||||||
|
{%- if activity_timers[(team, player)] -%}
|
||||||
|
<td class="center-column">{{ activity_timers[(team, player)].total_seconds() }}</td>
|
||||||
|
{%- else -%}
|
||||||
|
<td class="center-column">None</td>
|
||||||
|
{%- endif -%}
|
||||||
|
</tr>
|
||||||
|
{%- endfor -%}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% include "hintTable.html" with context %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
86
WebHostLib/templates/lttpTracker.html
Normal file
86
WebHostLib/templates/lttpTracker.html
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<title>{{ player_name }}'s Tracker</title>
|
||||||
|
<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/lttp-tracker.css") }}"/>
|
||||||
|
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/lttp-tracker.js") }}"></script>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div id="player-tracker-wrapper" data-tracker="{{ room.tracker|suuid }}">
|
||||||
|
<table id="inventory-table">
|
||||||
|
<tr>
|
||||||
|
<td><img src="{{ bow_url }}" class="{{ 'acquired' if bow_acquired }}" /></td>
|
||||||
|
<td><img src="{{ icons["Blue Boomerang"] }}" class="{{ 'acquired' if 'Blue Boomerang' in acquired_items }}" /></td>
|
||||||
|
<td><img src="{{ icons["Red Boomerang"] }}" class="{{ 'acquired' if 'Red Boomerang' in acquired_items }}" /></td>
|
||||||
|
<td><img src="{{ icons["Hookshot"] }}" class="{{ 'acquired' if 'Hookshot' in acquired_items }}" /></td>
|
||||||
|
<td><img src="{{ icons["Magic Powder"] }}" class="powder-fix {{ 'acquired' if 'Magic Powder' in acquired_items }}" /></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><img src="{{ icons["Fire Rod"] }}" class="{{ 'acquired' if "Fire Rod" in acquired_items }}" /></td>
|
||||||
|
<td><img src="{{ icons["Ice Rod"] }}" class="{{ 'acquired' if "Ice Rod" in acquired_items }}" /></td>
|
||||||
|
<td><img src="{{ icons["Bombos"] }}" class="{{ 'acquired' if "Bombos" in acquired_items }}" /></td>
|
||||||
|
<td><img src="{{ icons["Ether"] }}" class="{{ 'acquired' if "Ether" in acquired_items }}" /></td>
|
||||||
|
<td><img src="{{ icons["Quake"] }}" class="{{ 'acquired' if "Quake" in acquired_items }}" /></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><img src="{{ icons["Lamp"] }}" class="{{ 'acquired' if "Lamp" in acquired_items }}" /></td>
|
||||||
|
<td><img src="{{ icons["Hammer"] }}" class="{{ 'acquired' if "Hammer" in acquired_items }}" /></td>
|
||||||
|
<td><img src="{{ icons["Flute"] }}" class="{{ 'acquired' if "Flute" in acquired_items }}" /></td>
|
||||||
|
<td><img src="{{ icons["Bug Catching Net"] }}" class="{{ 'acquired' if "Bug Catching Net" in acquired_items }}" /></td>
|
||||||
|
<td><img src="{{ icons["Book of Mudora"] }}" class="{{ 'acquired' if "Book of Mudora" in acquired_items }}" /></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><img src="{{ icons["Bottle"] }}" class="{{ 'acquired' if "Bottle" in acquired_items }}" /></td>
|
||||||
|
<td><img src="{{ icons["Cane of Somaria"] }}" class="{{ 'acquired' if "Cane of Somaria" in acquired_items }}" /></td>
|
||||||
|
<td><img src="{{ icons["Cane of Byrna"] }}" class="{{ 'acquired' if "Cane of Byrna" in acquired_items }}" /></td>
|
||||||
|
<td><img src="{{ icons["Cape"] }}" class="{{ 'acquired' if "Cape" in acquired_items }}" /></td>
|
||||||
|
<td><img src="{{ icons["Magic Mirror"] }}" class="{{ 'acquired' if "Magic Mirror" in acquired_items }}" /></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><img src="{{ icons["Pegasus Boots"] }}" class="{{ 'acquired' if "Pegasus Boots" in acquired_items }}" /></td>
|
||||||
|
<td><img src="{{ glove_url }}" class="{{ 'acquired' if glove_acquired }}" /></td>
|
||||||
|
<td><img src="{{ icons["Flippers"] }}" class="{{ 'acquired' if "Flippers" in acquired_items }}" /></td>
|
||||||
|
<td><img src="{{ icons["Moon Pearl"] }}" class="{{ 'acquired' if "Moon Pearl" in acquired_items }}" /></td>
|
||||||
|
<td><img src="{{ icons["Mushroom"] }}" class="{{ 'acquired' if "Mushroom" in acquired_items }}" /></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><img src="{{ sword_url }}" class="{{ 'acquired' if sword_acquired }}" /></td>
|
||||||
|
<td><img src="{{ shield_url }}" class="{{ 'acquired' if shield_acquired }}" /></td>
|
||||||
|
<td><img src="{{ mail_url }}" class="acquired" /></td>
|
||||||
|
<td><img src="{{ icons["Shovel"] }}" class="{{ 'acquired' if "Shovel" in acquired_items }}" /></td>
|
||||||
|
<td><img src="{{ icons["Triforce"] }}" class="{{ 'acquired' if "Triforce" in acquired_items }}" /></td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
<table id="location-table">
|
||||||
|
<tr>
|
||||||
|
<th></th>
|
||||||
|
<th class="counter"><img src="{{ icons["Chest"] }}" /></th>
|
||||||
|
{% if key_locations and "Universal" not in key_locations %}
|
||||||
|
<th class="counter"><img src="{{ icons["Small Key"] }}" /></th>
|
||||||
|
{% endif %}
|
||||||
|
{% if big_key_locations %}
|
||||||
|
<th><img src="{{ icons["Big Key"] }}" /></th>
|
||||||
|
{% endif %}
|
||||||
|
</tr>
|
||||||
|
{% for area in sp_areas %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ area }}</td>
|
||||||
|
<td class="counter">{{ checks_done[area] }} / {{ checks_in_area[area] }}</td>
|
||||||
|
{% if key_locations and "Universal" not in key_locations %}
|
||||||
|
<td class="counter">
|
||||||
|
{{ inventory[small_key_ids[area]] if area in key_locations else '—' }}
|
||||||
|
</td>
|
||||||
|
{% endif %}
|
||||||
|
{% if big_key_locations %}
|
||||||
|
<td>
|
||||||
|
{{ '✔' if area in big_key_locations and inventory[big_key_ids[area]] else ('—' if area not in big_key_locations else '') }}
|
||||||
|
</td>
|
||||||
|
{% endif %}
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -22,7 +22,7 @@
|
|||||||
{% for patch in room.seed.slots|list|sort(attribute="player_id") %}
|
{% for patch in room.seed.slots|list|sort(attribute="player_id") %}
|
||||||
<tr>
|
<tr>
|
||||||
<td>{{ patch.player_id }}</td>
|
<td>{{ patch.player_id }}</td>
|
||||||
<td data-tooltip="Connect via TextClient"><a href="archipelago://{{ patch.player_name | e}}:None@{{ config['HOST_ADDRESS'] }}:{{ room.last_port }}">{{ patch.player_name }}</a></td>
|
<td data-tooltip="Connect via TextClient"><a href="archipelago://{{ patch.player_name | e}}:@{{ config['HOST_ADDRESS'] }}:{{ room.last_port }}">{{ patch.player_name }}</a></td>
|
||||||
<td>{{ patch.game }}</td>
|
<td>{{ patch.game }}</td>
|
||||||
<td>
|
<td>
|
||||||
{% if patch.data %}
|
{% if patch.data %}
|
||||||
@@ -47,9 +47,9 @@
|
|||||||
{% elif patch.game | supports_apdeltapatch %}
|
{% elif patch.game | supports_apdeltapatch %}
|
||||||
<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" %}
|
{% elif patch.game == "Dark Souls III" %}
|
||||||
<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 APMQ File...</a>
|
Download JSON File...</a>
|
||||||
{% else %}
|
{% else %}
|
||||||
No file to download for this game.
|
No file to download for this game.
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -8,18 +8,13 @@
|
|||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<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 }}">
|
<div id="player-tracker-wrapper" data-tracker="{{ room.tracker|suuid }}">
|
||||||
<table id="inventory-table">
|
<table id="inventory-table">
|
||||||
<tr>
|
<tr>
|
||||||
<td><img src="{{ tools_url }}" class="{{ 'acquired' }}" title="Progressive Tools" /></td>
|
<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="{{ weapons_url }}" class="{{ 'acquired' }}" title="Progressive Weapons" /></td>
|
||||||
<td><img src="{{ armor_url }}" class="{{ 'acquired' }}" title="Progressive Armor" /></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 }}"
|
<td><img src="{{ resource_crafting_url }}" class="{{ 'acquired' if 'Progressive Resource Crafting' in acquired_items }}"
|
||||||
title="Progressive Resource Crafting" /></td>
|
title="Progressive Resource Crafting" /></td>
|
||||||
<td><img src="{{ icons['Brewing Stand'] }}" class="{{ 'acquired' if 'Brewing' in acquired_items }}" title="Brewing" /></td>
|
<td><img src="{{ icons['Brewing Stand'] }}" class="{{ 'acquired' if 'Brewing' in acquired_items }}" title="Brewing" /></td>
|
||||||
<td>
|
<td>
|
||||||
73
WebHostLib/templates/multiTracker.html
Normal file
73
WebHostLib/templates/multiTracker.html
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
{% extends 'tablepage.html' %}
|
||||||
|
{% block head %}
|
||||||
|
{{ super() }}
|
||||||
|
<title>Multiworld Tracker</title>
|
||||||
|
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/tracker.css") }}"/>
|
||||||
|
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/trackerCommon.js") }}"></script>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block body %}
|
||||||
|
{% include 'header/dirtHeader.html' %}
|
||||||
|
{% include 'multiTrackerNavigation.html' %}
|
||||||
|
<div id="tracker-wrapper" data-tracker="{{ room.tracker|suuid }}">
|
||||||
|
<div id="tracker-header-bar">
|
||||||
|
<input placeholder="Search" id="search"/>
|
||||||
|
<span{% if not video %} hidden{% endif %} id="multi-stream-link">
|
||||||
|
<a target="_blank" href="https://multistream.me/
|
||||||
|
{%- for platform, link in video.values()|unique(False, 1)-%}
|
||||||
|
{%- if platform == "Twitch" -%}t{%- else -%}yt{%- endif -%}:{{- link -}}/
|
||||||
|
{%- endfor -%}">
|
||||||
|
Multistream
|
||||||
|
</a>
|
||||||
|
</span>
|
||||||
|
<span class="info">Clicking on a slot's number will bring up a slot-specific auto-tracker. This tracker will automatically update itself periodically.</span>
|
||||||
|
</div>
|
||||||
|
<div id="tables-container">
|
||||||
|
{% for team, players in checks_done.items() %}
|
||||||
|
<div class="table-wrapper">
|
||||||
|
<table id="checks-table" class="table non-unique-item-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>#</th>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Game</th>
|
||||||
|
<th>Status</th>
|
||||||
|
{% block custom_table_headers %}
|
||||||
|
{# implement this block in game-specific multi trackers #}
|
||||||
|
{% endblock %}
|
||||||
|
<th class="center-column">Checks</th>
|
||||||
|
<th class="center-column">%</th>
|
||||||
|
<th class="center-column hours">Last<br>Activity</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{%- for player, checks in players.items() -%}
|
||||||
|
<tr>
|
||||||
|
<td><a href="{{ url_for("get_player_tracker", tracker=room.tracker,
|
||||||
|
tracked_team=team, tracked_player=player)}}">{{ loop.index }}</a></td>
|
||||||
|
<td>{{ player_names[(team, loop.index)]|e }}</td>
|
||||||
|
<td>{{ games[player] }}</td>
|
||||||
|
<td>{{ {0: "Disconnected", 5: "Connected", 10: "Ready", 20: "Playing",
|
||||||
|
30: "Goal Completed"}.get(states[team, player], "Unknown State") }}</td>
|
||||||
|
{% block custom_table_row scoped %}
|
||||||
|
{# implement this block in game-specific multi trackers #}
|
||||||
|
{% endblock %}
|
||||||
|
<td class="center-column" data-sort="{{ checks["Total"] }}">
|
||||||
|
{{ checks["Total"] }}/{{ locations[player] | length }}
|
||||||
|
</td>
|
||||||
|
<td class="center-column">{{ percent_total_checks_done[team][player] }}</td>
|
||||||
|
{%- if activity_timers[team, player] -%}
|
||||||
|
<td class="center-column">{{ activity_timers[team, player].total_seconds() }}</td>
|
||||||
|
{%- else -%}
|
||||||
|
<td class="center-column">None</td>
|
||||||
|
{%- endif -%}
|
||||||
|
</tr>
|
||||||
|
{%- endfor -%}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% include "hintTable.html" with context %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
9
WebHostLib/templates/multiTrackerNavigation.html
Normal file
9
WebHostLib/templates/multiTrackerNavigation.html
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{%- if enabled_multiworld_trackers|length > 1 -%}
|
||||||
|
<div id="tracker-navigation">
|
||||||
|
{% for enabled_tracker in enabled_multiworld_trackers %}
|
||||||
|
{% set tracker_url = url_for(enabled_tracker.endpoint, tracker=room.tracker, game=enabled_tracker.name) %}
|
||||||
|
<a class="tracker-navigation-button{% if enabled_tracker.current %} selected{% endif %}"
|
||||||
|
href="{{ tracker_url }}">{{ enabled_tracker.name }}</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{%- endif -%}
|
||||||
@@ -1,144 +0,0 @@
|
|||||||
{% extends "tablepage.html" %}
|
|
||||||
{% block head %}
|
|
||||||
{{ super() }}
|
|
||||||
<title>Multiworld Tracker</title>
|
|
||||||
<link rel="stylesheet" type="text/css" href="{{ url_for("static", filename="styles/tracker.css") }}" />
|
|
||||||
<script type="application/ecmascript" src="{{ url_for("static", filename="assets/trackerCommon.js") }}"></script>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block body %}
|
|
||||||
{% include "header/dirtHeader.html" %}
|
|
||||||
{% include "multitrackerNavigation.html" %}
|
|
||||||
|
|
||||||
<div id="tracker-wrapper" data-tracker="{{ room.tracker | suuid }}">
|
|
||||||
<div id="tracker-header-bar">
|
|
||||||
<input placeholder="Search" id="search" />
|
|
||||||
|
|
||||||
<div
|
|
||||||
id="multi-stream-link"
|
|
||||||
class="tracker-navigation-bar"
|
|
||||||
{% if not videos %}style="display: none"{% endif %}
|
|
||||||
>
|
|
||||||
|
|
||||||
<a
|
|
||||||
class="tracker-navigation-button"
|
|
||||||
href="https://multistream.me/
|
|
||||||
{%- for platform, link in videos.values() | unique(False, 1) -%}
|
|
||||||
{%- if platform == "Twitch" -%}t{%- else -%}yt{%- endif -%}:{{- link -}}/
|
|
||||||
{%- endfor -%}"
|
|
||||||
target="_blank"
|
|
||||||
>
|
|
||||||
► Multistream
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="info">
|
|
||||||
Clicking on a slot's number will bring up the slot-specific tracker.
|
|
||||||
This tracker will automatically update itself periodically.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="tables-container">
|
|
||||||
{%- for team, players in room_players.items() -%}
|
|
||||||
<div class="table-wrapper">
|
|
||||||
<table id="checks-table" class="table non-unique-item-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>#</th>
|
|
||||||
<th>Name</th>
|
|
||||||
{% if current_tracker == "Generic" %}<th>Game</th>{% endif %}
|
|
||||||
<th>Status</th>
|
|
||||||
{% block custom_table_headers %}
|
|
||||||
{# Implement this block in game-specific multi-trackers. #}
|
|
||||||
{% endblock %}
|
|
||||||
<th class="center-column">Checks</th>
|
|
||||||
<th class="center-column">%</th>
|
|
||||||
<th class="center-column hours last-activity">Last<br>Activity</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{%- for player in players -%}
|
|
||||||
{%- if current_tracker == "Generic" or games[(team, player)] == current_tracker -%}
|
|
||||||
<tr>
|
|
||||||
<td>
|
|
||||||
<a href="{{ url_for("get_player_tracker", tracker=room.tracker, tracked_team=team, tracked_player=player) }}">
|
|
||||||
{{ player }}
|
|
||||||
</a>
|
|
||||||
</td>
|
|
||||||
<td>{{ player_names_with_alias[(team, player)] | e }}</td>
|
|
||||||
{%- if current_tracker == "Generic" -%}
|
|
||||||
<td>{{ games[(team, player)] }}</td>
|
|
||||||
{%- endif -%}
|
|
||||||
<td>
|
|
||||||
{{
|
|
||||||
{
|
|
||||||
0: "Disconnected",
|
|
||||||
5: "Connected",
|
|
||||||
10: "Ready",
|
|
||||||
20: "Playing",
|
|
||||||
30: "Goal Completed"
|
|
||||||
}.get(states[(team, player)], "Unknown State")
|
|
||||||
}}
|
|
||||||
</td>
|
|
||||||
|
|
||||||
{% block custom_table_row scoped %}
|
|
||||||
{# Implement this block in game-specific multi-trackers. #}
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% set location_count = locations[(team, player)] | length %}
|
|
||||||
<td class="center-column" data-sort="{{ locations_complete[(team, player)] }}">
|
|
||||||
{{ locations_complete[(team, player)] }}/{{ location_count }}
|
|
||||||
</td>
|
|
||||||
|
|
||||||
<td class="center-column">
|
|
||||||
{%- if locations[(team, player)] | length > 0 -%}
|
|
||||||
{% set percentage_of_completion = locations_complete[(team, player)] / location_count * 100 %}
|
|
||||||
{{ "{0:.2f}".format(percentage_of_completion) }}
|
|
||||||
{%- else -%}
|
|
||||||
100.00
|
|
||||||
{%- endif -%}
|
|
||||||
</td>
|
|
||||||
|
|
||||||
{%- if activity_timers[(team, player)] -%}
|
|
||||||
<td class="center-column">{{ activity_timers[(team, player)].total_seconds() }}</td>
|
|
||||||
{%- else -%}
|
|
||||||
<td class="center-column">None</td>
|
|
||||||
{%- endif -%}
|
|
||||||
</tr>
|
|
||||||
{%- endif -%}
|
|
||||||
{%- endfor -%}
|
|
||||||
</tbody>
|
|
||||||
|
|
||||||
{%- if not self.custom_table_headers() | trim -%}
|
|
||||||
<tfoot>
|
|
||||||
<tr>
|
|
||||||
<td colspan="2" style="text-align: right">Total</td>
|
|
||||||
<td>All Games</td>
|
|
||||||
<td>{{ completed_worlds[team] }}/{{ players | length }} Complete</td>
|
|
||||||
<td class="center-column">
|
|
||||||
{{ total_team_locations_complete[team] }}/{{ total_team_locations[team] }}
|
|
||||||
</td>
|
|
||||||
<td class="center-column">
|
|
||||||
{%- if total_team_locations[team] == 0 -%}
|
|
||||||
100
|
|
||||||
{%- else -%}
|
|
||||||
{{ "{0:.2f}".format(total_team_locations_complete[team] / total_team_locations[team] * 100) }}
|
|
||||||
{%- endif -%}
|
|
||||||
</td>
|
|
||||||
<td class="center-column last-activity"></td>
|
|
||||||
</tr>
|
|
||||||
</tfoot>
|
|
||||||
{%- endif -%}
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{%- endfor -%}
|
|
||||||
|
|
||||||
{% block custom_tables %}
|
|
||||||
{# Implement this block to create custom tables in game-specific multi-trackers. #}
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% include "multitrackerHintTable.html" with context %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
{% for team, hints in hints.items() %}
|
|
||||||
<div class="table-wrapper">
|
|
||||||
<table id="hints-table" class="table non-unique-item-table" data-order='[[5, "asc"], [0, "asc"]]'>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Finder</th>
|
|
||||||
<th>Receiver</th>
|
|
||||||
<th>Item</th>
|
|
||||||
<th>Location</th>
|
|
||||||
<th>Game</th>
|
|
||||||
<th>Entrance</th>
|
|
||||||
<th class="center-column">Found</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{%- for hint in hints -%}
|
|
||||||
{%-
|
|
||||||
if current_tracker == "Generic" or (
|
|
||||||
games[(team, hint.finding_player)] == current_tracker or
|
|
||||||
games[(team, hint.receiving_player)] == current_tracker
|
|
||||||
)
|
|
||||||
-%}
|
|
||||||
<tr>
|
|
||||||
<td>{{ player_names_with_alias[(team, hint.finding_player)] }}</td>
|
|
||||||
<td>{{ player_names_with_alias[(team, hint.receiving_player)] }}</td>
|
|
||||||
<td>{{ item_id_to_name[games[(team, hint.receiving_player)]][hint.item] }}</td>
|
|
||||||
<td>{{ location_id_to_name[games[(team, hint.finding_player)]][hint.location] }}</td>
|
|
||||||
<td>{{ games[(team, hint.finding_player)] }}</td>
|
|
||||||
<td>{% if hint.entrance %}{{ hint.entrance }}{% else %}Vanilla{% endif %}</td>
|
|
||||||
<td class="center-column">{% if hint.found %}✔{% endif %}</td>
|
|
||||||
</tr>
|
|
||||||
{% endif %}
|
|
||||||
{%- endfor -%}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
{% if enabled_trackers | length > 1 %}
|
|
||||||
<div id="tracker-navigation">
|
|
||||||
{# Multitracker game navigation. #}
|
|
||||||
<div class="tracker-navigation-bar">
|
|
||||||
{%- for game_tracker in enabled_trackers -%}
|
|
||||||
{%- set tracker_url = url_for("get_multiworld_tracker", tracker=room.tracker, game=game_tracker) -%}
|
|
||||||
<a
|
|
||||||
class="tracker-navigation-button{% if current_tracker == game_tracker %} selected{% endif %}"
|
|
||||||
href="{{ tracker_url }}"
|
|
||||||
>
|
|
||||||
{{ game_tracker }}
|
|
||||||
</a>
|
|
||||||
{%- endfor -%}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user