mirror of
https://github.com/ArchipelagoMW/Archipelago.git
synced 2026-03-22 15:45:04 -07:00
Compare commits
3 Commits
api-refere
...
style-lock
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f9dc1d5142 | ||
|
|
614c50e495 | ||
|
|
60e2b818b1 |
@@ -1,5 +0,0 @@
|
|||||||
[report]
|
|
||||||
exclude_lines =
|
|
||||||
pragma: no cover
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
if typing.TYPE_CHECKING:
|
|
||||||
35
.github/ISSUE_TEMPLATE/bug_report.yaml
vendored
35
.github/ISSUE_TEMPLATE/bug_report.yaml
vendored
@@ -1,35 +0,0 @@
|
|||||||
name: Bug Report
|
|
||||||
description: File a bug report.
|
|
||||||
title: "Bug: "
|
|
||||||
labels:
|
|
||||||
- bug / fix
|
|
||||||
body:
|
|
||||||
- type: markdown
|
|
||||||
attributes:
|
|
||||||
value: |
|
|
||||||
Thanks for taking the time to fill out this bug report! If this bug occurred during local generation check your
|
|
||||||
Archipelago install for a log (probably `C:\ProgramData\Archipelago\logs`)
|
|
||||||
and upload it with this report, as well as all yaml files used.
|
|
||||||
- type: textarea
|
|
||||||
id: what-happened
|
|
||||||
attributes:
|
|
||||||
label: What happened?
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
- type: textarea
|
|
||||||
id: expected-results
|
|
||||||
attributes:
|
|
||||||
label: What were the expected results?
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
- type: dropdown
|
|
||||||
id: version
|
|
||||||
attributes:
|
|
||||||
label: Software
|
|
||||||
description: Where did this bug occur?
|
|
||||||
options:
|
|
||||||
- Website
|
|
||||||
- Local generation
|
|
||||||
- While playing
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
17
.github/ISSUE_TEMPLATE/feature_request.yaml
vendored
17
.github/ISSUE_TEMPLATE/feature_request.yaml
vendored
@@ -1,17 +0,0 @@
|
|||||||
name: Feature Request
|
|
||||||
description: Request a feature!
|
|
||||||
title: "Category: "
|
|
||||||
labels:
|
|
||||||
- enhancement
|
|
||||||
body:
|
|
||||||
- type: markdown
|
|
||||||
attributes:
|
|
||||||
value: |
|
|
||||||
Please replace `Category` in the title with what this feature will be targeting, such as Core generation,
|
|
||||||
website, documentation, or a game.
|
|
||||||
Note: this is not for requesting new games to be added. If you would like to request a game, the best place to
|
|
||||||
ask is about it is in the [discord](https://archipelago.gg/discord).
|
|
||||||
- type: textarea
|
|
||||||
id: feature
|
|
||||||
attributes:
|
|
||||||
label: What feature would you like to see?
|
|
||||||
10
.github/ISSUE_TEMPLATE/task.yaml
vendored
10
.github/ISSUE_TEMPLATE/task.yaml
vendored
@@ -1,10 +0,0 @@
|
|||||||
name: Task
|
|
||||||
description: Submit a task to be done. If this is not targeting core, it should likely be elsewhere.
|
|
||||||
title: "Core: "
|
|
||||||
labels:
|
|
||||||
- core
|
|
||||||
- enhancement
|
|
||||||
body:
|
|
||||||
- type: textarea
|
|
||||||
attributes:
|
|
||||||
label: What task needs to be completed?
|
|
||||||
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'
|
|
||||||
12
.github/pull_request_template.md
vendored
12
.github/pull_request_template.md
vendored
@@ -1,12 +0,0 @@
|
|||||||
Please format your title with what portion of the project this pull request is
|
|
||||||
targeting and what it's changing.
|
|
||||||
|
|
||||||
ex. "MyGame4: implement new game" or "Docs: add new guide for customizing MyGame3"
|
|
||||||
|
|
||||||
## What is this fixing or adding?
|
|
||||||
|
|
||||||
|
|
||||||
## How was this tested?
|
|
||||||
|
|
||||||
|
|
||||||
## If this makes graphical changes, please attach screenshots.
|
|
||||||
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)
|
|
||||||
80
.github/workflows/analyze-modified-files.yml
vendored
80
.github/workflows/analyze-modified-files.yml
vendored
@@ -1,80 +0,0 @@
|
|||||||
name: Analyze modified files
|
|
||||||
|
|
||||||
on:
|
|
||||||
pull_request:
|
|
||||||
paths:
|
|
||||||
- "**.py"
|
|
||||||
push:
|
|
||||||
paths:
|
|
||||||
- "**.py"
|
|
||||||
|
|
||||||
env:
|
|
||||||
BASE: ${{ github.event.pull_request.base.sha }}
|
|
||||||
HEAD: ${{ github.event.pull_request.head.sha }}
|
|
||||||
BEFORE: ${{ github.event.before }}
|
|
||||||
AFTER: ${{ github.event.after }}
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
flake8-or-mypy:
|
|
||||||
strategy:
|
|
||||||
fail-fast: false
|
|
||||||
matrix:
|
|
||||||
task: [flake8, mypy]
|
|
||||||
|
|
||||||
name: ${{ matrix.task }}
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: "Determine modified files (pull_request)"
|
|
||||||
if: github.event_name == 'pull_request'
|
|
||||||
run: |
|
|
||||||
git fetch origin $BASE $HEAD
|
|
||||||
DIFF=$(git diff --diff-filter=d --name-only $BASE...$HEAD -- "*.py")
|
|
||||||
echo "modified files:"
|
|
||||||
echo "$DIFF"
|
|
||||||
echo "diff=${DIFF//$'\n'/$' '}" >> $GITHUB_ENV
|
|
||||||
|
|
||||||
- name: "Determine modified files (push)"
|
|
||||||
if: github.event_name == 'push' && github.event.before != '0000000000000000000000000000000000000000'
|
|
||||||
run: |
|
|
||||||
git fetch origin $BEFORE $AFTER
|
|
||||||
DIFF=$(git diff --diff-filter=d --name-only $BEFORE..$AFTER -- "*.py")
|
|
||||||
echo "modified files:"
|
|
||||||
echo "$DIFF"
|
|
||||||
echo "diff=${DIFF//$'\n'/$' '}" >> $GITHUB_ENV
|
|
||||||
|
|
||||||
- name: "Treat all files as modified (new branch)"
|
|
||||||
if: github.event_name == 'push' && github.event.before == '0000000000000000000000000000000000000000'
|
|
||||||
run: |
|
|
||||||
echo "diff=." >> $GITHUB_ENV
|
|
||||||
|
|
||||||
- uses: actions/setup-python@v5
|
|
||||||
if: env.diff != ''
|
|
||||||
with:
|
|
||||||
python-version: 3.8
|
|
||||||
|
|
||||||
- name: "Install dependencies"
|
|
||||||
if: env.diff != ''
|
|
||||||
run: |
|
|
||||||
python -m pip install --upgrade pip ${{ matrix.task }}
|
|
||||||
python ModuleUpdate.py --append "WebHostLib/requirements.txt" --force --yes
|
|
||||||
|
|
||||||
- name: "flake8: Stop the build if there are Python syntax errors or undefined names"
|
|
||||||
continue-on-error: false
|
|
||||||
if: env.diff != '' && matrix.task == 'flake8'
|
|
||||||
run: |
|
|
||||||
flake8 --count --select=E9,F63,F7,F82 --show-source --statistics ${{ env.diff }}
|
|
||||||
|
|
||||||
- name: "flake8: Lint modified files"
|
|
||||||
continue-on-error: true
|
|
||||||
if: env.diff != '' && matrix.task == 'flake8'
|
|
||||||
run: |
|
|
||||||
flake8 --count --max-complexity=14 --max-doc-length=120 --max-line-length=120 --statistics ${{ env.diff }}
|
|
||||||
|
|
||||||
- name: "mypy: Type check modified files"
|
|
||||||
continue-on-error: true
|
|
||||||
if: env.diff != '' && matrix.task == 'mypy'
|
|
||||||
run: |
|
|
||||||
mypy --follow-imports=silent --install-types --non-interactive --strict ${{ env.diff }}
|
|
||||||
92
.github/workflows/build.yml
vendored
92
.github/workflows/build.yml
vendored
@@ -2,24 +2,7 @@
|
|||||||
|
|
||||||
name: Build
|
name: Build
|
||||||
|
|
||||||
on:
|
on: workflow_dispatch
|
||||||
push:
|
|
||||||
paths:
|
|
||||||
- '.github/workflows/build.yml'
|
|
||||||
- 'setup.py'
|
|
||||||
- 'requirements.txt'
|
|
||||||
- '*.iss'
|
|
||||||
pull_request:
|
|
||||||
paths:
|
|
||||||
- '.github/workflows/build.yml'
|
|
||||||
- 'setup.py'
|
|
||||||
- 'requirements.txt'
|
|
||||||
- '*.iss'
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
env:
|
|
||||||
ENEMIZER_VERSION: 7.1
|
|
||||||
APPIMAGETOOL_VERSION: 13
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
# build-release-macos: # LF volunteer
|
# build-release-macos: # LF volunteer
|
||||||
@@ -27,85 +10,73 @@ 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@v2
|
||||||
- name: Install python
|
- name: Install python
|
||||||
uses: actions/setup-python@v5
|
uses: actions/setup-python@v3
|
||||||
with:
|
with:
|
||||||
python-version: '3.8'
|
python-version: '3.8'
|
||||||
- name: Download run-time dependencies
|
- name: Download run-time dependencies
|
||||||
run: |
|
run: |
|
||||||
Invoke-WebRequest -Uri https://github.com/Ijwu/Enemizer/releases/download/${Env:ENEMIZER_VERSION}/win-x64.zip -OutFile enemizer.zip
|
Invoke-WebRequest -Uri https://github.com/alttpo/sni/releases/download/v0.0.82/sni-v0.0.82-windows-amd64.zip -OutFile sni.zip
|
||||||
|
Expand-Archive -Path sni.zip -DestinationPath SNI -Force
|
||||||
|
Invoke-WebRequest -Uri https://github.com/Ijwu/Enemizer/releases/download/7.0.1/win-x64.zip -OutFile enemizer.zip
|
||||||
Expand-Archive -Path enemizer.zip -DestinationPath EnemizerCLI -Force
|
Expand-Archive -Path enemizer.zip -DestinationPath EnemizerCLI -Force
|
||||||
- name: Build
|
- name: Build
|
||||||
run: |
|
run: |
|
||||||
python -m pip install --upgrade pip
|
python -m pip install --upgrade pip setuptools
|
||||||
python setup.py build_exe --yes
|
pip install -r requirements.txt
|
||||||
$NAME="$(ls build | Select-String -Pattern 'exe')".Split('.',2)[1]
|
python setup.py build --yes
|
||||||
|
$NAME="$(ls build)".Split('.',2)[1]
|
||||||
$ZIP_NAME="Archipelago_$NAME.7z"
|
$ZIP_NAME="Archipelago_$NAME.7z"
|
||||||
echo "$NAME -> $ZIP_NAME"
|
|
||||||
echo "ZIP_NAME=$ZIP_NAME" >> $Env:GITHUB_ENV
|
echo "ZIP_NAME=$ZIP_NAME" >> $Env:GITHUB_ENV
|
||||||
New-Item -Path dist -ItemType Directory -Force
|
New-Item -Path dist -ItemType Directory -Force
|
||||||
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@v2
|
||||||
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-ubuntu1804:
|
||||||
runs-on: ubuntu-20.04
|
runs-on: ubuntu-18.04
|
||||||
steps:
|
steps:
|
||||||
# - copy code below to release.yml -
|
- uses: actions/checkout@v2
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- 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@v3
|
||||||
with:
|
with:
|
||||||
python-version: '3.11'
|
python-version: '3.9'
|
||||||
- name: Install build-time dependencies
|
- name: Install build-time dependencies
|
||||||
run: |
|
run: |
|
||||||
echo "PYTHON=python3.11" >> $GITHUB_ENV
|
echo "PYTHON=python3.9" >> $GITHUB_ENV
|
||||||
wget -nv https://github.com/AppImage/AppImageKit/releases/download/$APPIMAGETOOL_VERSION/appimagetool-x86_64.AppImage
|
wget -nv https://github.com/AppImage/AppImageKit/releases/download/13/appimagetool-x86_64.AppImage
|
||||||
chmod a+rx appimagetool-x86_64.AppImage
|
chmod a+rx appimagetool-x86_64.AppImage
|
||||||
./appimagetool-x86_64.AppImage --appimage-extract
|
./appimagetool-x86_64.AppImage --appimage-extract
|
||||||
echo -e '#/bin/sh\n./squashfs-root/AppRun "$@"' > appimagetool
|
echo -e '#/bin/sh\n./squashfs-root/AppRun "$@"' > appimagetool
|
||||||
chmod a+rx appimagetool
|
chmod a+rx appimagetool
|
||||||
- name: Download run-time dependencies
|
- name: Download run-time dependencies
|
||||||
run: |
|
run: |
|
||||||
wget -nv https://github.com/Ijwu/Enemizer/releases/download/$ENEMIZER_VERSION/ubuntu.16.04-x64.7z
|
wget -nv https://github.com/alttpo/sni/releases/download/v0.0.82/sni-v0.0.82-manylinux2014-amd64.tar.xz
|
||||||
|
tar xf sni-*.tar.xz
|
||||||
|
rm sni-*.tar.xz
|
||||||
|
mv sni-* SNI
|
||||||
|
wget -nv https://github.com/Ijwu/Enemizer/releases/download/7.0.1/ubuntu.16.04-x64.7z
|
||||||
7za x -oEnemizerCLI/ ubuntu.16.04-x64.7z
|
7za x -oEnemizerCLI/ ubuntu.16.04-x64.7z
|
||||||
- name: Build
|
- name: Build
|
||||||
run: |
|
run: |
|
||||||
# pygobject is an optional dependency for kivy that's not in requirements
|
# pygobject is an optional dependency for kivy that's not in requirements
|
||||||
# charset-normalizer was somehow incomplete in the github runner
|
"${{ env.PYTHON }}" -m pip install --upgrade pip virtualenv PyGObject setuptools
|
||||||
"${{ env.PYTHON }}" -m venv venv
|
"${{ env.PYTHON }}" -m venv venv
|
||||||
source venv/bin/activate
|
source venv/bin/activate
|
||||||
"${{ env.PYTHON }}" -m pip install --upgrade pip PyGObject charset-normalizer
|
pip install -r requirements.txt
|
||||||
python setup.py build_exe --yes bdist_appimage --yes
|
python setup.py build --yes bdist_appimage --yes
|
||||||
echo -e "setup.py build output:\n `ls build`"
|
echo -e "setup.py build output:\n `ls build`"
|
||||||
echo -e "setup.py dist output:\n `ls dist`"
|
echo -e "setup.py dist output:\n `ls dist`"
|
||||||
cd dist && export APPIMAGE_NAME="`ls *.AppImage`" && cd ..
|
cd dist && export APPIMAGE_NAME="`ls *.AppImage`" && cd ..
|
||||||
@@ -113,19 +84,14 @@ jobs:
|
|||||||
(cd build && DIR_NAME="`ls | grep exe`" && mv "$DIR_NAME" Archipelago && tar -czvf ../dist/$TAR_NAME Archipelago && mv Archipelago "$DIR_NAME")
|
(cd build && DIR_NAME="`ls | grep exe`" && mv "$DIR_NAME" Archipelago && tar -czvf ../dist/$TAR_NAME Archipelago && mv Archipelago "$DIR_NAME")
|
||||||
echo "APPIMAGE_NAME=$APPIMAGE_NAME" >> $GITHUB_ENV
|
echo "APPIMAGE_NAME=$APPIMAGE_NAME" >> $GITHUB_ENV
|
||||||
echo "TAR_NAME=$TAR_NAME" >> $GITHUB_ENV
|
echo "TAR_NAME=$TAR_NAME" >> $GITHUB_ENV
|
||||||
# - copy code above to release.yml -
|
|
||||||
- name: Build Again
|
|
||||||
run: |
|
|
||||||
source venv/bin/activate
|
|
||||||
python setup.py build_exe --yes
|
|
||||||
- name: Store AppImage
|
- name: Store AppImage
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v2
|
||||||
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@v2
|
||||||
with:
|
with:
|
||||||
name: ${{ env.TAR_NAME }}
|
name: ${{ env.TAR_NAME }}
|
||||||
path: dist/${{ env.TAR_NAME }}
|
path: dist/${{ env.TAR_NAME }}
|
||||||
|
|||||||
16
.github/workflows/codeql-analysis.yml
vendored
16
.github/workflows/codeql-analysis.yml
vendored
@@ -14,17 +14,9 @@ name: "CodeQL"
|
|||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [ main ]
|
branches: [ main ]
|
||||||
paths:
|
|
||||||
- '**.py'
|
|
||||||
- '**.js'
|
|
||||||
- '.github/workflows/codeql-analysis.yml'
|
|
||||||
pull_request:
|
pull_request:
|
||||||
# The branches below must be a subset of the branches above
|
# The branches below must be a subset of the branches above
|
||||||
branches: [ main ]
|
branches: [ main ]
|
||||||
paths:
|
|
||||||
- '**.py'
|
|
||||||
- '**.js'
|
|
||||||
- '.github/workflows/codeql-analysis.yml'
|
|
||||||
schedule:
|
schedule:
|
||||||
- cron: '44 8 * * 1'
|
- cron: '44 8 * * 1'
|
||||||
|
|
||||||
@@ -43,11 +35,11 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v2
|
||||||
|
|
||||||
# Initializes the CodeQL tools for scanning.
|
# Initializes the CodeQL tools for scanning.
|
||||||
- name: Initialize CodeQL
|
- name: Initialize CodeQL
|
||||||
uses: github/codeql-action/init@v2
|
uses: github/codeql-action/init@v1
|
||||||
with:
|
with:
|
||||||
languages: ${{ matrix.language }}
|
languages: ${{ matrix.language }}
|
||||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
# If you wish to specify custom queries, you can do so here or in a config file.
|
||||||
@@ -58,7 +50,7 @@ jobs:
|
|||||||
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
|
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
|
||||||
# If this step fails, then you should remove it and run the build manually (see below)
|
# If this step fails, then you should remove it and run the build manually (see below)
|
||||||
- name: Autobuild
|
- name: Autobuild
|
||||||
uses: github/codeql-action/autobuild@v2
|
uses: github/codeql-action/autobuild@v1
|
||||||
|
|
||||||
# ℹ️ Command-line programs to run using the OS shell.
|
# ℹ️ Command-line programs to run using the OS shell.
|
||||||
# 📚 https://git.io/JvXDl
|
# 📚 https://git.io/JvXDl
|
||||||
@@ -72,4 +64,4 @@ jobs:
|
|||||||
# make release
|
# make release
|
||||||
|
|
||||||
- name: Perform CodeQL Analysis
|
- name: Perform CodeQL Analysis
|
||||||
uses: github/codeql-action/analyze@v2
|
uses: github/codeql-action/analyze@v1
|
||||||
|
|||||||
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 }}
|
|
||||||
29
.github/workflows/lint.yml
vendored
Normal file
29
.github/workflows/lint.yml
vendored
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
# This workflow will install Python dependencies, run tests and lint with a single version of Python
|
||||||
|
# For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions
|
||||||
|
|
||||||
|
name: lint
|
||||||
|
|
||||||
|
on: [push, pull_request]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v2
|
||||||
|
- name: Set up Python 3.9
|
||||||
|
uses: actions/setup-python@v1
|
||||||
|
with:
|
||||||
|
python-version: 3.9
|
||||||
|
- name: Install dependencies
|
||||||
|
run: |
|
||||||
|
python -m pip install --upgrade pip
|
||||||
|
pip install flake8 pytest
|
||||||
|
if [ -f requirements.txt ]; then pip install -r requirements.txt; fi
|
||||||
|
- name: Lint with flake8
|
||||||
|
run: |
|
||||||
|
# stop the build if there are Python syntax errors or undefined names
|
||||||
|
flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics
|
||||||
|
# exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide
|
||||||
|
flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics
|
||||||
35
.github/workflows/release.yml
vendored
35
.github/workflows/release.yml
vendored
@@ -7,10 +7,6 @@ on:
|
|||||||
tags:
|
tags:
|
||||||
- '*.*.*'
|
- '*.*.*'
|
||||||
|
|
||||||
env:
|
|
||||||
ENEMIZER_VERSION: 7.1
|
|
||||||
APPIMAGETOOL_VERSION: 13
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
create-release:
|
create-release:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -18,7 +14,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
|
||||||
@@ -29,42 +25,45 @@ jobs:
|
|||||||
# build-release-windows: # this is done by hand because of signing
|
# build-release-windows: # this is done by hand because of signing
|
||||||
# build-release-macos: # LF volunteer
|
# build-release-macos: # LF volunteer
|
||||||
|
|
||||||
build-release-ubuntu2004:
|
build-release-ubuntu1804:
|
||||||
runs-on: ubuntu-20.04
|
runs-on: ubuntu-18.04
|
||||||
steps:
|
steps:
|
||||||
- name: Set env
|
- name: Set env
|
||||||
run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV
|
run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV
|
||||||
# - code below copied from build.yml -
|
# - code below copied from build.yml -
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v2
|
||||||
- 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@v3
|
||||||
with:
|
with:
|
||||||
python-version: '3.11'
|
python-version: '3.9'
|
||||||
- name: Install build-time dependencies
|
- name: Install build-time dependencies
|
||||||
run: |
|
run: |
|
||||||
echo "PYTHON=python3.11" >> $GITHUB_ENV
|
echo "PYTHON=python3.9" >> $GITHUB_ENV
|
||||||
wget -nv https://github.com/AppImage/AppImageKit/releases/download/$APPIMAGETOOL_VERSION/appimagetool-x86_64.AppImage
|
wget -nv https://github.com/AppImage/AppImageKit/releases/download/13/appimagetool-x86_64.AppImage
|
||||||
chmod a+rx appimagetool-x86_64.AppImage
|
chmod a+rx appimagetool-x86_64.AppImage
|
||||||
./appimagetool-x86_64.AppImage --appimage-extract
|
./appimagetool-x86_64.AppImage --appimage-extract
|
||||||
echo -e '#/bin/sh\n./squashfs-root/AppRun "$@"' > appimagetool
|
echo -e '#/bin/sh\n./squashfs-root/AppRun "$@"' > appimagetool
|
||||||
chmod a+rx appimagetool
|
chmod a+rx appimagetool
|
||||||
- name: Download run-time dependencies
|
- name: Download run-time dependencies
|
||||||
run: |
|
run: |
|
||||||
wget -nv https://github.com/Ijwu/Enemizer/releases/download/$ENEMIZER_VERSION/ubuntu.16.04-x64.7z
|
wget -nv https://github.com/alttpo/sni/releases/download/v0.0.82/sni-v0.0.82-manylinux2014-amd64.tar.xz
|
||||||
|
tar xf sni-*.tar.xz
|
||||||
|
rm sni-*.tar.xz
|
||||||
|
mv sni-* SNI
|
||||||
|
wget -nv https://github.com/Ijwu/Enemizer/releases/download/7.0.1/ubuntu.16.04-x64.7z
|
||||||
7za x -oEnemizerCLI/ ubuntu.16.04-x64.7z
|
7za x -oEnemizerCLI/ ubuntu.16.04-x64.7z
|
||||||
- name: Build
|
- name: Build
|
||||||
run: |
|
run: |
|
||||||
# pygobject is an optional dependency for kivy that's not in requirements
|
"${{ env.PYTHON }}" -m pip install --upgrade pip setuptools virtualenv PyGObject # pygobject should probably move to requirements
|
||||||
# charset-normalizer was somehow incomplete in the github runner
|
|
||||||
"${{ env.PYTHON }}" -m venv venv
|
"${{ env.PYTHON }}" -m venv venv
|
||||||
source venv/bin/activate
|
source venv/bin/activate
|
||||||
"${{ env.PYTHON }}" -m pip install --upgrade pip PyGObject charset-normalizer
|
pip install -r requirements.txt
|
||||||
python setup.py build_exe --yes bdist_appimage --yes
|
python setup.py build --yes bdist_appimage --yes
|
||||||
echo -e "setup.py build output:\n `ls build`"
|
echo -e "setup.py build output:\n `ls build`"
|
||||||
echo -e "setup.py dist output:\n `ls dist`"
|
echo -e "setup.py dist output:\n `ls dist`"
|
||||||
cd dist && export APPIMAGE_NAME="`ls *.AppImage`" && cd ..
|
cd dist && export APPIMAGE_NAME="`ls *.AppImage`" && cd ..
|
||||||
@@ -74,7 +73,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
|
|
||||||
34
.github/workflows/unittests.yml
vendored
34
.github/workflows/unittests.yml
vendored
@@ -3,25 +3,7 @@
|
|||||||
|
|
||||||
name: unittests
|
name: unittests
|
||||||
|
|
||||||
on:
|
on: [push, pull_request]
|
||||||
push:
|
|
||||||
paths:
|
|
||||||
- '**'
|
|
||||||
- '!docs/**'
|
|
||||||
- '!setup.py'
|
|
||||||
- '!*.iss'
|
|
||||||
- '!.gitignore'
|
|
||||||
- '!.github/workflows/**'
|
|
||||||
- '.github/workflows/unittests.yml'
|
|
||||||
pull_request:
|
|
||||||
paths:
|
|
||||||
- '**'
|
|
||||||
- '!docs/**'
|
|
||||||
- '!setup.py'
|
|
||||||
- '!*.iss'
|
|
||||||
- '!.gitignore'
|
|
||||||
- '!.github/workflows/**'
|
|
||||||
- '.github/workflows/unittests.yml'
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
@@ -36,27 +18,23 @@ jobs:
|
|||||||
- {version: '3.8'}
|
- {version: '3.8'}
|
||||||
- {version: '3.9'}
|
- {version: '3.9'}
|
||||||
- {version: '3.10'}
|
- {version: '3.10'}
|
||||||
- {version: '3.11'}
|
|
||||||
include:
|
include:
|
||||||
- python: {version: '3.8'} # win7 compat
|
- python: {version: '3.8'} # win7 compat
|
||||||
os: windows-latest
|
os: windows-latest
|
||||||
- python: {version: '3.11'} # current
|
- python: {version: '3.10'} # current
|
||||||
os: windows-latest
|
os: windows-latest
|
||||||
- python: {version: '3.11'} # current
|
|
||||||
os: macos-latest
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v2
|
||||||
- name: Set up Python ${{ matrix.python.version }}
|
- name: Set up Python ${{ matrix.python.version }}
|
||||||
uses: actions/setup-python@v5
|
uses: actions/setup-python@v1
|
||||||
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 flake8 pytest
|
||||||
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
|
|
||||||
- name: Unittests
|
- name: Unittests
|
||||||
run: |
|
run: |
|
||||||
pytest -n auto
|
pytest test
|
||||||
|
|||||||
33
.gitignore
vendored
33
.gitignore
vendored
@@ -4,23 +4,15 @@
|
|||||||
*_Spoiler.txt
|
*_Spoiler.txt
|
||||||
*.bmbp
|
*.bmbp
|
||||||
*.apbp
|
*.apbp
|
||||||
*.apl2ac
|
|
||||||
*.apm3
|
*.apm3
|
||||||
*.apmc
|
*.apmc
|
||||||
*.apz5
|
*.apz5
|
||||||
*.aptloz
|
|
||||||
*.apemerald
|
|
||||||
*.pyc
|
*.pyc
|
||||||
*.pyd
|
*.pyd
|
||||||
*.sfc
|
*.sfc
|
||||||
*.z64
|
*.z64
|
||||||
*.n64
|
*.n64
|
||||||
*.nes
|
*.nes
|
||||||
*.smc
|
|
||||||
*.sms
|
|
||||||
*.gb
|
|
||||||
*.gbc
|
|
||||||
*.gba
|
|
||||||
*.wixobj
|
*.wixobj
|
||||||
*.lck
|
*.lck
|
||||||
*.db3
|
*.db3
|
||||||
@@ -28,22 +20,14 @@
|
|||||||
*multisave
|
*multisave
|
||||||
*.archipelago
|
*.archipelago
|
||||||
*.apsave
|
*.apsave
|
||||||
*.BIN
|
|
||||||
*.puml
|
|
||||||
|
|
||||||
setups
|
|
||||||
build
|
build
|
||||||
bundle/components.wxs
|
bundle/components.wxs
|
||||||
dist
|
dist
|
||||||
/prof/
|
|
||||||
README.html
|
README.html
|
||||||
.vs/
|
.vs/
|
||||||
EnemizerCLI/
|
EnemizerCLI/
|
||||||
/Players/
|
/Players/
|
||||||
/SNI/
|
|
||||||
/sni-*/
|
|
||||||
/appimagetool*
|
|
||||||
/host.yaml
|
|
||||||
/options.yaml
|
/options.yaml
|
||||||
/config.yaml
|
/config.yaml
|
||||||
/logs/
|
/logs/
|
||||||
@@ -59,9 +43,7 @@ Output Logs/
|
|||||||
/freeze_requirements.txt
|
/freeze_requirements.txt
|
||||||
/Archipelago.zip
|
/Archipelago.zip
|
||||||
/setup.ini
|
/setup.ini
|
||||||
/installdelete.iss
|
|
||||||
/data/user.kv
|
|
||||||
/datapackage
|
|
||||||
|
|
||||||
# Byte-compiled / optimized / DLL files
|
# Byte-compiled / optimized / DLL files
|
||||||
__pycache__/
|
__pycache__/
|
||||||
@@ -142,15 +124,12 @@ ipython_config.py
|
|||||||
|
|
||||||
# Environments
|
# Environments
|
||||||
.env
|
.env
|
||||||
.venv*
|
.venv
|
||||||
env/
|
env/
|
||||||
venv/
|
venv/
|
||||||
/venv*/
|
|
||||||
ENV/
|
ENV/
|
||||||
env.bak/
|
env.bak/
|
||||||
venv.bak/
|
venv.bak/
|
||||||
.code-workspace
|
|
||||||
shell.nix
|
|
||||||
|
|
||||||
# Spyder project settings
|
# Spyder project settings
|
||||||
.spyderproject
|
.spyderproject
|
||||||
@@ -176,22 +155,14 @@ dmypy.json
|
|||||||
# Cython debug symbols
|
# Cython debug symbols
|
||||||
cython_debug/
|
cython_debug/
|
||||||
|
|
||||||
# Cython intermediates
|
|
||||||
_speedups.cpp
|
|
||||||
_speedups.html
|
|
||||||
|
|
||||||
# minecraft server stuff
|
# minecraft server stuff
|
||||||
jdk*/
|
jdk*/
|
||||||
minecraft*/
|
minecraft*/
|
||||||
minecraft_versions.json
|
minecraft_versions.json
|
||||||
!worlds/minecraft/
|
|
||||||
|
|
||||||
# pyenv
|
# pyenv
|
||||||
.python-version
|
.python-version
|
||||||
|
|
||||||
#undertale stuff
|
|
||||||
/Undertale/
|
|
||||||
|
|
||||||
# OS General Files
|
# OS General Files
|
||||||
.DS_Store
|
.DS_Store
|
||||||
.AppleDouble
|
.AppleDouble
|
||||||
|
|||||||
@@ -1,18 +0,0 @@
|
|||||||
<component name="ProjectRunConfigurationManager">
|
|
||||||
<configuration default="false" name="Archipelago Unittests" type="tests" factoryName="Unittests">
|
|
||||||
<module name="Archipelago" />
|
|
||||||
<option name="INTERPRETER_OPTIONS" value="" />
|
|
||||||
<option name="PARENT_ENVS" value="true" />
|
|
||||||
<option name="SDK_HOME" value="" />
|
|
||||||
<option name="WORKING_DIRECTORY" value="$PROJECT_DIR$" />
|
|
||||||
<option name="IS_MODULE_SDK" value="true" />
|
|
||||||
<option name="ADD_CONTENT_ROOTS" value="true" />
|
|
||||||
<option name="ADD_SOURCE_ROOTS" value="true" />
|
|
||||||
<EXTENSION ID="PythonCoverageRunConfigurationExtension" runner="coverage.py" />
|
|
||||||
<option name="_new_pattern" value="""" />
|
|
||||||
<option name="_new_additionalArguments" value="""" />
|
|
||||||
<option name="_new_target" value=""$PROJECT_DIR$/test"" />
|
|
||||||
<option name="_new_targetType" value=""PATH"" />
|
|
||||||
<method v="2" />
|
|
||||||
</configuration>
|
|
||||||
</component>
|
|
||||||
@@ -1,517 +0,0 @@
|
|||||||
import asyncio
|
|
||||||
import hashlib
|
|
||||||
import json
|
|
||||||
import time
|
|
||||||
import os
|
|
||||||
import bsdiff4
|
|
||||||
import subprocess
|
|
||||||
import zipfile
|
|
||||||
from asyncio import StreamReader, StreamWriter, CancelledError
|
|
||||||
from typing import List
|
|
||||||
|
|
||||||
|
|
||||||
import Utils
|
|
||||||
from NetUtils import ClientStatus
|
|
||||||
from Utils import async_start
|
|
||||||
from CommonClient import CommonContext, server_loop, gui_enabled, ClientCommandProcessor, logger, \
|
|
||||||
get_base_parser
|
|
||||||
from worlds.adventure import AdventureDeltaPatch
|
|
||||||
|
|
||||||
from worlds.adventure.Locations import base_location_id
|
|
||||||
from worlds.adventure.Rom import AdventureForeignItemInfo, AdventureAutoCollectLocation, BatNoTouchLocation
|
|
||||||
from worlds.adventure.Items import base_adventure_item_id, standard_item_max, item_table
|
|
||||||
from worlds.adventure.Offsets import static_item_element_size, connector_port_offset
|
|
||||||
|
|
||||||
SYSTEM_MESSAGE_ID = 0
|
|
||||||
|
|
||||||
CONNECTION_TIMING_OUT_STATUS = \
|
|
||||||
"Connection timing out. Please restart your emulator, then restart connector_adventure.lua"
|
|
||||||
CONNECTION_REFUSED_STATUS = \
|
|
||||||
"Connection Refused. Please start your emulator and make sure connector_adventure.lua is running"
|
|
||||||
CONNECTION_RESET_STATUS = \
|
|
||||||
"Connection was reset. Please restart your emulator, then restart connector_adventure.lua"
|
|
||||||
CONNECTION_TENTATIVE_STATUS = "Initial Connection Made"
|
|
||||||
CONNECTION_CONNECTED_STATUS = "Connected"
|
|
||||||
CONNECTION_INITIAL_STATUS = "Connection has not been initiated"
|
|
||||||
|
|
||||||
SCRIPT_VERSION = 1
|
|
||||||
|
|
||||||
|
|
||||||
class AdventureCommandProcessor(ClientCommandProcessor):
|
|
||||||
def __init__(self, ctx: CommonContext):
|
|
||||||
super().__init__(ctx)
|
|
||||||
|
|
||||||
def _cmd_2600(self):
|
|
||||||
"""Check 2600 Connection State"""
|
|
||||||
if isinstance(self.ctx, AdventureContext):
|
|
||||||
logger.info(f"2600 Status: {self.ctx.atari_status}")
|
|
||||||
|
|
||||||
def _cmd_aconnect(self):
|
|
||||||
"""Discard current atari 2600 connection state"""
|
|
||||||
if isinstance(self.ctx, AdventureContext):
|
|
||||||
self.ctx.atari_sync_task.cancel()
|
|
||||||
|
|
||||||
|
|
||||||
class AdventureContext(CommonContext):
|
|
||||||
command_processor = AdventureCommandProcessor
|
|
||||||
game = 'Adventure'
|
|
||||||
lua_connector_port: int = 17242
|
|
||||||
|
|
||||||
def __init__(self, server_address, password):
|
|
||||||
super().__init__(server_address, password)
|
|
||||||
self.freeincarnates_used: int = -1
|
|
||||||
self.freeincarnate_pending: int = 0
|
|
||||||
self.foreign_items: [AdventureForeignItemInfo] = []
|
|
||||||
self.autocollect_items: [AdventureAutoCollectLocation] = []
|
|
||||||
self.atari_streams: (StreamReader, StreamWriter) = None
|
|
||||||
self.atari_sync_task = None
|
|
||||||
self.messages = {}
|
|
||||||
self.locations_array = None
|
|
||||||
self.atari_status = CONNECTION_INITIAL_STATUS
|
|
||||||
self.awaiting_rom = False
|
|
||||||
self.display_msgs = True
|
|
||||||
self.deathlink_pending = False
|
|
||||||
self.set_deathlink = False
|
|
||||||
self.client_compatibility_mode = 0
|
|
||||||
self.items_handling = 0b111
|
|
||||||
self.checked_locations_sent: bool = False
|
|
||||||
self.port_offset = 0
|
|
||||||
self.bat_no_touch_locations: [BatNoTouchLocation] = []
|
|
||||||
self.local_item_locations = {}
|
|
||||||
self.dragon_speed_info = {}
|
|
||||||
|
|
||||||
options = Utils.get_options()
|
|
||||||
self.display_msgs = options["adventure_options"]["display_msgs"]
|
|
||||||
|
|
||||||
async def server_auth(self, password_requested: bool = False):
|
|
||||||
if password_requested and not self.password:
|
|
||||||
await super(AdventureContext, self).server_auth(password_requested)
|
|
||||||
if not self.auth:
|
|
||||||
self.auth = self.player_name
|
|
||||||
if not self.auth:
|
|
||||||
self.awaiting_rom = True
|
|
||||||
logger.info('Awaiting connection to adventure_connector to get Player information')
|
|
||||||
return
|
|
||||||
|
|
||||||
await self.send_connect()
|
|
||||||
|
|
||||||
def _set_message(self, msg: str, msg_id: int):
|
|
||||||
if self.display_msgs:
|
|
||||||
self.messages[(time.time(), msg_id)] = msg
|
|
||||||
|
|
||||||
def on_package(self, cmd: str, args: dict):
|
|
||||||
if cmd == 'Connected':
|
|
||||||
self.locations_array = None
|
|
||||||
if Utils.get_options()["adventure_options"].get("death_link", False):
|
|
||||||
self.set_deathlink = True
|
|
||||||
async_start(self.get_freeincarnates_used())
|
|
||||||
elif cmd == "RoomInfo":
|
|
||||||
self.seed_name = args['seed_name']
|
|
||||||
elif cmd == 'Print':
|
|
||||||
msg = args['text']
|
|
||||||
if ': !' not in msg:
|
|
||||||
self._set_message(msg, SYSTEM_MESSAGE_ID)
|
|
||||||
elif cmd == "ReceivedItems":
|
|
||||||
msg = f"Received {', '.join([self.item_names[item.item] for item in args['items']])}"
|
|
||||||
self._set_message(msg, SYSTEM_MESSAGE_ID)
|
|
||||||
elif cmd == "Retrieved":
|
|
||||||
if f"adventure_{self.auth}_freeincarnates_used" in args["keys"]:
|
|
||||||
self.freeincarnates_used = args["keys"][f"adventure_{self.auth}_freeincarnates_used"]
|
|
||||||
if self.freeincarnates_used is None:
|
|
||||||
self.freeincarnates_used = 0
|
|
||||||
self.freeincarnates_used += self.freeincarnate_pending
|
|
||||||
self.send_pending_freeincarnates()
|
|
||||||
elif cmd == "SetReply":
|
|
||||||
if args["key"] == f"adventure_{self.auth}_freeincarnates_used":
|
|
||||||
self.freeincarnates_used = args["value"]
|
|
||||||
if self.freeincarnates_used is None:
|
|
||||||
self.freeincarnates_used = 0
|
|
||||||
self.freeincarnates_used += self.freeincarnate_pending
|
|
||||||
self.send_pending_freeincarnates()
|
|
||||||
|
|
||||||
def on_deathlink(self, data: dict):
|
|
||||||
self.deathlink_pending = True
|
|
||||||
super().on_deathlink(data)
|
|
||||||
|
|
||||||
def run_gui(self):
|
|
||||||
from kvui import GameManager
|
|
||||||
|
|
||||||
class AdventureManager(GameManager):
|
|
||||||
logging_pairs = [
|
|
||||||
("Client", "Archipelago")
|
|
||||||
]
|
|
||||||
base_title = "Archipelago Adventure Client"
|
|
||||||
|
|
||||||
self.ui = AdventureManager(self)
|
|
||||||
self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI")
|
|
||||||
|
|
||||||
async def get_freeincarnates_used(self):
|
|
||||||
if self.server and not self.server.socket.closed:
|
|
||||||
await self.send_msgs([{"cmd": "SetNotify", "keys": [f"adventure_{self.auth}_freeincarnates_used"]}])
|
|
||||||
await self.send_msgs([{"cmd": "Get", "keys": [f"adventure_{self.auth}_freeincarnates_used"]}])
|
|
||||||
|
|
||||||
def send_pending_freeincarnates(self):
|
|
||||||
if self.freeincarnate_pending > 0:
|
|
||||||
async_start(self.send_pending_freeincarnates_impl(self.freeincarnate_pending))
|
|
||||||
self.freeincarnate_pending = 0
|
|
||||||
|
|
||||||
async def send_pending_freeincarnates_impl(self, send_val: int) -> None:
|
|
||||||
await self.send_msgs([{"cmd": "Set", "key": f"adventure_{self.auth}_freeincarnates_used",
|
|
||||||
"default": 0, "want_reply": False,
|
|
||||||
"operations": [{"operation": "add", "value": send_val}]}])
|
|
||||||
|
|
||||||
async def used_freeincarnate(self) -> None:
|
|
||||||
if self.server and not self.server.socket.closed:
|
|
||||||
await self.send_msgs([{"cmd": "Set", "key": f"adventure_{self.auth}_freeincarnates_used",
|
|
||||||
"default": 0, "want_reply": True,
|
|
||||||
"operations": [{"operation": "add", "value": 1}]}])
|
|
||||||
else:
|
|
||||||
self.freeincarnate_pending = self.freeincarnate_pending + 1
|
|
||||||
|
|
||||||
|
|
||||||
def convert_item_id(ap_item_id: int):
|
|
||||||
static_item_index = ap_item_id - base_adventure_item_id
|
|
||||||
return static_item_index * static_item_element_size
|
|
||||||
|
|
||||||
|
|
||||||
def get_payload(ctx: AdventureContext):
|
|
||||||
current_time = time.time()
|
|
||||||
items = []
|
|
||||||
dragon_speed_update = {}
|
|
||||||
diff_a_locked = ctx.diff_a_mode > 0
|
|
||||||
diff_b_locked = ctx.diff_b_mode > 0
|
|
||||||
freeincarnate_count = 0
|
|
||||||
for item in ctx.items_received:
|
|
||||||
item_id_str = str(item.item)
|
|
||||||
if base_adventure_item_id < item.item <= standard_item_max:
|
|
||||||
items.append(convert_item_id(item.item))
|
|
||||||
elif item_id_str in ctx.dragon_speed_info:
|
|
||||||
if item.item in dragon_speed_update:
|
|
||||||
last_index = len(ctx.dragon_speed_info[item_id_str]) - 1
|
|
||||||
dragon_speed_update[item.item] = ctx.dragon_speed_info[item_id_str][last_index]
|
|
||||||
else:
|
|
||||||
dragon_speed_update[item.item] = ctx.dragon_speed_info[item_id_str][0]
|
|
||||||
elif item.item == item_table["Left Difficulty Switch"].id:
|
|
||||||
diff_a_locked = False
|
|
||||||
elif item.item == item_table["Right Difficulty Switch"].id:
|
|
||||||
diff_b_locked = False
|
|
||||||
elif item.item == item_table["Freeincarnate"].id:
|
|
||||||
freeincarnate_count = freeincarnate_count + 1
|
|
||||||
freeincarnates_available = 0
|
|
||||||
|
|
||||||
if ctx.freeincarnates_used >= 0:
|
|
||||||
freeincarnates_available = freeincarnate_count - (ctx.freeincarnates_used + ctx.freeincarnate_pending)
|
|
||||||
ret = json.dumps(
|
|
||||||
{
|
|
||||||
"items": items,
|
|
||||||
"messages": {f'{key[0]}:{key[1]}': value for key, value in ctx.messages.items()
|
|
||||||
if key[0] > current_time - 10},
|
|
||||||
"deathlink": ctx.deathlink_pending,
|
|
||||||
"dragon_speeds": dragon_speed_update,
|
|
||||||
"difficulty_a_locked": diff_a_locked,
|
|
||||||
"difficulty_b_locked": diff_b_locked,
|
|
||||||
"freeincarnates_available": freeincarnates_available,
|
|
||||||
"bat_logic": ctx.bat_logic
|
|
||||||
}
|
|
||||||
)
|
|
||||||
ctx.deathlink_pending = False
|
|
||||||
return ret
|
|
||||||
|
|
||||||
|
|
||||||
async def parse_locations(data: List, ctx: AdventureContext):
|
|
||||||
locations = data
|
|
||||||
|
|
||||||
# for loc_name, loc_data in location_table.items():
|
|
||||||
|
|
||||||
# if flags["EventFlag"][280] & 1 and not ctx.finished_game:
|
|
||||||
# await ctx.send_msgs([
|
|
||||||
# {"cmd": "StatusUpdate",
|
|
||||||
# "status": 30}
|
|
||||||
# ])
|
|
||||||
# ctx.finished_game = True
|
|
||||||
if locations == ctx.locations_array:
|
|
||||||
return
|
|
||||||
ctx.locations_array = locations
|
|
||||||
if locations is not None:
|
|
||||||
await ctx.send_msgs([{"cmd": "LocationChecks", "locations": locations}])
|
|
||||||
|
|
||||||
|
|
||||||
def send_ap_foreign_items(adventure_context):
|
|
||||||
foreign_item_json_list = []
|
|
||||||
autocollect_item_json_list = []
|
|
||||||
bat_no_touch_locations_json_list = []
|
|
||||||
for fi in adventure_context.foreign_items:
|
|
||||||
foreign_item_json_list.append(fi.get_dict())
|
|
||||||
for fi in adventure_context.autocollect_items:
|
|
||||||
autocollect_item_json_list.append(fi.get_dict())
|
|
||||||
for ntl in adventure_context.bat_no_touch_locations:
|
|
||||||
bat_no_touch_locations_json_list.append(ntl.get_dict())
|
|
||||||
payload = json.dumps(
|
|
||||||
{
|
|
||||||
"foreign_items": foreign_item_json_list,
|
|
||||||
"autocollect_items": autocollect_item_json_list,
|
|
||||||
"local_item_locations": adventure_context.local_item_locations,
|
|
||||||
"bat_no_touch_locations": bat_no_touch_locations_json_list
|
|
||||||
}
|
|
||||||
)
|
|
||||||
print("sending foreign items")
|
|
||||||
msg = payload.encode()
|
|
||||||
(reader, writer) = adventure_context.atari_streams
|
|
||||||
writer.write(msg)
|
|
||||||
writer.write(b'\n')
|
|
||||||
|
|
||||||
|
|
||||||
def send_checked_locations_if_needed(adventure_context):
|
|
||||||
if not adventure_context.checked_locations_sent and adventure_context.checked_locations is not None:
|
|
||||||
if len(adventure_context.checked_locations) == 0:
|
|
||||||
return
|
|
||||||
checked_short_ids = []
|
|
||||||
for location in adventure_context.checked_locations:
|
|
||||||
checked_short_ids.append(location - base_location_id)
|
|
||||||
print("Sending checked locations")
|
|
||||||
payload = json.dumps(
|
|
||||||
{
|
|
||||||
"checked_locations": checked_short_ids,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
msg = payload.encode()
|
|
||||||
(reader, writer) = adventure_context.atari_streams
|
|
||||||
writer.write(msg)
|
|
||||||
writer.write(b'\n')
|
|
||||||
adventure_context.checked_locations_sent = True
|
|
||||||
|
|
||||||
|
|
||||||
async def atari_sync_task(ctx: AdventureContext):
|
|
||||||
logger.info("Starting Atari 2600 connector. Use /2600 for status information")
|
|
||||||
while not ctx.exit_event.is_set():
|
|
||||||
try:
|
|
||||||
error_status = None
|
|
||||||
if ctx.atari_streams:
|
|
||||||
(reader, writer) = ctx.atari_streams
|
|
||||||
msg = get_payload(ctx).encode()
|
|
||||||
writer.write(msg)
|
|
||||||
writer.write(b'\n')
|
|
||||||
try:
|
|
||||||
await asyncio.wait_for(writer.drain(), timeout=1.5)
|
|
||||||
try:
|
|
||||||
# Data will return a dict with 1+ fields
|
|
||||||
# 1. A keepalive response of the Players Name (always)
|
|
||||||
# 2. romhash field with sha256 hash of the ROM memory region
|
|
||||||
# 3. locations, messages, and deathLink
|
|
||||||
# 4. freeincarnate, to indicate a freeincarnate was used
|
|
||||||
data = await asyncio.wait_for(reader.readline(), timeout=5)
|
|
||||||
data_decoded = json.loads(data.decode())
|
|
||||||
if 'scriptVersion' not in data_decoded or data_decoded['scriptVersion'] != SCRIPT_VERSION:
|
|
||||||
msg = "You are connecting with an incompatible Lua script version. Ensure your connector " \
|
|
||||||
"Lua and AdventureClient are from the same Archipelago installation."
|
|
||||||
logger.info(msg, extra={'compact_gui': True})
|
|
||||||
ctx.gui_error('Error', msg)
|
|
||||||
error_status = CONNECTION_RESET_STATUS
|
|
||||||
if ctx.seed_name and bytes(ctx.seed_name, encoding='ASCII') != ctx.seed_name_from_data:
|
|
||||||
msg = "The server is running a different multiworld than your client is. " \
|
|
||||||
"(invalid seed_name)"
|
|
||||||
logger.info(msg, extra={'compact_gui': True})
|
|
||||||
ctx.gui_error('Error', msg)
|
|
||||||
error_status = CONNECTION_RESET_STATUS
|
|
||||||
if 'romhash' in data_decoded:
|
|
||||||
if ctx.rom_hash.upper() != data_decoded['romhash'].upper():
|
|
||||||
msg = "The rom hash does not match the client rom hash data"
|
|
||||||
print("got " + data_decoded['romhash'])
|
|
||||||
print("expected " + str(ctx.rom_hash))
|
|
||||||
logger.info(msg, extra={'compact_gui': True})
|
|
||||||
ctx.gui_error('Error', msg)
|
|
||||||
error_status = CONNECTION_RESET_STATUS
|
|
||||||
if ctx.auth is None:
|
|
||||||
ctx.auth = ctx.player_name
|
|
||||||
if ctx.awaiting_rom:
|
|
||||||
await ctx.server_auth(False)
|
|
||||||
if 'locations' in data_decoded and ctx.game and ctx.atari_status == CONNECTION_CONNECTED_STATUS \
|
|
||||||
and not error_status and ctx.auth:
|
|
||||||
# Not just a keep alive ping, parse
|
|
||||||
async_start(parse_locations(data_decoded['locations'], ctx))
|
|
||||||
if 'deathLink' in data_decoded and data_decoded['deathLink'] > 0 and 'DeathLink' in ctx.tags:
|
|
||||||
dragon_name = "a dragon"
|
|
||||||
if data_decoded['deathLink'] == 1:
|
|
||||||
dragon_name = "Rhindle"
|
|
||||||
elif data_decoded['deathLink'] == 2:
|
|
||||||
dragon_name = "Yorgle"
|
|
||||||
elif data_decoded['deathLink'] == 3:
|
|
||||||
dragon_name = "Grundle"
|
|
||||||
print (ctx.auth + " has been eaten by " + dragon_name )
|
|
||||||
await ctx.send_death(ctx.auth + " has been eaten by " + dragon_name)
|
|
||||||
# TODO - also if player reincarnates with a dragon onscreen ' dies to avoid being eaten by '
|
|
||||||
if 'victory' in data_decoded and not ctx.finished_game:
|
|
||||||
await ctx.send_msgs([{"cmd": "StatusUpdate", "status": ClientStatus.CLIENT_GOAL}])
|
|
||||||
ctx.finished_game = True
|
|
||||||
if 'freeincarnate' in data_decoded:
|
|
||||||
await ctx.used_freeincarnate()
|
|
||||||
if ctx.set_deathlink:
|
|
||||||
await ctx.update_death_link(True)
|
|
||||||
send_checked_locations_if_needed(ctx)
|
|
||||||
except asyncio.TimeoutError:
|
|
||||||
logger.debug("Read Timed Out, Reconnecting")
|
|
||||||
error_status = CONNECTION_TIMING_OUT_STATUS
|
|
||||||
writer.close()
|
|
||||||
ctx.atari_streams = None
|
|
||||||
except ConnectionResetError as e:
|
|
||||||
logger.debug("Read failed due to Connection Lost, Reconnecting")
|
|
||||||
error_status = CONNECTION_RESET_STATUS
|
|
||||||
writer.close()
|
|
||||||
ctx.atari_streams = None
|
|
||||||
except TimeoutError:
|
|
||||||
logger.debug("Connection Timed Out, Reconnecting")
|
|
||||||
error_status = CONNECTION_TIMING_OUT_STATUS
|
|
||||||
writer.close()
|
|
||||||
ctx.atari_streams = None
|
|
||||||
except ConnectionResetError:
|
|
||||||
logger.debug("Connection Lost, Reconnecting")
|
|
||||||
error_status = CONNECTION_RESET_STATUS
|
|
||||||
writer.close()
|
|
||||||
ctx.atari_streams = None
|
|
||||||
except CancelledError:
|
|
||||||
logger.debug("Connection Cancelled, Reconnecting")
|
|
||||||
error_status = CONNECTION_RESET_STATUS
|
|
||||||
writer.close()
|
|
||||||
ctx.atari_streams = None
|
|
||||||
pass
|
|
||||||
except Exception as e:
|
|
||||||
print("unknown exception " + e)
|
|
||||||
raise
|
|
||||||
if ctx.atari_status == CONNECTION_TENTATIVE_STATUS:
|
|
||||||
if not error_status:
|
|
||||||
logger.info("Successfully Connected to 2600")
|
|
||||||
ctx.atari_status = CONNECTION_CONNECTED_STATUS
|
|
||||||
ctx.checked_locations_sent = False
|
|
||||||
send_ap_foreign_items(ctx)
|
|
||||||
send_checked_locations_if_needed(ctx)
|
|
||||||
else:
|
|
||||||
ctx.atari_status = f"Was tentatively connected but error occurred: {error_status}"
|
|
||||||
elif error_status:
|
|
||||||
ctx.atari_status = error_status
|
|
||||||
logger.info("Lost connection to 2600 and attempting to reconnect. Use /2600 for status updates")
|
|
||||||
else:
|
|
||||||
try:
|
|
||||||
port = ctx.lua_connector_port + ctx.port_offset
|
|
||||||
logger.debug(f"Attempting to connect to 2600 on port {port}")
|
|
||||||
print(f"Attempting to connect to 2600 on port {port}")
|
|
||||||
ctx.atari_streams = await asyncio.wait_for(
|
|
||||||
asyncio.open_connection("localhost",
|
|
||||||
port),
|
|
||||||
timeout=10)
|
|
||||||
ctx.atari_status = CONNECTION_TENTATIVE_STATUS
|
|
||||||
except TimeoutError:
|
|
||||||
logger.debug("Connection Timed Out, Trying Again")
|
|
||||||
ctx.atari_status = CONNECTION_TIMING_OUT_STATUS
|
|
||||||
continue
|
|
||||||
except ConnectionRefusedError:
|
|
||||||
logger.debug("Connection Refused, Trying Again")
|
|
||||||
ctx.atari_status = CONNECTION_REFUSED_STATUS
|
|
||||||
continue
|
|
||||||
except CancelledError:
|
|
||||||
pass
|
|
||||||
except CancelledError:
|
|
||||||
pass
|
|
||||||
print("exiting atari sync task")
|
|
||||||
|
|
||||||
|
|
||||||
async def run_game(romfile):
|
|
||||||
auto_start = Utils.get_options()["adventure_options"].get("rom_start", True)
|
|
||||||
rom_args = Utils.get_options()["adventure_options"].get("rom_args")
|
|
||||||
if auto_start is True:
|
|
||||||
import webbrowser
|
|
||||||
webbrowser.open(romfile)
|
|
||||||
elif os.path.isfile(auto_start):
|
|
||||||
open_args = [auto_start, romfile]
|
|
||||||
if rom_args is not None:
|
|
||||||
open_args.insert(1, rom_args)
|
|
||||||
subprocess.Popen(open_args,
|
|
||||||
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
||||||
|
|
||||||
|
|
||||||
async def patch_and_run_game(patch_file, ctx):
|
|
||||||
base_name = os.path.splitext(patch_file)[0]
|
|
||||||
comp_path = base_name + '.a26'
|
|
||||||
try:
|
|
||||||
base_rom = AdventureDeltaPatch.get_source_data()
|
|
||||||
except Exception as msg:
|
|
||||||
logger.info(msg, extra={'compact_gui': True})
|
|
||||||
ctx.gui_error('Error', msg)
|
|
||||||
|
|
||||||
with open(Utils.local_path("data", "adventure_basepatch.bsdiff4"), "rb") as file:
|
|
||||||
basepatch = bytes(file.read())
|
|
||||||
|
|
||||||
base_patched_rom_data = bsdiff4.patch(base_rom, basepatch)
|
|
||||||
|
|
||||||
with zipfile.ZipFile(patch_file, 'r') as patch_archive:
|
|
||||||
if not AdventureDeltaPatch.check_version(patch_archive):
|
|
||||||
logger.error("apadvn version doesn't match this client. Make sure your generator and client are the same")
|
|
||||||
raise Exception("apadvn version doesn't match this client.")
|
|
||||||
|
|
||||||
ctx.foreign_items = AdventureDeltaPatch.read_foreign_items(patch_archive)
|
|
||||||
ctx.autocollect_items = AdventureDeltaPatch.read_autocollect_items(patch_archive)
|
|
||||||
ctx.local_item_locations = AdventureDeltaPatch.read_local_item_locations(patch_archive)
|
|
||||||
ctx.dragon_speed_info = AdventureDeltaPatch.read_dragon_speed_info(patch_archive)
|
|
||||||
ctx.seed_name_from_data, ctx.player_name = AdventureDeltaPatch.read_rom_info(patch_archive)
|
|
||||||
ctx.diff_a_mode, ctx.diff_b_mode = AdventureDeltaPatch.read_difficulty_switch_info(patch_archive)
|
|
||||||
ctx.bat_logic = AdventureDeltaPatch.read_bat_logic(patch_archive)
|
|
||||||
ctx.bat_no_touch_locations = AdventureDeltaPatch.read_bat_no_touch(patch_archive)
|
|
||||||
ctx.rom_deltas = AdventureDeltaPatch.read_rom_deltas(patch_archive)
|
|
||||||
ctx.auth = ctx.player_name
|
|
||||||
|
|
||||||
patched_rom_data = AdventureDeltaPatch.apply_rom_deltas(base_patched_rom_data, ctx.rom_deltas)
|
|
||||||
rom_hash = hashlib.sha256()
|
|
||||||
rom_hash.update(patched_rom_data)
|
|
||||||
ctx.rom_hash = rom_hash.hexdigest()
|
|
||||||
ctx.port_offset = patched_rom_data[connector_port_offset]
|
|
||||||
|
|
||||||
with open(comp_path, "wb") as patched_rom_file:
|
|
||||||
patched_rom_file.write(patched_rom_data)
|
|
||||||
|
|
||||||
async_start(run_game(comp_path))
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
|
|
||||||
Utils.init_logging("AdventureClient")
|
|
||||||
|
|
||||||
async def main():
|
|
||||||
parser = get_base_parser()
|
|
||||||
parser.add_argument('patch_file', default="", type=str, nargs="?",
|
|
||||||
help='Path to an ADVNTURE.BIN rom file')
|
|
||||||
parser.add_argument('port', default=17242, type=int, nargs="?",
|
|
||||||
help='port for adventure_connector connection')
|
|
||||||
args = parser.parse_args()
|
|
||||||
|
|
||||||
ctx = AdventureContext(args.connect, args.password)
|
|
||||||
ctx.server_task = asyncio.create_task(server_loop(ctx), name="ServerLoop")
|
|
||||||
if gui_enabled:
|
|
||||||
ctx.run_gui()
|
|
||||||
ctx.run_cli()
|
|
||||||
ctx.atari_sync_task = asyncio.create_task(atari_sync_task(ctx), name="Adventure Sync")
|
|
||||||
|
|
||||||
if args.patch_file:
|
|
||||||
ext = args.patch_file.split(".")[len(args.patch_file.split(".")) - 1].lower()
|
|
||||||
if ext == "apadvn":
|
|
||||||
logger.info("apadvn file supplied, beginning patching process...")
|
|
||||||
async_start(patch_and_run_game(args.patch_file, ctx))
|
|
||||||
else:
|
|
||||||
logger.warning(f"Unknown patch file extension {ext}")
|
|
||||||
if args.port is int:
|
|
||||||
ctx.lua_connector_port = args.port
|
|
||||||
|
|
||||||
await ctx.exit_event.wait()
|
|
||||||
ctx.server_address = None
|
|
||||||
|
|
||||||
await ctx.shutdown()
|
|
||||||
|
|
||||||
if ctx.atari_sync_task:
|
|
||||||
await ctx.atari_sync_task
|
|
||||||
print("finished atari_sync_task (main)")
|
|
||||||
|
|
||||||
|
|
||||||
import colorama
|
|
||||||
|
|
||||||
colorama.init()
|
|
||||||
|
|
||||||
asyncio.run(main())
|
|
||||||
colorama.deinit()
|
|
||||||
1546
BaseClasses.py
1546
BaseClasses.py
File diff suppressed because it is too large
Load Diff
@@ -1,9 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import ModuleUpdate
|
|
||||||
ModuleUpdate.update()
|
|
||||||
|
|
||||||
from worlds._bizhawk.context import launch
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
launch()
|
|
||||||
397
CommonClient.py
397
CommonClient.py
@@ -1,13 +1,10 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import copy
|
|
||||||
import logging
|
import logging
|
||||||
import asyncio
|
import asyncio
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
import sys
|
import sys
|
||||||
import typing
|
import typing
|
||||||
import time
|
import time
|
||||||
import functools
|
|
||||||
|
|
||||||
import ModuleUpdate
|
import ModuleUpdate
|
||||||
ModuleUpdate.update()
|
ModuleUpdate.update()
|
||||||
@@ -20,15 +17,10 @@ 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, ClientStatus, Permission, NetworkSlot
|
||||||
RawJSONtoTextParser, add_json_text, add_json_location, add_json_item, JSONTypes)
|
from Utils import Version, stream_input
|
||||||
from Utils import Version, stream_input, async_start
|
|
||||||
from worlds import network_data_package, AutoWorldRegister
|
from worlds import network_data_package, AutoWorldRegister
|
||||||
import os
|
import os
|
||||||
import ssl
|
|
||||||
|
|
||||||
if typing.TYPE_CHECKING:
|
|
||||||
import kvui
|
|
||||||
|
|
||||||
logger = logging.getLogger("Client")
|
logger = logging.getLogger("Client")
|
||||||
|
|
||||||
@@ -36,12 +28,6 @@ logger = logging.getLogger("Client")
|
|||||||
gui_enabled = not sys.stdout or "--nogui" not in sys.argv
|
gui_enabled = not sys.stdout or "--nogui" not in sys.argv
|
||||||
|
|
||||||
|
|
||||||
@Utils.cache_argsless
|
|
||||||
def get_ssl_context():
|
|
||||||
import certifi
|
|
||||||
return ssl.create_default_context(ssl.Purpose.SERVER_AUTH, cafile=certifi.where())
|
|
||||||
|
|
||||||
|
|
||||||
class ClientCommandProcessor(CommandProcessor):
|
class ClientCommandProcessor(CommandProcessor):
|
||||||
def __init__(self, ctx: CommonContext):
|
def __init__(self, ctx: CommonContext):
|
||||||
self.ctx = ctx
|
self.ctx = ctx
|
||||||
@@ -56,45 +42,33 @@ class ClientCommandProcessor(CommandProcessor):
|
|||||||
|
|
||||||
def _cmd_connect(self, address: str = "") -> bool:
|
def _cmd_connect(self, address: str = "") -> bool:
|
||||||
"""Connect to a MultiWorld Server"""
|
"""Connect to a MultiWorld Server"""
|
||||||
if address:
|
self.ctx.server_address = None
|
||||||
self.ctx.server_address = None
|
self.ctx.username = None
|
||||||
self.ctx.username = None
|
asyncio.create_task(self.ctx.connect(address if address else None), name="connecting")
|
||||||
elif not self.ctx.server_address:
|
|
||||||
self.output("Please specify an address.")
|
|
||||||
return False
|
|
||||||
async_start(self.ctx.connect(address if address else None), name="connecting")
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def _cmd_disconnect(self) -> bool:
|
def _cmd_disconnect(self) -> bool:
|
||||||
"""Disconnect from a MultiWorld Server"""
|
"""Disconnect from a MultiWorld Server"""
|
||||||
async_start(self.ctx.disconnect(), name="disconnecting")
|
self.ctx.server_address = None
|
||||||
|
self.ctx.username = None
|
||||||
|
asyncio.create_task(self.ctx.disconnect(), name="disconnecting")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def _cmd_received(self) -> bool:
|
def _cmd_received(self) -> bool:
|
||||||
"""List all received items"""
|
"""List all received items"""
|
||||||
item: NetworkItem
|
logger.info(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) -> bool:
|
||||||
"""List all missing location checks, from your local game state.
|
"""List all missing location checks, from your local game state"""
|
||||||
Can be given text, which will be used as filter."""
|
|
||||||
if not self.ctx.game:
|
if not self.ctx.game:
|
||||||
self.output("No game set, cannot determine missing checks.")
|
self.output("No game set, cannot determine missing checks.")
|
||||||
return False
|
return False
|
||||||
count = 0
|
count = 0
|
||||||
checked_count = 0
|
checked_count = 0
|
||||||
for location, location_id in AutoWorldRegister.world_types[self.ctx.game].location_name_to_id.items():
|
for location, location_id in AutoWorldRegister.world_types[self.ctx.game].location_name_to_id.items():
|
||||||
if filter_text and filter_text not in location:
|
|
||||||
continue
|
|
||||||
if location_id < 0:
|
if location_id < 0:
|
||||||
continue
|
continue
|
||||||
if location_id not in self.ctx.locations_checked:
|
if location_id not in self.ctx.locations_checked:
|
||||||
@@ -115,40 +89,16 @@ class ClientCommandProcessor(CommandProcessor):
|
|||||||
|
|
||||||
def _cmd_items(self):
|
def _cmd_items(self):
|
||||||
"""List all item names for the currently running game."""
|
"""List all item names for the currently running game."""
|
||||||
if not self.ctx.game:
|
|
||||||
self.output("No game set, cannot determine existing items.")
|
|
||||||
return False
|
|
||||||
self.output(f"Item Names for {self.ctx.game}")
|
self.output(f"Item Names for {self.ctx.game}")
|
||||||
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:
|
|
||||||
self.output("No game set, cannot determine existing locations.")
|
|
||||||
return False
|
|
||||||
self.output(f"Location Names for {self.ctx.game}")
|
self.output(f"Location Names for {self.ctx.game}")
|
||||||
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
|
||||||
@@ -158,12 +108,12 @@ class ClientCommandProcessor(CommandProcessor):
|
|||||||
else:
|
else:
|
||||||
state = ClientStatus.CLIENT_CONNECTED
|
state = ClientStatus.CLIENT_CONNECTED
|
||||||
self.output("Unreadied.")
|
self.output("Unreadied.")
|
||||||
async_start(self.ctx.send_msgs([{"cmd": "StatusUpdate", "status": state}]), name="send StatusUpdate")
|
asyncio.create_task(self.ctx.send_msgs([{"cmd": "StatusUpdate", "status": state}]), name="send StatusUpdate")
|
||||||
|
|
||||||
def default(self, raw: str):
|
def default(self, raw: str):
|
||||||
raw = self.ctx.on_user_say(raw)
|
raw = self.ctx.on_user_say(raw)
|
||||||
if raw:
|
if raw:
|
||||||
async_start(self.ctx.send_msgs([{"cmd": "Say", "text": raw}]), name="send Say")
|
asyncio.create_task(self.ctx.send_msgs([{"cmd": "Say", "text": raw}]), name="send Say")
|
||||||
|
|
||||||
|
|
||||||
class CommonContext:
|
class CommonContext:
|
||||||
@@ -171,9 +121,8 @@ class CommonContext:
|
|||||||
tags: typing.Set[str] = {"AP"}
|
tags: typing.Set[str] = {"AP"}
|
||||||
game: typing.Optional[str] = None
|
game: typing.Optional[str] = None
|
||||||
items_handling: typing.Optional[int] = None
|
items_handling: typing.Optional[int] = None
|
||||||
want_slot_data: bool = True # should slot_data be retrieved via Connect
|
|
||||||
|
|
||||||
# data package
|
# datapackage
|
||||||
# Contents in flux until connection to server is made, to download correct data for this multiworld.
|
# Contents in flux until connection to server is made, to download correct data for this multiworld.
|
||||||
item_names: typing.Dict[int, str] = Utils.KeyedDefaultDict(lambda code: f'Unknown item (ID:{code})')
|
item_names: typing.Dict[int, str] = Utils.KeyedDefaultDict(lambda code: f'Unknown item (ID:{code})')
|
||||||
location_names: typing.Dict[int, str] = Utils.KeyedDefaultDict(lambda code: f'Unknown location (ID:{code})')
|
location_names: typing.Dict[int, str] = Utils.KeyedDefaultDict(lambda code: f'Unknown location (ID:{code})')
|
||||||
@@ -181,57 +130,37 @@ class CommonContext:
|
|||||||
# defaults
|
# defaults
|
||||||
starting_reconnect_delay: int = 5
|
starting_reconnect_delay: int = 5
|
||||||
current_reconnect_delay: int = starting_reconnect_delay
|
current_reconnect_delay: int = starting_reconnect_delay
|
||||||
command_processor: typing.Type[CommandProcessor] = ClientCommandProcessor
|
command_processor: type(CommandProcessor) = ClientCommandProcessor
|
||||||
ui = None
|
ui = None
|
||||||
ui_task: typing.Optional["asyncio.Task[None]"] = None
|
ui_task: typing.Optional[asyncio.Task] = None
|
||||||
input_task: typing.Optional["asyncio.Task[None]"] = None
|
input_task: typing.Optional[asyncio.Task] = None
|
||||||
keep_alive_task: typing.Optional["asyncio.Task[None]"] = None
|
keep_alive_task: typing.Optional[asyncio.Task] = None
|
||||||
server_task: typing.Optional["asyncio.Task[None]"] = None
|
server_task: typing.Optional[asyncio.Task] = None
|
||||||
autoreconnect_task: typing.Optional["asyncio.Task[None]"] = None
|
|
||||||
disconnected_intentionally: bool = False
|
|
||||||
server: typing.Optional[Endpoint] = None
|
server: typing.Optional[Endpoint] = None
|
||||||
server_version: Version = Version(0, 0, 0)
|
server_version: Version = Version(0, 0, 0)
|
||||||
generator_version: Version = Version(0, 0, 0)
|
current_energy_link_value: int = 0 # 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
|
||||||
|
|
||||||
# remaining type info
|
# remaining type info
|
||||||
slot_info: typing.Dict[int, NetworkSlot]
|
slot_info: typing.Dict[int, NetworkSlot]
|
||||||
server_address: typing.Optional[str]
|
server_address: str
|
||||||
password: typing.Optional[str]
|
password: typing.Optional[str]
|
||||||
hint_cost: typing.Optional[int]
|
hint_cost: typing.Optional[int]
|
||||||
hint_points: typing.Optional[int]
|
|
||||||
player_names: typing.Dict[int, str]
|
player_names: typing.Dict[int, str]
|
||||||
|
|
||||||
finished_game: bool
|
|
||||||
ready: bool
|
|
||||||
team: typing.Optional[int]
|
|
||||||
slot: typing.Optional[int]
|
|
||||||
auth: typing.Optional[str]
|
|
||||||
seed_name: typing.Optional[str]
|
|
||||||
|
|
||||||
# locations
|
# locations
|
||||||
locations_checked: typing.Set[int] # local state
|
locations_checked: typing.Set[int] # local state
|
||||||
locations_scouted: typing.Set[int]
|
locations_scouted: typing.Set[int]
|
||||||
items_received: typing.List[NetworkItem]
|
missing_locations: typing.Set[int]
|
||||||
missing_locations: typing.Set[int] # server state
|
|
||||||
checked_locations: typing.Set[int] # server state
|
checked_locations: typing.Set[int] # server state
|
||||||
server_locations: typing.Set[int] # all locations the server knows of, missing_location | checked_locations
|
|
||||||
locations_info: typing.Dict[int, NetworkItem]
|
locations_info: typing.Dict[int, NetworkItem]
|
||||||
|
|
||||||
# data storage
|
|
||||||
stored_data: typing.Dict[str, typing.Any]
|
|
||||||
stored_data_notification_keys: typing.Set[str]
|
|
||||||
|
|
||||||
# internals
|
# internals
|
||||||
# current message box through kvui
|
# current message box through kvui
|
||||||
_messagebox: typing.Optional["kvui.MessageBox"] = None
|
_messagebox = None
|
||||||
# message box reporting a loss of connection
|
|
||||||
_messagebox_connection_loss: typing.Optional["kvui.MessageBox"] = None
|
|
||||||
|
|
||||||
def __init__(self, server_address: typing.Optional[str], password: typing.Optional[str]) -> None:
|
def __init__(self, server_address, password):
|
||||||
# server state
|
# server state
|
||||||
self.server_address = server_address
|
self.server_address = server_address
|
||||||
self.username = None
|
self.username = None
|
||||||
@@ -239,7 +168,7 @@ class CommonContext:
|
|||||||
self.hint_cost = None
|
self.hint_cost = None
|
||||||
self.slot_info = {}
|
self.slot_info = {}
|
||||||
self.permissions = {
|
self.permissions = {
|
||||||
"release": "disabled",
|
"forfeit": "disabled",
|
||||||
"collect": "disabled",
|
"collect": "disabled",
|
||||||
"remaining": "disabled",
|
"remaining": "disabled",
|
||||||
}
|
}
|
||||||
@@ -255,14 +184,10 @@ class CommonContext:
|
|||||||
self.locations_checked = set() # local state
|
self.locations_checked = set() # local state
|
||||||
self.locations_scouted = set()
|
self.locations_scouted = set()
|
||||||
self.items_received = []
|
self.items_received = []
|
||||||
self.missing_locations = set() # server state
|
self.missing_locations = set()
|
||||||
self.checked_locations = set() # server state
|
self.checked_locations = set() # server state
|
||||||
self.server_locations = set() # all locations the server knows of, missing_location | checked_locations
|
|
||||||
self.locations_info = {}
|
self.locations_info = {}
|
||||||
|
|
||||||
self.stored_data = {}
|
|
||||||
self.stored_data_notification_keys = set()
|
|
||||||
|
|
||||||
self.input_queue = asyncio.Queue()
|
self.input_queue = asyncio.Queue()
|
||||||
self.input_requests = 0
|
self.input_requests = 0
|
||||||
|
|
||||||
@@ -272,22 +197,11 @@ 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_datapackage(network_data_package)
|
||||||
self.update_data_package(network_data_package)
|
|
||||||
|
|
||||||
# execution
|
# execution
|
||||||
self.keep_alive_task = asyncio.create_task(keep_alive(self), name="Bouncy")
|
self.keep_alive_task = asyncio.create_task(keep_alive(self), name="Bouncy")
|
||||||
|
|
||||||
@property
|
|
||||||
def suggested_address(self) -> str:
|
|
||||||
if self.server_address:
|
|
||||||
return self.server_address
|
|
||||||
return Utils.persistent_load().get("client", {}).get("last_server_address", "")
|
|
||||||
|
|
||||||
@functools.cached_property
|
|
||||||
def raw_text_parser(self) -> RawJSONtoTextParser:
|
|
||||||
return RawJSONtoTextParser(self)
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def total_locations(self) -> typing.Optional[int]:
|
def total_locations(self) -> typing.Optional[int]:
|
||||||
"""Will return None until connected."""
|
"""Will return None until connected."""
|
||||||
@@ -295,9 +209,9 @@ class CommonContext:
|
|||||||
return len(self.checked_locations | self.missing_locations)
|
return len(self.checked_locations | self.missing_locations)
|
||||||
|
|
||||||
async def connection_closed(self):
|
async def connection_closed(self):
|
||||||
|
self.reset_server_state()
|
||||||
if self.server and self.server.socket is not None:
|
if self.server and self.server.socket is not None:
|
||||||
await self.server.socket.close()
|
await self.server.socket.close()
|
||||||
self.reset_server_state()
|
|
||||||
|
|
||||||
def reset_server_state(self):
|
def reset_server_state(self):
|
||||||
self.auth = None
|
self.auth = None
|
||||||
@@ -306,28 +220,22 @@ class CommonContext:
|
|||||||
self.items_received = []
|
self.items_received = []
|
||||||
self.locations_info = {}
|
self.locations_info = {}
|
||||||
self.server_version = Version(0, 0, 0)
|
self.server_version = Version(0, 0, 0)
|
||||||
self.generator_version = Version(0, 0, 0)
|
|
||||||
self.server = None
|
self.server = None
|
||||||
self.server_task = None
|
self.server_task = None
|
||||||
self.hint_cost = None
|
self.hint_cost = None
|
||||||
self.permissions = {
|
self.permissions = {
|
||||||
"release": "disabled",
|
"forfeit": "disabled",
|
||||||
"collect": "disabled",
|
"collect": "disabled",
|
||||||
"remaining": "disabled",
|
"remaining": "disabled",
|
||||||
}
|
}
|
||||||
|
|
||||||
async def disconnect(self, allow_autoreconnect: bool = False):
|
async def disconnect(self):
|
||||||
if not allow_autoreconnect:
|
|
||||||
self.disconnected_intentionally = True
|
|
||||||
if self.cancel_autoreconnect():
|
|
||||||
logger.info("Cancelled auto-reconnect.")
|
|
||||||
if self.server and not self.server.socket.closed:
|
if self.server and not self.server.socket.closed:
|
||||||
await self.server.socket.close()
|
await self.server.socket.close()
|
||||||
if self.server_task is not None:
|
if self.server_task is not None:
|
||||||
await self.server_task
|
await self.server_task
|
||||||
|
|
||||||
async def send_msgs(self, msgs: typing.List[typing.Any]) -> None:
|
async def send_msgs(self, msgs):
|
||||||
""" `msgs` JSON serializable """
|
|
||||||
if not self.server or not self.server.socket.open or self.server.socket.closed:
|
if not self.server or not self.server.socket.open or self.server.socket.closed:
|
||||||
return
|
return
|
||||||
await self.server.socket.send(encode(msgs))
|
await self.server.socket.send(encode(msgs))
|
||||||
@@ -355,36 +263,25 @@ class CommonContext:
|
|||||||
logger.info('Enter slot name:')
|
logger.info('Enter slot name:')
|
||||||
self.auth = await self.console_input()
|
self.auth = await self.console_input()
|
||||||
|
|
||||||
async def send_connect(self, **kwargs: typing.Any) -> None:
|
async def send_connect(self, **kwargs):
|
||||||
""" send `Connect` packet to log in to server """
|
|
||||||
payload = {
|
payload = {
|
||||||
'cmd': 'Connect',
|
'cmd': 'Connect',
|
||||||
'password': self.password, 'name': self.auth, 'version': Utils.version_tuple,
|
'password': self.password, 'name': self.auth, 'version': Utils.version_tuple,
|
||||||
'tags': self.tags, 'items_handling': self.items_handling,
|
'tags': self.tags, 'items_handling': self.items_handling,
|
||||||
'uuid': Utils.get_unique_identifier(), 'game': self.game, "slot_data": self.want_slot_data,
|
'uuid': Utils.get_unique_identifier(), 'game': self.game
|
||||||
}
|
}
|
||||||
if kwargs:
|
if kwargs:
|
||||||
payload.update(kwargs)
|
payload.update(kwargs)
|
||||||
await self.send_msgs([payload])
|
await self.send_msgs([payload])
|
||||||
|
|
||||||
async def console_input(self) -> str:
|
async def console_input(self):
|
||||||
if self.ui:
|
|
||||||
self.ui.focus_textinput()
|
|
||||||
self.input_requests += 1
|
self.input_requests += 1
|
||||||
return await self.input_queue.get()
|
return await self.input_queue.get()
|
||||||
|
|
||||||
async def connect(self, address: typing.Optional[str] = None) -> None:
|
async def connect(self, address=None):
|
||||||
""" disconnect any previous connection, and open new connection to the server """
|
|
||||||
await self.disconnect()
|
await self.disconnect()
|
||||||
self.server_task = asyncio.create_task(server_loop(self, address), name="server loop")
|
self.server_task = asyncio.create_task(server_loop(self, address), name="server loop")
|
||||||
|
|
||||||
def cancel_autoreconnect(self) -> bool:
|
|
||||||
if self.autoreconnect_task:
|
|
||||||
self.autoreconnect_task.cancel()
|
|
||||||
self.autoreconnect_task = None
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
def slot_concerns_self(self, slot) -> bool:
|
def slot_concerns_self(self, slot) -> bool:
|
||||||
if slot == self.slot:
|
if slot == self.slot:
|
||||||
return True
|
return True
|
||||||
@@ -392,29 +289,15 @@ class CommonContext:
|
|||||||
return self.slot in self.slot_info[slot].group_members
|
return self.slot in self.slot_info[slot].group_members
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def is_echoed_chat(self, print_json_packet: dict) -> bool:
|
|
||||||
return print_json_packet.get("type", "") == "Chat" \
|
|
||||||
and print_json_packet.get("team", None) == self.team \
|
|
||||||
and print_json_packet.get("slot", None) == self.slot
|
|
||||||
|
|
||||||
def is_uninteresting_item_send(self, print_json_packet: dict) -> bool:
|
|
||||||
"""Helper function for filtering out ItemSend prints that do not concern the local player."""
|
|
||||||
return print_json_packet.get("type", "") == "ItemSend" \
|
|
||||||
and not self.slot_concerns_self(print_json_packet["receiving"]) \
|
|
||||||
and not self.slot_concerns_self(print_json_packet["item"].player)
|
|
||||||
|
|
||||||
def on_print(self, args: dict):
|
def on_print(self, args: dict):
|
||||||
logger.info(args["text"])
|
logger.info(args["text"])
|
||||||
|
|
||||||
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."""
|
||||||
@@ -437,7 +320,6 @@ class CommonContext:
|
|||||||
async def shutdown(self):
|
async def shutdown(self):
|
||||||
self.server_address = ""
|
self.server_address = ""
|
||||||
self.username = None
|
self.username = None
|
||||||
self.cancel_autoreconnect()
|
|
||||||
if self.server and not self.server.socket.closed:
|
if self.server and not self.server.socket.closed:
|
||||||
await self.server.socket.close()
|
await self.server.socket.close()
|
||||||
if self.server_task:
|
if self.server_task:
|
||||||
@@ -453,42 +335,32 @@ class CommonContext:
|
|||||||
self.input_task.cancel()
|
self.input_task.cancel()
|
||||||
|
|
||||||
# DataPackage
|
# DataPackage
|
||||||
async def prepare_data_package(self, relevant_games: typing.Set[str],
|
async def prepare_datapackage(self, relevant_games: typing.Set[str],
|
||||||
remote_date_package_versions: typing.Dict[str, int],
|
remote_datepackage_versions: typing.Dict[str, int]):
|
||||||
remote_data_package_checksums: typing.Dict[str, str]):
|
|
||||||
"""Validate that all data is present for the current multiworld.
|
"""Validate that all data is present for the current multiworld.
|
||||||
Download, assimilate and cache missing data from the server."""
|
Download, assimilate and cache missing data from the server."""
|
||||||
# by documentation any game can use Archipelago locations/items -> always relevant
|
# by documentation any game can use Archipelago locations/items -> always relevant
|
||||||
relevant_games.add("Archipelago")
|
relevant_games.add("Archipelago")
|
||||||
|
|
||||||
|
cache_package = Utils.persistent_load().get("datapackage", {}).get("games", {})
|
||||||
needed_updates: typing.Set[str] = set()
|
needed_updates: typing.Set[str] = set()
|
||||||
for game in relevant_games:
|
for game in relevant_games:
|
||||||
if game not in remote_date_package_versions and game not in remote_data_package_checksums:
|
remote_version: int = remote_datepackage_versions[game]
|
||||||
continue
|
|
||||||
|
|
||||||
remote_version: int = remote_date_package_versions.get(game, 0)
|
if remote_version == 0: # custom datapackage for this game
|
||||||
remote_checksum: typing.Optional[str] = remote_data_package_checksums.get(game)
|
|
||||||
|
|
||||||
if remote_version == 0 and not remote_checksum: # custom data package and no checksum for this game
|
|
||||||
needed_updates.add(game)
|
needed_updates.add(game)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
local_version: int = network_data_package["games"].get(game, {}).get("version", 0)
|
local_version: int = network_data_package["games"].get(game, {}).get("version", 0)
|
||||||
local_checksum: typing.Optional[str] = network_data_package["games"].get(game, {}).get("checksum")
|
|
||||||
# no action required if local version is new enough
|
# no action required if local version is new enough
|
||||||
if (not remote_checksum and (remote_version > local_version or remote_version == 0)) \
|
if remote_version > local_version:
|
||||||
or remote_checksum != local_checksum:
|
cache_version: int = cache_package.get(game, {}).get("version", 0)
|
||||||
cached_game = Utils.load_data_package_for_checksum(game, remote_checksum)
|
|
||||||
cache_version: int = cached_game.get("version", 0)
|
|
||||||
cache_checksum: typing.Optional[str] = cached_game.get("checksum")
|
|
||||||
# download remote version if cache is not new enough
|
# download remote version if cache is not new enough
|
||||||
if (not remote_checksum and (remote_version > cache_version or remote_version == 0)) \
|
if remote_version > cache_version:
|
||||||
or remote_checksum != cache_checksum:
|
|
||||||
needed_updates.add(game)
|
needed_updates.add(game)
|
||||||
else:
|
else:
|
||||||
self.update_game(cached_game)
|
self.update_game(cache_package[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():
|
||||||
@@ -496,37 +368,19 @@ class CommonContext:
|
|||||||
for location_name, location_id in game_package["location_name_to_id"].items():
|
for location_name, location_id in game_package["location_name_to_id"].items():
|
||||||
self.location_names[location_id] = location_name
|
self.location_names[location_id] = location_name
|
||||||
|
|
||||||
def update_data_package(self, data_package: dict):
|
def update_datapackage(self, data_package: dict):
|
||||||
for game, game_data in data_package["games"].items():
|
for game, gamedata in data_package["games"].items():
|
||||||
self.update_game(game_data)
|
self.update_game(gamedata)
|
||||||
|
|
||||||
def consume_network_data_package(self, data_package: dict):
|
def consume_network_datapackage(self, data_package: dict):
|
||||||
self.update_data_package(data_package)
|
self.update_datapackage(data_package)
|
||||||
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():
|
|
||||||
Utils.store_data_package_for_checksum(game, game_data)
|
|
||||||
|
|
||||||
# data storage
|
|
||||||
|
|
||||||
def set_notify(self, *keys: str) -> None:
|
|
||||||
"""Subscribe to be notified of changes to selected data storage keys.
|
|
||||||
|
|
||||||
The values can be accessed via the "stored_data" attribute of this context, which is a dictionary mapping the
|
|
||||||
names of the data storage keys to the latest values received from the server.
|
|
||||||
"""
|
|
||||||
if new_keys := (set(keys) - self.stored_data_notification_keys):
|
|
||||||
self.stored_data_notification_keys.update(new_keys)
|
|
||||||
async_start(self.send_msgs([{"cmd": "Get",
|
|
||||||
"keys": list(new_keys)},
|
|
||||||
{"cmd": "SetNotify",
|
|
||||||
"keys": list(new_keys)}]))
|
|
||||||
|
|
||||||
# DeathLink hooks
|
# DeathLink hooks
|
||||||
|
|
||||||
def on_deathlink(self, data: typing.Dict[str, typing.Any]) -> None:
|
def on_deathlink(self, data: dict):
|
||||||
"""Gets dispatched when a new DeathLink is triggered by another linked player."""
|
"""Gets dispatched when a new DeathLink is triggered by another linked player."""
|
||||||
self.last_death_link = max(data["time"], self.last_death_link)
|
self.last_death_link = max(data["time"], self.last_death_link)
|
||||||
text = data.get("cause", "")
|
text = data.get("cause", "")
|
||||||
@@ -557,10 +411,10 @@ class CommonContext:
|
|||||||
if old_tags != self.tags and self.server and not self.server.socket.closed:
|
if old_tags != self.tags and self.server and not self.server.socket.closed:
|
||||||
await self.send_msgs([{"cmd": "ConnectUpdate", "tags": self.tags}])
|
await self.send_msgs([{"cmd": "ConnectUpdate", "tags": self.tags}])
|
||||||
|
|
||||||
def gui_error(self, title: str, text: typing.Union[Exception, str]) -> typing.Optional["kvui.MessageBox"]:
|
def gui_error(self, title: str, text: typing.Union[Exception, str]):
|
||||||
"""Displays an error messagebox"""
|
"""Displays an error messagebox"""
|
||||||
if not self.ui:
|
if not self.ui:
|
||||||
return None
|
return
|
||||||
title = title or "Error"
|
title = title or "Error"
|
||||||
from kvui import MessageBox
|
from kvui import MessageBox
|
||||||
if self._messagebox:
|
if self._messagebox:
|
||||||
@@ -577,13 +431,6 @@ class CommonContext:
|
|||||||
# display error
|
# display error
|
||||||
self._messagebox = MessageBox(title, text, error=True)
|
self._messagebox = MessageBox(title, text, error=True)
|
||||||
self._messagebox.open()
|
self._messagebox.open()
|
||||||
return self._messagebox
|
|
||||||
|
|
||||||
def handle_connection_loss(self, msg: str) -> None:
|
|
||||||
"""Helper for logging and displaying a loss of connection. Must be called from an except block."""
|
|
||||||
exc_info = sys.exc_info()
|
|
||||||
logger.exception(msg, exc_info=exc_info, extra={'compact_gui': True})
|
|
||||||
self._messagebox_connection_loss = self.gui_error(msg, exc_info[1])
|
|
||||||
|
|
||||||
def run_gui(self):
|
def run_gui(self):
|
||||||
"""Import kivy UI system and start running it as self.ui_task."""
|
"""Import kivy UI system and start running it as self.ui_task."""
|
||||||
@@ -620,7 +467,7 @@ async def keep_alive(ctx: CommonContext, seconds_between_checks=100):
|
|||||||
seconds_elapsed = 0
|
seconds_elapsed = 0
|
||||||
|
|
||||||
|
|
||||||
async def server_loop(ctx: CommonContext, address: typing.Optional[str] = None) -> None:
|
async def server_loop(ctx: CommonContext, address=None):
|
||||||
if ctx.server and ctx.server.socket:
|
if ctx.server and ctx.server.socket:
|
||||||
logger.error('Already connected')
|
logger.error('Already connected')
|
||||||
return
|
return
|
||||||
@@ -633,11 +480,6 @@ async def server_loop(ctx: CommonContext, address: typing.Optional[str] = None)
|
|||||||
logger.info('Please connect to an Archipelago server.')
|
logger.info('Please connect to an Archipelago server.')
|
||||||
return
|
return
|
||||||
|
|
||||||
ctx.cancel_autoreconnect()
|
|
||||||
if ctx._messagebox_connection_loss:
|
|
||||||
ctx._messagebox_connection_loss.dismiss()
|
|
||||||
ctx._messagebox_connection_loss = None
|
|
||||||
|
|
||||||
address = f"ws://{address}" if "://" not in address \
|
address = f"ws://{address}" if "://" not in address \
|
||||||
else address.replace("archipelago://", "ws://")
|
else address.replace("archipelago://", "ws://")
|
||||||
|
|
||||||
@@ -646,50 +488,42 @@ 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:
|
|
||||||
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,
|
|
||||||
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)
|
||||||
logger.info('Connected')
|
logger.info('Connected')
|
||||||
ctx.server_address = address
|
ctx.server_address = address
|
||||||
ctx.current_reconnect_delay = ctx.starting_reconnect_delay
|
ctx.current_reconnect_delay = ctx.starting_reconnect_delay
|
||||||
ctx.disconnected_intentionally = False
|
|
||||||
async for data in ctx.server.socket:
|
async for data in ctx.server.socket:
|
||||||
for msg in decode(data):
|
for msg in decode(data):
|
||||||
await process_server_cmd(ctx, msg)
|
await process_server_cmd(ctx, msg)
|
||||||
logger.warning(f"Disconnected from multiworld server{reconnect_hint()}")
|
logger.warning('Disconnected from multiworld server, type /connect to reconnect')
|
||||||
except websockets.InvalidMessage:
|
except ConnectionRefusedError as e:
|
||||||
# probably encrypted
|
msg = 'Connection refused by the server. May not be running Archipelago on that address or port.'
|
||||||
if address.startswith("ws://"):
|
logger.exception(msg, extra={'compact_gui': True})
|
||||||
# try wss
|
ctx.gui_error(msg, e)
|
||||||
await server_loop(ctx, "ws" + address[1:])
|
except websockets.InvalidURI as e:
|
||||||
else:
|
msg = 'Failed to connect to the multiworld server (invalid URI)'
|
||||||
ctx.handle_connection_loss(f"Lost connection to the multiworld server due to InvalidMessage"
|
logger.exception(msg, extra={'compact_gui': True})
|
||||||
f"{reconnect_hint()}")
|
ctx.gui_error(msg, e)
|
||||||
except ConnectionRefusedError:
|
except OSError as e:
|
||||||
ctx.handle_connection_loss("Connection refused by the server. "
|
msg = 'Failed to connect to the multiworld server'
|
||||||
"May not be running Archipelago on that address or port.")
|
logger.exception(msg, extra={'compact_gui': True})
|
||||||
except websockets.InvalidURI:
|
ctx.gui_error(msg, e)
|
||||||
ctx.handle_connection_loss("Failed to connect to the multiworld server (invalid URI)")
|
except Exception as e:
|
||||||
except OSError:
|
msg = 'Lost connection to the multiworld server, type /connect to reconnect'
|
||||||
ctx.handle_connection_loss("Failed to connect to the multiworld server")
|
logger.exception(msg, extra={'compact_gui': True})
|
||||||
except Exception:
|
ctx.gui_error(msg, e)
|
||||||
ctx.handle_connection_loss(f"Lost connection to the multiworld server{reconnect_hint()}")
|
|
||||||
finally:
|
finally:
|
||||||
await ctx.connection_closed()
|
await ctx.connection_closed()
|
||||||
if ctx.server_address and ctx.username and not ctx.disconnected_intentionally:
|
if ctx.server_address:
|
||||||
logger.info(f"... automatically reconnecting in {ctx.current_reconnect_delay} seconds")
|
logger.info(f"... reconnecting in {ctx.current_reconnect_delay}s")
|
||||||
assert ctx.autoreconnect_task is None
|
asyncio.create_task(server_autoreconnect(ctx), name="server auto reconnect")
|
||||||
ctx.autoreconnect_task = asyncio.create_task(server_autoreconnect(ctx), name="server auto reconnect")
|
|
||||||
ctx.current_reconnect_delay *= 2
|
ctx.current_reconnect_delay *= 2
|
||||||
|
|
||||||
|
|
||||||
@@ -715,16 +549,11 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
|
|||||||
logger.info('Room Information:')
|
logger.info('Room Information:')
|
||||||
logger.info('--------------------------------')
|
logger.info('--------------------------------')
|
||||||
version = args["version"]
|
version = args["version"]
|
||||||
ctx.server_version = Version(*version)
|
ctx.server_version = tuple(version)
|
||||||
|
version = ".".join(str(item) for item in version)
|
||||||
|
|
||||||
if "generator_version" in args:
|
logger.info(f'Server protocol version: {version}')
|
||||||
ctx.generator_version = Version(*args["generator_version"])
|
logger.info("Server protocol tags: " + ", ".join(args["tags"]))
|
||||||
logger.info(f'Server protocol version: {ctx.server_version.as_simple_string()}, '
|
|
||||||
f'generator version: {ctx.generator_version.as_simple_string()}, '
|
|
||||||
f'tags: {", ".join(args["tags"])}')
|
|
||||||
else:
|
|
||||||
logger.info(f'Server protocol version: {ctx.server_version.as_simple_string()}, '
|
|
||||||
f'tags: {", ".join(args["tags"])}')
|
|
||||||
if args['password']:
|
if args['password']:
|
||||||
logger.info('Password required')
|
logger.info('Password required')
|
||||||
ctx.update_permissions(args.get("permissions", {}))
|
ctx.update_permissions(args.get("permissions", {}))
|
||||||
@@ -749,27 +578,23 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
|
|||||||
current_team = network_player.team
|
current_team = network_player.team
|
||||||
logger.info(' %s (Player %d)' % (network_player.alias, network_player.slot))
|
logger.info(' %s (Player %d)' % (network_player.alias, network_player.slot))
|
||||||
|
|
||||||
# update data package
|
# update datapackage
|
||||||
data_package_versions = args.get("datapackage_versions", {})
|
await ctx.prepare_datapackage(set(args["games"]), args["datapackage_versions"])
|
||||||
data_package_checksums = args.get("datapackage_checksums", {})
|
|
||||||
await ctx.prepare_data_package(set(args["games"]), data_package_versions, data_package_checksums)
|
|
||||||
|
|
||||||
await ctx.server_auth(args['password'])
|
await ctx.server_auth(args['password'])
|
||||||
|
|
||||||
elif cmd == 'DataPackage':
|
elif cmd == 'DataPackage':
|
||||||
ctx.consume_network_data_package(args['data'])
|
logger.info("Got new ID/Name DataPackage")
|
||||||
|
ctx.consume_network_datapackage(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
|
||||||
@@ -788,9 +613,7 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
|
|||||||
ctx.slot = args["slot"]
|
ctx.slot = args["slot"]
|
||||||
# int keys get lost in JSON transfer
|
# int keys get lost in JSON transfer
|
||||||
ctx.slot_info = {int(pid): data for pid, data in args["slot_info"].items()}
|
ctx.slot_info = {int(pid): data for pid, data in args["slot_info"].items()}
|
||||||
ctx.hint_points = args.get("hint_points", 0)
|
|
||||||
ctx.consume_players_package(args["players"])
|
ctx.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",
|
||||||
@@ -798,11 +621,6 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
|
|||||||
if ctx.locations_scouted:
|
if ctx.locations_scouted:
|
||||||
msgs.append({"cmd": "LocationScouts",
|
msgs.append({"cmd": "LocationScouts",
|
||||||
"locations": list(ctx.locations_scouted)})
|
"locations": list(ctx.locations_scouted)})
|
||||||
if ctx.stored_data_notification_keys:
|
|
||||||
msgs.append({"cmd": "Get",
|
|
||||||
"keys": list(ctx.stored_data_notification_keys)})
|
|
||||||
msgs.append({"cmd": "SetNotify",
|
|
||||||
"keys": list(ctx.stored_data_notification_keys)})
|
|
||||||
if msgs:
|
if msgs:
|
||||||
await ctx.send_msgs(msgs)
|
await ctx.send_msgs(msgs)
|
||||||
if ctx.finished_game:
|
if ctx.finished_game:
|
||||||
@@ -814,10 +632,6 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
|
|||||||
# when /missing is used for the client side view of what is missing.
|
# when /missing is used for the client side view of what is missing.
|
||||||
ctx.missing_locations = set(args["missing_locations"])
|
ctx.missing_locations = set(args["missing_locations"])
|
||||||
ctx.checked_locations = set(args["checked_locations"])
|
ctx.checked_locations = set(args["checked_locations"])
|
||||||
ctx.server_locations = ctx.missing_locations | ctx. checked_locations
|
|
||||||
|
|
||||||
server_url = urllib.parse.urlparse(ctx.server_address)
|
|
||||||
Utils.persistent_store("client", "last_server_address", server_url.netloc)
|
|
||||||
|
|
||||||
elif cmd == 'ReceivedItems':
|
elif cmd == 'ReceivedItems':
|
||||||
start_index = args["index"]
|
start_index = args["index"]
|
||||||
@@ -866,17 +680,8 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
|
|||||||
# we can skip checking "DeathLink" in ctx.tags, as otherwise we wouldn't have been send this
|
# we can skip checking "DeathLink" in ctx.tags, as otherwise we wouldn't have been send this
|
||||||
if "DeathLink" in tags and ctx.last_death_link != args["data"]["time"]:
|
if "DeathLink" in tags and ctx.last_death_link != args["data"]["time"]:
|
||||||
ctx.on_deathlink(args["data"])
|
ctx.on_deathlink(args["data"])
|
||||||
|
|
||||||
elif cmd == "Retrieved":
|
|
||||||
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"]
|
if args["key"] == "EnergyLink":
|
||||||
if ctx.ui and f"_read_hints_{ctx.team}_{ctx.slot}" == args["key"]:
|
|
||||||
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()
|
||||||
@@ -906,7 +711,7 @@ async def console_loop(ctx: CommonContext):
|
|||||||
logger.exception(e)
|
logger.exception(e)
|
||||||
|
|
||||||
|
|
||||||
def get_base_parser(description: typing.Optional[str] = None):
|
def get_base_parser(description=None):
|
||||||
import argparse
|
import argparse
|
||||||
parser = argparse.ArgumentParser(description=description)
|
parser = argparse.ArgumentParser(description=description)
|
||||||
parser.add_argument('--connect', default=None, help='Address of the multiworld host.')
|
parser.add_argument('--connect', default=None, help='Address of the multiworld host.')
|
||||||
@@ -916,13 +721,13 @@ def get_base_parser(description: typing.Optional[str] = None):
|
|||||||
return parser
|
return parser
|
||||||
|
|
||||||
|
|
||||||
def run_as_textclient():
|
if __name__ == '__main__':
|
||||||
|
# Text Mode to use !hint and such with games that have no text entry
|
||||||
|
|
||||||
class TextContext(CommonContext):
|
class TextContext(CommonContext):
|
||||||
# Text Mode to use !hint and such with games that have no text entry
|
tags = {"AP", "IgnoreGame", "TextOnly"}
|
||||||
tags = CommonContext.tags | {"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
|
|
||||||
|
|
||||||
async def server_auth(self, password_requested: bool = False):
|
async def server_auth(self, password_requested: bool = False):
|
||||||
if password_requested and not self.password:
|
if password_requested and not self.password:
|
||||||
@@ -934,13 +739,11 @@ def run_as_textclient():
|
|||||||
if cmd == "Connected":
|
if cmd == "Connected":
|
||||||
self.game = self.slot_info[self.slot].game
|
self.game = self.slot_info[self.slot].game
|
||||||
|
|
||||||
async def disconnect(self, allow_autoreconnect: bool = False):
|
|
||||||
self.game = ""
|
|
||||||
await super().disconnect(allow_autoreconnect)
|
|
||||||
|
|
||||||
async def main(args):
|
async def main(args):
|
||||||
ctx = TextContext(args.connect, args.password)
|
ctx = TextContext(args.connect, args.password)
|
||||||
ctx.auth = args.name
|
ctx.auth = args.name
|
||||||
|
ctx.server_address = args.connect
|
||||||
ctx.server_task = asyncio.create_task(server_loop(ctx), name="server loop")
|
ctx.server_task = asyncio.create_task(server_loop(ctx), name="server loop")
|
||||||
|
|
||||||
if gui_enabled:
|
if gui_enabled:
|
||||||
@@ -950,6 +753,7 @@ def run_as_textclient():
|
|||||||
await ctx.exit_event.wait()
|
await ctx.exit_event.wait()
|
||||||
await ctx.shutdown()
|
await ctx.shutdown()
|
||||||
|
|
||||||
|
|
||||||
import colorama
|
import colorama
|
||||||
|
|
||||||
parser = get_base_parser(description="Gameless Archipelago Client, for text interfacing.")
|
parser = get_base_parser(description="Gameless Archipelago Client, for text interfacing.")
|
||||||
@@ -969,8 +773,3 @@ def run_as_textclient():
|
|||||||
|
|
||||||
asyncio.run(main(args))
|
asyncio.run(main(args))
|
||||||
colorama.deinit()
|
colorama.deinit()
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
logging.getLogger().setLevel(logging.INFO) # force log-level to work around log level resetting to WARNING
|
|
||||||
run_as_textclient()
|
|
||||||
|
|||||||
66
FF1Client.py
66
FF1Client.py
@@ -1,5 +1,4 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import copy
|
|
||||||
import json
|
import json
|
||||||
import time
|
import time
|
||||||
from asyncio import StreamReader, StreamWriter
|
from asyncio import StreamReader, StreamWriter
|
||||||
@@ -7,15 +6,14 @@ from typing import List
|
|||||||
|
|
||||||
|
|
||||||
import Utils
|
import Utils
|
||||||
from Utils import async_start
|
from CommonClient import CommonContext, server_loop, gui_enabled, console_loop, ClientCommandProcessor, logger, \
|
||||||
from CommonClient import CommonContext, server_loop, gui_enabled, ClientCommandProcessor, logger, \
|
|
||||||
get_base_parser
|
get_base_parser
|
||||||
|
|
||||||
SYSTEM_MESSAGE_ID = 0
|
SYSTEM_MESSAGE_ID = 0
|
||||||
|
|
||||||
CONNECTION_TIMING_OUT_STATUS = "Connection timing out. Please restart your emulator, then restart connector_ff1.lua"
|
CONNECTION_TIMING_OUT_STATUS = "Connection timing out. Please restart your emulator, then restart ff1_connector.lua"
|
||||||
CONNECTION_REFUSED_STATUS = "Connection Refused. Please start your emulator and make sure connector_ff1.lua is running"
|
CONNECTION_REFUSED_STATUS = "Connection Refused. Please start your emulator and make sure ff1_connector.lua is running"
|
||||||
CONNECTION_RESET_STATUS = "Connection was reset. Please restart your emulator, then restart connector_ff1.lua"
|
CONNECTION_RESET_STATUS = "Connection was reset. Please restart your emulator, then restart ff1_connector.lua"
|
||||||
CONNECTION_TENTATIVE_STATUS = "Initial Connection Made"
|
CONNECTION_TENTATIVE_STATUS = "Initial Connection Made"
|
||||||
CONNECTION_CONNECTED_STATUS = "Connected"
|
CONNECTION_CONNECTED_STATUS = "Connected"
|
||||||
CONNECTION_INITIAL_STATUS = "Connection has not been initiated"
|
CONNECTION_INITIAL_STATUS = "Connection has not been initiated"
|
||||||
@@ -33,7 +31,7 @@ class FF1CommandProcessor(ClientCommandProcessor):
|
|||||||
logger.info(f"NES Status: {self.ctx.nes_status}")
|
logger.info(f"NES Status: {self.ctx.nes_status}")
|
||||||
|
|
||||||
def _cmd_toggle_msgs(self):
|
def _cmd_toggle_msgs(self):
|
||||||
"""Toggle displaying messages in EmuHawk"""
|
"""Toggle displaying messages in bizhawk"""
|
||||||
global DISPLAY_MSGS
|
global DISPLAY_MSGS
|
||||||
DISPLAY_MSGS = not DISPLAY_MSGS
|
DISPLAY_MSGS = not DISPLAY_MSGS
|
||||||
logger.info(f"Messages are now {'enabled' if DISPLAY_MSGS else 'disabled'}")
|
logger.info(f"Messages are now {'enabled' if DISPLAY_MSGS else 'disabled'}")
|
||||||
@@ -66,37 +64,41 @@ class FF1Context(CommonContext):
|
|||||||
|
|
||||||
def _set_message(self, msg: str, msg_id: int):
|
def _set_message(self, msg: str, msg_id: int):
|
||||||
if DISPLAY_MSGS:
|
if DISPLAY_MSGS:
|
||||||
self.messages[time.time(), msg_id] = msg
|
self.messages[(time.time(), msg_id)] = msg
|
||||||
|
|
||||||
def on_package(self, cmd: str, args: dict):
|
def on_package(self, cmd: str, args: dict):
|
||||||
if cmd == 'Connected':
|
if cmd == 'Connected':
|
||||||
async_start(parse_locations(self.locations_array, self, True))
|
asyncio.create_task(parse_locations(self.locations_array, self, True))
|
||||||
elif cmd == 'Print':
|
elif cmd == 'Print':
|
||||||
msg = args['text']
|
msg = args['text']
|
||||||
if ': !' not in msg:
|
if ': !' not in msg:
|
||||||
self._set_message(msg, SYSTEM_MESSAGE_ID)
|
self._set_message(msg, SYSTEM_MESSAGE_ID)
|
||||||
|
elif cmd == "ReceivedItems":
|
||||||
def on_print_json(self, args: dict):
|
msg = f"Received {', '.join([self.item_names[item.item] for item in args['items']])}"
|
||||||
if self.ui:
|
self._set_message(msg, SYSTEM_MESSAGE_ID)
|
||||||
self.ui.print_json(copy.deepcopy(args["data"]))
|
elif cmd == 'PrintJSON':
|
||||||
else:
|
print_type = args['type']
|
||||||
text = self.jsontotextparser(copy.deepcopy(args["data"]))
|
item = args['item']
|
||||||
logger.info(text)
|
receiving_player_id = args['receiving']
|
||||||
relevant = args.get("type", None) in {"Hint", "ItemSend"}
|
receiving_player_name = self.player_names[receiving_player_id]
|
||||||
if relevant:
|
sending_player_id = item.player
|
||||||
item = args["item"]
|
sending_player_name = self.player_names[item.player]
|
||||||
# goes to this world
|
if print_type == 'Hint':
|
||||||
if self.slot_concerns_self(args["receiving"]):
|
msg = f"Hint: Your {self.item_names[item.item]} is at" \
|
||||||
relevant = True
|
f" {self.player_names[item.player]}'s {self.location_names[item.location]}"
|
||||||
# found in this world
|
self._set_message(msg, item.item)
|
||||||
elif self.slot_concerns_self(item.player):
|
elif print_type == 'ItemSend' and receiving_player_id != self.slot:
|
||||||
relevant = True
|
if sending_player_id == self.slot:
|
||||||
# not related
|
if receiving_player_id == self.slot:
|
||||||
else:
|
msg = f"You found your own {self.item_names[item.item]}"
|
||||||
relevant = False
|
else:
|
||||||
if relevant:
|
msg = f"You sent {self.item_names[item.item]} to {receiving_player_name}"
|
||||||
item = args["item"]
|
else:
|
||||||
msg = self.raw_text_parser(copy.deepcopy(args["data"]))
|
if receiving_player_id == sending_player_id:
|
||||||
|
msg = f"{sending_player_name} found their {self.item_names[item.item]}"
|
||||||
|
else:
|
||||||
|
msg = f"{sending_player_name} sent {self.item_names[item.item]} to " \
|
||||||
|
f"{receiving_player_name}"
|
||||||
self._set_message(msg, item.item)
|
self._set_message(msg, item.item)
|
||||||
|
|
||||||
def run_gui(self):
|
def run_gui(self):
|
||||||
@@ -181,7 +183,7 @@ async def nes_sync_task(ctx: FF1Context):
|
|||||||
# print(data_decoded)
|
# print(data_decoded)
|
||||||
if ctx.game is not None and 'locations' in data_decoded:
|
if ctx.game is not None and 'locations' in data_decoded:
|
||||||
# Not just a keep alive ping, parse
|
# Not just a keep alive ping, parse
|
||||||
async_start(parse_locations(data_decoded['locations'], ctx, False))
|
asyncio.create_task(parse_locations(data_decoded['locations'], ctx, False))
|
||||||
if not ctx.auth:
|
if not ctx.auth:
|
||||||
ctx.auth = ''.join([chr(i) for i in data_decoded['playerName'] if i != 0])
|
ctx.auth = ''.join([chr(i) for i in data_decoded['playerName'] if i != 0])
|
||||||
if ctx.auth == '':
|
if ctx.auth == '':
|
||||||
|
|||||||
@@ -1,12 +1,433 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
import os
|
||||||
|
import logging
|
||||||
|
import json
|
||||||
|
import string
|
||||||
|
import copy
|
||||||
|
import subprocess
|
||||||
|
import time
|
||||||
|
import random
|
||||||
|
|
||||||
import ModuleUpdate
|
import ModuleUpdate
|
||||||
ModuleUpdate.update()
|
ModuleUpdate.update()
|
||||||
|
|
||||||
from worlds.factorio.Client import check_stdin, launch
|
import factorio_rcon
|
||||||
|
import colorama
|
||||||
|
import asyncio
|
||||||
|
from queue import Queue
|
||||||
import Utils
|
import Utils
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
Utils.init_logging("FactorioClient", exception_logger="Client")
|
Utils.init_logging("FactorioClient", exception_logger="Client")
|
||||||
check_stdin()
|
|
||||||
launch()
|
from CommonClient import CommonContext, server_loop, ClientCommandProcessor, logger, gui_enabled, get_base_parser
|
||||||
|
from MultiServer import mark_raw
|
||||||
|
from NetUtils import NetworkItem, ClientStatus, JSONtoTextParser, JSONMessagePart
|
||||||
|
|
||||||
|
from worlds.factorio import Factorio
|
||||||
|
|
||||||
|
|
||||||
|
class FactorioCommandProcessor(ClientCommandProcessor):
|
||||||
|
ctx: FactorioContext
|
||||||
|
|
||||||
|
@mark_raw
|
||||||
|
def _cmd_factorio(self, text: str) -> bool:
|
||||||
|
"""Send the following command to the bound Factorio Server."""
|
||||||
|
if self.ctx.rcon_client:
|
||||||
|
# TODO: Print the command non-silently only for race seeds, or otherwise block anything but /factorio /save in race seeds.
|
||||||
|
self.ctx.print_to_game(f"/factorio {text}")
|
||||||
|
result = self.ctx.rcon_client.send_command(text)
|
||||||
|
if result:
|
||||||
|
self.output(result)
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _cmd_resync(self):
|
||||||
|
"""Manually trigger a resync."""
|
||||||
|
self.ctx.awaiting_bridge = True
|
||||||
|
|
||||||
|
|
||||||
|
class FactorioContext(CommonContext):
|
||||||
|
command_processor = FactorioCommandProcessor
|
||||||
|
game = "Factorio"
|
||||||
|
items_handling = 0b111 # full remote
|
||||||
|
|
||||||
|
# updated by spinup server
|
||||||
|
mod_version: Utils.Version = Utils.Version(0, 0, 0)
|
||||||
|
|
||||||
|
def __init__(self, server_address, password):
|
||||||
|
super(FactorioContext, self).__init__(server_address, password)
|
||||||
|
self.send_index: int = 0
|
||||||
|
self.rcon_client = None
|
||||||
|
self.awaiting_bridge = False
|
||||||
|
self.write_data_path = None
|
||||||
|
self.death_link_tick: int = 0 # last send death link on Factorio layer
|
||||||
|
self.factorio_json_text_parser = FactorioJSONtoTextParser(self)
|
||||||
|
self.energy_link_increment = 0
|
||||||
|
self.last_deplete = 0
|
||||||
|
|
||||||
|
async def server_auth(self, password_requested: bool = False):
|
||||||
|
if password_requested and not self.password:
|
||||||
|
await super(FactorioContext, self).server_auth(password_requested)
|
||||||
|
|
||||||
|
if self.rcon_client:
|
||||||
|
await get_info(self, self.rcon_client) # retrieve current auth code
|
||||||
|
else:
|
||||||
|
raise Exception("Cannot connect to a server with unknown own identity, "
|
||||||
|
"bridge to Factorio first.")
|
||||||
|
|
||||||
|
await self.send_connect()
|
||||||
|
|
||||||
|
def on_print(self, args: dict):
|
||||||
|
super(FactorioContext, self).on_print(args)
|
||||||
|
if self.rcon_client:
|
||||||
|
self.print_to_game(args['text'])
|
||||||
|
|
||||||
|
def on_print_json(self, args: dict):
|
||||||
|
if self.rcon_client:
|
||||||
|
text = self.factorio_json_text_parser(copy.deepcopy(args["data"]))
|
||||||
|
self.print_to_game(text)
|
||||||
|
super(FactorioContext, self).on_print_json(args)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def savegame_name(self) -> str:
|
||||||
|
return f"AP_{self.seed_name}_{self.auth}_Save.zip"
|
||||||
|
|
||||||
|
def print_to_game(self, text):
|
||||||
|
self.rcon_client.send_command(f"/ap-print [font=default-large-bold]Archipelago:[/font] "
|
||||||
|
f"{text}")
|
||||||
|
|
||||||
|
def on_deathlink(self, data: dict):
|
||||||
|
if self.rcon_client:
|
||||||
|
self.rcon_client.send_command(f"/ap-deathlink {data['source']}")
|
||||||
|
super(FactorioContext, self).on_deathlink(data)
|
||||||
|
|
||||||
|
def on_package(self, cmd: str, args: dict):
|
||||||
|
if cmd in {"Connected", "RoomUpdate"}:
|
||||||
|
# catch up sync anything that is already cleared.
|
||||||
|
if "checked_locations" in args and args["checked_locations"]:
|
||||||
|
self.rcon_client.send_commands({item_name: f'/ap-get-technology ap-{item_name}-\t-1' for
|
||||||
|
item_name in args["checked_locations"]})
|
||||||
|
if cmd == "Connected" and self.energy_link_increment:
|
||||||
|
asyncio.create_task(self.send_msgs([{
|
||||||
|
"cmd": "SetNotify", "keys": ["EnergyLink"]
|
||||||
|
}]))
|
||||||
|
elif cmd == "SetReply":
|
||||||
|
if args["key"] == "EnergyLink":
|
||||||
|
if self.energy_link_increment and args.get("last_deplete", -1) == self.last_deplete:
|
||||||
|
# it's our deplete request
|
||||||
|
gained = int(args["original_value"] - args["value"])
|
||||||
|
gained_text = Utils.format_SI_prefix(gained) + "J"
|
||||||
|
if gained:
|
||||||
|
logger.debug(f"EnergyLink: Received {gained_text}. "
|
||||||
|
f"{Utils.format_SI_prefix(args['value'])}J remaining.")
|
||||||
|
self.rcon_client.send_command(f"/ap-energylink {gained}")
|
||||||
|
|
||||||
|
def run_gui(self):
|
||||||
|
from kvui import GameManager
|
||||||
|
|
||||||
|
class FactorioManager(GameManager):
|
||||||
|
logging_pairs = [
|
||||||
|
("Client", "Archipelago"),
|
||||||
|
("FactorioServer", "Factorio Server Log"),
|
||||||
|
("FactorioWatcher", "Bridge Data Log"),
|
||||||
|
]
|
||||||
|
base_title = "Archipelago Factorio Client"
|
||||||
|
|
||||||
|
self.ui = FactorioManager(self)
|
||||||
|
self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI")
|
||||||
|
|
||||||
|
|
||||||
|
async def game_watcher(ctx: FactorioContext):
|
||||||
|
bridge_logger = logging.getLogger("FactorioWatcher")
|
||||||
|
from worlds.factorio.Technologies import lookup_id_to_name
|
||||||
|
next_bridge = time.perf_counter() + 1
|
||||||
|
try:
|
||||||
|
while not ctx.exit_event.is_set():
|
||||||
|
# TODO: restore on-demand refresh
|
||||||
|
if ctx.rcon_client and time.perf_counter() > next_bridge:
|
||||||
|
next_bridge = time.perf_counter() + 1
|
||||||
|
ctx.awaiting_bridge = False
|
||||||
|
data = json.loads(ctx.rcon_client.send_command("/ap-sync"))
|
||||||
|
if not ctx.auth:
|
||||||
|
pass # auth failed, wait for new attempt
|
||||||
|
elif data["slot_name"] != ctx.auth:
|
||||||
|
bridge_logger.warning(f"Connected World is not the expected one {data['slot_name']} != {ctx.auth}")
|
||||||
|
elif data["seed_name"] != ctx.seed_name:
|
||||||
|
bridge_logger.warning(
|
||||||
|
f"Connected Multiworld is not the expected one {data['seed_name']} != {ctx.seed_name}")
|
||||||
|
else:
|
||||||
|
data = data["info"]
|
||||||
|
research_data = data["research_done"]
|
||||||
|
research_data = {int(tech_name.split("-")[1]) for tech_name in research_data}
|
||||||
|
victory = data["victory"]
|
||||||
|
await ctx.update_death_link(data["death_link"])
|
||||||
|
|
||||||
|
if not ctx.finished_game and victory:
|
||||||
|
await ctx.send_msgs([{"cmd": "StatusUpdate", "status": ClientStatus.CLIENT_GOAL}])
|
||||||
|
ctx.finished_game = True
|
||||||
|
|
||||||
|
if ctx.locations_checked != research_data:
|
||||||
|
bridge_logger.debug(
|
||||||
|
f"New researches done: "
|
||||||
|
f"{[lookup_id_to_name[rid] for rid in research_data - ctx.locations_checked]}")
|
||||||
|
ctx.locations_checked = research_data
|
||||||
|
await ctx.send_msgs([{"cmd": 'LocationChecks', "locations": tuple(research_data)}])
|
||||||
|
death_link_tick = data.get("death_link_tick", 0)
|
||||||
|
if death_link_tick != ctx.death_link_tick:
|
||||||
|
ctx.death_link_tick = death_link_tick
|
||||||
|
if "DeathLink" in ctx.tags:
|
||||||
|
asyncio.create_task(ctx.send_death())
|
||||||
|
if ctx.energy_link_increment:
|
||||||
|
in_world_bridges = data["energy_bridges"]
|
||||||
|
if in_world_bridges:
|
||||||
|
in_world_energy = data["energy"]
|
||||||
|
if in_world_energy < (ctx.energy_link_increment * in_world_bridges):
|
||||||
|
# attempt to refill
|
||||||
|
ctx.last_deplete = time.time()
|
||||||
|
asyncio.create_task(ctx.send_msgs([{
|
||||||
|
"cmd": "Set", "key": "EnergyLink", "operations":
|
||||||
|
[{"operation": "add", "value": -ctx.energy_link_increment * in_world_bridges},
|
||||||
|
{"operation": "max", "value": 0}],
|
||||||
|
"last_deplete": ctx.last_deplete
|
||||||
|
}]))
|
||||||
|
# Above Capacity - (len(Bridges) * ENERGY_INCREMENT)
|
||||||
|
elif in_world_energy > (in_world_bridges * ctx.energy_link_increment * 5) - \
|
||||||
|
ctx.energy_link_increment*in_world_bridges:
|
||||||
|
value = ctx.energy_link_increment * in_world_bridges
|
||||||
|
asyncio.create_task(ctx.send_msgs([{
|
||||||
|
"cmd": "Set", "key": "EnergyLink", "operations":
|
||||||
|
[{"operation": "add", "value": value}]
|
||||||
|
}]))
|
||||||
|
ctx.rcon_client.send_command(
|
||||||
|
f"/ap-energylink -{value}")
|
||||||
|
logger.debug(f"EnergyLink: Sent {Utils.format_SI_prefix(value)}J")
|
||||||
|
|
||||||
|
await asyncio.sleep(0.1)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logging.exception(e)
|
||||||
|
logging.error("Aborted Factorio Server Bridge")
|
||||||
|
|
||||||
|
|
||||||
|
def stream_factorio_output(pipe, queue, process):
|
||||||
|
def queuer():
|
||||||
|
while process.poll() is None:
|
||||||
|
text = pipe.readline().strip()
|
||||||
|
if text:
|
||||||
|
queue.put_nowait(text)
|
||||||
|
|
||||||
|
from threading import Thread
|
||||||
|
|
||||||
|
thread = Thread(target=queuer, name="Factorio Output Queue", daemon=True)
|
||||||
|
thread.start()
|
||||||
|
return thread
|
||||||
|
|
||||||
|
|
||||||
|
async def factorio_server_watcher(ctx: FactorioContext):
|
||||||
|
savegame_name = os.path.abspath(ctx.savegame_name)
|
||||||
|
if not os.path.exists(savegame_name):
|
||||||
|
logger.info(f"Creating savegame {savegame_name}")
|
||||||
|
subprocess.run((
|
||||||
|
executable, "--create", savegame_name, "--preset", "archipelago"
|
||||||
|
))
|
||||||
|
factorio_process = subprocess.Popen((executable, "--start-server", ctx.savegame_name,
|
||||||
|
*(str(elem) for elem in server_args)),
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stdin=subprocess.DEVNULL,
|
||||||
|
encoding="utf-8")
|
||||||
|
factorio_server_logger.info("Started Factorio Server")
|
||||||
|
factorio_queue = Queue()
|
||||||
|
stream_factorio_output(factorio_process.stdout, factorio_queue, factorio_process)
|
||||||
|
stream_factorio_output(factorio_process.stderr, factorio_queue, factorio_process)
|
||||||
|
try:
|
||||||
|
while not ctx.exit_event.is_set():
|
||||||
|
if factorio_process.poll():
|
||||||
|
factorio_server_logger.info("Factorio server has exited.")
|
||||||
|
ctx.exit_event.set()
|
||||||
|
|
||||||
|
while not factorio_queue.empty():
|
||||||
|
msg = factorio_queue.get()
|
||||||
|
factorio_queue.task_done()
|
||||||
|
|
||||||
|
if not ctx.rcon_client and "Starting RCON interface at IP ADDR:" in msg:
|
||||||
|
ctx.rcon_client = factorio_rcon.RCONClient("localhost", rcon_port, rcon_password)
|
||||||
|
if not ctx.server:
|
||||||
|
logger.info("Established bridge to Factorio Server. "
|
||||||
|
"Ready to connect to Archipelago via /connect")
|
||||||
|
|
||||||
|
if not ctx.awaiting_bridge and "Archipelago Bridge Data available for game tick " in msg:
|
||||||
|
ctx.awaiting_bridge = True
|
||||||
|
factorio_server_logger.debug(msg)
|
||||||
|
else:
|
||||||
|
factorio_server_logger.info(msg)
|
||||||
|
if ctx.rcon_client:
|
||||||
|
commands = {}
|
||||||
|
while ctx.send_index < len(ctx.items_received):
|
||||||
|
transfer_item: NetworkItem = ctx.items_received[ctx.send_index]
|
||||||
|
item_id = transfer_item.item
|
||||||
|
player_name = ctx.player_names[transfer_item.player]
|
||||||
|
if item_id not in Factorio.item_id_to_name:
|
||||||
|
factorio_server_logger.error(f"Cannot send unknown item ID: {item_id}")
|
||||||
|
else:
|
||||||
|
item_name = Factorio.item_id_to_name[item_id]
|
||||||
|
factorio_server_logger.info(f"Sending {item_name} to Nauvis from {player_name}.")
|
||||||
|
commands[ctx.send_index] = f'/ap-get-technology {item_name}\t{ctx.send_index}\t{player_name}'
|
||||||
|
ctx.send_index += 1
|
||||||
|
if commands:
|
||||||
|
ctx.rcon_client.send_commands(commands)
|
||||||
|
await asyncio.sleep(0.1)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logging.exception(e)
|
||||||
|
logging.error("Aborted Factorio Server Bridge")
|
||||||
|
ctx.rcon_client = None
|
||||||
|
ctx.exit_event.set()
|
||||||
|
|
||||||
|
finally:
|
||||||
|
factorio_process.terminate()
|
||||||
|
factorio_process.wait(5)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_info(ctx: FactorioContext, rcon_client: factorio_rcon.RCONClient):
|
||||||
|
info = json.loads(rcon_client.send_command("/ap-rcon-info"))
|
||||||
|
ctx.auth = info["slot_name"]
|
||||||
|
ctx.seed_name = info["seed_name"]
|
||||||
|
# 0.2.0 addition, not present earlier
|
||||||
|
death_link = bool(info.get("death_link", False))
|
||||||
|
ctx.energy_link_increment = info.get("energy_link", 0)
|
||||||
|
logger.debug(f"Energy Link Increment: {ctx.energy_link_increment}")
|
||||||
|
if ctx.energy_link_increment and ctx.ui:
|
||||||
|
ctx.ui.enable_energy_link()
|
||||||
|
await ctx.update_death_link(death_link)
|
||||||
|
|
||||||
|
|
||||||
|
async def factorio_spinup_server(ctx: FactorioContext) -> bool:
|
||||||
|
savegame_name = os.path.abspath("Archipelago.zip")
|
||||||
|
if not os.path.exists(savegame_name):
|
||||||
|
logger.info(f"Creating savegame {savegame_name}")
|
||||||
|
subprocess.run((
|
||||||
|
executable, "--create", savegame_name
|
||||||
|
))
|
||||||
|
factorio_process = subprocess.Popen(
|
||||||
|
(executable, "--start-server", savegame_name, *(str(elem) for elem in server_args)),
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stdin=subprocess.DEVNULL,
|
||||||
|
encoding="utf-8")
|
||||||
|
factorio_server_logger.info("Started Information Exchange Factorio Server")
|
||||||
|
factorio_queue = Queue()
|
||||||
|
stream_factorio_output(factorio_process.stdout, factorio_queue, factorio_process)
|
||||||
|
stream_factorio_output(factorio_process.stderr, factorio_queue, factorio_process)
|
||||||
|
rcon_client = None
|
||||||
|
try:
|
||||||
|
while not ctx.auth:
|
||||||
|
while not factorio_queue.empty():
|
||||||
|
msg = factorio_queue.get()
|
||||||
|
factorio_server_logger.info(msg)
|
||||||
|
if "Loading mod AP-" in msg and msg.endswith("(data.lua)"):
|
||||||
|
parts = msg.split()
|
||||||
|
ctx.mod_version = Utils.Version(*(int(number) for number in parts[-2].split(".")))
|
||||||
|
elif "Write data path: " in msg:
|
||||||
|
ctx.write_data_path = Utils.get_text_between(msg, "Write data path: ", " [")
|
||||||
|
if "AppData" in ctx.write_data_path:
|
||||||
|
logger.warning("It appears your mods are loaded from Appdata, "
|
||||||
|
"this can lead to problems with multiple Factorio instances. "
|
||||||
|
"If this is the case, you will get a file locked error running Factorio.")
|
||||||
|
if not rcon_client and "Starting RCON interface at IP ADDR:" in msg:
|
||||||
|
rcon_client = factorio_rcon.RCONClient("localhost", rcon_port, rcon_password)
|
||||||
|
if ctx.mod_version == ctx.__class__.mod_version:
|
||||||
|
raise Exception("No Archipelago mod was loaded. Aborting.")
|
||||||
|
await get_info(ctx, rcon_client)
|
||||||
|
await asyncio.sleep(0.01)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception(e, extra={"compact_gui": True})
|
||||||
|
msg = "Aborted Factorio Server Bridge"
|
||||||
|
logger.error(msg)
|
||||||
|
ctx.gui_error(msg, e)
|
||||||
|
ctx.exit_event.set()
|
||||||
|
|
||||||
|
else:
|
||||||
|
logger.info(
|
||||||
|
f"Got World Information from AP Mod {tuple(ctx.mod_version)} for seed {ctx.seed_name} in slot {ctx.auth}")
|
||||||
|
return True
|
||||||
|
finally:
|
||||||
|
factorio_process.terminate()
|
||||||
|
factorio_process.wait(5)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
async def main(args):
|
||||||
|
ctx = FactorioContext(args.connect, args.password)
|
||||||
|
ctx.server_task = asyncio.create_task(server_loop(ctx), name="ServerLoop")
|
||||||
|
|
||||||
|
if gui_enabled:
|
||||||
|
ctx.run_gui()
|
||||||
|
ctx.run_cli()
|
||||||
|
|
||||||
|
factorio_server_task = asyncio.create_task(factorio_spinup_server(ctx), name="FactorioSpinupServer")
|
||||||
|
successful_launch = await factorio_server_task
|
||||||
|
if successful_launch:
|
||||||
|
factorio_server_task = asyncio.create_task(factorio_server_watcher(ctx), name="FactorioServer")
|
||||||
|
progression_watcher = asyncio.create_task(
|
||||||
|
game_watcher(ctx), name="FactorioProgressionWatcher")
|
||||||
|
|
||||||
|
await ctx.exit_event.wait()
|
||||||
|
ctx.server_address = None
|
||||||
|
|
||||||
|
await progression_watcher
|
||||||
|
await factorio_server_task
|
||||||
|
|
||||||
|
await ctx.shutdown()
|
||||||
|
|
||||||
|
|
||||||
|
class FactorioJSONtoTextParser(JSONtoTextParser):
|
||||||
|
def _handle_color(self, node: JSONMessagePart):
|
||||||
|
colors = node["color"].split(";")
|
||||||
|
for color in colors:
|
||||||
|
if color in self.color_codes:
|
||||||
|
node["text"] = f"[color=#{self.color_codes[color]}]{node['text']}[/color]"
|
||||||
|
return self._handle_text(node)
|
||||||
|
return self._handle_text(node)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
parser = get_base_parser(description="Optional arguments to FactorioClient follow. "
|
||||||
|
"Remaining arguments get passed into bound Factorio instance."
|
||||||
|
"Refer to Factorio --help for those.")
|
||||||
|
parser.add_argument('--rcon-port', default='24242', type=int, help='Port to use to communicate with Factorio')
|
||||||
|
parser.add_argument('--rcon-password', help='Password to authenticate with RCON.')
|
||||||
|
parser.add_argument('--server-settings', help='Factorio server settings configuration file.')
|
||||||
|
|
||||||
|
args, rest = parser.parse_known_args()
|
||||||
|
colorama.init()
|
||||||
|
rcon_port = args.rcon_port
|
||||||
|
rcon_password = args.rcon_password if args.rcon_password else ''.join(
|
||||||
|
random.choice(string.ascii_letters) for x in range(32))
|
||||||
|
|
||||||
|
factorio_server_logger = logging.getLogger("FactorioServer")
|
||||||
|
options = Utils.get_options()
|
||||||
|
executable = options["factorio_options"]["executable"]
|
||||||
|
server_settings = args.server_settings if args.server_settings else options["factorio_options"].get("server_settings", None)
|
||||||
|
if server_settings:
|
||||||
|
server_settings = os.path.abspath(server_settings)
|
||||||
|
|
||||||
|
if not os.path.exists(os.path.dirname(executable)):
|
||||||
|
raise FileNotFoundError(f"Path {os.path.dirname(executable)} does not exist or could not be accessed.")
|
||||||
|
if os.path.isdir(executable): # user entered a path to a directory, let's find the executable therein
|
||||||
|
executable = os.path.join(executable, "factorio")
|
||||||
|
if not os.path.isfile(executable):
|
||||||
|
if os.path.isfile(executable + ".exe"):
|
||||||
|
executable = executable + ".exe"
|
||||||
|
else:
|
||||||
|
raise FileNotFoundError(f"Path {executable} is not an executable file.")
|
||||||
|
|
||||||
|
if server_settings and os.path.isfile(server_settings):
|
||||||
|
server_args = ("--rcon-port", rcon_port, "--rcon-password", rcon_password, "--server-settings", server_settings, *rest)
|
||||||
|
else:
|
||||||
|
server_args = ("--rcon-port", rcon_port, "--rcon-password", rcon_password, *rest)
|
||||||
|
|
||||||
|
asyncio.run(main(args))
|
||||||
|
colorama.deinit()
|
||||||
|
|||||||
520
Generate.py
520
Generate.py
@@ -2,66 +2,108 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import logging
|
import logging
|
||||||
import os
|
|
||||||
import random
|
import random
|
||||||
import string
|
|
||||||
import urllib.parse
|
|
||||||
import urllib.request
|
import urllib.request
|
||||||
from collections import Counter
|
import urllib.parse
|
||||||
from typing import Any, Dict, Tuple, Union
|
from typing import Set, Dict, Tuple, Callable, Any, Union
|
||||||
|
import os
|
||||||
|
from collections import Counter, ChainMap
|
||||||
|
import string
|
||||||
|
import enum
|
||||||
|
|
||||||
import ModuleUpdate
|
import ModuleUpdate
|
||||||
|
|
||||||
ModuleUpdate.update()
|
ModuleUpdate.update()
|
||||||
|
|
||||||
import copy
|
|
||||||
import Utils
|
import Utils
|
||||||
import Options
|
from worlds.alttp import Options as LttPOptions
|
||||||
from BaseClasses import seeddigits, get_seed, PlandoOptions
|
from worlds.generic import PlandoConnection
|
||||||
from Main import main as ERmain
|
from Utils import parse_yamls, version_tuple, __version__, tuplize_version, get_options, local_path, user_path
|
||||||
from settings import get_settings
|
|
||||||
from Utils import parse_yamls, version_tuple, __version__, tuplize_version
|
|
||||||
from worlds.alttp.EntranceRandomizer import parse_arguments
|
from worlds.alttp.EntranceRandomizer import parse_arguments
|
||||||
|
from Main import main as ERmain
|
||||||
|
from BaseClasses import seeddigits, get_seed
|
||||||
|
import Options
|
||||||
|
from worlds.alttp import Bosses
|
||||||
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
|
import copy
|
||||||
from worlds import failed_world_loads
|
|
||||||
|
|
||||||
|
class PlandoSettings(enum.IntFlag):
|
||||||
|
items = 0b0001
|
||||||
|
connections = 0b0010
|
||||||
|
texts = 0b0100
|
||||||
|
bosses = 0b1000
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_option_string(cls, option_string: str) -> PlandoSettings:
|
||||||
|
result = cls(0)
|
||||||
|
for part in option_string.split(","):
|
||||||
|
part = part.strip().lower()
|
||||||
|
if part:
|
||||||
|
result = cls._handle_part(part, result)
|
||||||
|
return result
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_set(cls, option_set: Set[str]) -> PlandoSettings:
|
||||||
|
result = cls(0)
|
||||||
|
for part in option_set:
|
||||||
|
result = cls._handle_part(part, result)
|
||||||
|
return result
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _handle_part(cls, part: str, base: PlandoSettings) -> PlandoSettings:
|
||||||
|
try:
|
||||||
|
part = cls[part]
|
||||||
|
except Exception as e:
|
||||||
|
raise KeyError(f"{part} is not a recognized name for a plando module. "
|
||||||
|
f"Known options: {', '.join(flag.name for flag in cls)}") from e
|
||||||
|
else:
|
||||||
|
return base | part
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
if self.value:
|
||||||
|
return ", ".join((flag.name for flag in PlandoSettings if self.value & flag.value))
|
||||||
|
return "Off"
|
||||||
|
|
||||||
|
|
||||||
def mystery_argparse():
|
def mystery_argparse():
|
||||||
options = get_settings()
|
options = get_options()
|
||||||
defaults = options.generator
|
defaults = options["generator"]
|
||||||
|
|
||||||
|
def resolve_path(path: str, resolver: Callable[[str], str]) -> str:
|
||||||
|
return path if os.path.isabs(path) else resolver(path)
|
||||||
|
|
||||||
parser = argparse.ArgumentParser(description="CMD Generation Interface, defaults come from host.yaml.")
|
parser = 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=resolve_path(defaults["player_files_path"], user_path),
|
||||||
help="Input directory for player files.")
|
help="Input directory for player files.")
|
||||||
parser.add_argument('--seed', help='Define seed number to generate.', type=int)
|
parser.add_argument('--seed', help='Define seed number to generate.', type=int)
|
||||||
parser.add_argument('--multi', default=defaults.players, type=lambda value: max(int(value), 1))
|
parser.add_argument('--multi', default=defaults["players"], type=lambda value: max(int(value), 1))
|
||||||
parser.add_argument('--spoiler', type=int, default=defaults.spoiler)
|
parser.add_argument('--spoiler', type=int, default=defaults["spoiler"])
|
||||||
parser.add_argument('--outputpath', default=options.general_options.output_path,
|
parser.add_argument('--lttp_rom', default=options["lttp_options"]["rom_file"],
|
||||||
|
help="Path to the 1.0 JP LttP Baserom.") # absolute, relative to cwd or relative to app path
|
||||||
|
parser.add_argument('--sm_rom', default=options["sm_options"]["rom_file"],
|
||||||
|
help="Path to the 1.0 JP SM Baserom.")
|
||||||
|
parser.add_argument('--enemizercli', default=resolve_path(defaults["enemizer_path"], local_path))
|
||||||
|
parser.add_argument('--outputpath', default=resolve_path(options["general_options"]["output_path"], user_path),
|
||||||
help="Path to output folder. Absolute or relative to cwd.") # absolute or relative to cwd
|
help="Path to output folder. Absolute or relative to cwd.") # absolute or relative to cwd
|
||||||
parser.add_argument('--race', action='store_true', default=defaults.race)
|
parser.add_argument('--race', action='store_true', default=defaults["race"])
|
||||||
parser.add_argument('--meta_file_path', default=defaults.meta_file_path)
|
parser.add_argument('--meta_file_path', default=defaults["meta_file_path"])
|
||||||
parser.add_argument('--log_level', default='info', help='Sets log level')
|
parser.add_argument('--log_level', default='info', help='Sets log level')
|
||||||
parser.add_argument('--yaml_output', default=0, type=lambda value: max(int(value), 0),
|
parser.add_argument('--yaml_output', default=0, type=lambda value: max(int(value), 0),
|
||||||
help='Output rolled mystery results to yaml up to specified number (made for async multiworld)')
|
help='Output rolled mystery results to yaml up to specified number (made for async multiworld)')
|
||||||
parser.add_argument('--plando', default=defaults.plando_options,
|
parser.add_argument('--plando', default=defaults["plando_options"],
|
||||||
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",
|
|
||||||
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)
|
||||||
if not os.path.isabs(args.meta_file_path):
|
if not os.path.isabs(args.meta_file_path):
|
||||||
args.meta_file_path = os.path.join(args.player_files_path, args.meta_file_path)
|
args.meta_file_path = os.path.join(args.player_files_path, args.meta_file_path)
|
||||||
args.plando: PlandoOptions = PlandoOptions.from_option_string(args.plando)
|
args.plando: PlandoSettings = PlandoSettings.from_option_string(args.plando)
|
||||||
return args, options
|
return args, options
|
||||||
|
|
||||||
|
|
||||||
@@ -72,16 +114,12 @@ def get_seed_name(random_source) -> str:
|
|||||||
def main(args=None, callback=ERmain):
|
def main(args=None, callback=ERmain):
|
||||||
if not args:
|
if not args:
|
||||||
args, options = mystery_argparse()
|
args, options = mystery_argparse()
|
||||||
else:
|
|
||||||
options = get_settings()
|
|
||||||
|
|
||||||
seed = get_seed(args.seed)
|
seed = get_seed(args.seed)
|
||||||
Utils.init_logging(f"Generate_{seed}", loglevel=args.log_level)
|
|
||||||
random.seed(seed)
|
random.seed(seed)
|
||||||
seed_name = get_seed_name(random)
|
seed_name = get_seed_name(random)
|
||||||
|
|
||||||
if args.race:
|
if args.race:
|
||||||
logging.info("Race mode enabled. Using non-deterministic random source.")
|
|
||||||
random.seed() # reset to time-based random source
|
random.seed() # reset to time-based random source
|
||||||
|
|
||||||
weights_cache: Dict[str, Tuple[Any, ...]] = {}
|
weights_cache: Dict[str, Tuple[Any, ...]] = {}
|
||||||
@@ -89,73 +127,68 @@ def main(args=None, callback=ERmain):
|
|||||||
try:
|
try:
|
||||||
weights_cache[args.weights_file_path] = read_weights_yamls(args.weights_file_path)
|
weights_cache[args.weights_file_path] = read_weights_yamls(args.weights_file_path)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise ValueError(f"File {args.weights_file_path} is invalid. Please fix your yaml.") from e
|
raise ValueError(f"File {args.weights_file_path} is destroyed. Please fix your yaml.") from e
|
||||||
logging.info(f"Weights: {args.weights_file_path} >> "
|
print(f"Weights: {args.weights_file_path} >> "
|
||||||
f"{get_choice('description', weights_cache[args.weights_file_path][-1], 'No description specified')}")
|
f"{get_choice('description', weights_cache[args.weights_file_path][-1], 'No description specified')}")
|
||||||
|
|
||||||
if args.meta_file_path and os.path.exists(args.meta_file_path):
|
if args.meta_file_path and os.path.exists(args.meta_file_path):
|
||||||
try:
|
try:
|
||||||
meta_weights = read_weights_yamls(args.meta_file_path)[-1]
|
meta_weights = read_weights_yamls(args.meta_file_path)[-1]
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise ValueError(f"File {args.meta_file_path} is invalid. Please fix your yaml.") from e
|
raise ValueError(f"File {args.meta_file_path} is destroyed. Please fix your yaml.") from e
|
||||||
logging.info(f"Meta: {args.meta_file_path} >> {get_choice('meta_description', meta_weights)}")
|
print(f"Meta: {args.meta_file_path} >> {get_choice('meta_description', meta_weights)}")
|
||||||
try: # meta description allows us to verify that the file named meta.yaml is intentionally a meta file
|
try: # meta description allows us to verify that the file named meta.yaml is intentionally a meta file
|
||||||
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
|
||||||
player_files = {}
|
player_files = {}
|
||||||
for file in os.scandir(args.player_files_path):
|
for file in os.scandir(args.player_files_path):
|
||||||
fname = file.name
|
fname = file.name
|
||||||
if file.is_file() and not fname.startswith(".") and \
|
if file.is_file() and not file.name.startswith(".") and \
|
||||||
os.path.join(args.player_files_path, fname) not in {args.meta_file_path, args.weights_file_path}:
|
os.path.join(args.player_files_path, fname) not in {args.meta_file_path, args.weights_file_path}:
|
||||||
path = os.path.join(args.player_files_path, fname)
|
path = os.path.join(args.player_files_path, fname)
|
||||||
try:
|
try:
|
||||||
weights_cache[fname] = read_weights_yamls(path)
|
weights_cache[fname] = read_weights_yamls(path)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise ValueError(f"File {fname} is invalid. Please fix your yaml.") from e
|
raise ValueError(f"File {fname} is destroyed. 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}:
|
for yaml in yaml_data:
|
||||||
for yaml in yaml_data:
|
print(f"P{player_id} Weights: {filename} >> "
|
||||||
logging.info(f"P{player_id} Weights: {filename} >> "
|
f"{get_choice('description', yaml, 'No description specified')}")
|
||||||
f"{get_choice('description', yaml, 'No description specified')}")
|
player_files[player_id] = filename
|
||||||
player_files[player_id] = filename
|
player_id += 1
|
||||||
player_id += 1
|
|
||||||
|
|
||||||
args.multi = max(player_id - 1, args.multi)
|
args.multi = max(player_id - 1, args.multi)
|
||||||
|
print(f"Generating for {args.multi} player{'s' if args.multi > 1 else ''}, {seed_name} Seed {seed} with plando: "
|
||||||
if args.multi == 0:
|
f"{args.plando}")
|
||||||
raise ValueError(
|
|
||||||
"No individual player files found and number of players is 0. "
|
|
||||||
"Provide individual player files or specify the number of players via host.yaml or --multi."
|
|
||||||
)
|
|
||||||
|
|
||||||
logging.info(f"Generating for {args.multi} player{'s' if args.multi > 1 else ''}, "
|
|
||||||
f"{seed_name} Seed {seed} with plando: {args.plando}")
|
|
||||||
|
|
||||||
if not weights_cache:
|
if not weights_cache:
|
||||||
raise Exception(f"No weights found. "
|
raise Exception(f"No weights found. Provide a general weights file ({args.weights_file_path}) or individual player files. "
|
||||||
f"Provide a general weights file ({args.weights_file_path}) or individual player files. "
|
|
||||||
f"A mix is also permitted.")
|
f"A mix is also permitted.")
|
||||||
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.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_output = args.skip_output
|
Utils.init_logging(f"Generate_{seed}", loglevel=args.log_level)
|
||||||
|
|
||||||
|
erargs.lttp_rom = args.lttp_rom
|
||||||
|
erargs.sm_rom = args.sm_rom
|
||||||
|
erargs.enemizercli = args.enemizercli
|
||||||
|
|
||||||
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 +200,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 +211,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:
|
||||||
@@ -203,15 +235,15 @@ def main(args=None, callback=ERmain):
|
|||||||
elif not erargs.name[player]: # if name was not specified, generate it from filename
|
elif not erargs.name[player]: # if name was not specified, generate it from filename
|
||||||
erargs.name[player] = os.path.splitext(os.path.split(path)[-1])[0]
|
erargs.name[player] = os.path.splitext(os.path.split(path)[-1])[0]
|
||||||
erargs.name[player] = handle_name(erargs.name[player], player, name_counter)
|
erargs.name[player] = handle_name(erargs.name[player], player, name_counter)
|
||||||
|
|
||||||
player += 1
|
player += 1
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise ValueError(f"File {path} is invalid. Please fix your yaml.") from e
|
raise ValueError(f"File {path} is destroyed. Please fix your yaml.") from e
|
||||||
else:
|
else:
|
||||||
raise RuntimeError(f'No weights specified for player {player}')
|
raise RuntimeError(f'No weights specified for player {player}')
|
||||||
|
|
||||||
if len(set(name.lower() for name in erargs.name.values())) != len(erargs.name):
|
if len(set(erargs.name.values())) != len(erargs.name):
|
||||||
raise Exception(f"Names have to be unique. Names: {Counter(name.lower() for name in erargs.name.values())}")
|
raise Exception(f"Names have to be unique. Names: {Counter(erargs.name.values())}")
|
||||||
|
|
||||||
if args.yaml_output:
|
if args.yaml_output:
|
||||||
import yaml
|
import yaml
|
||||||
@@ -235,7 +267,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, ...]:
|
||||||
@@ -294,50 +326,66 @@ class SafeDict(dict):
|
|||||||
|
|
||||||
|
|
||||||
def handle_name(name: str, player: int, name_counter: Counter):
|
def handle_name(name: str, player: int, name_counter: Counter):
|
||||||
name_counter[name.lower()] += 1
|
name_counter[name] += 1
|
||||||
number = name_counter[name.lower()]
|
|
||||||
new_name = "%".join([x.replace("%number%", "{number}").replace("%player%", "{player}") for x in name.split("%%")])
|
new_name = "%".join([x.replace("%number%", "{number}").replace("%player%", "{player}") for x in name.split("%%")])
|
||||||
new_name = string.Formatter().vformat(new_name, (), SafeDict(number=number,
|
new_name = string.Formatter().vformat(new_name, (), SafeDict(number=name_counter[name],
|
||||||
NUMBER=(number if number > 1 else ''),
|
NUMBER=(name_counter[name] if name_counter[
|
||||||
|
name] > 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
|
||||||
|
|
||||||
|
|
||||||
|
available_boss_names: Set[str] = {boss.lower() for boss in Bosses.boss_table if boss not in
|
||||||
|
{'Agahnim', 'Agahnim2', 'Ganon'}}
|
||||||
|
available_boss_locations: Set[str] = {f"{loc.lower()}{f' {level}' if level else ''}" for loc, level in
|
||||||
|
Bosses.boss_location_table}
|
||||||
|
|
||||||
|
boss_shuffle_options = {None: 'none',
|
||||||
|
'none': 'none',
|
||||||
|
'basic': 'basic',
|
||||||
|
'full': 'full',
|
||||||
|
'chaos': 'chaos',
|
||||||
|
'singularity': 'singularity'
|
||||||
|
}
|
||||||
|
|
||||||
|
goals = {
|
||||||
|
'ganon': 'ganon',
|
||||||
|
'crystals': 'crystals',
|
||||||
|
'bosses': 'bosses',
|
||||||
|
'pedestal': 'pedestal',
|
||||||
|
'ganon_pedestal': 'ganonpedestal',
|
||||||
|
'triforce_hunt': 'triforcehunt',
|
||||||
|
'local_triforce_hunt': 'localtriforcehunt',
|
||||||
|
'ganon_triforce_hunt': 'ganontriforcehunt',
|
||||||
|
'local_ganon_triforce_hunt': 'localganontriforcehunt',
|
||||||
|
'ice_rod_hunt': 'icerodhunt',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def roll_percentage(percentage: Union[int, float]) -> bool:
|
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 +396,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 options[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:
|
||||||
@@ -373,12 +430,12 @@ def roll_linked_options(weights: dict) -> dict:
|
|||||||
else:
|
else:
|
||||||
logging.debug(f"linked option {option_set['name']} skipped.")
|
logging.debug(f"linked option {option_set['name']} skipped.")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise ValueError(f"Linked option {option_set['name']} is invalid. "
|
raise ValueError(f"Linked option {option_set['name']} is destroyed. "
|
||||||
f"Please fix your linked option.") from e
|
f"Please fix your linked option.") from e
|
||||||
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,37 +458,71 @@ 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 destroyed. "
|
||||||
f"Please fix your triggers.") from e
|
f"Please fix your triggers.") from e
|
||||||
return weights
|
return weights
|
||||||
|
|
||||||
|
|
||||||
def handle_option(ret: argparse.Namespace, game_weights: dict, option_key: str, option: type(Options.Option), plando_options: PlandoOptions):
|
def get_plando_bosses(boss_shuffle: str, plando_options: Set[str]) -> str:
|
||||||
try:
|
if boss_shuffle in boss_shuffle_options:
|
||||||
if option_key in game_weights:
|
return boss_shuffle_options[boss_shuffle]
|
||||||
|
elif PlandoSettings.bosses in plando_options:
|
||||||
|
options = boss_shuffle.lower().split(";")
|
||||||
|
remainder_shuffle = "none" # vanilla
|
||||||
|
bosses = []
|
||||||
|
for boss in options:
|
||||||
|
if boss in boss_shuffle_options:
|
||||||
|
remainder_shuffle = boss_shuffle_options[boss]
|
||||||
|
elif "-" in boss:
|
||||||
|
loc, boss_name = boss.split("-")
|
||||||
|
if boss_name not in available_boss_names:
|
||||||
|
raise ValueError(f"Unknown Boss name {boss_name}")
|
||||||
|
if loc not in available_boss_locations:
|
||||||
|
raise ValueError(f"Unknown Boss Location {loc}")
|
||||||
|
level = ''
|
||||||
|
if loc.split(" ")[-1] in {"top", "middle", "bottom"}:
|
||||||
|
# split off level
|
||||||
|
loc = loc.split(" ")
|
||||||
|
level = f" {loc[-1]}"
|
||||||
|
loc = " ".join(loc[:-1])
|
||||||
|
loc = loc.title().replace("Of", "of")
|
||||||
|
if not Bosses.can_place_boss(boss_name.title(), loc, level):
|
||||||
|
raise ValueError(f"Cannot place {boss_name} at {loc}{level}")
|
||||||
|
bosses.append(boss)
|
||||||
|
elif boss not in available_boss_names:
|
||||||
|
raise ValueError(f"Unknown Boss name or Boss shuffle option {boss}.")
|
||||||
|
else:
|
||||||
|
bosses.append(boss)
|
||||||
|
return ";".join(bosses + [remainder_shuffle])
|
||||||
|
else:
|
||||||
|
raise Exception(f"Boss Shuffle {boss_shuffle} is unknown and boss plando is turned off.")
|
||||||
|
|
||||||
|
|
||||||
|
def handle_option(ret: argparse.Namespace, game_weights: dict, option_key: str, option: type(Options.Option)):
|
||||||
|
if option_key in game_weights:
|
||||||
|
try:
|
||||||
if not option.supports_weighting:
|
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"
|
if hasattr(player_option, "verify"):
|
||||||
setattr(ret, option_key, player_option)
|
player_option.verify(AutoWorldRegister.world_types[ret.game])
|
||||||
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(option.default))
|
||||||
|
|
||||||
|
|
||||||
def roll_settings(weights: dict, plando_options: PlandoOptions = PlandoOptions.bosses):
|
def roll_settings(weights: dict, plando_options: PlandoSettings = PlandoSettings.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:
|
||||||
@@ -439,73 +530,162 @@ def roll_settings(weights: dict, plando_options: PlandoOptions = PlandoOptions.b
|
|||||||
if tuplize_version(version) > version_tuple:
|
if tuplize_version(version) > version_tuple:
|
||||||
raise Exception(f"Settings reports required version of generator is at least {version}, "
|
raise Exception(f"Settings reports required version of generator is at least {version}, "
|
||||||
f"however generator is of version {__version__}")
|
f"however generator is of version {__version__}")
|
||||||
required_plando_options = PlandoOptions.from_option_string(requirements.get("plando", ""))
|
required_plando_options = PlandoSettings.from_option_string(requirements.get("plando", ""))
|
||||||
if required_plando_options not in plando_options:
|
if required_plando_options not in plando_options:
|
||||||
if required_plando_options:
|
if required_plando_options:
|
||||||
raise Exception(f"Settings reports required plando module {str(required_plando_options)}, "
|
raise Exception(f"Settings reports required plando module {str(required_plando_options)}, "
|
||||||
f"which is not enabled.")
|
f"which is not enabled.")
|
||||||
|
|
||||||
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:
|
|
||||||
picks = Utils.get_fuzzy_results(ret.game, list(AutoWorldRegister.world_types) + failed_world_loads, limit=1)[0]
|
|
||||||
if picks[0] in failed_world_loads:
|
|
||||||
raise Exception(f"No functional world found to handle game {ret.game}. "
|
|
||||||
f"Did you mean '{picks[0]}' ({picks[1]}% sure)? "
|
|
||||||
f"If so, it appears the world failed to initialize correctly.")
|
|
||||||
raise Exception(f"No world found to handle game {ret.game}. Did you mean '{picks[0]}' ({picks[1]}% sure)? "
|
|
||||||
f"Check your spelling or installation of that world.")
|
|
||||||
|
|
||||||
if ret.game not in weights:
|
if ret.game not in weights:
|
||||||
raise Exception(f"No game options for selected game \"{ret.game}\" found.")
|
raise Exception(f"No game options for selected game \"{ret.game}\" found.")
|
||||||
|
|
||||||
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():
|
if ret.game in AutoWorldRegister.world_types:
|
||||||
handle_option(ret, game_weights, option_key, option, plando_options)
|
for option_key, option in world_type.option_definitions.items():
|
||||||
for option_key in game_weights:
|
handle_option(ret, game_weights, option_key, option)
|
||||||
if option_key in {"triggers", *valid_trigger_names}:
|
for option_key, option in Options.per_game_common_options.items():
|
||||||
continue
|
# skip setting this option if already set from common_options, defaulting to root option
|
||||||
logging.warning(f"{option_key} is not a valid option name for {ret.game} and is not present in triggers.")
|
if not (option_key in Options.common_options and option_key not in game_weights):
|
||||||
if PlandoOptions.items in plando_options:
|
handle_option(ret, game_weights, option_key, option)
|
||||||
ret.plando_items = game_weights.get("plando_items", [])
|
if PlandoSettings.items in plando_options:
|
||||||
if ret.game == "A Link to the Past":
|
ret.plando_items = game_weights.get("plando_items", [])
|
||||||
roll_alttp_settings(ret, game_weights, plando_options)
|
if ret.game == "Minecraft" or ret.game == "Ocarina of Time":
|
||||||
if PlandoOptions.connections in plando_options:
|
# bad hardcoded behavior to make this work for now
|
||||||
ret.plando_connections = []
|
ret.plando_connections = []
|
||||||
options = game_weights.get("plando_connections", [])
|
if PlandoSettings.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)
|
||||||
|
else:
|
||||||
|
raise Exception(f"Unsupported game {ret.game}")
|
||||||
|
|
||||||
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)
|
||||||
|
|
||||||
|
boss_shuffle = get_choice_legacy('boss_shuffle', weights)
|
||||||
|
ret.shufflebosses = get_plando_bosses(boss_shuffle, plando_options)
|
||||||
|
|
||||||
|
ret.enemy_damage = {None: 'default',
|
||||||
|
'default': 'default',
|
||||||
|
'shuffled': 'shuffled',
|
||||||
|
'random': 'chaos', # to be removed
|
||||||
|
'chaos': 'chaos',
|
||||||
|
}[get_choice_legacy('enemy_damage', weights)]
|
||||||
|
|
||||||
|
ret.enemy_health = get_choice_legacy('enemy_health', weights)
|
||||||
|
|
||||||
|
ret.timer = {'none': False,
|
||||||
|
None: False,
|
||||||
|
False: False,
|
||||||
|
'timed': 'timed',
|
||||||
|
'timed_ohko': 'timed-ohko',
|
||||||
|
'ohko': 'ohko',
|
||||||
|
'timed_countdown': 'timed-countdown',
|
||||||
|
'display': 'display'}[get_choice_legacy('timer', weights, False)]
|
||||||
|
|
||||||
|
ret.countdown_start_time = int(get_choice_legacy('countdown_start_time', weights, 10))
|
||||||
|
ret.red_clock_time = int(get_choice_legacy('red_clock_time', weights, -2))
|
||||||
|
ret.blue_clock_time = int(get_choice_legacy('blue_clock_time', weights, 2))
|
||||||
|
ret.green_clock_time = int(get_choice_legacy('green_clock_time', weights, 4))
|
||||||
|
|
||||||
|
ret.dungeon_counters = get_choice_legacy('dungeon_counters', weights, 'default')
|
||||||
|
|
||||||
|
ret.shuffle_prizes = get_choice_legacy('shuffle_prizes', weights, "g")
|
||||||
|
|
||||||
|
ret.required_medallions = [get_choice_legacy("misery_mire_medallion", weights, "random"),
|
||||||
|
get_choice_legacy("turtle_rock_medallion", weights, "random")]
|
||||||
|
|
||||||
|
for index, medallion in enumerate(ret.required_medallions):
|
||||||
|
ret.required_medallions[index] = {"ether": "Ether", "quake": "Quake", "bombos": "Bombos", "random": "random"} \
|
||||||
|
.get(medallion.lower(), None)
|
||||||
|
if not ret.required_medallions[index]:
|
||||||
|
raise Exception(f"unknown Medallion {medallion} for {'misery mire' if index == 0 else 'turtle rock'}")
|
||||||
|
|
||||||
ret.plando_texts = {}
|
ret.plando_texts = {}
|
||||||
if PlandoOptions.texts in plando_options:
|
if PlandoSettings.texts in plando_options:
|
||||||
tt = TextTable()
|
tt = TextTable()
|
||||||
tt.removeUnwantedText()
|
tt.removeUnwantedText()
|
||||||
options = weights.get("plando_texts", [])
|
options = weights.get("plando_texts", [])
|
||||||
@@ -516,6 +696,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 PlandoSettings.connections in plando_options:
|
||||||
|
options = weights.get("plando_connections", [])
|
||||||
|
for placement in options:
|
||||||
|
if roll_percentage(get_choice_legacy("percentage", placement, 100)):
|
||||||
|
ret.plando_connections.append(PlandoConnection(
|
||||||
|
get_choice_legacy("entrance", placement),
|
||||||
|
get_choice_legacy("exit", placement),
|
||||||
|
get_choice_legacy("direction", placement, "both")
|
||||||
|
))
|
||||||
|
|
||||||
ret.sprite_pool = weights.get('sprite_pool', [])
|
ret.sprite_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 +734,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)
|
||||||
|
|||||||
@@ -1,8 +0,0 @@
|
|||||||
import ModuleUpdate
|
|
||||||
import Utils
|
|
||||||
from worlds.kh2.Client import launch
|
|
||||||
ModuleUpdate.update()
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
Utils.init_logging("KH2Client", exception_logger="Client")
|
|
||||||
launch()
|
|
||||||
296
Launcher.py
296
Launcher.py
@@ -10,32 +10,20 @@ Scroll down to components= to add components to the launcher as well as setup.py
|
|||||||
|
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import itertools
|
|
||||||
import logging
|
|
||||||
import multiprocessing
|
|
||||||
import shlex
|
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
import webbrowser
|
|
||||||
from os.path import isfile
|
from os.path import isfile
|
||||||
from shutil import which
|
import sys
|
||||||
from typing import Sequence, Union, Optional
|
from typing import Iterable, Sequence, Callable, Union, Optional
|
||||||
|
import subprocess
|
||||||
import Utils
|
import itertools
|
||||||
import settings
|
from Utils import is_frozen, user_path, local_path, init_logging, open_filename, messagebox,\
|
||||||
from worlds.LauncherComponents import Component, components, Type, SuffixIdentifier, icon_paths
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
import ModuleUpdate
|
|
||||||
ModuleUpdate.update()
|
|
||||||
|
|
||||||
from Utils import is_frozen, user_path, local_path, init_logging, open_filename, messagebox, \
|
|
||||||
is_windows, is_macos, is_linux
|
is_windows, is_macos, is_linux
|
||||||
|
from shutil import which
|
||||||
|
import shlex
|
||||||
|
from enum import Enum, auto
|
||||||
|
|
||||||
|
|
||||||
def open_host_yaml():
|
def open_host_yaml():
|
||||||
file = settings.get_settings().filename
|
file = user_path('host.yaml')
|
||||||
assert file, "host.yaml missing"
|
|
||||||
if is_linux:
|
if is_linux:
|
||||||
exe = which('sensible-editor') or which('gedit') or \
|
exe = which('sensible-editor') or which('gedit') or \
|
||||||
which('xdg-open') or which('gnome-open') or which('kde-open')
|
which('xdg-open') or which('gnome-open') or which('kde-open')
|
||||||
@@ -44,103 +32,160 @@ def open_host_yaml():
|
|||||||
exe = which("open")
|
exe = which("open")
|
||||||
subprocess.Popen([exe, file])
|
subprocess.Popen([exe, file])
|
||||||
else:
|
else:
|
||||||
|
import webbrowser
|
||||||
webbrowser.open(file)
|
webbrowser.open(file)
|
||||||
|
|
||||||
|
|
||||||
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():
|
|
||||||
from Options import generate_yaml_templates
|
|
||||||
|
|
||||||
target = Utils.user_path("Players", "Templates")
|
|
||||||
generate_yaml_templates(target, False)
|
|
||||||
open_folder(target)
|
|
||||||
|
|
||||||
|
|
||||||
def browse_files():
|
def browse_files():
|
||||||
open_folder(user_path())
|
file = user_path()
|
||||||
|
|
||||||
|
|
||||||
def open_folder(folder_path):
|
|
||||||
if is_linux:
|
if is_linux:
|
||||||
exe = which('xdg-open') or which('gnome-open') or which('kde-open')
|
exe = which('xdg-open') or which('gnome-open') or which('kde-open')
|
||||||
subprocess.Popen([exe, folder_path])
|
subprocess.Popen([exe, file])
|
||||||
elif is_macos:
|
elif is_macos:
|
||||||
exe = which("open")
|
exe = which("open")
|
||||||
subprocess.Popen([exe, folder_path])
|
subprocess.Popen([exe, file])
|
||||||
else:
|
else:
|
||||||
webbrowser.open(folder_path)
|
import webbrowser
|
||||||
|
webbrowser.open(file)
|
||||||
|
|
||||||
|
|
||||||
def update_settings():
|
class Type(Enum):
|
||||||
from settings import get_settings
|
TOOL = auto()
|
||||||
get_settings().save()
|
FUNC = auto() # not a real component
|
||||||
|
CLIENT = auto()
|
||||||
|
ADJUSTER = auto()
|
||||||
|
|
||||||
|
|
||||||
components.extend([
|
class SuffixIdentifier:
|
||||||
|
suffixes: Iterable[str]
|
||||||
|
|
||||||
|
def __init__(self, *args: str):
|
||||||
|
self.suffixes = args
|
||||||
|
|
||||||
|
def __call__(self, path: str):
|
||||||
|
if isinstance(path, str):
|
||||||
|
for suffix in self.suffixes:
|
||||||
|
if path.endswith(suffix):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
class Component:
|
||||||
|
display_name: str
|
||||||
|
type: Optional[Type]
|
||||||
|
script_name: Optional[str]
|
||||||
|
frozen_name: Optional[str]
|
||||||
|
icon: str # just the name, no suffix
|
||||||
|
cli: bool
|
||||||
|
func: Optional[Callable]
|
||||||
|
file_identifier: Optional[Callable[[str], bool]]
|
||||||
|
|
||||||
|
def __init__(self, display_name: str, script_name: Optional[str] = None, frozen_name: Optional[str] = None,
|
||||||
|
cli: bool = False, icon: str = 'icon', component_type: Type = None, func: Optional[Callable] = None,
|
||||||
|
file_identifier: Optional[Callable[[str], bool]] = None):
|
||||||
|
self.display_name = display_name
|
||||||
|
self.script_name = script_name
|
||||||
|
self.frozen_name = frozen_name or f'Archipelago{script_name}' if script_name else None
|
||||||
|
self.icon = icon
|
||||||
|
self.cli = cli
|
||||||
|
self.type = component_type or \
|
||||||
|
None if not display_name else \
|
||||||
|
Type.FUNC if func else \
|
||||||
|
Type.CLIENT if 'Client' in display_name else \
|
||||||
|
Type.ADJUSTER if 'Adjuster' in display_name else Type.TOOL
|
||||||
|
self.func = func
|
||||||
|
self.file_identifier = file_identifier
|
||||||
|
|
||||||
|
def handles_file(self, path: str):
|
||||||
|
return self.file_identifier(path) if self.file_identifier else False
|
||||||
|
|
||||||
|
|
||||||
|
components: Iterable[Component] = (
|
||||||
|
# Launcher
|
||||||
|
Component('', 'Launcher'),
|
||||||
|
# Core
|
||||||
|
Component('Host', 'MultiServer', 'ArchipelagoServer', cli=True,
|
||||||
|
file_identifier=SuffixIdentifier('.archipelago', '.zip')),
|
||||||
|
Component('Generate', 'Generate', cli=True),
|
||||||
|
Component('Text Client', 'CommonClient', 'ArchipelagoTextClient'),
|
||||||
|
# SNI
|
||||||
|
Component('SNI Client', 'SNIClient',
|
||||||
|
file_identifier=SuffixIdentifier('.apz3', '.apm3', '.apsoe', '.aplttp', '.apsm', '.apsmz3', '.apdkc3')),
|
||||||
|
Component('LttP Adjuster', 'LttPAdjuster'),
|
||||||
|
# Factorio
|
||||||
|
Component('Factorio Client', 'FactorioClient'),
|
||||||
|
# Minecraft
|
||||||
|
Component('Minecraft Client', 'MinecraftClient', icon='mcicon', cli=True,
|
||||||
|
file_identifier=SuffixIdentifier('.apmc')),
|
||||||
|
# Ocarina of Time
|
||||||
|
Component('OoT Client', 'OoTClient',
|
||||||
|
file_identifier=SuffixIdentifier('.apz5')),
|
||||||
|
Component('OoT Adjuster', 'OoTAdjuster'),
|
||||||
|
# FF1
|
||||||
|
Component('FF1 Client', 'FF1Client'),
|
||||||
|
# ChecksFinder
|
||||||
|
Component('ChecksFinder Client', 'ChecksFinderClient'),
|
||||||
|
# Starcraft 2
|
||||||
|
Component('Starcraft 2 Client', 'Starcraft2Client'),
|
||||||
# Functions
|
# 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('Browse Files', func=browse_files),
|
||||||
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")),
|
icon_paths = {
|
||||||
Component("Browse Files", func=browse_files),
|
'icon': local_path('data', 'icon.ico' if is_windows else 'icon.png'),
|
||||||
])
|
'mcicon': local_path('data', 'mcicon.ico')
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def identify(path: Union[None, str]):
|
def identify(path: Union[None, str]):
|
||||||
if path is None:
|
if path is None:
|
||||||
return None, None
|
return None, 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.script_name, component
|
||||||
elif path == component.display_name or path == component.script_name:
|
return (None, None, None) if '/' in path or '\\' in path else (None, path, None)
|
||||||
return None, component
|
|
||||||
return None, None
|
|
||||||
|
|
||||||
|
|
||||||
def get_exe(component: Union[str, Component]) -> Optional[Sequence[str]]:
|
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,19 +206,17 @@ 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.relativelayout import RelativeLayout
|
|
||||||
|
|
||||||
class Launcher(App):
|
class Launcher(App):
|
||||||
base_title: str = "Archipelago Launcher"
|
base_title: str = "Archipelago Launcher"
|
||||||
container: ContainerLayout
|
container: ContainerLayout
|
||||||
grid: GridLayout
|
grid: GridLayout
|
||||||
|
|
||||||
_tools = {c.display_name: c for c in components if c.type == Type.TOOL}
|
_tools = {c.display_name: c for c in components if c.type == Type.TOOL and isfile(get_exe(c)[-1])}
|
||||||
_clients = {c.display_name: c for c in components if c.type == Type.CLIENT}
|
_clients = {c.display_name: c for c in components if c.type == Type.CLIENT and isfile(get_exe(c)[-1])}
|
||||||
_adjusters = {c.display_name: c for c in components if c.type == Type.ADJUSTER}
|
_adjusters = {c.display_name: c for c in components if c.type == Type.ADJUSTER and isfile(get_exe(c)[-1])}
|
||||||
_miscs = {c.display_name: c for c in components if c.type == Type.MISC}
|
_funcs = {c.display_name: c for c in components if c.type == Type.FUNC}
|
||||||
|
|
||||||
def __init__(self, ctx=None):
|
def __init__(self, ctx=None):
|
||||||
self.title = self.base_title
|
self.title = self.base_title
|
||||||
@@ -185,115 +228,64 @@ 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="Clients", size_hint_y=None, height=40))
|
|
||||||
tool_layout = ScrollBox()
|
|
||||||
tool_layout.layout.orientation = "vertical"
|
|
||||||
self.grid.add_widget(tool_layout)
|
|
||||||
client_layout = ScrollBox()
|
|
||||||
client_layout.layout.orientation = "vertical"
|
|
||||||
self.grid.add_widget(client_layout)
|
|
||||||
|
|
||||||
def build_button(component: Component) -> Widget:
|
|
||||||
"""
|
|
||||||
Builds a button widget for a given component.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
component (Component): The component associated with the button.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
None. The button is added to the parent grid layout.
|
|
||||||
|
|
||||||
"""
|
|
||||||
button = Button(text=component.display_name, size_hint_y=None, height=40)
|
|
||||||
button.component = component
|
|
||||||
button.bind(on_release=self.component_action)
|
|
||||||
if component.icon != "icon":
|
|
||||||
image = AsyncImage(source=icon_paths[component.icon],
|
|
||||||
size=(38, 38), size_hint=(None, 1), pos=(5, 0))
|
|
||||||
box_layout = RelativeLayout(size_hint_y=None, height=40)
|
|
||||||
box_layout.add_widget(button)
|
|
||||||
box_layout.add_widget(image)
|
|
||||||
return box_layout
|
|
||||||
return button
|
|
||||||
|
|
||||||
|
button_layout = self.grid # make buttons fill the window
|
||||||
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._funcs.items(), self._adjusters.items()), self._clients.items()):
|
||||||
# column 1
|
# column 1
|
||||||
if tool:
|
if tool:
|
||||||
tool_layout.layout.add_widget(build_button(tool[1]))
|
button = Button(text=tool[0])
|
||||||
|
button.component = tool[1]
|
||||||
|
button.bind(on_release=self.component_action)
|
||||||
|
button_layout.add_widget(button)
|
||||||
|
else:
|
||||||
|
button_layout.add_widget(Label())
|
||||||
# column 2
|
# column 2
|
||||||
if client:
|
if client:
|
||||||
client_layout.layout.add_widget(build_button(client[1]))
|
button = Button(text=client[0])
|
||||||
|
button.component = client[1]
|
||||||
|
button.bind(on_press=self.component_action)
|
||||||
|
button_layout.add_widget(button)
|
||||||
|
else:
|
||||||
|
button_layout.add_widget(Label())
|
||||||
|
|
||||||
return self.container
|
return self.container
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def component_action(button):
|
def component_action(button):
|
||||||
if button.component.func:
|
if button.component.type == Type.FUNC:
|
||||||
button.component.func()
|
button.component.func()
|
||||||
else:
|
else:
|
||||||
launch(get_exe(button.component), button.component.cli)
|
launch(get_exe(button.component), button.component.cli)
|
||||||
|
|
||||||
def _stop(self, *largs):
|
|
||||||
# ran into what appears to be https://groups.google.com/g/kivy-users/c/saWDLoYCSZ4 with PyCharm.
|
|
||||||
# Closing the window explicitly cleans it up.
|
|
||||||
self.root_window.close()
|
|
||||||
super()._stop(*largs)
|
|
||||||
|
|
||||||
Launcher().run()
|
Launcher().run()
|
||||||
|
|
||||||
|
|
||||||
def run_component(component: Component, *args):
|
|
||||||
if component.func:
|
|
||||||
component.func(*args)
|
|
||||||
elif component.script_name:
|
|
||||||
subprocess.run([*get_exe(component.script_name), *args])
|
|
||||||
else:
|
|
||||||
logging.warning(f"Component {component} does not appear to be executable.")
|
|
||||||
|
|
||||||
|
|
||||||
def main(args: Optional[Union[argparse.Namespace, dict]] = None):
|
def main(args: Optional[Union[argparse.Namespace, dict]] = None):
|
||||||
if isinstance(args, argparse.Namespace):
|
if isinstance(args, argparse.Namespace):
|
||||||
args = {k: v for k, v in args._get_kwargs()}
|
args = {k: v for k, v in args._get_kwargs()}
|
||||||
elif not args:
|
elif not args:
|
||||||
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
|
||||||
if component:
|
if component:
|
||||||
args['component'] = component
|
args['component'] = component
|
||||||
if not component:
|
|
||||||
logging.warning(f"Could not identify Component responsible for {args['Patch|Game|Component']}")
|
|
||||||
|
|
||||||
if args["update_settings"]:
|
|
||||||
update_settings()
|
|
||||||
if 'file' in args:
|
if 'file' in args:
|
||||||
run_component(args["component"], args["file"], *args["args"])
|
subprocess.run([*get_exe(args['component']), args['file'], *args['args']])
|
||||||
elif 'component' in args:
|
elif 'component' in args:
|
||||||
run_component(args["component"], *args["args"])
|
subprocess.run([*get_exe(args['component']), *args['args']])
|
||||||
elif not args["update_settings"]:
|
else:
|
||||||
run_gui()
|
run_gui()
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
init_logging('Launcher')
|
init_logging('Launcher')
|
||||||
Utils.freeze_support()
|
|
||||||
multiprocessing.set_start_method("spawn") # if launched process uses kivy, fork won't work
|
|
||||||
parser = argparse.ArgumentParser(description='Archipelago Launcher')
|
parser = argparse.ArgumentParser(description='Archipelago Launcher')
|
||||||
run_group = parser.add_argument_group("Run")
|
parser.add_argument('Patch|Game|Component', type=str, nargs='?',
|
||||||
run_group.add_argument("--update_settings", action="store_true",
|
help="Pass either a patch file, a generated game or the name of a component to run.")
|
||||||
help="Update host.yaml and exit.")
|
parser.add_argument('args', nargs="*", help="Arguments to pass to component.")
|
||||||
run_group.add_argument("Patch|Game|Component", type=str, nargs="?",
|
|
||||||
help="Pass either a patch file, a generated game or the name of a component to run.")
|
|
||||||
run_group.add_argument("args", nargs="*",
|
|
||||||
help="Arguments to pass to component.")
|
|
||||||
main(parser.parse_args())
|
main(parser.parse_args())
|
||||||
|
|
||||||
from worlds.LauncherComponents import processes
|
|
||||||
for process in processes:
|
|
||||||
# we await all child processes to close before we tear down the process host
|
|
||||||
# this makes it feel like each one is its own program, as the Launcher is closed now
|
|
||||||
process.join()
|
|
||||||
|
|||||||
@@ -1,701 +0,0 @@
|
|||||||
import ModuleUpdate
|
|
||||||
ModuleUpdate.update()
|
|
||||||
|
|
||||||
import Utils
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
Utils.init_logging("LinksAwakeningContext", exception_logger="Client")
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import base64
|
|
||||||
import binascii
|
|
||||||
import colorama
|
|
||||||
import io
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
import select
|
|
||||||
import shlex
|
|
||||||
import socket
|
|
||||||
import struct
|
|
||||||
import sys
|
|
||||||
import subprocess
|
|
||||||
import time
|
|
||||||
import typing
|
|
||||||
|
|
||||||
|
|
||||||
from CommonClient import (CommonContext, get_base_parser, gui_enabled, logger,
|
|
||||||
server_loop)
|
|
||||||
from NetUtils import ClientStatus
|
|
||||||
from worlds.ladx.Common import BASE_ID as LABaseID
|
|
||||||
from worlds.ladx.GpsTracker import GpsTracker
|
|
||||||
from worlds.ladx.ItemTracker import ItemTracker
|
|
||||||
from worlds.ladx.LADXR.checkMetadata import checkMetadataTable
|
|
||||||
from worlds.ladx.Locations import get_locations_to_id, meta_to_name
|
|
||||||
from worlds.ladx.Tracker import LocationTracker, MagpieBridge
|
|
||||||
|
|
||||||
|
|
||||||
class GameboyException(Exception):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class RetroArchDisconnectError(GameboyException):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class InvalidEmulatorStateError(GameboyException):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class BadRetroArchResponse(GameboyException):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def magpie_logo():
|
|
||||||
from kivy.uix.image import CoreImage
|
|
||||||
binary_data = """
|
|
||||||
iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAIAAACQkWg2AAAAAXN
|
|
||||||
SR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA
|
|
||||||
7DAcdvqGQAAADGSURBVDhPhVLBEcIwDHOYhjHCBuXHj2OTbAL8+
|
|
||||||
MEGZIxOQ1CinOOk0Op0bmo7tlXXeR9FJMYDLOD9mwcLjQK7+hSZ
|
|
||||||
wgcWMZJOAGeGKtChNHFL0j+FZD3jSCuo0w7l03wDrWdg00C4/aW
|
|
||||||
eDEYNenuzPOfPspBnxf0kssE80vN0L8361j10P03DK4x6FHabuV
|
|
||||||
ear8fHme+b17rwSjbAXeUMLb+EVTV2QHm46MWQanmnydA98KsVS
|
|
||||||
XkV+qFpGQXrLhT/fqraQeQLuplpNH5g+WkAAAAASUVORK5CYII="""
|
|
||||||
binary_data = base64.b64decode(binary_data)
|
|
||||||
data = io.BytesIO(binary_data)
|
|
||||||
return CoreImage(data, ext="png").texture
|
|
||||||
|
|
||||||
|
|
||||||
class LAClientConstants:
|
|
||||||
# Connector version
|
|
||||||
VERSION = 0x01
|
|
||||||
#
|
|
||||||
# Memory locations of LADXR
|
|
||||||
ROMGameID = 0x0051 # 4 bytes
|
|
||||||
SlotName = 0x0134
|
|
||||||
# Unused
|
|
||||||
# ROMWorldID = 0x0055
|
|
||||||
# ROMConnectorVersion = 0x0056
|
|
||||||
# RO: We should only act if this is higher then 6, as it indicates that the game is running normally
|
|
||||||
wGameplayType = 0xDB95
|
|
||||||
# RO: Starts at 0, increases every time an item is received from the server and processed
|
|
||||||
wLinkSyncSequenceNumber = 0xDDF6
|
|
||||||
wLinkStatusBits = 0xDDF7 # RW:
|
|
||||||
# Bit0: wLinkGive* contains valid data, set from script cleared from ROM.
|
|
||||||
wLinkHealth = 0xDB5A
|
|
||||||
wLinkGiveItem = 0xDDF8 # RW
|
|
||||||
wLinkGiveItemFrom = 0xDDF9 # RW
|
|
||||||
# All of these six bytes are unused, we can repurpose
|
|
||||||
# wLinkSendItemRoomHigh = 0xDDFA # RO
|
|
||||||
# wLinkSendItemRoomLow = 0xDDFB # RO
|
|
||||||
# wLinkSendItemTarget = 0xDDFC # RO
|
|
||||||
# wLinkSendItemItem = 0xDDFD # RO
|
|
||||||
# wLinkSendShopItem = 0xDDFE # RO, which item to send (1 based, order of the shop items)
|
|
||||||
# RO, which player to send to, but it's just the X position of the NPC used, so 0x18 is player 0
|
|
||||||
# wLinkSendShopTarget = 0xDDFF
|
|
||||||
|
|
||||||
|
|
||||||
wRecvIndex = 0xDDFD # Two bytes
|
|
||||||
wCheckAddress = 0xC0FF - 0x4
|
|
||||||
WRamCheckSize = 0x4
|
|
||||||
WRamSafetyValue = bytearray([0]*WRamCheckSize)
|
|
||||||
|
|
||||||
MinGameplayValue = 0x06
|
|
||||||
MaxGameplayValue = 0x1A
|
|
||||||
VictoryGameplayAndSub = 0x0102
|
|
||||||
|
|
||||||
|
|
||||||
class RAGameboy():
|
|
||||||
cache = []
|
|
||||||
cache_start = 0
|
|
||||||
cache_size = 0
|
|
||||||
last_cache_read = None
|
|
||||||
socket = None
|
|
||||||
|
|
||||||
def __init__(self, address, port) -> None:
|
|
||||||
self.address = address
|
|
||||||
self.port = port
|
|
||||||
self.socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
||||||
assert (self.socket)
|
|
||||||
self.socket.setblocking(False)
|
|
||||||
|
|
||||||
async def send_command(self, command, timeout=1.0):
|
|
||||||
self.send(f'{command}\n')
|
|
||||||
response_str = await self.async_recv()
|
|
||||||
self.check_command_response(command, response_str)
|
|
||||||
return response_str.rstrip()
|
|
||||||
|
|
||||||
async def get_retroarch_version(self):
|
|
||||||
return await self.send_command("VERSION")
|
|
||||||
|
|
||||||
async def get_retroarch_status(self):
|
|
||||||
return await self.send_command("GET_STATUS")
|
|
||||||
|
|
||||||
def set_cache_limits(self, cache_start, cache_size):
|
|
||||||
self.cache_start = cache_start
|
|
||||||
self.cache_size = cache_size
|
|
||||||
|
|
||||||
def send(self, b):
|
|
||||||
if type(b) is str:
|
|
||||||
b = b.encode('ascii')
|
|
||||||
self.socket.sendto(b, (self.address, self.port))
|
|
||||||
|
|
||||||
def recv(self):
|
|
||||||
select.select([self.socket], [], [])
|
|
||||||
response, _ = self.socket.recvfrom(4096)
|
|
||||||
return response
|
|
||||||
|
|
||||||
async def async_recv(self, timeout=1.0):
|
|
||||||
response = await asyncio.wait_for(asyncio.get_event_loop().sock_recv(self.socket, 4096), timeout)
|
|
||||||
return response
|
|
||||||
|
|
||||||
async def check_safe_gameplay(self, throw=True):
|
|
||||||
async def check_wram():
|
|
||||||
check_values = await self.async_read_memory(LAClientConstants.wCheckAddress, LAClientConstants.WRamCheckSize)
|
|
||||||
|
|
||||||
if check_values != LAClientConstants.WRamSafetyValue:
|
|
||||||
if throw:
|
|
||||||
raise InvalidEmulatorStateError()
|
|
||||||
return False
|
|
||||||
return True
|
|
||||||
|
|
||||||
if not await check_wram():
|
|
||||||
if throw:
|
|
||||||
raise InvalidEmulatorStateError()
|
|
||||||
return False
|
|
||||||
|
|
||||||
gameplay_value = await self.async_read_memory(LAClientConstants.wGameplayType)
|
|
||||||
gameplay_value = gameplay_value[0]
|
|
||||||
# In gameplay or credits
|
|
||||||
if not (LAClientConstants.MinGameplayValue <= gameplay_value <= LAClientConstants.MaxGameplayValue) and gameplay_value != 0x1:
|
|
||||||
if throw:
|
|
||||||
logger.info("invalid emu state")
|
|
||||||
raise InvalidEmulatorStateError()
|
|
||||||
return False
|
|
||||||
if not await check_wram():
|
|
||||||
if throw:
|
|
||||||
raise InvalidEmulatorStateError()
|
|
||||||
return False
|
|
||||||
return True
|
|
||||||
|
|
||||||
# We're sadly unable to update the whole cache at once
|
|
||||||
# as RetroArch only gives back some number of bytes at a time
|
|
||||||
# So instead read as big as chunks at a time as we can manage
|
|
||||||
async def update_cache(self):
|
|
||||||
# First read the safety address - if it's invalid, bail
|
|
||||||
self.cache = []
|
|
||||||
|
|
||||||
if not await self.check_safe_gameplay():
|
|
||||||
return
|
|
||||||
|
|
||||||
cache = []
|
|
||||||
remaining_size = self.cache_size
|
|
||||||
while remaining_size:
|
|
||||||
block = await self.async_read_memory(self.cache_start + len(cache), remaining_size)
|
|
||||||
remaining_size -= len(block)
|
|
||||||
cache += block
|
|
||||||
|
|
||||||
if not await self.check_safe_gameplay():
|
|
||||||
return
|
|
||||||
|
|
||||||
self.cache = cache
|
|
||||||
self.last_cache_read = time.time()
|
|
||||||
|
|
||||||
async def read_memory_cache(self, addresses):
|
|
||||||
# TODO: can we just update once per frame?
|
|
||||||
if not self.last_cache_read or self.last_cache_read + 0.1 < time.time():
|
|
||||||
await self.update_cache()
|
|
||||||
if not self.cache:
|
|
||||||
return None
|
|
||||||
assert (len(self.cache) == self.cache_size)
|
|
||||||
for address in addresses:
|
|
||||||
assert self.cache_start <= address <= self.cache_start + self.cache_size
|
|
||||||
r = {address: self.cache[address - self.cache_start]
|
|
||||||
for address in addresses}
|
|
||||||
return r
|
|
||||||
|
|
||||||
async def async_read_memory_safe(self, address, size=1):
|
|
||||||
# whenever we do a read for a check, we need to make sure that we aren't reading
|
|
||||||
# garbage memory values - we also need to protect against reading a value, then the emulator resetting
|
|
||||||
#
|
|
||||||
# ...actually, we probably _only_ need the post check
|
|
||||||
|
|
||||||
# Check before read
|
|
||||||
if not await self.check_safe_gameplay():
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Do read
|
|
||||||
r = await self.async_read_memory(address, size)
|
|
||||||
|
|
||||||
# Check after read
|
|
||||||
if not await self.check_safe_gameplay():
|
|
||||||
return None
|
|
||||||
|
|
||||||
return r
|
|
||||||
|
|
||||||
def check_command_response(self, command: str, response: bytes):
|
|
||||||
if command == "VERSION":
|
|
||||||
ok = re.match("\d+\.\d+\.\d+", response.decode('ascii')) is not None
|
|
||||||
else:
|
|
||||||
ok = response.startswith(command.encode())
|
|
||||||
if not ok:
|
|
||||||
logger.warning(f"Bad response to command {command} - {response}")
|
|
||||||
raise BadRetroArchResponse()
|
|
||||||
|
|
||||||
def read_memory(self, address, size=1):
|
|
||||||
command = "READ_CORE_MEMORY"
|
|
||||||
|
|
||||||
self.send(f'{command} {hex(address)} {size}\n')
|
|
||||||
response = self.recv()
|
|
||||||
|
|
||||||
self.check_command_response(command, response)
|
|
||||||
|
|
||||||
splits = response.decode().split(" ", 2)
|
|
||||||
# Ignore the address for now
|
|
||||||
if splits[2][:2] == "-1":
|
|
||||||
raise BadRetroArchResponse()
|
|
||||||
|
|
||||||
# TODO: check response address, check hex behavior between RA and BH
|
|
||||||
|
|
||||||
return bytearray.fromhex(splits[2])
|
|
||||||
|
|
||||||
async def async_read_memory(self, address, size=1):
|
|
||||||
command = "READ_CORE_MEMORY"
|
|
||||||
|
|
||||||
self.send(f'{command} {hex(address)} {size}\n')
|
|
||||||
response = await self.async_recv()
|
|
||||||
self.check_command_response(command, response)
|
|
||||||
response = response[:-1]
|
|
||||||
splits = response.decode().split(" ", 2)
|
|
||||||
try:
|
|
||||||
response_addr = int(splits[1], 16)
|
|
||||||
except ValueError:
|
|
||||||
raise BadRetroArchResponse()
|
|
||||||
|
|
||||||
if response_addr != address:
|
|
||||||
raise BadRetroArchResponse()
|
|
||||||
|
|
||||||
ret = bytearray.fromhex(splits[2])
|
|
||||||
if len(ret) > size:
|
|
||||||
raise BadRetroArchResponse()
|
|
||||||
return ret
|
|
||||||
|
|
||||||
def write_memory(self, address, bytes):
|
|
||||||
command = "WRITE_CORE_MEMORY"
|
|
||||||
|
|
||||||
self.send(f'{command} {hex(address)} {" ".join(hex(b) for b in bytes)}')
|
|
||||||
select.select([self.socket], [], [])
|
|
||||||
response, _ = self.socket.recvfrom(4096)
|
|
||||||
self.check_command_response(command, response)
|
|
||||||
splits = response.decode().split(" ", 3)
|
|
||||||
|
|
||||||
assert (splits[0] == command)
|
|
||||||
|
|
||||||
if splits[2] == "-1":
|
|
||||||
logger.info(splits[3])
|
|
||||||
|
|
||||||
|
|
||||||
class LinksAwakeningClient():
|
|
||||||
socket = None
|
|
||||||
gameboy = None
|
|
||||||
tracker = None
|
|
||||||
auth = None
|
|
||||||
game_crc = None
|
|
||||||
pending_deathlink = False
|
|
||||||
deathlink_debounce = True
|
|
||||||
recvd_checks = {}
|
|
||||||
retroarch_address = None
|
|
||||||
retroarch_port = None
|
|
||||||
gameboy = None
|
|
||||||
|
|
||||||
def msg(self, m):
|
|
||||||
logger.info(m)
|
|
||||||
s = f"SHOW_MSG {m}\n"
|
|
||||||
self.gameboy.send(s)
|
|
||||||
|
|
||||||
def __init__(self, retroarch_address="127.0.0.1", retroarch_port=55355):
|
|
||||||
self.retroarch_address = retroarch_address
|
|
||||||
self.retroarch_port = retroarch_port
|
|
||||||
pass
|
|
||||||
|
|
||||||
stop_bizhawk_spam = False
|
|
||||||
async def wait_for_retroarch_connection(self):
|
|
||||||
if not self.stop_bizhawk_spam:
|
|
||||||
logger.info("Waiting on connection to Retroarch...")
|
|
||||||
self.stop_bizhawk_spam = True
|
|
||||||
self.gameboy = RAGameboy(self.retroarch_address, self.retroarch_port)
|
|
||||||
|
|
||||||
while True:
|
|
||||||
try:
|
|
||||||
version = await self.gameboy.get_retroarch_version()
|
|
||||||
NO_CONTENT = b"GET_STATUS CONTENTLESS"
|
|
||||||
status = NO_CONTENT
|
|
||||||
core_type = None
|
|
||||||
GAME_BOY = b"game_boy"
|
|
||||||
while status == NO_CONTENT or core_type != GAME_BOY:
|
|
||||||
status = await self.gameboy.get_retroarch_status()
|
|
||||||
if status.count(b" ") < 2:
|
|
||||||
await asyncio.sleep(1.0)
|
|
||||||
continue
|
|
||||||
GET_STATUS, PLAYING, info = status.split(b" ", 2)
|
|
||||||
if status.count(b",") < 2:
|
|
||||||
await asyncio.sleep(1.0)
|
|
||||||
continue
|
|
||||||
core_type, rom_name, self.game_crc = info.split(b",", 2)
|
|
||||||
if core_type != GAME_BOY:
|
|
||||||
logger.info(
|
|
||||||
f"Core type should be '{GAME_BOY}', found {core_type} instead - wrong type of ROM?")
|
|
||||||
await asyncio.sleep(1.0)
|
|
||||||
continue
|
|
||||||
self.stop_bizhawk_spam = False
|
|
||||||
logger.info(f"Connected to Retroarch {version.decode('ascii', errors='replace')} "
|
|
||||||
f"running {rom_name.decode('ascii', errors='replace')}")
|
|
||||||
return
|
|
||||||
except (BlockingIOError, TimeoutError, ConnectionResetError):
|
|
||||||
await asyncio.sleep(1.0)
|
|
||||||
pass
|
|
||||||
|
|
||||||
async def reset_auth(self):
|
|
||||||
auth = binascii.hexlify(await self.gameboy.async_read_memory(0x0134, 12)).decode()
|
|
||||||
self.auth = auth
|
|
||||||
|
|
||||||
async def wait_and_init_tracker(self):
|
|
||||||
await self.wait_for_game_ready()
|
|
||||||
self.tracker = LocationTracker(self.gameboy)
|
|
||||||
self.item_tracker = ItemTracker(self.gameboy)
|
|
||||||
self.gps_tracker = GpsTracker(self.gameboy)
|
|
||||||
|
|
||||||
async def recved_item_from_ap(self, item_id, from_player, next_index):
|
|
||||||
# Don't allow getting an item until you've got your first check
|
|
||||||
if not self.tracker.has_start_item():
|
|
||||||
return
|
|
||||||
|
|
||||||
# Spin until we either:
|
|
||||||
# get an exception from a bad read (emu shut down or reset)
|
|
||||||
# beat the game
|
|
||||||
# the client handles the last pending item
|
|
||||||
status = (await self.gameboy.async_read_memory_safe(LAClientConstants.wLinkStatusBits))[0]
|
|
||||||
while not (await self.is_victory()) and status & 1 == 1:
|
|
||||||
time.sleep(0.1)
|
|
||||||
status = (await self.gameboy.async_read_memory_safe(LAClientConstants.wLinkStatusBits))[0]
|
|
||||||
|
|
||||||
item_id -= LABaseID
|
|
||||||
# The player name table only goes up to 100, so don't go past that
|
|
||||||
# Even if it didn't, the remote player _index_ byte is just a byte, so 255 max
|
|
||||||
if from_player > 100:
|
|
||||||
from_player = 100
|
|
||||||
|
|
||||||
next_index += 1
|
|
||||||
self.gameboy.write_memory(LAClientConstants.wLinkGiveItem, [
|
|
||||||
item_id, from_player])
|
|
||||||
status |= 1
|
|
||||||
status = self.gameboy.write_memory(LAClientConstants.wLinkStatusBits, [status])
|
|
||||||
self.gameboy.write_memory(LAClientConstants.wRecvIndex, struct.pack(">H", next_index))
|
|
||||||
|
|
||||||
should_reset_auth = False
|
|
||||||
async def wait_for_game_ready(self):
|
|
||||||
logger.info("Waiting on game to be in valid state...")
|
|
||||||
while not await self.gameboy.check_safe_gameplay(throw=False):
|
|
||||||
if self.should_reset_auth:
|
|
||||||
self.should_reset_auth = False
|
|
||||||
raise GameboyException("Resetting due to wrong archipelago server")
|
|
||||||
logger.info("Game connection ready!")
|
|
||||||
|
|
||||||
async def is_victory(self):
|
|
||||||
return (await self.gameboy.read_memory_cache([LAClientConstants.wGameplayType]))[LAClientConstants.wGameplayType] == 1
|
|
||||||
|
|
||||||
async def main_tick(self, item_get_cb, win_cb, deathlink_cb):
|
|
||||||
await self.tracker.readChecks(item_get_cb)
|
|
||||||
await self.item_tracker.readItems()
|
|
||||||
await self.gps_tracker.read_location()
|
|
||||||
|
|
||||||
current_health = (await self.gameboy.read_memory_cache([LAClientConstants.wLinkHealth]))[LAClientConstants.wLinkHealth]
|
|
||||||
if self.deathlink_debounce and current_health != 0:
|
|
||||||
self.deathlink_debounce = False
|
|
||||||
elif not self.deathlink_debounce and current_health == 0:
|
|
||||||
# logger.info("YOU DIED.")
|
|
||||||
await deathlink_cb()
|
|
||||||
self.deathlink_debounce = True
|
|
||||||
|
|
||||||
if self.pending_deathlink:
|
|
||||||
logger.info("Got a deathlink")
|
|
||||||
self.gameboy.write_memory(LAClientConstants.wLinkHealth, [0])
|
|
||||||
self.pending_deathlink = False
|
|
||||||
self.deathlink_debounce = True
|
|
||||||
|
|
||||||
if await self.is_victory():
|
|
||||||
await win_cb()
|
|
||||||
|
|
||||||
recv_index = struct.unpack(">H", await self.gameboy.async_read_memory(LAClientConstants.wRecvIndex, 2))[0]
|
|
||||||
|
|
||||||
# Play back one at a time
|
|
||||||
if recv_index in self.recvd_checks:
|
|
||||||
item = self.recvd_checks[recv_index]
|
|
||||||
await self.recved_item_from_ap(item.item, item.player, recv_index)
|
|
||||||
|
|
||||||
|
|
||||||
all_tasks = set()
|
|
||||||
|
|
||||||
def create_task_log_exception(awaitable) -> asyncio.Task:
|
|
||||||
async def _log_exception(awaitable):
|
|
||||||
try:
|
|
||||||
return await awaitable
|
|
||||||
except Exception as e:
|
|
||||||
logger.exception(e)
|
|
||||||
pass
|
|
||||||
finally:
|
|
||||||
all_tasks.remove(task)
|
|
||||||
task = asyncio.create_task(_log_exception(awaitable))
|
|
||||||
all_tasks.add(task)
|
|
||||||
|
|
||||||
|
|
||||||
class LinksAwakeningContext(CommonContext):
|
|
||||||
tags = {"AP"}
|
|
||||||
game = "Links Awakening DX"
|
|
||||||
items_handling = 0b101
|
|
||||||
want_slot_data = True
|
|
||||||
la_task = None
|
|
||||||
client = None
|
|
||||||
# TODO: does this need to re-read on reset?
|
|
||||||
found_checks = []
|
|
||||||
last_resend = time.time()
|
|
||||||
|
|
||||||
magpie_enabled = False
|
|
||||||
magpie = None
|
|
||||||
magpie_task = None
|
|
||||||
won = False
|
|
||||||
|
|
||||||
def __init__(self, server_address: typing.Optional[str], password: typing.Optional[str], magpie: typing.Optional[bool]) -> None:
|
|
||||||
self.client = LinksAwakeningClient()
|
|
||||||
if magpie:
|
|
||||||
self.magpie_enabled = True
|
|
||||||
self.magpie = MagpieBridge()
|
|
||||||
super().__init__(server_address, password)
|
|
||||||
|
|
||||||
def run_gui(self) -> None:
|
|
||||||
import webbrowser
|
|
||||||
import kvui
|
|
||||||
from kvui import Button, GameManager
|
|
||||||
from kivy.uix.image import Image
|
|
||||||
|
|
||||||
class LADXManager(GameManager):
|
|
||||||
logging_pairs = [
|
|
||||||
("Client", "Archipelago"),
|
|
||||||
("Tracker", "Tracker"),
|
|
||||||
]
|
|
||||||
base_title = "Archipelago Links Awakening DX Client"
|
|
||||||
|
|
||||||
def build(self):
|
|
||||||
b = super().build()
|
|
||||||
|
|
||||||
if self.ctx.magpie_enabled:
|
|
||||||
button = Button(text="", size=(30, 30), size_hint_x=None,
|
|
||||||
on_press=lambda _: webbrowser.open('https://magpietracker.us/?enable_autotracker=1'))
|
|
||||||
image = Image(size=(16, 16), texture=magpie_logo())
|
|
||||||
button.add_widget(image)
|
|
||||||
|
|
||||||
def set_center(_, center):
|
|
||||||
image.center = center
|
|
||||||
button.bind(center=set_center)
|
|
||||||
|
|
||||||
self.connect_layout.add_widget(button)
|
|
||||||
return b
|
|
||||||
|
|
||||||
self.ui = LADXManager(self)
|
|
||||||
self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI")
|
|
||||||
|
|
||||||
async def send_checks(self):
|
|
||||||
message = [{"cmd": 'LocationChecks', "locations": self.found_checks}]
|
|
||||||
await self.send_msgs(message)
|
|
||||||
|
|
||||||
had_invalid_slot_data = None
|
|
||||||
def event_invalid_slot(self):
|
|
||||||
# The next time we try to connect, reset the game loop for new auth
|
|
||||||
self.had_invalid_slot_data = True
|
|
||||||
self.auth = None
|
|
||||||
# Don't try to autoreconnect, it will just fail
|
|
||||||
self.disconnected_intentionally = True
|
|
||||||
CommonContext.event_invalid_slot(self)
|
|
||||||
|
|
||||||
ENABLE_DEATHLINK = False
|
|
||||||
async def send_deathlink(self):
|
|
||||||
if self.ENABLE_DEATHLINK:
|
|
||||||
message = [{"cmd": 'Deathlink',
|
|
||||||
'time': time.time(),
|
|
||||||
'cause': 'Had a nightmare',
|
|
||||||
# 'source': self.slot_info[self.slot].name,
|
|
||||||
}]
|
|
||||||
await self.send_msgs(message)
|
|
||||||
|
|
||||||
async def send_victory(self):
|
|
||||||
if not self.won:
|
|
||||||
message = [{"cmd": "StatusUpdate",
|
|
||||||
"status": ClientStatus.CLIENT_GOAL}]
|
|
||||||
logger.info("victory!")
|
|
||||||
await self.send_msgs(message)
|
|
||||||
self.won = True
|
|
||||||
|
|
||||||
async def on_deathlink(self, data: typing.Dict[str, typing.Any]) -> None:
|
|
||||||
if self.ENABLE_DEATHLINK:
|
|
||||||
self.client.pending_deathlink = True
|
|
||||||
|
|
||||||
def new_checks(self, item_ids, ladxr_ids):
|
|
||||||
self.found_checks += item_ids
|
|
||||||
create_task_log_exception(self.send_checks())
|
|
||||||
if self.magpie_enabled:
|
|
||||||
create_task_log_exception(self.magpie.send_new_checks(ladxr_ids))
|
|
||||||
|
|
||||||
async def server_auth(self, password_requested: bool = False):
|
|
||||||
if password_requested and not self.password:
|
|
||||||
await super(LinksAwakeningContext, self).server_auth(password_requested)
|
|
||||||
|
|
||||||
if self.had_invalid_slot_data:
|
|
||||||
# We are connecting when previously we had the wrong ROM or server - just in case
|
|
||||||
# re-read the ROM so that if the user had the correct address but wrong ROM, we
|
|
||||||
# allow a successful reconnect
|
|
||||||
self.client.should_reset_auth = True
|
|
||||||
self.had_invalid_slot_data = False
|
|
||||||
|
|
||||||
while self.client.auth == None:
|
|
||||||
await asyncio.sleep(0.1)
|
|
||||||
self.auth = self.client.auth
|
|
||||||
await self.send_connect()
|
|
||||||
|
|
||||||
def on_package(self, cmd: str, args: dict):
|
|
||||||
if cmd == "Connected":
|
|
||||||
self.game = self.slot_info[self.slot].game
|
|
||||||
# TODO - use watcher_event
|
|
||||||
if cmd == "ReceivedItems":
|
|
||||||
for index, item in enumerate(args["items"], start=args["index"]):
|
|
||||||
self.client.recvd_checks[index] = item
|
|
||||||
|
|
||||||
async def sync(self):
|
|
||||||
sync_msg = [{'cmd': 'Sync'}]
|
|
||||||
await self.send_msgs(sync_msg)
|
|
||||||
|
|
||||||
item_id_lookup = get_locations_to_id()
|
|
||||||
|
|
||||||
async def run_game_loop(self):
|
|
||||||
def on_item_get(ladxr_checks):
|
|
||||||
checks = [self.item_id_lookup[meta_to_name(
|
|
||||||
checkMetadataTable[check.id])] for check in ladxr_checks]
|
|
||||||
self.new_checks(checks, [check.id for check in ladxr_checks])
|
|
||||||
|
|
||||||
async def victory():
|
|
||||||
await self.send_victory()
|
|
||||||
|
|
||||||
async def deathlink():
|
|
||||||
await self.send_deathlink()
|
|
||||||
|
|
||||||
if self.magpie_enabled:
|
|
||||||
self.magpie_task = asyncio.create_task(self.magpie.serve())
|
|
||||||
|
|
||||||
# yield to allow UI to start
|
|
||||||
await asyncio.sleep(0)
|
|
||||||
|
|
||||||
while True:
|
|
||||||
try:
|
|
||||||
# TODO: cancel all client tasks
|
|
||||||
if not self.client.stop_bizhawk_spam:
|
|
||||||
logger.info("(Re)Starting game loop")
|
|
||||||
self.found_checks.clear()
|
|
||||||
# On restart of game loop, clear all checks, just in case we swapped ROMs
|
|
||||||
# this isn't totally neccessary, but is extra safety against cross-ROM contamination
|
|
||||||
self.client.recvd_checks.clear()
|
|
||||||
await self.client.wait_for_retroarch_connection()
|
|
||||||
await self.client.reset_auth()
|
|
||||||
# If we find ourselves with new auth after the reset, reconnect
|
|
||||||
if self.auth and self.client.auth != self.auth:
|
|
||||||
# It would be neat to reconnect here, but connection needs this loop to be running
|
|
||||||
logger.info("Detected new ROM, disconnecting...")
|
|
||||||
await self.disconnect()
|
|
||||||
continue
|
|
||||||
|
|
||||||
if not self.client.recvd_checks:
|
|
||||||
await self.sync()
|
|
||||||
|
|
||||||
await self.client.wait_and_init_tracker()
|
|
||||||
|
|
||||||
while True:
|
|
||||||
await self.client.main_tick(on_item_get, victory, deathlink)
|
|
||||||
await asyncio.sleep(0.1)
|
|
||||||
now = time.time()
|
|
||||||
if self.last_resend + 5.0 < now:
|
|
||||||
self.last_resend = now
|
|
||||||
await self.send_checks()
|
|
||||||
if self.magpie_enabled:
|
|
||||||
try:
|
|
||||||
self.magpie.set_checks(self.client.tracker.all_checks)
|
|
||||||
await self.magpie.set_item_tracker(self.client.item_tracker)
|
|
||||||
await self.magpie.send_gps(self.client.gps_tracker)
|
|
||||||
except Exception:
|
|
||||||
# Don't let magpie errors take out the client
|
|
||||||
pass
|
|
||||||
if self.client.should_reset_auth:
|
|
||||||
self.client.should_reset_auth = False
|
|
||||||
raise GameboyException("Resetting due to wrong archipelago server")
|
|
||||||
except (GameboyException, asyncio.TimeoutError, TimeoutError, ConnectionResetError):
|
|
||||||
await asyncio.sleep(1.0)
|
|
||||||
|
|
||||||
def run_game(romfile: str) -> None:
|
|
||||||
auto_start = typing.cast(typing.Union[bool, str],
|
|
||||||
Utils.get_options()["ladx_options"].get("rom_start", True))
|
|
||||||
if auto_start is True:
|
|
||||||
import webbrowser
|
|
||||||
webbrowser.open(romfile)
|
|
||||||
elif isinstance(auto_start, str):
|
|
||||||
args = shlex.split(auto_start)
|
|
||||||
# Specify full path to ROM as we are going to cd in popen
|
|
||||||
full_rom_path = os.path.realpath(romfile)
|
|
||||||
args.append(full_rom_path)
|
|
||||||
try:
|
|
||||||
# set cwd so that paths to lua scripts are always relative to our client
|
|
||||||
if getattr(sys, 'frozen', False):
|
|
||||||
# The application is frozen
|
|
||||||
script_dir = os.path.dirname(sys.executable)
|
|
||||||
else:
|
|
||||||
script_dir = os.path.dirname(os.path.realpath(__file__))
|
|
||||||
|
|
||||||
subprocess.Popen(args, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, cwd=script_dir)
|
|
||||||
except FileNotFoundError:
|
|
||||||
logger.error(f"Couldn't launch ROM, {args[0]} is missing")
|
|
||||||
|
|
||||||
async def main():
|
|
||||||
parser = get_base_parser(description="Link's Awakening Client.")
|
|
||||||
parser.add_argument("--url", help="Archipelago connection url")
|
|
||||||
parser.add_argument("--no-magpie", dest='magpie', default=True, action='store_false', help="Disable magpie bridge")
|
|
||||||
parser.add_argument('diff_file', default="", type=str, nargs="?",
|
|
||||||
help='Path to a .apladx Archipelago Binary Patch file')
|
|
||||||
|
|
||||||
args = parser.parse_args()
|
|
||||||
|
|
||||||
if args.diff_file:
|
|
||||||
import Patch
|
|
||||||
logger.info("patch file was supplied - creating rom...")
|
|
||||||
meta, rom_file = Patch.create_rom_file(args.diff_file)
|
|
||||||
if "server" in meta and not args.connect:
|
|
||||||
args.connect = meta["server"]
|
|
||||||
logger.info(f"wrote rom file to {rom_file}")
|
|
||||||
|
|
||||||
|
|
||||||
ctx = LinksAwakeningContext(args.connect, args.password, args.magpie)
|
|
||||||
|
|
||||||
ctx.server_task = asyncio.create_task(server_loop(ctx), name="server loop")
|
|
||||||
|
|
||||||
# TODO: nothing about the lambda about has to be in a lambda
|
|
||||||
ctx.la_task = create_task_log_exception(ctx.run_game_loop())
|
|
||||||
if gui_enabled:
|
|
||||||
ctx.run_gui()
|
|
||||||
ctx.run_cli()
|
|
||||||
|
|
||||||
# Down below run_gui so that we get errors out of the process
|
|
||||||
if args.diff_file:
|
|
||||||
run_game(rom_file)
|
|
||||||
|
|
||||||
await ctx.exit_event.wait()
|
|
||||||
await ctx.shutdown()
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
colorama.init()
|
|
||||||
asyncio.run(main())
|
|
||||||
colorama.deinit()
|
|
||||||
211
LttPAdjuster.py
211
LttPAdjuster.py
@@ -25,17 +25,15 @@ ModuleUpdate.update()
|
|||||||
|
|
||||||
from worlds.alttp.Rom import Sprite, LocalRom, apply_rom_settings, get_base_rom_bytes
|
from worlds.alttp.Rom import Sprite, LocalRom, apply_rom_settings, get_base_rom_bytes
|
||||||
from Utils import output_path, local_path, user_path, open_file, get_cert_none_ssl_context, persistent_store, \
|
from Utils import output_path, local_path, user_path, open_file, get_cert_none_ssl_context, persistent_store, \
|
||||||
get_adjuster_settings, get_adjuster_settings_no_defaults, tkinter_center_window, init_logging
|
get_adjuster_settings, tkinter_center_window, init_logging
|
||||||
|
from Patch import GAME_ALTTP
|
||||||
|
|
||||||
GAME_ALTTP = "A Link to the Past"
|
|
||||||
|
|
||||||
|
|
||||||
class AdjusterWorld(object):
|
class AdjusterWorld(object):
|
||||||
def __init__(self, sprite_pool):
|
def __init__(self, sprite_pool):
|
||||||
import random
|
import random
|
||||||
self.sprite_pool = {1: sprite_pool}
|
self.sprite_pool = {1: sprite_pool}
|
||||||
self.per_slot_randoms = {1: random}
|
self.slot_seeds = {1: random}
|
||||||
|
|
||||||
|
|
||||||
class ArgumentDefaultsHelpFormatter(argparse.RawTextHelpFormatter):
|
class ArgumentDefaultsHelpFormatter(argparse.RawTextHelpFormatter):
|
||||||
@@ -43,49 +41,8 @@ class ArgumentDefaultsHelpFormatter(argparse.RawTextHelpFormatter):
|
|||||||
def _get_help_string(self, action):
|
def _get_help_string(self, action):
|
||||||
return textwrap.dedent(action.help)
|
return textwrap.dedent(action.help)
|
||||||
|
|
||||||
# See argparse.BooleanOptionalAction
|
|
||||||
class BooleanOptionalActionWithDisable(argparse.Action):
|
|
||||||
def __init__(self,
|
|
||||||
option_strings,
|
|
||||||
dest,
|
|
||||||
default=None,
|
|
||||||
type=None,
|
|
||||||
choices=None,
|
|
||||||
required=False,
|
|
||||||
help=None,
|
|
||||||
metavar=None):
|
|
||||||
|
|
||||||
_option_strings = []
|
def main():
|
||||||
for option_string in option_strings:
|
|
||||||
_option_strings.append(option_string)
|
|
||||||
|
|
||||||
if option_string.startswith('--'):
|
|
||||||
option_string = '--disable' + option_string[2:]
|
|
||||||
_option_strings.append(option_string)
|
|
||||||
|
|
||||||
if help is not None and default is not None:
|
|
||||||
help += " (default: %(default)s)"
|
|
||||||
|
|
||||||
super().__init__(
|
|
||||||
option_strings=_option_strings,
|
|
||||||
dest=dest,
|
|
||||||
nargs=0,
|
|
||||||
default=default,
|
|
||||||
type=type,
|
|
||||||
choices=choices,
|
|
||||||
required=required,
|
|
||||||
help=help,
|
|
||||||
metavar=metavar)
|
|
||||||
|
|
||||||
def __call__(self, parser, namespace, values, option_string=None):
|
|
||||||
if option_string in self.option_strings:
|
|
||||||
setattr(namespace, self.dest, not option_string.startswith('--disable'))
|
|
||||||
|
|
||||||
def format_usage(self):
|
|
||||||
return ' | '.join(self.option_strings)
|
|
||||||
|
|
||||||
|
|
||||||
def get_argparser() -> argparse.ArgumentParser:
|
|
||||||
parser = argparse.ArgumentParser(formatter_class=ArgumentDefaultsHelpFormatter)
|
parser = argparse.ArgumentParser(formatter_class=ArgumentDefaultsHelpFormatter)
|
||||||
|
|
||||||
parser.add_argument('rom', nargs="?", default='AP_LttP.sfc', help='Path to an ALttP rom to adjust.')
|
parser.add_argument('rom', nargs="?", default='AP_LttP.sfc', help='Path to an ALttP rom to adjust.')
|
||||||
@@ -93,8 +50,6 @@ def get_argparser() -> argparse.ArgumentParser:
|
|||||||
help='Path to an ALttP Japan(1.0) rom to use as a base.')
|
help='Path to an ALttP Japan(1.0) rom to use as a base.')
|
||||||
parser.add_argument('--loglevel', default='info', const='info', nargs='?',
|
parser.add_argument('--loglevel', default='info', const='info', nargs='?',
|
||||||
choices=['error', 'info', 'warning', 'debug'], help='Select level of logging for output.')
|
choices=['error', 'info', 'warning', 'debug'], help='Select level of logging for output.')
|
||||||
parser.add_argument('--auto_apply', default='ask',
|
|
||||||
choices=['ask', 'always', 'never'], help='Whether or not to apply settings automatically in the future.')
|
|
||||||
parser.add_argument('--menuspeed', default='normal', const='normal', nargs='?',
|
parser.add_argument('--menuspeed', default='normal', const='normal', nargs='?',
|
||||||
choices=['normal', 'instant', 'double', 'triple', 'quadruple', 'half'],
|
choices=['normal', 'instant', 'double', 'triple', 'quadruple', 'half'],
|
||||||
help='''\
|
help='''\
|
||||||
@@ -104,7 +59,7 @@ def get_argparser() -> argparse.ArgumentParser:
|
|||||||
parser.add_argument('--quickswap', help='Enable quick item swapping with L and R.', action='store_true')
|
parser.add_argument('--quickswap', help='Enable quick item swapping with L and R.', action='store_true')
|
||||||
parser.add_argument('--deathlink', help='Enable DeathLink system.', action='store_true')
|
parser.add_argument('--deathlink', help='Enable DeathLink system.', action='store_true')
|
||||||
parser.add_argument('--allowcollect', help='Allow collection of other player items', action='store_true')
|
parser.add_argument('--allowcollect', help='Allow collection of other player items', action='store_true')
|
||||||
parser.add_argument('--music', default=True, help='Enables/Disables game music.', action=BooleanOptionalActionWithDisable)
|
parser.add_argument('--disablemusic', help='Disables game music.', action='store_true')
|
||||||
parser.add_argument('--triforcehud', default='hide_goal', const='hide_goal', nargs='?',
|
parser.add_argument('--triforcehud', default='hide_goal', const='hide_goal', nargs='?',
|
||||||
choices=['normal', 'hide_goal', 'hide_required', 'hide_both'],
|
choices=['normal', 'hide_goal', 'hide_required', 'hide_both'],
|
||||||
help='''\
|
help='''\
|
||||||
@@ -128,6 +83,9 @@ def get_argparser() -> argparse.ArgumentParser:
|
|||||||
parser.add_argument('--ow_palettes', default='default',
|
parser.add_argument('--ow_palettes', default='default',
|
||||||
choices=['default', 'random', 'blackout', 'puke', 'classic', 'grayscale', 'negative', 'dizzy',
|
choices=['default', 'random', 'blackout', 'puke', 'classic', 'grayscale', 'negative', 'dizzy',
|
||||||
'sick'])
|
'sick'])
|
||||||
|
parser.add_argument('--link_palettes', default='default',
|
||||||
|
choices=['default', 'random', 'blackout', 'puke', 'classic', 'grayscale', 'negative', 'dizzy',
|
||||||
|
'sick'])
|
||||||
parser.add_argument('--shield_palettes', default='default',
|
parser.add_argument('--shield_palettes', default='default',
|
||||||
choices=['default', 'random', 'blackout', 'puke', 'classic', 'grayscale', 'negative', 'dizzy',
|
choices=['default', 'random', 'blackout', 'puke', 'classic', 'grayscale', 'negative', 'dizzy',
|
||||||
'sick'])
|
'sick'])
|
||||||
@@ -147,23 +105,10 @@ def get_argparser() -> argparse.ArgumentParser:
|
|||||||
Alternatively, can be a ALttP Rom patched with a Link
|
Alternatively, can be a ALttP Rom patched with a Link
|
||||||
sprite that will be extracted.
|
sprite that will be extracted.
|
||||||
''')
|
''')
|
||||||
parser.add_argument('--sprite_pool', nargs='+', default=[], help='''
|
parser.add_argument('--names', default='', type=str)
|
||||||
A list of sprites to pull from.
|
|
||||||
''')
|
|
||||||
parser.add_argument('--oof', help='''\
|
|
||||||
Path to a sound effect to replace Link's "oof" sound.
|
|
||||||
Needs to be in a .brr format and have a length of no
|
|
||||||
more than 2673 bytes, created from a 16-bit signed PCM
|
|
||||||
.wav at 12khz. https://github.com/boldowa/snesbrr
|
|
||||||
''')
|
|
||||||
parser.add_argument('--update_sprites', action='store_true', help='Update Sprite Database, then exit.')
|
parser.add_argument('--update_sprites', action='store_true', help='Update Sprite Database, then exit.')
|
||||||
return parser
|
args = parser.parse_args()
|
||||||
|
args.music = not args.disablemusic
|
||||||
|
|
||||||
def main():
|
|
||||||
parser = get_argparser()
|
|
||||||
args = parser.parse_args(namespace=get_adjuster_settings_no_defaults(GAME_ALTTP))
|
|
||||||
|
|
||||||
# set up logger
|
# set up logger
|
||||||
loglevel = {'error': logging.ERROR, 'info': logging.INFO, 'warning': logging.WARNING, 'debug': logging.DEBUG}[
|
loglevel = {'error': logging.ERROR, 'info': logging.INFO, 'warning': logging.WARNING, 'debug': logging.DEBUG}[
|
||||||
args.loglevel]
|
args.loglevel]
|
||||||
@@ -179,13 +124,6 @@ def main():
|
|||||||
if args.sprite is not None and not os.path.isfile(args.sprite) and not Sprite.get_sprite_from_name(args.sprite):
|
if args.sprite is not None and not os.path.isfile(args.sprite) and not Sprite.get_sprite_from_name(args.sprite):
|
||||||
input('Could not find link sprite sheet at given location. \nPress Enter to exit.')
|
input('Could not find link sprite sheet at given location. \nPress Enter to exit.')
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
if args.oof is not None and not os.path.isfile(args.oof):
|
|
||||||
input('Could not find oof sound effect at given location. \nPress Enter to exit.')
|
|
||||||
sys.exit(1)
|
|
||||||
if args.oof is not None and os.path.getsize(args.oof) > 2673:
|
|
||||||
input('"oof" sound effect cannot exceed 2673 bytes. \nPress Enter to exit.')
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
|
|
||||||
args, path = adjust(args=args)
|
args, path = adjust(args=args)
|
||||||
if isinstance(args.sprite, Sprite):
|
if isinstance(args.sprite, Sprite):
|
||||||
@@ -201,7 +139,7 @@ def adjust(args):
|
|||||||
vanillaRom = args.baserom
|
vanillaRom = args.baserom
|
||||||
if not os.path.exists(vanillaRom) and not os.path.isabs(vanillaRom):
|
if not os.path.exists(vanillaRom) and not os.path.isabs(vanillaRom):
|
||||||
vanillaRom = local_path(vanillaRom)
|
vanillaRom = local_path(vanillaRom)
|
||||||
if os.path.splitext(args.rom)[-1].lower() == '.aplttp':
|
if os.path.splitext(args.rom)[-1].lower() in {'.apbp', '.aplttp'}:
|
||||||
import Patch
|
import Patch
|
||||||
meta, args.rom = Patch.create_rom_file(args.rom)
|
meta, args.rom = Patch.create_rom_file(args.rom)
|
||||||
|
|
||||||
@@ -225,7 +163,7 @@ def adjust(args):
|
|||||||
world = getattr(args, "world")
|
world = getattr(args, "world")
|
||||||
|
|
||||||
apply_rom_settings(rom, args.heartbeep, args.heartcolor, args.quickswap, args.menuspeed, args.music,
|
apply_rom_settings(rom, args.heartbeep, args.heartcolor, args.quickswap, args.menuspeed, args.music,
|
||||||
args.sprite, args.oof, palettes_options, reduceflashing=args.reduceflashing or racerom, world=world,
|
args.sprite, palettes_options, reduceflashing=args.reduceflashing or racerom, world=world,
|
||||||
deathlink=args.deathlink, allowcollect=args.allowcollect)
|
deathlink=args.deathlink, allowcollect=args.allowcollect)
|
||||||
path = output_path(f'{os.path.basename(args.rom)[:-4]}_adjusted.sfc')
|
path = output_path(f'{os.path.basename(args.rom)[:-4]}_adjusted.sfc')
|
||||||
rom.write_to_file(path)
|
rom.write_to_file(path)
|
||||||
@@ -240,7 +178,7 @@ def adjustGUI():
|
|||||||
from tkinter import Tk, LEFT, BOTTOM, TOP, \
|
from tkinter import Tk, LEFT, BOTTOM, TOP, \
|
||||||
StringVar, Frame, Label, X, Entry, Button, filedialog, messagebox, ttk
|
StringVar, Frame, Label, X, Entry, Button, filedialog, messagebox, ttk
|
||||||
from argparse import Namespace
|
from argparse import Namespace
|
||||||
from Utils import __version__ as MWVersion
|
from Main import __version__ as MWVersion
|
||||||
adjustWindow = Tk()
|
adjustWindow = Tk()
|
||||||
adjustWindow.wm_title("Archipelago %s LttP Adjuster" % MWVersion)
|
adjustWindow.wm_title("Archipelago %s LttP Adjuster" % MWVersion)
|
||||||
set_icon(adjustWindow)
|
set_icon(adjustWindow)
|
||||||
@@ -257,7 +195,7 @@ def adjustGUI():
|
|||||||
romEntry2 = Entry(romDialogFrame, textvariable=romVar2)
|
romEntry2 = Entry(romDialogFrame, textvariable=romVar2)
|
||||||
|
|
||||||
def RomSelect2():
|
def RomSelect2():
|
||||||
rom = filedialog.askopenfilename(filetypes=[("Rom Files", (".sfc", ".smc", ".aplttp")), ("All Files", "*")])
|
rom = filedialog.askopenfilename(filetypes=[("Rom Files", (".sfc", ".smc", ".apbp")), ("All Files", "*")])
|
||||||
romVar2.set(rom)
|
romVar2.set(rom)
|
||||||
|
|
||||||
romSelectButton2 = Button(romDialogFrame, text='Select Rom', command=RomSelect2)
|
romSelectButton2 = Button(romDialogFrame, text='Select Rom', command=RomSelect2)
|
||||||
@@ -287,7 +225,6 @@ def adjustGUI():
|
|||||||
guiargs.sprite = rom_vars.sprite
|
guiargs.sprite = rom_vars.sprite
|
||||||
if rom_vars.sprite_pool:
|
if rom_vars.sprite_pool:
|
||||||
guiargs.world = AdjusterWorld(rom_vars.sprite_pool)
|
guiargs.world = AdjusterWorld(rom_vars.sprite_pool)
|
||||||
guiargs.oof = rom_vars.oof
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
guiargs, path = adjust(args=guiargs)
|
guiargs, path = adjust(args=guiargs)
|
||||||
@@ -326,7 +263,6 @@ def adjustGUI():
|
|||||||
else:
|
else:
|
||||||
guiargs.sprite = rom_vars.sprite
|
guiargs.sprite = rom_vars.sprite
|
||||||
guiargs.sprite_pool = rom_vars.sprite_pool
|
guiargs.sprite_pool = rom_vars.sprite_pool
|
||||||
guiargs.oof = rom_vars.oof
|
|
||||||
persistent_store("adjuster", GAME_ALTTP, guiargs)
|
persistent_store("adjuster", GAME_ALTTP, guiargs)
|
||||||
messagebox.showinfo(title="Success", message="Settings saved to persistent storage")
|
messagebox.showinfo(title="Success", message="Settings saved to persistent storage")
|
||||||
|
|
||||||
@@ -543,38 +479,11 @@ class BackgroundTaskProgressNullWindow(BackgroundTask):
|
|||||||
self.stop()
|
self.stop()
|
||||||
|
|
||||||
|
|
||||||
class AttachTooltip(object):
|
|
||||||
|
|
||||||
def __init__(self, parent, text):
|
|
||||||
self._parent = parent
|
|
||||||
self._text = text
|
|
||||||
self._window = None
|
|
||||||
parent.bind('<Enter>', lambda event : self.show())
|
|
||||||
parent.bind('<Leave>', lambda event : self.hide())
|
|
||||||
|
|
||||||
def show(self):
|
|
||||||
if self._window or not self._text:
|
|
||||||
return
|
|
||||||
self._window = Toplevel(self._parent)
|
|
||||||
#remove window bar controls
|
|
||||||
self._window.wm_overrideredirect(1)
|
|
||||||
#adjust positioning
|
|
||||||
x, y, *_ = self._parent.bbox("insert")
|
|
||||||
x = x + self._parent.winfo_rootx() + 20
|
|
||||||
y = y + self._parent.winfo_rooty() + 20
|
|
||||||
self._window.wm_geometry("+{0}+{1}".format(x,y))
|
|
||||||
#show text
|
|
||||||
label = Label(self._window, text=self._text, justify=LEFT)
|
|
||||||
label.pack(ipadx=1)
|
|
||||||
|
|
||||||
def hide(self):
|
|
||||||
if self._window:
|
|
||||||
self._window.destroy()
|
|
||||||
self._window = None
|
|
||||||
|
|
||||||
|
|
||||||
def get_rom_frame(parent=None):
|
def get_rom_frame(parent=None):
|
||||||
adjuster_settings = get_adjuster_settings(GAME_ALTTP)
|
adjuster_settings = get_adjuster_settings(GAME_ALTTP)
|
||||||
|
if not adjuster_settings:
|
||||||
|
adjuster_settings = Namespace()
|
||||||
|
adjuster_settings.baserom = "Zelda no Densetsu - Kamigami no Triforce (Japan).sfc"
|
||||||
|
|
||||||
romFrame = Frame(parent)
|
romFrame = Frame(parent)
|
||||||
baseRomLabel = Label(romFrame, text='LttP Base Rom: ')
|
baseRomLabel = Label(romFrame, text='LttP Base Rom: ')
|
||||||
@@ -602,8 +511,32 @@ def get_rom_frame(parent=None):
|
|||||||
|
|
||||||
return romFrame, romVar
|
return romFrame, romVar
|
||||||
|
|
||||||
|
|
||||||
def get_rom_options_frame(parent=None):
|
def get_rom_options_frame(parent=None):
|
||||||
adjuster_settings = get_adjuster_settings(GAME_ALTTP)
|
adjuster_settings = get_adjuster_settings(GAME_ALTTP)
|
||||||
|
defaults = {
|
||||||
|
"auto_apply": 'ask',
|
||||||
|
"music": True,
|
||||||
|
"reduceflashing": True,
|
||||||
|
"deathlink": False,
|
||||||
|
"sprite": None,
|
||||||
|
"quickswap": True,
|
||||||
|
"menuspeed": 'normal',
|
||||||
|
"heartcolor": 'red',
|
||||||
|
"heartbeep": 'normal',
|
||||||
|
"ow_palettes": 'default',
|
||||||
|
"uw_palettes": 'default',
|
||||||
|
"hud_palettes": 'default',
|
||||||
|
"sword_palettes": 'default',
|
||||||
|
"shield_palettes": 'default',
|
||||||
|
"sprite_pool": [],
|
||||||
|
"allowcollect": False,
|
||||||
|
}
|
||||||
|
if not adjuster_settings:
|
||||||
|
adjuster_settings = Namespace()
|
||||||
|
for key, defaultvalue in defaults.items():
|
||||||
|
if not hasattr(adjuster_settings, key):
|
||||||
|
setattr(adjuster_settings, key, defaultvalue)
|
||||||
|
|
||||||
romOptionsFrame = LabelFrame(parent, text="Rom options")
|
romOptionsFrame = LabelFrame(parent, text="Rom options")
|
||||||
romOptionsFrame.columnconfigure(0, weight=1)
|
romOptionsFrame.columnconfigure(0, weight=1)
|
||||||
@@ -663,50 +596,12 @@ def get_rom_options_frame(parent=None):
|
|||||||
spriteEntry.pack(side=LEFT)
|
spriteEntry.pack(side=LEFT)
|
||||||
spriteSelectButton.pack(side=LEFT)
|
spriteSelectButton.pack(side=LEFT)
|
||||||
|
|
||||||
oofDialogFrame = Frame(romOptionsFrame)
|
|
||||||
oofDialogFrame.grid(row=1, column=1)
|
|
||||||
baseOofLabel = Label(oofDialogFrame, text='"OOF" Sound:')
|
|
||||||
|
|
||||||
vars.oofNameVar = StringVar()
|
|
||||||
vars.oof = adjuster_settings.oof
|
|
||||||
|
|
||||||
def set_oof(oof_param):
|
|
||||||
nonlocal vars
|
|
||||||
if isinstance(oof_param, str) and os.path.isfile(oof_param) and os.path.getsize(oof_param) <= 2673:
|
|
||||||
vars.oof = oof_param
|
|
||||||
vars.oofNameVar.set(oof_param.rsplit('/',1)[-1])
|
|
||||||
else:
|
|
||||||
vars.oof = None
|
|
||||||
vars.oofNameVar.set('(unchanged)')
|
|
||||||
|
|
||||||
set_oof(adjuster_settings.oof)
|
|
||||||
oofEntry = Label(oofDialogFrame, textvariable=vars.oofNameVar)
|
|
||||||
|
|
||||||
def OofSelect():
|
|
||||||
nonlocal vars
|
|
||||||
oof_file = filedialog.askopenfilename(
|
|
||||||
filetypes=[("BRR files", ".brr"),
|
|
||||||
("All Files", "*")])
|
|
||||||
try:
|
|
||||||
set_oof(oof_file)
|
|
||||||
except Exception:
|
|
||||||
set_oof(None)
|
|
||||||
|
|
||||||
oofSelectButton = Button(oofDialogFrame, text='...', command=OofSelect)
|
|
||||||
AttachTooltip(oofSelectButton,
|
|
||||||
text="Select a .brr file no more than 2673 bytes.\n" + \
|
|
||||||
"This can be created from a <=0.394s 16-bit signed PCM .wav file at 12khz using snesbrr.")
|
|
||||||
|
|
||||||
baseOofLabel.pack(side=LEFT)
|
|
||||||
oofEntry.pack(side=LEFT)
|
|
||||||
oofSelectButton.pack(side=LEFT)
|
|
||||||
|
|
||||||
vars.quickSwapVar = IntVar(value=adjuster_settings.quickswap)
|
vars.quickSwapVar = IntVar(value=adjuster_settings.quickswap)
|
||||||
quickSwapCheckbutton = Checkbutton(romOptionsFrame, text="L/R Quickswapping", variable=vars.quickSwapVar)
|
quickSwapCheckbutton = Checkbutton(romOptionsFrame, text="L/R Quickswapping", variable=vars.quickSwapVar)
|
||||||
quickSwapCheckbutton.grid(row=1, column=0, sticky=E)
|
quickSwapCheckbutton.grid(row=1, column=0, sticky=E)
|
||||||
|
|
||||||
menuspeedFrame = Frame(romOptionsFrame)
|
menuspeedFrame = Frame(romOptionsFrame)
|
||||||
menuspeedFrame.grid(row=6, column=1, sticky=E)
|
menuspeedFrame.grid(row=1, column=1, sticky=E)
|
||||||
menuspeedLabel = Label(menuspeedFrame, text='Menu speed')
|
menuspeedLabel = Label(menuspeedFrame, text='Menu speed')
|
||||||
menuspeedLabel.pack(side=LEFT)
|
menuspeedLabel.pack(side=LEFT)
|
||||||
vars.menuspeedVar = StringVar()
|
vars.menuspeedVar = StringVar()
|
||||||
@@ -830,7 +725,7 @@ def get_rom_options_frame(parent=None):
|
|||||||
vars.auto_apply = StringVar(value=adjuster_settings.auto_apply)
|
vars.auto_apply = StringVar(value=adjuster_settings.auto_apply)
|
||||||
autoApplyFrame = Frame(romOptionsFrame)
|
autoApplyFrame = Frame(romOptionsFrame)
|
||||||
autoApplyFrame.grid(row=9, column=0, columnspan=2, sticky=W)
|
autoApplyFrame.grid(row=9, column=0, columnspan=2, sticky=W)
|
||||||
filler = Label(autoApplyFrame, text="Automatically apply last used settings on opening .aplttp files")
|
filler = Label(autoApplyFrame, text="Automatically apply last used settings on opening .apbp files")
|
||||||
filler.pack(side=TOP, expand=True, fill=X)
|
filler.pack(side=TOP, expand=True, fill=X)
|
||||||
askRadio = Radiobutton(autoApplyFrame, text='Ask', variable=vars.auto_apply, value='ask')
|
askRadio = Radiobutton(autoApplyFrame, text='Ask', variable=vars.auto_apply, value='ask')
|
||||||
askRadio.pack(side=LEFT, padx=5, pady=5)
|
askRadio.pack(side=LEFT, padx=5, pady=5)
|
||||||
@@ -857,7 +752,6 @@ class SpriteSelector():
|
|||||||
self.window['pady'] = 5
|
self.window['pady'] = 5
|
||||||
self.spritesPerRow = 32
|
self.spritesPerRow = 32
|
||||||
self.all_sprites = []
|
self.all_sprites = []
|
||||||
self.invalid_sprites = []
|
|
||||||
self.sprite_pool = spritePool
|
self.sprite_pool = spritePool
|
||||||
|
|
||||||
def open_custom_sprite_dir(_evt):
|
def open_custom_sprite_dir(_evt):
|
||||||
@@ -939,13 +833,6 @@ class SpriteSelector():
|
|||||||
self.window.focus()
|
self.window.focus()
|
||||||
tkinter_center_window(self.window)
|
tkinter_center_window(self.window)
|
||||||
|
|
||||||
if self.invalid_sprites:
|
|
||||||
invalid = sorted(self.invalid_sprites)
|
|
||||||
logging.warning(f"The following sprites are invalid: {', '.join(invalid)}")
|
|
||||||
msg = f"{invalid[0]} "
|
|
||||||
msg += f"and {len(invalid)-1} more are invalid" if len(invalid) > 1 else "is invalid"
|
|
||||||
messagebox.showerror("Invalid sprites detected", msg, parent=self.window)
|
|
||||||
|
|
||||||
def remove_from_sprite_pool(self, button, spritename):
|
def remove_from_sprite_pool(self, button, spritename):
|
||||||
self.callback(("remove", spritename))
|
self.callback(("remove", spritename))
|
||||||
self.spritePoolButtons.buttons.remove(button)
|
self.spritePoolButtons.buttons.remove(button)
|
||||||
@@ -1004,20 +891,13 @@ 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)
|
||||||
|
|
||||||
sprites = []
|
sprites = []
|
||||||
|
|
||||||
for file in os.listdir(path):
|
for file in os.listdir(path):
|
||||||
if file == '.gitignore':
|
sprites.append((file, Sprite(os.path.join(path, file))))
|
||||||
continue
|
|
||||||
sprite = Sprite(os.path.join(path, file))
|
|
||||||
if sprite.valid:
|
|
||||||
sprites.append((file, sprite))
|
|
||||||
else:
|
|
||||||
self.invalid_sprites.append(file)
|
|
||||||
|
|
||||||
sprites.sort(key=lambda s: str.lower(s[1].name or "").strip())
|
sprites.sort(key=lambda s: str.lower(s[1].name or "").strip())
|
||||||
|
|
||||||
@@ -1160,6 +1040,7 @@ class SpriteSelector():
|
|||||||
def custom_sprite_dir(self):
|
def custom_sprite_dir(self):
|
||||||
return user_path("data", "sprites", "custom")
|
return user_path("data", "sprites", "custom")
|
||||||
|
|
||||||
|
|
||||||
def get_image_for_sprite(sprite, gif_only: bool = False):
|
def get_image_for_sprite(sprite, gif_only: bool = False):
|
||||||
if not sprite.valid:
|
if not sprite.valid:
|
||||||
return None
|
return None
|
||||||
|
|||||||
376
MMBN3Client.py
376
MMBN3Client.py
@@ -1,376 +0,0 @@
|
|||||||
import asyncio
|
|
||||||
import hashlib
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import multiprocessing
|
|
||||||
import subprocess
|
|
||||||
import zipfile
|
|
||||||
|
|
||||||
from asyncio import StreamReader, StreamWriter
|
|
||||||
|
|
||||||
import bsdiff4
|
|
||||||
|
|
||||||
from CommonClient import CommonContext, server_loop, gui_enabled, \
|
|
||||||
ClientCommandProcessor, logger, get_base_parser
|
|
||||||
import Utils
|
|
||||||
from NetUtils import ClientStatus
|
|
||||||
from worlds.mmbn3.Items import items_by_id
|
|
||||||
from worlds.mmbn3.Rom import get_base_rom_path
|
|
||||||
from worlds.mmbn3.Locations import all_locations, scoutable_locations
|
|
||||||
|
|
||||||
SYSTEM_MESSAGE_ID = 0
|
|
||||||
|
|
||||||
CONNECTION_TIMING_OUT_STATUS = "Connection timing out. Please restart your emulator, then restart connector_mmbn3.lua"
|
|
||||||
CONNECTION_REFUSED_STATUS = \
|
|
||||||
"Connection refused. Please start your emulator and make sure connector_mmbn3.lua is running"
|
|
||||||
CONNECTION_RESET_STATUS = "Connection was reset. Please restart your emulator, then restart connector_mmbn3.lua"
|
|
||||||
CONNECTION_TENTATIVE_STATUS = "Initial Connection Made"
|
|
||||||
CONNECTION_CONNECTED_STATUS = "Connected"
|
|
||||||
CONNECTION_INITIAL_STATUS = "Connection has not been initiated"
|
|
||||||
CONNECTION_INCORRECT_ROM = "Supplied Base Rom does not match US GBA Blue Version. Please provide the correct ROM version"
|
|
||||||
|
|
||||||
script_version: int = 2
|
|
||||||
|
|
||||||
debugEnabled = False
|
|
||||||
locations_checked = []
|
|
||||||
items_sent = []
|
|
||||||
itemIndex = 1
|
|
||||||
|
|
||||||
CHECKSUM_BLUE = "6fe31df0144759b34ad666badaacc442"
|
|
||||||
|
|
||||||
|
|
||||||
class MMBN3CommandProcessor(ClientCommandProcessor):
|
|
||||||
def __init__(self, ctx):
|
|
||||||
super().__init__(ctx)
|
|
||||||
|
|
||||||
def _cmd_gba(self):
|
|
||||||
"""Check GBA Connection State"""
|
|
||||||
if isinstance(self.ctx, MMBN3Context):
|
|
||||||
logger.info(f"GBA Status: {self.ctx.gba_status}")
|
|
||||||
|
|
||||||
def _cmd_debug(self):
|
|
||||||
"""Toggle the Debug Text overlay in ROM"""
|
|
||||||
global debugEnabled
|
|
||||||
debugEnabled = not debugEnabled
|
|
||||||
logger.info("Debug Overlay Enabled" if debugEnabled else "Debug Overlay Disabled")
|
|
||||||
|
|
||||||
|
|
||||||
class MMBN3Context(CommonContext):
|
|
||||||
command_processor = MMBN3CommandProcessor
|
|
||||||
game = "MegaMan Battle Network 3"
|
|
||||||
items_handling = 0b101 # full local except starting items
|
|
||||||
|
|
||||||
def __init__(self, server_address, password):
|
|
||||||
super().__init__(server_address, password)
|
|
||||||
self.gba_streams: (StreamReader, StreamWriter) = None
|
|
||||||
self.gba_sync_task = None
|
|
||||||
self.gba_status = CONNECTION_INITIAL_STATUS
|
|
||||||
self.awaiting_rom = False
|
|
||||||
self.location_table = {}
|
|
||||||
self.version_warning = False
|
|
||||||
self.auth_name = None
|
|
||||||
self.slot_data = dict()
|
|
||||||
self.patching_error = False
|
|
||||||
self.sent_hints = []
|
|
||||||
|
|
||||||
async def server_auth(self, password_requested: bool = False):
|
|
||||||
if password_requested and not self.password:
|
|
||||||
await super(MMBN3Context, self).server_auth(password_requested)
|
|
||||||
|
|
||||||
if self.auth_name is None:
|
|
||||||
self.awaiting_rom = True
|
|
||||||
logger.info("No ROM detected, awaiting conection to Bizhawk to authenticate to the multiworld server")
|
|
||||||
return
|
|
||||||
|
|
||||||
logger.info("Attempting to decode from ROM... ")
|
|
||||||
self.awaiting_rom = False
|
|
||||||
self.auth = self.auth_name.decode("utf8").replace('\x00', '')
|
|
||||||
logger.info("Connecting as "+self.auth)
|
|
||||||
await self.send_connect(name=self.auth)
|
|
||||||
|
|
||||||
def run_gui(self):
|
|
||||||
from kvui import GameManager
|
|
||||||
|
|
||||||
class MMBN3Manager(GameManager):
|
|
||||||
logging_pairs = [
|
|
||||||
("Client", "Archipelago")
|
|
||||||
]
|
|
||||||
base_title = "Archipelago MegaMan Battle Network 3 Client"
|
|
||||||
|
|
||||||
self.ui = MMBN3Manager(self)
|
|
||||||
self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI")
|
|
||||||
|
|
||||||
def on_package(self, cmd: str, args: dict):
|
|
||||||
if cmd == 'Connected':
|
|
||||||
self.slot_data = args.get("slot_data", {})
|
|
||||||
print(self.slot_data)
|
|
||||||
|
|
||||||
class ItemInfo:
|
|
||||||
id = 0x00
|
|
||||||
sender = ""
|
|
||||||
type = ""
|
|
||||||
count = 1
|
|
||||||
itemName = "Unknown"
|
|
||||||
itemID = 0x00 # Item ID, Chip ID, etc.
|
|
||||||
subItemID = 0x00 # Code for chips, color for programs
|
|
||||||
itemIndex = 1
|
|
||||||
|
|
||||||
def __init__(self, id, sender, type):
|
|
||||||
self.id = id
|
|
||||||
self.sender = sender
|
|
||||||
self.type = type
|
|
||||||
|
|
||||||
def get_json(self):
|
|
||||||
json_data = {
|
|
||||||
"id": self.id,
|
|
||||||
"sender": self.sender,
|
|
||||||
"type": self.type,
|
|
||||||
"itemName": self.itemName,
|
|
||||||
"itemID": self.itemID,
|
|
||||||
"subItemID": self.subItemID,
|
|
||||||
"count": self.count,
|
|
||||||
"itemIndex": self.itemIndex
|
|
||||||
}
|
|
||||||
return json_data
|
|
||||||
|
|
||||||
|
|
||||||
def get_payload(ctx: MMBN3Context):
|
|
||||||
global debugEnabled
|
|
||||||
|
|
||||||
items_sent = []
|
|
||||||
for i, item in enumerate(ctx.items_received):
|
|
||||||
item_data = items_by_id[item.item]
|
|
||||||
new_item = ItemInfo(i, ctx.player_names[item.player], item_data.type)
|
|
||||||
new_item.itemIndex = i+1
|
|
||||||
new_item.itemName = item_data.itemName
|
|
||||||
new_item.type = item_data.type
|
|
||||||
new_item.itemID = item_data.itemID
|
|
||||||
new_item.subItemID = item_data.subItemID
|
|
||||||
new_item.count = item_data.count
|
|
||||||
items_sent.append(new_item)
|
|
||||||
|
|
||||||
return json.dumps({
|
|
||||||
"items": [item.get_json() for item in items_sent],
|
|
||||||
"debug": debugEnabled
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
async def parse_payload(payload: dict, ctx: MMBN3Context, force: bool):
|
|
||||||
# Game completion handling
|
|
||||||
if payload["gameComplete"] and not ctx.finished_game:
|
|
||||||
await ctx.send_msgs([{
|
|
||||||
"cmd": "StatusUpdate",
|
|
||||||
"status": ClientStatus.CLIENT_GOAL
|
|
||||||
}])
|
|
||||||
ctx.finished_game = True
|
|
||||||
|
|
||||||
# Locations handling
|
|
||||||
if ctx.location_table != payload["locations"]:
|
|
||||||
ctx.location_table = payload["locations"]
|
|
||||||
locs = [loc.id for loc in all_locations
|
|
||||||
if check_location_packet(loc, ctx.location_table)]
|
|
||||||
await ctx.send_msgs([{
|
|
||||||
"cmd": "LocationChecks",
|
|
||||||
"locations": locs
|
|
||||||
}])
|
|
||||||
|
|
||||||
# If trade hinting is enabled, send scout checks
|
|
||||||
if ctx.slot_data.get("trade_quest_hinting", 0) == 2:
|
|
||||||
trade_bits = [loc.id for loc in scoutable_locations
|
|
||||||
if check_location_scouted(loc, payload["locations"])]
|
|
||||||
scouted_locs = [loc for loc in trade_bits if loc not in ctx.sent_hints]
|
|
||||||
if len(scouted_locs) > 0:
|
|
||||||
ctx.sent_hints.extend(scouted_locs)
|
|
||||||
await ctx.send_msgs([{
|
|
||||||
"cmd": "LocationScouts",
|
|
||||||
"locations": scouted_locs,
|
|
||||||
"create_as_hint": 2
|
|
||||||
}])
|
|
||||||
|
|
||||||
|
|
||||||
def check_location_packet(location, memory):
|
|
||||||
if len(memory) == 0:
|
|
||||||
return False
|
|
||||||
# Our keys have to be strings to come through the JSON lua plugin so we have to turn our memory address into a string as well
|
|
||||||
location_key = hex(location.flag_byte)[2:]
|
|
||||||
byte = memory.get(location_key)
|
|
||||||
if byte is not None:
|
|
||||||
return byte & location.flag_mask
|
|
||||||
|
|
||||||
|
|
||||||
def check_location_scouted(location, memory):
|
|
||||||
if len(memory) == 0:
|
|
||||||
return False
|
|
||||||
location_key = hex(location.hint_flag)[2:]
|
|
||||||
byte = memory.get(location_key)
|
|
||||||
if byte is not None:
|
|
||||||
return byte & location.hint_flag_mask
|
|
||||||
|
|
||||||
|
|
||||||
async def gba_sync_task(ctx: MMBN3Context):
|
|
||||||
logger.info("Starting GBA connector. Use /gba for status information.")
|
|
||||||
if ctx.patching_error:
|
|
||||||
logger.error('Unable to Patch ROM. No ROM provided or ROM does not match US GBA Blue Version.')
|
|
||||||
while not ctx.exit_event.is_set():
|
|
||||||
error_status = None
|
|
||||||
if ctx.gba_streams:
|
|
||||||
(reader, writer) = ctx.gba_streams
|
|
||||||
msg = get_payload(ctx).encode()
|
|
||||||
writer.write(msg)
|
|
||||||
writer.write(b'\n')
|
|
||||||
try:
|
|
||||||
await asyncio.wait_for(writer.drain(), timeout=1.5)
|
|
||||||
try:
|
|
||||||
# Data will return a dict with up to four fields
|
|
||||||
# 1. str: player name (always)
|
|
||||||
# 2. int: script version (always)
|
|
||||||
# 3. dict[str, byte]: value of location's memory byte
|
|
||||||
# 4. bool: whether the game currently registers as complete
|
|
||||||
data = await asyncio.wait_for(reader.readline(), timeout=10)
|
|
||||||
data_decoded = json.loads(data.decode())
|
|
||||||
reported_version = data_decoded.get("scriptVersion", 0)
|
|
||||||
if reported_version >= script_version:
|
|
||||||
if ctx.game is not None and "locations" in data_decoded:
|
|
||||||
# Not just a keep alive ping, parse
|
|
||||||
asyncio.create_task((parse_payload(data_decoded, ctx, False)))
|
|
||||||
if not ctx.auth:
|
|
||||||
ctx.auth_name = bytes(data_decoded["playerName"])
|
|
||||||
|
|
||||||
if ctx.awaiting_rom:
|
|
||||||
logger.info("Awaiting data from ROM...")
|
|
||||||
await ctx.server_auth(False)
|
|
||||||
else:
|
|
||||||
if not ctx.version_warning:
|
|
||||||
logger.warning(f"Your Lua script is version {reported_version}, expected {script_version}."
|
|
||||||
"Please update to the latest version."
|
|
||||||
"Your connection to the Archipelago server will not be accepted.")
|
|
||||||
ctx.version_warning = True
|
|
||||||
except asyncio.TimeoutError:
|
|
||||||
logger.debug("Read Timed Out, Reconnecting")
|
|
||||||
error_status = CONNECTION_TIMING_OUT_STATUS
|
|
||||||
writer.close()
|
|
||||||
ctx.gba_streams = None
|
|
||||||
except ConnectionResetError:
|
|
||||||
logger.debug("Read failed due to Connection Lost, Reconnecting")
|
|
||||||
error_status = CONNECTION_RESET_STATUS
|
|
||||||
writer.close()
|
|
||||||
ctx.gba_streams = None
|
|
||||||
except TimeoutError:
|
|
||||||
logger.debug("Connection Timed Out, Reconnecting")
|
|
||||||
error_status = CONNECTION_TIMING_OUT_STATUS
|
|
||||||
writer.close()
|
|
||||||
ctx.gba_streams = None
|
|
||||||
except ConnectionResetError:
|
|
||||||
logger.debug("Connection Lost, Reconnecting")
|
|
||||||
error_status = CONNECTION_RESET_STATUS
|
|
||||||
writer.close()
|
|
||||||
ctx.gba_streams = None
|
|
||||||
if ctx.gba_status == CONNECTION_TENTATIVE_STATUS:
|
|
||||||
if not error_status:
|
|
||||||
logger.info("Successfully Connected to GBA")
|
|
||||||
ctx.gba_status = CONNECTION_CONNECTED_STATUS
|
|
||||||
else:
|
|
||||||
ctx.gba_status = f"Was tentatively connected but error occurred: {error_status}"
|
|
||||||
elif error_status:
|
|
||||||
ctx.gba_status = error_status
|
|
||||||
logger.info("Lost connection to GBA and attempting to reconnect. Use /gba for status updates")
|
|
||||||
else:
|
|
||||||
try:
|
|
||||||
logger.debug("Attempting to connect to GBA")
|
|
||||||
ctx.gba_streams = await asyncio.wait_for(asyncio.open_connection("localhost", 28922), timeout=10)
|
|
||||||
ctx.gba_status = CONNECTION_TENTATIVE_STATUS
|
|
||||||
except TimeoutError:
|
|
||||||
logger.debug("Connection Timed Out, Trying Again")
|
|
||||||
ctx.gba_status = CONNECTION_TIMING_OUT_STATUS
|
|
||||||
continue
|
|
||||||
except ConnectionRefusedError:
|
|
||||||
logger.debug("Connection Refused, Trying Again")
|
|
||||||
ctx.gba_status = CONNECTION_REFUSED_STATUS
|
|
||||||
continue
|
|
||||||
|
|
||||||
|
|
||||||
async def run_game(romfile):
|
|
||||||
options = Utils.get_options().get("mmbn3_options", None)
|
|
||||||
if options is None:
|
|
||||||
auto_start = True
|
|
||||||
else:
|
|
||||||
auto_start = options.get("rom_start", True)
|
|
||||||
if auto_start:
|
|
||||||
import webbrowser
|
|
||||||
webbrowser.open(romfile)
|
|
||||||
elif os.path.isfile(auto_start):
|
|
||||||
subprocess.Popen([auto_start, romfile],
|
|
||||||
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
||||||
|
|
||||||
|
|
||||||
async def patch_and_run_game(apmmbn3_file):
|
|
||||||
base_name = os.path.splitext(apmmbn3_file)[0]
|
|
||||||
|
|
||||||
with zipfile.ZipFile(apmmbn3_file, 'r') as patch_archive:
|
|
||||||
try:
|
|
||||||
with patch_archive.open("delta.bsdiff4", 'r') as stream:
|
|
||||||
patch_data = stream.read()
|
|
||||||
except KeyError:
|
|
||||||
raise FileNotFoundError("Patch file missing from archive.")
|
|
||||||
rom_file = get_base_rom_path()
|
|
||||||
|
|
||||||
with open(rom_file, 'rb') as rom:
|
|
||||||
rom_bytes = rom.read()
|
|
||||||
|
|
||||||
patched_bytes = bsdiff4.patch(rom_bytes, patch_data)
|
|
||||||
patched_rom_file = base_name+".gba"
|
|
||||||
with open(patched_rom_file, 'wb') as patched_rom:
|
|
||||||
patched_rom.write(patched_bytes)
|
|
||||||
|
|
||||||
asyncio.create_task(run_game(patched_rom_file))
|
|
||||||
|
|
||||||
|
|
||||||
def confirm_checksum():
|
|
||||||
rom_file = get_base_rom_path()
|
|
||||||
if not os.path.exists(rom_file):
|
|
||||||
return False
|
|
||||||
|
|
||||||
with open(rom_file, 'rb') as rom:
|
|
||||||
rom_bytes = rom.read()
|
|
||||||
|
|
||||||
basemd5 = hashlib.md5()
|
|
||||||
basemd5.update(rom_bytes)
|
|
||||||
return CHECKSUM_BLUE == basemd5.hexdigest()
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
Utils.init_logging("MMBN3Client")
|
|
||||||
|
|
||||||
async def main():
|
|
||||||
multiprocessing.freeze_support()
|
|
||||||
parser = get_base_parser()
|
|
||||||
parser.add_argument("patch_file", default="", type=str, nargs="?",
|
|
||||||
help="Path to an APMMBN3 file")
|
|
||||||
args = parser.parse_args()
|
|
||||||
checksum_matches = confirm_checksum()
|
|
||||||
if checksum_matches:
|
|
||||||
if args.patch_file:
|
|
||||||
asyncio.create_task(patch_and_run_game(args.patch_file))
|
|
||||||
|
|
||||||
ctx = MMBN3Context(args.connect, args.password)
|
|
||||||
if not checksum_matches:
|
|
||||||
ctx.patching_error = True
|
|
||||||
ctx.server_task = asyncio.create_task(server_loop(ctx), name="Server Loop")
|
|
||||||
if gui_enabled:
|
|
||||||
ctx.run_gui()
|
|
||||||
ctx.run_cli()
|
|
||||||
|
|
||||||
ctx.gba_sync_task = asyncio.create_task(gba_sync_task(ctx), name="GBA Sync")
|
|
||||||
await ctx.exit_event.wait()
|
|
||||||
ctx.server_address = None
|
|
||||||
await ctx.shutdown()
|
|
||||||
|
|
||||||
if ctx.gba_sync_task:
|
|
||||||
await ctx.gba_sync_task
|
|
||||||
|
|
||||||
import colorama
|
|
||||||
|
|
||||||
colorama.init()
|
|
||||||
|
|
||||||
asyncio.run(main())
|
|
||||||
colorama.deinit()
|
|
||||||
628
Main.py
628
Main.py
@@ -1,192 +1,151 @@
|
|||||||
import collections
|
import collections
|
||||||
import concurrent.futures
|
from itertools import zip_longest, chain
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import time
|
||||||
|
import zlib
|
||||||
|
import concurrent.futures
|
||||||
import pickle
|
import pickle
|
||||||
import tempfile
|
import tempfile
|
||||||
import time
|
|
||||||
import zipfile
|
import zipfile
|
||||||
import zlib
|
from typing import Dict, Tuple, Optional, Set
|
||||||
from typing import Dict, List, Optional, Set, Tuple, Union
|
|
||||||
|
|
||||||
import worlds
|
from BaseClasses import MultiWorld, CollectionState, Region, RegionType, LocationProgressType, Location
|
||||||
from BaseClasses import CollectionState, Item, Location, LocationProgressType, MultiWorld, Region
|
from worlds.alttp.Items import item_name_groups
|
||||||
from Fill import balance_multiworld_progression, distribute_items_restrictive, distribute_planned, flood_items
|
from worlds.alttp.Regions import lookup_vanilla_location_to_entrance
|
||||||
from Options import StartInventoryPool
|
from Fill import distribute_items_restrictive, flood_items, balance_multiworld_progression, distribute_planned
|
||||||
from Utils import __version__, output_path, version_tuple
|
from worlds.alttp.Shops import SHOP_ID_START, total_shop_slots, FillDisabledShopSlots
|
||||||
from settings import get_settings
|
from Utils import output_path, get_options, __version__, version_tuple
|
||||||
|
from worlds.generic.Rules import locality_rules, exclusion_rules, group_locality_rules
|
||||||
from worlds import AutoWorld
|
from worlds import AutoWorld
|
||||||
from worlds.generic.Rules import exclusion_rules, locality_rules
|
|
||||||
|
|
||||||
__all__ = ["main"]
|
ordered_areas = (
|
||||||
|
'Light World', 'Dark World', 'Hyrule Castle', 'Agahnims Tower', 'Eastern Palace', 'Desert Palace',
|
||||||
|
'Tower of Hera', 'Palace of Darkness', 'Swamp Palace', 'Skull Woods', 'Thieves Town', 'Ice Palace',
|
||||||
|
'Misery Mire', 'Turtle Rock', 'Ganons Tower', "Total"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = None):
|
def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = None):
|
||||||
if not baked_server_options:
|
if not baked_server_options:
|
||||||
baked_server_options = get_settings().server_options.as_dict()
|
baked_server_options = get_options()["server_options"]
|
||||||
assert isinstance(baked_server_options, dict)
|
|
||||||
if args.outputpath:
|
if args.outputpath:
|
||||||
os.makedirs(args.outputpath, exist_ok=True)
|
os.makedirs(args.outputpath, exist_ok=True)
|
||||||
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 world.seed))
|
||||||
multiworld.plando_options = args.plando_options
|
|
||||||
multiworld.plando_items = args.plando_items.copy()
|
|
||||||
multiworld.plando_texts = args.plando_texts.copy()
|
|
||||||
multiworld.plando_connections = args.plando_connections.copy()
|
|
||||||
multiworld.game = args.game.copy()
|
|
||||||
multiworld.player_name = args.name.copy()
|
|
||||||
multiworld.sprite = args.sprite.copy()
|
|
||||||
multiworld.sprite_pool = args.sprite_pool.copy()
|
|
||||||
|
|
||||||
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.timer = args.timer.copy()
|
||||||
|
world.countdown_start_time = args.countdown_start_time.copy()
|
||||||
|
world.red_clock_time = args.red_clock_time.copy()
|
||||||
|
world.blue_clock_time = args.blue_clock_time.copy()
|
||||||
|
world.green_clock_time = args.green_clock_time.copy()
|
||||||
|
world.dungeon_counters = args.dungeon_counters.copy()
|
||||||
|
world.triforce_pieces_available = args.triforce_pieces_available.copy()
|
||||||
|
world.triforce_pieces_required = args.triforce_pieces_required.copy()
|
||||||
|
world.shop_shuffle = args.shop_shuffle.copy()
|
||||||
|
world.shuffle_prizes = args.shuffle_prizes.copy()
|
||||||
|
world.sprite_pool = args.sprite_pool.copy()
|
||||||
|
world.dark_room_logic = args.dark_room_logic.copy()
|
||||||
|
world.plando_items = args.plando_items.copy()
|
||||||
|
world.plando_texts = args.plando_texts.copy()
|
||||||
|
world.plando_connections = args.plando_connections.copy()
|
||||||
|
world.required_medallions = args.required_medallions.copy()
|
||||||
|
world.game = args.game.copy()
|
||||||
|
world.player_name = args.name.copy()
|
||||||
|
world.enemizer = args.enemizercli
|
||||||
|
world.sprite = args.sprite.copy()
|
||||||
|
world.glitch_triforce = args.glitch_triforce # This is enabled/disabled globally, no per player option.
|
||||||
|
|
||||||
logger.info(f"Found {len(AutoWorld.AutoWorldRegister.world_types)} World Types:")
|
world.set_options(args)
|
||||||
|
world.set_item_links()
|
||||||
|
world.state = CollectionState(world)
|
||||||
|
logger.info('Archipelago Version %s - Seed: %s\n', __version__, world.seed)
|
||||||
|
|
||||||
|
logger.info("Found World Types:")
|
||||||
longest_name = max(len(text) for text in AutoWorld.AutoWorldRegister.world_types)
|
longest_name = max(len(text) for text in AutoWorld.AutoWorldRegister.world_types)
|
||||||
|
numlength = 8
|
||||||
max_item = 0
|
|
||||||
max_location = 0
|
|
||||||
for cls in AutoWorld.AutoWorldRegister.world_types.values():
|
|
||||||
if cls.item_id_to_name:
|
|
||||||
max_item = max(max_item, max(cls.item_id_to_name))
|
|
||||||
max_location = max(max_location, max(cls.location_id_to_name))
|
|
||||||
|
|
||||||
item_digits = len(str(max_item))
|
|
||||||
location_digits = len(str(max_location))
|
|
||||||
item_count = len(str(max(len(cls.item_names) for cls in AutoWorld.AutoWorldRegister.world_types.values())))
|
|
||||||
location_count = len(str(max(len(cls.location_names) for cls in AutoWorld.AutoWorldRegister.world_types.values())))
|
|
||||||
del max_item, max_location
|
|
||||||
|
|
||||||
for name, cls in AutoWorld.AutoWorldRegister.world_types.items():
|
for name, cls in AutoWorld.AutoWorldRegister.world_types.items():
|
||||||
if not cls.hidden and len(cls.item_names) > 0:
|
if not cls.hidden:
|
||||||
logger.info(f" {name:{longest_name}}: {len(cls.item_names):{item_count}} "
|
logger.info(f" {name:{longest_name}}: {len(cls.item_names):3} "
|
||||||
f"Items (IDs: {min(cls.item_id_to_name):{item_digits}} - "
|
f"Items (IDs: {min(cls.item_id_to_name):{numlength}} - "
|
||||||
f"{max(cls.item_id_to_name):{item_digits}}) | "
|
f"{max(cls.item_id_to_name):{numlength}}) | "
|
||||||
f"{len(cls.location_names):{location_count}} "
|
f"{len(cls.location_names):3} "
|
||||||
f"Locations (IDs: {min(cls.location_id_to_name):{location_digits}} - "
|
f"Locations (IDs: {min(cls.location_id_to_name):{numlength}} - "
|
||||||
f"{max(cls.location_id_to_name):{location_digits}})")
|
f"{max(cls.location_id_to_name):{numlength}})")
|
||||||
|
|
||||||
del item_digits, location_digits, item_count, location_count
|
AutoWorld.call_stage(world, "assert_generate")
|
||||||
|
|
||||||
# This assertion method should not be necessary to run if we are not outputting any multidata.
|
AutoWorld.call_all(world, "generate_early")
|
||||||
if not args.skip_output:
|
|
||||||
AutoWorld.call_stage(multiworld, "assert_generate")
|
|
||||||
|
|
||||||
AutoWorld.call_all(multiworld, "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 player in world.player_ids:
|
||||||
"start_inventory_from_pool",
|
if player in world.get_game_players("A Link to the Past"):
|
||||||
StartInventoryPool({})).value.items():
|
# enforce pre-defined local items.
|
||||||
for _ in range(count):
|
if world.goal[player] in ["localtriforcehunt", "localganontriforcehunt"]:
|
||||||
multiworld.push_precollected(multiworld.create_item(item_name, player))
|
world.local_items[player].value.add('Triforce Piece')
|
||||||
# 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.')
|
# Not possible to place pendants/crystals out side of boss prizes yet.
|
||||||
AutoWorld.call_all(multiworld, "create_regions")
|
world.non_local_items[player].value -= item_name_groups['Pendants']
|
||||||
|
world.non_local_items[player].value -= item_name_groups['Crystals']
|
||||||
|
|
||||||
|
# items can't be both local and non-local, prefer local
|
||||||
|
world.non_local_items[player].value -= world.local_items[player].value
|
||||||
|
|
||||||
|
logger.info('Creating World.')
|
||||||
|
AutoWorld.call_all(world, "create_regions")
|
||||||
|
|
||||||
logger.info('Creating Items.')
|
logger.info('Creating Items.')
|
||||||
AutoWorld.call_all(multiworld, "create_items")
|
AutoWorld.call_all(world, "create_items")
|
||||||
|
|
||||||
logger.info('Calculating Access Rules.')
|
logger.info('Calculating Access Rules.')
|
||||||
|
if world.players > 1:
|
||||||
for player in multiworld.player_ids:
|
for player in world.player_ids:
|
||||||
# items can't be both local and non-local, prefer local
|
locality_rules(world, player)
|
||||||
multiworld.worlds[player].options.non_local_items.value -= multiworld.worlds[player].options.local_items.value
|
group_locality_rules(world)
|
||||||
multiworld.worlds[player].options.non_local_items.value -= set(multiworld.local_early_items[player])
|
|
||||||
|
|
||||||
AutoWorld.call_all(multiworld, "set_rules")
|
|
||||||
|
|
||||||
for player in multiworld.player_ids:
|
|
||||||
exclusion_rules(multiworld, player, multiworld.worlds[player].options.exclude_locations.value)
|
|
||||||
multiworld.worlds[player].options.priority_locations.value -= multiworld.worlds[player].options.exclude_locations.value
|
|
||||||
for location_name in multiworld.worlds[player].options.priority_locations.value:
|
|
||||||
try:
|
|
||||||
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.
|
|
||||||
if multiworld.players > 1:
|
|
||||||
locality_rules(multiworld)
|
|
||||||
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")
|
|
||||||
|
|
||||||
# remove starting inventory from pool items.
|
AutoWorld.call_all(world, "set_rules")
|
||||||
# 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):
|
|
||||||
new_items: List[Item] = []
|
|
||||||
depletion_pool: Dict[int, Dict[str, int]] = {
|
|
||||||
player: getattr(multiworld.worlds[player].options,
|
|
||||||
"start_inventory_from_pool",
|
|
||||||
StartInventoryPool({})).value.copy()
|
|
||||||
for player in multiworld.player_ids
|
|
||||||
}
|
|
||||||
for player, items in depletion_pool.items():
|
|
||||||
player_world: AutoWorld.World = multiworld.worlds[player]
|
|
||||||
for count in items.values():
|
|
||||||
for _ in range(count):
|
|
||||||
new_items.append(player_world.create_filler())
|
|
||||||
target: int = sum(sum(items.values()) for items in depletion_pool.values())
|
|
||||||
for i, item in enumerate(multiworld.itempool):
|
|
||||||
if depletion_pool[item.player].get(item.name, 0):
|
|
||||||
target -= 1
|
|
||||||
depletion_pool[item.player][item.name] -= 1
|
|
||||||
# quick abort if we have found all items
|
|
||||||
if not target:
|
|
||||||
new_items.extend(multiworld.itempool[i+1:])
|
|
||||||
break
|
|
||||||
else:
|
|
||||||
new_items.append(item)
|
|
||||||
|
|
||||||
# leftovers?
|
for player in world.player_ids:
|
||||||
if target:
|
exclusion_rules(world, player, world.exclude_locations[player].value)
|
||||||
for player, remaining_items in depletion_pool.items():
|
world.priority_locations[player].value -= world.exclude_locations[player].value
|
||||||
remaining_items = {name: count for name, count in remaining_items.items() if count}
|
for location_name in world.priority_locations[player].value:
|
||||||
if remaining_items:
|
world.get_location(location_name, player).progress_type = LocationProgressType.PRIORITY
|
||||||
raise Exception(f"{multiworld.get_player_name(player)}"
|
|
||||||
f" is trying to remove items from their pool that don't exist: {remaining_items}")
|
AutoWorld.call_all(world, "generate_basic")
|
||||||
assert len(multiworld.itempool) == len(new_items), "Item Pool amounts should not change."
|
|
||||||
multiworld.itempool[:] = new_items
|
|
||||||
|
|
||||||
# temporary home for item links, should be moved out of Main
|
# 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]):
|
||||||
Optional[Dict[int, Dict[str, int]]], Optional[Dict[str, int]]
|
classifications = 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
|
||||||
@@ -194,7 +153,7 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
|||||||
for player in players.copy():
|
for player in players.copy():
|
||||||
if all([counters[player][item] == 0 for item in shared_pool]):
|
if all([counters[player][item] == 0 for item in shared_pool]):
|
||||||
players.remove(player)
|
players.remove(player)
|
||||||
del (counters[player])
|
del(counters[player])
|
||||||
|
|
||||||
if not players:
|
if not players:
|
||||||
return None, None
|
return None, None
|
||||||
@@ -206,14 +165,14 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
|||||||
counters[player][item] = count
|
counters[player][item] = count
|
||||||
else:
|
else:
|
||||||
for player in players:
|
for player in players:
|
||||||
del (counters[player][item])
|
del(counters[player][item])
|
||||||
return counters, classifications
|
return counters, classifications
|
||||||
|
|
||||||
common_item_count, classifications = find_common_pool(group["players"], group["item_pool"])
|
common_item_count, classifications = find_common_pool(group["players"], group["item_pool"])
|
||||||
if not common_item_count:
|
if not common_item_count:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
new_itempool: List[Item] = []
|
new_itempool = []
|
||||||
for item_name, item_count in next(iter(common_item_count.values())).items():
|
for item_name, item_count in next(iter(common_item_count.values())).items():
|
||||||
for _ in range(item_count):
|
for _ in range(item_count):
|
||||||
new_item = group["world"].create_item(item_name)
|
new_item = group["world"].create_item(item_name)
|
||||||
@@ -221,13 +180,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", RegionType.Generic, "ItemLink", group_id, world)
|
||||||
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,75 +197,126 @@ 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"]:
|
|
||||||
item_player = group_id
|
|
||||||
else:
|
|
||||||
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", 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", 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:
|
||||||
balance_multiworld_progression(multiworld)
|
balance_multiworld_progression(world)
|
||||||
else:
|
|
||||||
logger.info("Progression balancing skipped.")
|
|
||||||
|
|
||||||
# we're about to output using multithreading, so we're removing the global random state to prevent accidental use
|
|
||||||
multiworld.random.passthrough = False
|
|
||||||
|
|
||||||
if args.skip_output:
|
|
||||||
logger.info('Done. Skipped output/spoiler generation. Total Time: %s', time.perf_counter() - start)
|
|
||||||
return multiworld
|
|
||||||
|
|
||||||
logger.info(f'Beginning output...')
|
logger.info(f'Beginning output...')
|
||||||
outfilebase = 'AP_' + multiworld.seed_name
|
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))
|
||||||
|
|
||||||
|
def get_entrance_to_region(region: Region):
|
||||||
|
for entrance in region.entrances:
|
||||||
|
if entrance.parent_region.type in (RegionType.DarkWorld, RegionType.LightWorld, RegionType.Generic):
|
||||||
|
return entrance
|
||||||
|
for entrance in region.entrances: # BFS might be better here, trying DFS for now.
|
||||||
|
return get_entrance_to_region(entrance.parent_region)
|
||||||
|
|
||||||
# collect ER hint info
|
# collect ER hint info
|
||||||
er_hint_data: Dict[int, Dict[int, str]] = {}
|
er_hint_data = {player: {} for player in world.get_game_players("A Link to the Past") if
|
||||||
AutoWorld.call_all(multiworld, 'extend_hint_information', er_hint_data)
|
world.shuffle[player] != "vanilla" or world.retro_caves[player]}
|
||||||
|
|
||||||
|
for region in world.regions:
|
||||||
|
if region.player in er_hint_data and region.locations:
|
||||||
|
main_entrance = get_entrance_to_region(region)
|
||||||
|
for location in region.locations:
|
||||||
|
if type(location.address) == int: # skips events and crystals
|
||||||
|
if lookup_vanilla_location_to_entrance[location.address] != main_entrance.name:
|
||||||
|
er_hint_data[region.player][location.address] = main_entrance.name
|
||||||
|
|
||||||
|
checks_in_area = {player: {area: list() for area in ordered_areas}
|
||||||
|
for player in range(1, world.players + 1)}
|
||||||
|
|
||||||
|
for player in range(1, world.players + 1):
|
||||||
|
checks_in_area[player]["Total"] = 0
|
||||||
|
|
||||||
|
for location in world.get_filled_locations():
|
||||||
|
if type(location.address) is int:
|
||||||
|
main_entrance = get_entrance_to_region(location.parent_region)
|
||||||
|
if location.game != "A Link to the Past":
|
||||||
|
checks_in_area[location.player]["Light World"].append(location.address)
|
||||||
|
elif location.parent_region.dungeon:
|
||||||
|
dungeonname = {'Inverted Agahnims Tower': 'Agahnims Tower',
|
||||||
|
'Inverted Ganons Tower': 'Ganons Tower'} \
|
||||||
|
.get(location.parent_region.dungeon.name, location.parent_region.dungeon.name)
|
||||||
|
checks_in_area[location.player][dungeonname].append(location.address)
|
||||||
|
elif location.parent_region.type == RegionType.LightWorld:
|
||||||
|
checks_in_area[location.player]["Light World"].append(location.address)
|
||||||
|
elif location.parent_region.type == RegionType.DarkWorld:
|
||||||
|
checks_in_area[location.player]["Dark World"].append(location.address)
|
||||||
|
elif main_entrance.parent_region.type == RegionType.LightWorld:
|
||||||
|
checks_in_area[location.player]["Light World"].append(location.address)
|
||||||
|
elif main_entrance.parent_region.type == RegionType.DarkWorld:
|
||||||
|
checks_in_area[location.player]["Dark World"].append(location.address)
|
||||||
|
checks_in_area[location.player]["Total"] += 1
|
||||||
|
|
||||||
|
oldmancaves = []
|
||||||
|
takeanyregions = ["Old Man Sword Cave", "Take-Any #1", "Take-Any #2", "Take-Any #3", "Take-Any #4"]
|
||||||
|
for index, take_any in enumerate(takeanyregions):
|
||||||
|
for region in [world.get_region(take_any, player) for player in
|
||||||
|
world.get_game_players("A Link to the Past") if world.retro_caves[player]]:
|
||||||
|
item = world.create_item(
|
||||||
|
region.shop.inventory[(0 if take_any == "Old Man Sword Cave" else 1)]['item'],
|
||||||
|
region.player)
|
||||||
|
player = region.player
|
||||||
|
location_id = SHOP_ID_START + total_shop_slots + index
|
||||||
|
|
||||||
|
main_entrance = get_entrance_to_region(region)
|
||||||
|
if main_entrance.parent_region.type == RegionType.LightWorld:
|
||||||
|
checks_in_area[player]["Light World"].append(location_id)
|
||||||
|
else:
|
||||||
|
checks_in_area[player]["Dark World"].append(location_id)
|
||||||
|
checks_in_area[player]["Total"] += 1
|
||||||
|
|
||||||
|
er_hint_data[player][location_id] = main_entrance.name
|
||||||
|
oldmancaves.append(((location_id, player), (item.code, player)))
|
||||||
|
|
||||||
|
FillDisabledShopSlots(world)
|
||||||
|
|
||||||
def write_multidata():
|
def write_multidata():
|
||||||
import NetUtils
|
import NetUtils
|
||||||
@@ -315,67 +325,63 @@ 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:
|
|
||||||
slot_data[slot] = multiworld.worlds[slot].fill_slot_data()
|
for slot in world.player_ids:
|
||||||
|
slot_data[slot] = world.worlds[slot].fill_slot_data()
|
||||||
|
|
||||||
def precollect_hint(location):
|
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
|
|
||||||
data_package = {
|
|
||||||
game_world.game: worlds.network_data_package["games"][game_world.game]
|
|
||||||
for game_world in multiworld.worlds.values()
|
|
||||||
}
|
|
||||||
|
|
||||||
checks_in_area: Dict[int, Dict[str, Union[int, List[int]]]] = {}
|
|
||||||
|
|
||||||
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()},
|
"names": names, # TODO: remove around 0.2.5 in favor of slot_info
|
||||||
|
"games": games, # TODO: remove around 0.2.5 in favor of slot_info
|
||||||
|
"connect_names": {name: (0, player) for player, name in world.player_name.items()},
|
||||||
|
"remote_items": {player for player in world.player_ids if
|
||||||
|
world.worlds[player].remote_items},
|
||||||
|
"remote_start_inventory": {player for player in world.player_ids if
|
||||||
|
world.worlds[player].remote_start_inventory},
|
||||||
"locations": locations_data,
|
"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 +391,9 @@ 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,
|
|
||||||
}
|
}
|
||||||
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 +401,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 +417,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)
|
create_playthrough(world)
|
||||||
|
|
||||||
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 +430,144 @@ 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
|
||||||
|
|
||||||
|
|
||||||
|
def create_playthrough(world):
|
||||||
|
"""Destructive to the world while it is run, damage gets repaired afterwards."""
|
||||||
|
# get locations containing progress items
|
||||||
|
prog_locations = {location for location in world.get_filled_locations() if location.item.advancement}
|
||||||
|
state_cache = [None]
|
||||||
|
collection_spheres = []
|
||||||
|
state = CollectionState(world)
|
||||||
|
sphere_candidates = set(prog_locations)
|
||||||
|
logging.debug('Building up collection spheres.')
|
||||||
|
while sphere_candidates:
|
||||||
|
|
||||||
|
# build up spheres of collection radius.
|
||||||
|
# Everything in each sphere is independent from each other in dependencies and only depends on lower spheres
|
||||||
|
|
||||||
|
sphere = {location for location in sphere_candidates if state.can_reach(location)}
|
||||||
|
|
||||||
|
for location in sphere:
|
||||||
|
state.collect(location.item, True, location)
|
||||||
|
|
||||||
|
sphere_candidates -= sphere
|
||||||
|
collection_spheres.append(sphere)
|
||||||
|
state_cache.append(state.copy())
|
||||||
|
|
||||||
|
logging.debug('Calculated sphere %i, containing %i of %i progress items.', len(collection_spheres), len(sphere),
|
||||||
|
len(prog_locations))
|
||||||
|
if not sphere:
|
||||||
|
logging.debug('The following items could not be reached: %s', ['%s (Player %d) at %s (Player %d)' % (
|
||||||
|
location.item.name, location.item.player, location.name, location.player) for location in
|
||||||
|
sphere_candidates])
|
||||||
|
if any([world.accessibility[location.item.player] != 'minimal' for location in sphere_candidates]):
|
||||||
|
raise RuntimeError(f'Not all progression items reachable ({sphere_candidates}). '
|
||||||
|
f'Something went terribly wrong here.')
|
||||||
|
else:
|
||||||
|
world.spoiler.unreachables = sphere_candidates
|
||||||
|
break
|
||||||
|
|
||||||
|
# in the second phase, we cull each sphere such that the game is still beatable,
|
||||||
|
# reducing each range of influence to the bare minimum required inside it
|
||||||
|
restore_later = {}
|
||||||
|
for num, sphere in reversed(tuple(enumerate(collection_spheres))):
|
||||||
|
to_delete = set()
|
||||||
|
for location in sphere:
|
||||||
|
# we remove the item at location and check if game is still beatable
|
||||||
|
logging.debug('Checking if %s (Player %d) is required to beat the game.', location.item.name,
|
||||||
|
location.item.player)
|
||||||
|
old_item = location.item
|
||||||
|
location.item = None
|
||||||
|
if world.can_beat_game(state_cache[num]):
|
||||||
|
to_delete.add(location)
|
||||||
|
restore_later[location] = old_item
|
||||||
|
else:
|
||||||
|
# still required, got to keep it around
|
||||||
|
location.item = old_item
|
||||||
|
|
||||||
|
# cull entries in spheres for spoiler walkthrough at end
|
||||||
|
sphere -= to_delete
|
||||||
|
|
||||||
|
# second phase, sphere 0
|
||||||
|
removed_precollected = []
|
||||||
|
for item in (i for i in chain.from_iterable(world.precollected_items.values()) if i.advancement):
|
||||||
|
logging.debug('Checking if %s (Player %d) is required to beat the game.', item.name, item.player)
|
||||||
|
world.precollected_items[item.player].remove(item)
|
||||||
|
world.state.remove(item)
|
||||||
|
if not world.can_beat_game():
|
||||||
|
world.push_precollected(item)
|
||||||
|
else:
|
||||||
|
removed_precollected.append(item)
|
||||||
|
|
||||||
|
# we are now down to just the required progress items in collection_spheres. Unfortunately
|
||||||
|
# the previous pruning stage could potentially have made certain items dependant on others
|
||||||
|
# in the same or later sphere (because the location had 2 ways to access but the item originally
|
||||||
|
# used to access it was deemed not required.) So we need to do one final sphere collection pass
|
||||||
|
# to build up the correct spheres
|
||||||
|
|
||||||
|
required_locations = {item for sphere in collection_spheres for item in sphere}
|
||||||
|
state = CollectionState(world)
|
||||||
|
collection_spheres = []
|
||||||
|
while required_locations:
|
||||||
|
state.sweep_for_events(key_only=True)
|
||||||
|
|
||||||
|
sphere = set(filter(state.can_reach, required_locations))
|
||||||
|
|
||||||
|
for location in sphere:
|
||||||
|
state.collect(location.item, True, location)
|
||||||
|
|
||||||
|
required_locations -= sphere
|
||||||
|
|
||||||
|
collection_spheres.append(sphere)
|
||||||
|
|
||||||
|
logging.debug('Calculated final sphere %i, containing %i of %i progress items.', len(collection_spheres),
|
||||||
|
len(sphere), len(required_locations))
|
||||||
|
if not sphere:
|
||||||
|
raise RuntimeError(f'Not all required items reachable. Unreachable locations: {required_locations}')
|
||||||
|
|
||||||
|
def flist_to_iter(node):
|
||||||
|
while node:
|
||||||
|
value, node = node
|
||||||
|
yield value
|
||||||
|
|
||||||
|
def get_path(state, region):
|
||||||
|
reversed_path_as_flist = state.path.get(region, (region, None))
|
||||||
|
string_path_flat = reversed(list(map(str, flist_to_iter(reversed_path_as_flist))))
|
||||||
|
# Now we combine the flat string list into (region, exit) pairs
|
||||||
|
pathsiter = iter(string_path_flat)
|
||||||
|
pathpairs = zip_longest(pathsiter, pathsiter)
|
||||||
|
return list(pathpairs)
|
||||||
|
|
||||||
|
world.spoiler.paths = {}
|
||||||
|
topology_worlds = (player for player in world.player_ids if world.worlds[player].topology_present)
|
||||||
|
for player in topology_worlds:
|
||||||
|
world.spoiler.paths.update(
|
||||||
|
{str(location): get_path(state, location.parent_region) for sphere in collection_spheres for location in
|
||||||
|
sphere if location.player == player})
|
||||||
|
if player in world.get_game_players("A Link to the Past"):
|
||||||
|
# If Pyramid Fairy Entrance needs to be reached, also path to Big Bomb Shop
|
||||||
|
# Maybe move the big bomb over to the Event system instead?
|
||||||
|
if any(exit_path == 'Pyramid Fairy' for path in world.spoiler.paths.values() for (_, exit_path) in path):
|
||||||
|
if world.mode[player] != 'inverted':
|
||||||
|
world.spoiler.paths[str(world.get_region('Big Bomb Shop', player))] = \
|
||||||
|
get_path(state, world.get_region('Big Bomb Shop', player))
|
||||||
|
else:
|
||||||
|
world.spoiler.paths[str(world.get_region('Inverted Big Bomb Shop', player))] = \
|
||||||
|
get_path(state, world.get_region('Inverted Big Bomb Shop', player))
|
||||||
|
|
||||||
|
# we can finally output our playthrough
|
||||||
|
world.spoiler.playthrough = {"0": sorted([str(item) for item in
|
||||||
|
chain.from_iterable(world.precollected_items.values())
|
||||||
|
if item.advancement])}
|
||||||
|
|
||||||
|
for i, sphere in enumerate(collection_spheres):
|
||||||
|
world.spoiler.playthrough[str(i + 1)] = {str(location): str(location.item) for location in sorted(sphere)}
|
||||||
|
|
||||||
|
# repair the world again
|
||||||
|
for location, item in restore_later.items():
|
||||||
|
location.item = item
|
||||||
|
|
||||||
|
for item in removed_precollected:
|
||||||
|
world.push_precollected(item)
|
||||||
|
|||||||
@@ -77,34 +77,49 @@ def read_apmc_file(apmc_file):
|
|||||||
return json.loads(b64decode(f.read()))
|
return json.loads(b64decode(f.read()))
|
||||||
|
|
||||||
|
|
||||||
def update_mod(forge_dir, url: str):
|
def update_mod(forge_dir, minecraft_version: str, get_prereleases=False):
|
||||||
"""Check mod version, download new mod from GitHub releases page if needed. """
|
"""Check mod version, download new mod from GitHub releases page if needed. """
|
||||||
ap_randomizer = find_ap_randomizer_jar(forge_dir)
|
ap_randomizer = find_ap_randomizer_jar(forge_dir)
|
||||||
os.path.basename(url)
|
|
||||||
if ap_randomizer is not None:
|
|
||||||
logging.info(f"Your current mod is {ap_randomizer}.")
|
|
||||||
else:
|
|
||||||
logging.info(f"You do not have the AP randomizer mod installed.")
|
|
||||||
|
|
||||||
if ap_randomizer != os.path.basename(url):
|
client_releases_endpoint = "https://api.github.com/repos/KonoTyran/Minecraft_AP_Randomizer/releases"
|
||||||
logging.info(f"A new release of the Minecraft AP randomizer mod was found: "
|
resp = requests.get(client_releases_endpoint)
|
||||||
f"{os.path.basename(url)}")
|
if resp.status_code == 200: # OK
|
||||||
if prompt_yes_no("Would you like to update?"):
|
try:
|
||||||
old_ap_mod = os.path.join(forge_dir, 'mods', ap_randomizer) if ap_randomizer is not None else None
|
latest_release = next(filter(lambda release: (not release['prerelease'] or get_prereleases) and
|
||||||
new_ap_mod = os.path.join(forge_dir, 'mods', os.path.basename(url))
|
(minecraft_version in release['assets'][0]['name']),
|
||||||
logging.info("Downloading AP randomizer mod. This may take a moment...")
|
resp.json()))
|
||||||
apmod_resp = requests.get(url)
|
if ap_randomizer != latest_release['assets'][0]['name']:
|
||||||
if apmod_resp.status_code == 200:
|
logging.info(f"A new release of the Minecraft AP randomizer mod was found: "
|
||||||
with open(new_ap_mod, 'wb') as f:
|
f"{latest_release['assets'][0]['name']}")
|
||||||
f.write(apmod_resp.content)
|
if ap_randomizer is not None:
|
||||||
logging.info(f"Wrote new mod file to {new_ap_mod}")
|
logging.info(f"Your current mod is {ap_randomizer}.")
|
||||||
if old_ap_mod is not None:
|
else:
|
||||||
os.remove(old_ap_mod)
|
logging.info(f"You do not have the AP randomizer mod installed.")
|
||||||
logging.info(f"Removed old mod file from {old_ap_mod}")
|
if prompt_yes_no("Would you like to update?"):
|
||||||
else:
|
old_ap_mod = os.path.join(forge_dir, 'mods', ap_randomizer) if ap_randomizer is not None else None
|
||||||
logging.error(f"Error retrieving the randomizer mod (status code {apmod_resp.status_code}).")
|
new_ap_mod = os.path.join(forge_dir, 'mods', latest_release['assets'][0]['name'])
|
||||||
logging.error(f"Please report this issue on the Archipelago Discord server.")
|
logging.info("Downloading AP randomizer mod. This may take a moment...")
|
||||||
sys.exit(1)
|
apmod_resp = requests.get(latest_release['assets'][0]['browser_download_url'])
|
||||||
|
if apmod_resp.status_code == 200:
|
||||||
|
with open(new_ap_mod, 'wb') as f:
|
||||||
|
f.write(apmod_resp.content)
|
||||||
|
logging.info(f"Wrote new mod file to {new_ap_mod}")
|
||||||
|
if old_ap_mod is not None:
|
||||||
|
os.remove(old_ap_mod)
|
||||||
|
logging.info(f"Removed old mod file from {old_ap_mod}")
|
||||||
|
else:
|
||||||
|
logging.error(f"Error retrieving the randomizer mod (status code {apmod_resp.status_code}).")
|
||||||
|
logging.error(f"Please report this issue on the Archipelago Discord server.")
|
||||||
|
sys.exit(1)
|
||||||
|
except StopIteration:
|
||||||
|
logging.warning(f"No compatible mod version found for {minecraft_version}.")
|
||||||
|
if not prompt_yes_no("Run server anyway?"):
|
||||||
|
sys.exit(0)
|
||||||
|
else:
|
||||||
|
logging.error(f"Error checking for randomizer mod updates (status code {resp.status_code}).")
|
||||||
|
logging.error(f"If this was not expected, please report this issue on the Archipelago Discord server.")
|
||||||
|
if not prompt_yes_no("Continue anyways?"):
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
|
||||||
def check_eula(forge_dir):
|
def check_eula(forge_dir):
|
||||||
@@ -249,13 +264,8 @@ def get_minecraft_versions(version, release_channel="release"):
|
|||||||
return next(filter(lambda entry: entry["version"] == version, data[release_channel]))
|
return next(filter(lambda entry: entry["version"] == version, data[release_channel]))
|
||||||
else:
|
else:
|
||||||
return resp.json()[release_channel][0]
|
return resp.json()[release_channel][0]
|
||||||
except (StopIteration, KeyError):
|
except StopIteration:
|
||||||
logging.error(f"No compatible mod version found for client version {version} on \"{release_channel}\" channel.")
|
logging.error(f"No compatible mod version found for client version {version}.")
|
||||||
if release_channel != "release":
|
|
||||||
logging.error("Consider switching \"release_channel\" to \"release\" in your Host.yaml file")
|
|
||||||
else:
|
|
||||||
logging.error("No suitable mod found on the \"release\" channel. Please Contact us on discord to report this error.")
|
|
||||||
sys.exit(0)
|
|
||||||
|
|
||||||
|
|
||||||
def is_correct_forge(forge_dir) -> bool:
|
def is_correct_forge(forge_dir) -> bool:
|
||||||
@@ -276,8 +286,6 @@ if __name__ == '__main__':
|
|||||||
help="specify java version.")
|
help="specify java version.")
|
||||||
parser.add_argument('--forge', '-f', metavar='1.18.2-40.1.0', dest='forge', type=str, default=False, action='store',
|
parser.add_argument('--forge', '-f', metavar='1.18.2-40.1.0', dest='forge', type=str, default=False, action='store',
|
||||||
help="specify forge version. (Minecraft Version-Forge Version)")
|
help="specify forge version. (Minecraft Version-Forge Version)")
|
||||||
parser.add_argument('--version', '-v', metavar='9', dest='data_version', type=int, action='store',
|
|
||||||
help="specify Mod data version to download.")
|
|
||||||
|
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
apmc_file = os.path.abspath(args.apmc_file) if args.apmc_file else None
|
apmc_file = os.path.abspath(args.apmc_file) if args.apmc_file else None
|
||||||
@@ -288,22 +296,21 @@ if __name__ == '__main__':
|
|||||||
options = Utils.get_options()
|
options = Utils.get_options()
|
||||||
channel = args.channel or options["minecraft_options"]["release_channel"]
|
channel = args.channel or options["minecraft_options"]["release_channel"]
|
||||||
apmc_data = None
|
apmc_data = None
|
||||||
data_version = args.data_version or None
|
data_version = None
|
||||||
|
|
||||||
if apmc_file is None and not args.install:
|
if apmc_file is None and not args.install:
|
||||||
apmc_file = Utils.open_filename('Select APMC file', (('APMC File', ('.apmc',)),))
|
apmc_file = Utils.open_filename('Select APMC file', (('APMC File', ('.apmc',)),))
|
||||||
|
|
||||||
if apmc_file is not None and data_version is None:
|
if apmc_file is not None:
|
||||||
apmc_data = read_apmc_file(apmc_file)
|
apmc_data = read_apmc_file(apmc_file)
|
||||||
data_version = apmc_data.get('client_version', '')
|
data_version = apmc_data.get('client_version', '')
|
||||||
|
|
||||||
versions = get_minecraft_versions(data_version, channel)
|
versions = get_minecraft_versions(data_version, channel)
|
||||||
|
|
||||||
forge_dir = options["minecraft_options"]["forge_directory"]
|
forge_dir = Utils.user_path(options["minecraft_options"]["forge_directory"])
|
||||||
max_heap = options["minecraft_options"]["max_heap_size"]
|
max_heap = options["minecraft_options"]["max_heap_size"]
|
||||||
forge_version = args.forge or versions["forge"]
|
forge_version = args.forge or versions["forge"]
|
||||||
java_version = args.java or versions["java"]
|
java_version = args.java or versions["java"]
|
||||||
mod_url = versions["url"]
|
|
||||||
java_dir = find_jdk_dir(java_version)
|
java_dir = find_jdk_dir(java_version)
|
||||||
|
|
||||||
if args.install:
|
if args.install:
|
||||||
@@ -337,7 +344,7 @@ if __name__ == '__main__':
|
|||||||
if not max_heap_re.match(max_heap):
|
if not max_heap_re.match(max_heap):
|
||||||
raise Exception(f"Max heap size {max_heap} in incorrect format. Use a number followed by M or G, e.g. 512M or 2G.")
|
raise Exception(f"Max heap size {max_heap} in incorrect format. Use a number followed by M or G, e.g. 512M or 2G.")
|
||||||
|
|
||||||
update_mod(forge_dir, mod_url)
|
update_mod(forge_dir, f"MC{forge_version.split('-')[0]}", channel != "release")
|
||||||
replace_apmc_files(forge_dir, apmc_file)
|
replace_apmc_files(forge_dir, apmc_file)
|
||||||
check_eula(forge_dir)
|
check_eula(forge_dir)
|
||||||
server_process = run_forge_server(forge_dir, java_version, max_heap)
|
server_process = run_forge_server(forge_dir, java_version, max_heap)
|
||||||
|
|||||||
121
ModuleUpdate.py
121
ModuleUpdate.py
@@ -1,142 +1,57 @@
|
|||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
import subprocess
|
import subprocess
|
||||||
import multiprocessing
|
import pkg_resources
|
||||||
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)
|
update_ran = getattr(sys, "frozen", False) # don't run update if environment is frozen/compiled
|
||||||
_skip_update = bool(getattr(sys, "frozen", False) or multiprocessing.parent_process())
|
|
||||||
update_ran = _skip_update
|
|
||||||
|
|
||||||
|
|
||||||
class RequirementsSet(set):
|
|
||||||
def add(self, e):
|
|
||||||
global update_ran
|
|
||||||
update_ran &= _skip_update
|
|
||||||
super().add(e)
|
|
||||||
|
|
||||||
def update(self, *s):
|
|
||||||
global update_ran
|
|
||||||
update_ran &= _skip_update
|
|
||||||
super().update(*s)
|
|
||||||
|
|
||||||
|
|
||||||
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")):
|
||||||
# skip .* (hidden / disabled) folders
|
if entry.is_dir():
|
||||||
if not entry.name.startswith("."):
|
req_file = os.path.join(entry.path, "requirements.txt")
|
||||||
if entry.is_dir():
|
if os.path.exists(req_file):
|
||||||
req_file = os.path.join(entry.path, "requirements.txt")
|
requirements_files.add(req_file)
|
||||||
if os.path.exists(req_file):
|
|
||||||
requirements_files.add(req_file)
|
|
||||||
|
|
||||||
|
|
||||||
def check_pip():
|
|
||||||
# detect if pip is available
|
|
||||||
try:
|
|
||||||
import pip # noqa: F401
|
|
||||||
except ImportError:
|
|
||||||
raise RuntimeError("pip not available. Please install pip.")
|
|
||||||
|
|
||||||
|
|
||||||
def confirm(msg: str):
|
|
||||||
try:
|
|
||||||
input(f"\n{msg}")
|
|
||||||
except KeyboardInterrupt:
|
|
||||||
print("\nAborting")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
|
|
||||||
def update_command():
|
def update_command():
|
||||||
check_pip()
|
|
||||||
for file in requirements_files:
|
for file in requirements_files:
|
||||||
subprocess.call([sys.executable, "-m", "pip", "install", "-r", file, "--upgrade"])
|
subprocess.call([sys.executable, '-m', 'pip', 'install', '-r', file, '--upgrade'])
|
||||||
|
|
||||||
|
|
||||||
def install_pkg_resources(yes=False):
|
def update(yes=False, force=False):
|
||||||
try:
|
|
||||||
import pkg_resources # noqa: F401
|
|
||||||
except ImportError:
|
|
||||||
check_pip()
|
|
||||||
if not yes:
|
|
||||||
confirm("pkg_resources not found, press enter to install it")
|
|
||||||
subprocess.call([sys.executable, "-m", "pip", "install", "--upgrade", "setuptools"])
|
|
||||||
|
|
||||||
|
|
||||||
def update(yes: bool = False, force: bool = False) -> None:
|
|
||||||
global update_ran
|
global update_ran
|
||||||
if not update_ran:
|
if not update_ran:
|
||||||
update_ran = True
|
update_ran = True
|
||||||
|
|
||||||
if force:
|
if force:
|
||||||
update_command()
|
update_command()
|
||||||
return
|
return
|
||||||
|
|
||||||
install_pkg_resources(yes=yes)
|
|
||||||
import pkg_resources
|
|
||||||
|
|
||||||
prev = "" # if a line ends in \ we store here and merge later
|
|
||||||
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 line.startswith('https://'):
|
||||||
if not prev:
|
# extract name and version from url
|
||||||
continue # ignore comments
|
wheel = line.split('/')[-1]
|
||||||
line = ""
|
name, version, _ = wheel.split('-', 2)
|
||||||
elif line.rstrip("\r\n").endswith("\\"):
|
line = f'{name}=={version}'
|
||||||
prev = prev + line.rstrip("\r\n")[:-1] + " " # continue on next line
|
|
||||||
continue
|
|
||||||
line = prev + line
|
|
||||||
line = line.split("--hash=")[0] # remove hashes from requirement for version checking
|
|
||||||
prev = ""
|
|
||||||
if line.startswith(("https://", "git+https://")):
|
|
||||||
# extract name and version for url
|
|
||||||
rest = line.split('/')[-1]
|
|
||||||
line = ""
|
|
||||||
if "#egg=" in rest:
|
|
||||||
# from egg info
|
|
||||||
rest, egg = rest.split("#egg=", 1)
|
|
||||||
egg = egg.split(";", 1)[0].rstrip()
|
|
||||||
if any(compare in egg for compare in ("==", ">=", ">", "<", "<=", "!=")):
|
|
||||||
warnings.warn(f"Specifying version as #egg={egg} will become unavailable in pip 25.0. "
|
|
||||||
"Use name @ url#version instead.", DeprecationWarning)
|
|
||||||
line = egg
|
|
||||||
else:
|
|
||||||
egg = ""
|
|
||||||
if "@" in rest and not line:
|
|
||||||
raise ValueError("Can't deduce version from requirement")
|
|
||||||
elif not line:
|
|
||||||
# from filename
|
|
||||||
rest = rest.replace(".zip", "-").replace(".tar.gz", "-")
|
|
||||||
name, version, _ = rest.split("-", 2)
|
|
||||||
line = f'{egg or name}=={version}'
|
|
||||||
elif "@" in line and "#" in line:
|
|
||||||
# PEP 508 does not allow us to specify a version, so we use custom syntax
|
|
||||||
# name @ url#version ; marker
|
|
||||||
name, rest = line.split("@", 1)
|
|
||||||
version = rest.split("#", 1)[1].split(";", 1)[0].rstrip()
|
|
||||||
line = f"{name.rstrip()}=={version}"
|
|
||||||
if ";" in rest: # keep marker
|
|
||||||
line += rest[rest.find(";"):]
|
|
||||||
requirements = pkg_resources.parse_requirements(line)
|
requirements = pkg_resources.parse_requirements(line)
|
||||||
for requirement in map(str, requirements):
|
for requirement in requirements:
|
||||||
|
requirement = str(requirement)
|
||||||
try:
|
try:
|
||||||
pkg_resources.require(requirement)
|
pkg_resources.require(requirement)
|
||||||
except pkg_resources.ResolutionError:
|
except pkg_resources.ResolutionError:
|
||||||
if not yes:
|
if not yes:
|
||||||
import traceback
|
import traceback
|
||||||
traceback.print_exc()
|
traceback.print_exc()
|
||||||
confirm(f"Requirement {requirement} is not satisfied, press enter to install it")
|
input(f'Requirement {requirement} is not satisfied, press enter to install it')
|
||||||
update_command()
|
update_command()
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|||||||
1203
MultiServer.py
1203
MultiServer.py
File diff suppressed because it is too large
Load Diff
107
NetUtils.py
107
NetUtils.py
@@ -2,12 +2,11 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import typing
|
import typing
|
||||||
import enum
|
import enum
|
||||||
import warnings
|
|
||||||
from json import JSONEncoder, JSONDecoder
|
from json import JSONEncoder, JSONDecoder
|
||||||
|
|
||||||
import websockets
|
import websockets
|
||||||
|
|
||||||
from Utils import ByValue, Version
|
from Utils import Version
|
||||||
|
|
||||||
|
|
||||||
class JSONMessagePart(typing.TypedDict, total=False):
|
class JSONMessagePart(typing.TypedDict, total=False):
|
||||||
@@ -21,7 +20,7 @@ class JSONMessagePart(typing.TypedDict, total=False):
|
|||||||
flags: int
|
flags: int
|
||||||
|
|
||||||
|
|
||||||
class ClientStatus(ByValue, enum.IntEnum):
|
class ClientStatus(enum.IntEnum):
|
||||||
CLIENT_UNKNOWN = 0
|
CLIENT_UNKNOWN = 0
|
||||||
CLIENT_CONNECTED = 5
|
CLIENT_CONNECTED = 5
|
||||||
CLIENT_READY = 10
|
CLIENT_READY = 10
|
||||||
@@ -29,22 +28,22 @@ class ClientStatus(ByValue, enum.IntEnum):
|
|||||||
CLIENT_GOAL = 30
|
CLIENT_GOAL = 30
|
||||||
|
|
||||||
|
|
||||||
class SlotType(ByValue, enum.IntFlag):
|
class SlotType(enum.IntFlag):
|
||||||
spectator = 0b00
|
spectator = 0b00
|
||||||
player = 0b01
|
player = 0b01
|
||||||
group = 0b10
|
group = 0b10
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def always_goal(self) -> bool:
|
def always_goal(self) -> bool:
|
||||||
"""Mark this slot as having reached its goal instantly."""
|
"""Mark this slot has having reached its goal instantly."""
|
||||||
return self.value != 0b01
|
return self.value != 0b01
|
||||||
|
|
||||||
|
|
||||||
class Permission(ByValue, enum.IntFlag):
|
class Permission(enum.IntFlag):
|
||||||
disabled = 0b000 # 0, completely disables access
|
disabled = 0b000 # 0, completely disables access
|
||||||
enabled = 0b001 # 1, allows manual use
|
enabled = 0b001 # 1, allows manual use
|
||||||
goal = 0b010 # 2, allows manual use after goal completion
|
goal = 0b010 # 2, allows manual use after goal completion
|
||||||
auto = 0b110 # 6, forces use after goal completion, only works for release
|
auto = 0b110 # 6, forces use after goal completion, only works for forfeit
|
||||||
auto_enabled = 0b111 # 7, forces use after goal completion, allows manual use any time
|
auto_enabled = 0b111 # 7, forces use after goal completion, allows manual use any time
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -87,7 +86,7 @@ def _scan_for_TypedTuples(obj: typing.Any) -> typing.Any:
|
|||||||
data = obj._asdict()
|
data = obj._asdict()
|
||||||
data["class"] = obj.__class__.__name__
|
data["class"] = obj.__class__.__name__
|
||||||
return data
|
return data
|
||||||
if isinstance(obj, (tuple, list, set, frozenset)):
|
if isinstance(obj, (tuple, list, set)):
|
||||||
return tuple(_scan_for_TypedTuples(o) for o in obj)
|
return tuple(_scan_for_TypedTuples(o) for o in obj)
|
||||||
if isinstance(obj, dict):
|
if isinstance(obj, dict):
|
||||||
return {key: _scan_for_TypedTuples(value) for key, value in obj.items()}
|
return {key: _scan_for_TypedTuples(value) for key, value in obj.items()}
|
||||||
@@ -101,7 +100,7 @@ _encode = JSONEncoder(
|
|||||||
).encode
|
).encode
|
||||||
|
|
||||||
|
|
||||||
def encode(obj: typing.Any) -> str:
|
def encode(obj):
|
||||||
return _encode(_scan_for_TypedTuples(obj))
|
return _encode(_scan_for_TypedTuples(obj))
|
||||||
|
|
||||||
|
|
||||||
@@ -110,7 +109,7 @@ def get_any_version(data: dict) -> Version:
|
|||||||
return Version(int(data["major"]), int(data["minor"]), int(data["build"]))
|
return Version(int(data["major"]), int(data["minor"]), int(data["build"]))
|
||||||
|
|
||||||
|
|
||||||
allowlist = {
|
whitelist = {
|
||||||
"NetworkPlayer": NetworkPlayer,
|
"NetworkPlayer": NetworkPlayer,
|
||||||
"NetworkItem": NetworkItem,
|
"NetworkItem": NetworkItem,
|
||||||
"NetworkSlot": NetworkSlot
|
"NetworkSlot": NetworkSlot
|
||||||
@@ -126,7 +125,7 @@ def _object_hook(o: typing.Any) -> typing.Any:
|
|||||||
hook = custom_hooks.get(o.get("class", None), None)
|
hook = custom_hooks.get(o.get("class", None), None)
|
||||||
if hook:
|
if hook:
|
||||||
return hook(o)
|
return hook(o)
|
||||||
cls = allowlist.get(o.get("class", None), None)
|
cls = whitelist.get(o.get("class", None), None)
|
||||||
if cls:
|
if cls:
|
||||||
for key in tuple(o):
|
for key in tuple(o):
|
||||||
if key not in cls._fields:
|
if key not in cls._fields:
|
||||||
@@ -290,8 +289,8 @@ def add_json_item(parts: list, item_id: int, player: int = 0, item_flags: int =
|
|||||||
parts.append({"text": str(item_id), "player": player, "flags": item_flags, "type": JSONTypes.item_id, **kwargs})
|
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):
|
||||||
@@ -344,85 +343,3 @@ class Hint(typing.NamedTuple):
|
|||||||
@property
|
@property
|
||||||
def local(self):
|
def local(self):
|
||||||
return self.receiving_player == self.finding_player
|
return self.receiving_player == self.finding_player
|
||||||
|
|
||||||
|
|
||||||
class _LocationStore(dict, typing.MutableMapping[int, typing.Dict[int, typing.Tuple[int, int, int]]]):
|
|
||||||
def __init__(self, values: typing.MutableMapping[int, typing.Dict[int, typing.Tuple[int, int, int]]]):
|
|
||||||
super().__init__(values)
|
|
||||||
|
|
||||||
if not self:
|
|
||||||
raise ValueError(f"Rejecting game with 0 players")
|
|
||||||
|
|
||||||
if len(self) != max(self):
|
|
||||||
raise ValueError("Player IDs not continuous")
|
|
||||||
|
|
||||||
if len(self.get(0, {})):
|
|
||||||
raise ValueError("Invalid player id 0 for location")
|
|
||||||
|
|
||||||
def find_item(self, slots: typing.Set[int], seeked_item_id: int
|
|
||||||
) -> typing.Generator[typing.Tuple[int, int, int, int, int], None, None]:
|
|
||||||
for finding_player, check_data in self.items():
|
|
||||||
for location_id, (item_id, receiving_player, item_flags) in check_data.items():
|
|
||||||
if receiving_player in slots and item_id == seeked_item_id:
|
|
||||||
yield finding_player, location_id, item_id, receiving_player, item_flags
|
|
||||||
|
|
||||||
def get_for_player(self, slot: int) -> typing.Dict[int, typing.Set[int]]:
|
|
||||||
import collections
|
|
||||||
all_locations: typing.Dict[int, typing.Set[int]] = collections.defaultdict(set)
|
|
||||||
for source_slot, location_data in self.items():
|
|
||||||
for location_id, values in location_data.items():
|
|
||||||
if values[1] == slot:
|
|
||||||
all_locations[source_slot].add(location_id)
|
|
||||||
return all_locations
|
|
||||||
|
|
||||||
def get_checked(self, state: typing.Dict[typing.Tuple[int, int], typing.Set[int]], team: int, slot: int
|
|
||||||
) -> typing.List[int]:
|
|
||||||
checked = state[team, slot]
|
|
||||||
if not checked:
|
|
||||||
# This optimizes the case where everyone connects to a fresh game at the same time.
|
|
||||||
return []
|
|
||||||
return [location_id for
|
|
||||||
location_id in self[slot] if
|
|
||||||
location_id in checked]
|
|
||||||
|
|
||||||
def get_missing(self, state: typing.Dict[typing.Tuple[int, int], typing.Set[int]], team: int, slot: int
|
|
||||||
) -> typing.List[int]:
|
|
||||||
checked = state[team, slot]
|
|
||||||
if not checked:
|
|
||||||
# This optimizes the case where everyone connects to a fresh game at the same time.
|
|
||||||
return list(self[slot])
|
|
||||||
return [location_id for
|
|
||||||
location_id in self[slot] if
|
|
||||||
location_id not in checked]
|
|
||||||
|
|
||||||
def get_remaining(self, state: typing.Dict[typing.Tuple[int, int], typing.Set[int]], team: int, slot: int
|
|
||||||
) -> typing.List[int]:
|
|
||||||
checked = state[team, slot]
|
|
||||||
player_locations = self[slot]
|
|
||||||
return sorted([player_locations[location_id][0] for
|
|
||||||
location_id in player_locations if
|
|
||||||
location_id not in checked])
|
|
||||||
|
|
||||||
|
|
||||||
if typing.TYPE_CHECKING: # type-check with pure python implementation until we have a typing stub
|
|
||||||
LocationStore = _LocationStore
|
|
||||||
else:
|
|
||||||
try:
|
|
||||||
from _speedups import LocationStore
|
|
||||||
import _speedups
|
|
||||||
import os.path
|
|
||||||
if os.path.isfile("_speedups.pyx") and os.path.getctime(_speedups.__file__) < os.path.getctime("_speedups.pyx"):
|
|
||||||
warnings.warn(f"{_speedups.__file__} outdated! "
|
|
||||||
f"Please rebuild with `cythonize -b -i _speedups.pyx` or delete it!")
|
|
||||||
except ImportError:
|
|
||||||
try:
|
|
||||||
import pyximport
|
|
||||||
pyximport.install()
|
|
||||||
except ImportError:
|
|
||||||
pyximport = None
|
|
||||||
try:
|
|
||||||
from _speedups import LocationStore
|
|
||||||
except ImportError:
|
|
||||||
warnings.warn("_speedups not available. Falling back to pure python LocationStore. "
|
|
||||||
"Install a matching C++ compiler for your platform to compile _speedups.")
|
|
||||||
LocationStore = _LocationStore
|
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import argparse
|
|||||||
import logging
|
import logging
|
||||||
import random
|
import random
|
||||||
import os
|
import os
|
||||||
import zipfile
|
|
||||||
from itertools import chain
|
from itertools import chain
|
||||||
|
|
||||||
from BaseClasses import MultiWorld
|
from BaseClasses import MultiWorld
|
||||||
@@ -44,7 +43,7 @@ def adjustGUI():
|
|||||||
StringVar, IntVar, Checkbutton, Frame, Label, X, Entry, Button, \
|
StringVar, IntVar, Checkbutton, Frame, Label, X, Entry, Button, \
|
||||||
OptionMenu, filedialog, messagebox, ttk
|
OptionMenu, filedialog, messagebox, ttk
|
||||||
from argparse import Namespace
|
from argparse import Namespace
|
||||||
from Utils import __version__ as MWVersion
|
from Main import __version__ as MWVersion
|
||||||
|
|
||||||
window = tk.Tk()
|
window = tk.Tk()
|
||||||
window.wm_title(f"Archipelago {MWVersion} OoT Adjuster")
|
window.wm_title(f"Archipelago {MWVersion} OoT Adjuster")
|
||||||
@@ -195,10 +194,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.slot_seeds = {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)
|
||||||
@@ -218,18 +217,13 @@ def adjust(args):
|
|||||||
# Load up the ROM
|
# Load up the ROM
|
||||||
rom = Rom(file=args.rom, force_use=True)
|
rom = Rom(file=args.rom, force_use=True)
|
||||||
delete_zootdec = True
|
delete_zootdec = True
|
||||||
elif os.path.splitext(args.rom)[-1] in ['.apz5', '.zpf']:
|
elif os.path.splitext(args.rom)[-1] == '.apz5':
|
||||||
# Load vanilla ROM
|
# Load vanilla ROM
|
||||||
rom = Rom(file=args.vanilla_rom, force_use=True)
|
rom = Rom(file=args.vanilla_rom, force_use=True)
|
||||||
apz5_file = args.rom
|
|
||||||
base_name = os.path.splitext(apz5_file)[0]
|
|
||||||
# Patch file
|
# Patch file
|
||||||
apply_patch_file(rom, apz5_file,
|
apply_patch_file(rom, args.rom)
|
||||||
sub_file=(os.path.basename(base_name) + '.zpf'
|
|
||||||
if zipfile.is_zipfile(apz5_file)
|
|
||||||
else None))
|
|
||||||
else:
|
else:
|
||||||
raise Exception("Invalid file extension; requires .n64, .z64, .apz5, .zpf")
|
raise Exception("Invalid file extension; requires .n64, .z64, .apz5")
|
||||||
# Call patch_cosmetics
|
# Call patch_cosmetics
|
||||||
try:
|
try:
|
||||||
patch_cosmetics(ootworld, rom)
|
patch_cosmetics(ootworld, rom)
|
||||||
|
|||||||
93
OoTClient.py
93
OoTClient.py
@@ -3,23 +3,20 @@ import json
|
|||||||
import os
|
import os
|
||||||
import multiprocessing
|
import multiprocessing
|
||||||
import subprocess
|
import subprocess
|
||||||
import zipfile
|
|
||||||
from asyncio import StreamReader, StreamWriter
|
from asyncio import StreamReader, StreamWriter
|
||||||
|
|
||||||
# CommonClient import first to trigger ModuleUpdater
|
from CommonClient import CommonContext, server_loop, gui_enabled, console_loop, \
|
||||||
from CommonClient import CommonContext, server_loop, gui_enabled, \
|
|
||||||
ClientCommandProcessor, logger, get_base_parser
|
ClientCommandProcessor, logger, get_base_parser
|
||||||
import Utils
|
import Utils
|
||||||
from Utils import async_start
|
|
||||||
from worlds import network_data_package
|
from worlds import network_data_package
|
||||||
from worlds.oot.Rom import Rom, compress_rom_file
|
from worlds.oot.Rom import Rom, compress_rom_file
|
||||||
from worlds.oot.N64Patch import apply_patch_file
|
from worlds.oot.N64Patch import apply_patch_file
|
||||||
from worlds.oot.Utils import data_path
|
from worlds.oot.Utils import data_path
|
||||||
|
|
||||||
|
|
||||||
CONNECTION_TIMING_OUT_STATUS = "Connection timing out. Please restart your emulator, then restart connector_oot.lua"
|
CONNECTION_TIMING_OUT_STATUS = "Connection timing out. Please restart your emulator, then restart oot_connector.lua"
|
||||||
CONNECTION_REFUSED_STATUS = "Connection refused. Please start your emulator and make sure connector_oot.lua is running"
|
CONNECTION_REFUSED_STATUS = "Connection refused. Please start your emulator and make sure oot_connector.lua is running"
|
||||||
CONNECTION_RESET_STATUS = "Connection was reset. Please restart your emulator, then restart connector_oot.lua"
|
CONNECTION_RESET_STATUS = "Connection was reset. Please restart your emulator, then restart oot_connector.lua"
|
||||||
CONNECTION_TENTATIVE_STATUS = "Initial Connection Made"
|
CONNECTION_TENTATIVE_STATUS = "Initial Connection Made"
|
||||||
CONNECTION_CONNECTED_STATUS = "Connected"
|
CONNECTION_CONNECTED_STATUS = "Connected"
|
||||||
CONNECTION_INITIAL_STATUS = "Connection has not been initiated"
|
CONNECTION_INITIAL_STATUS = "Connection has not been initiated"
|
||||||
@@ -51,7 +48,7 @@ deathlink_sent_this_death: we interacted with the multiworld on this death, wait
|
|||||||
|
|
||||||
oot_loc_name_to_id = network_data_package["games"]["Ocarina of Time"]["location_name_to_id"]
|
oot_loc_name_to_id = network_data_package["games"]["Ocarina of Time"]["location_name_to_id"]
|
||||||
|
|
||||||
script_version: int = 3
|
script_version: int = 2
|
||||||
|
|
||||||
def get_item_value(ap_id):
|
def get_item_value(ap_id):
|
||||||
return ap_id - 66000
|
return ap_id - 66000
|
||||||
@@ -71,7 +68,7 @@ class OoTCommandProcessor(ClientCommandProcessor):
|
|||||||
if isinstance(self.ctx, OoTContext):
|
if isinstance(self.ctx, OoTContext):
|
||||||
self.ctx.deathlink_client_override = True
|
self.ctx.deathlink_client_override = True
|
||||||
self.ctx.deathlink_enabled = not self.ctx.deathlink_enabled
|
self.ctx.deathlink_enabled = not self.ctx.deathlink_enabled
|
||||||
async_start(self.ctx.update_death_link(self.ctx.deathlink_enabled), name="Update Deathlink")
|
asyncio.create_task(self.ctx.update_death_link(self.ctx.deathlink_enabled), name="Update Deathlink")
|
||||||
|
|
||||||
|
|
||||||
class OoTContext(CommonContext):
|
class OoTContext(CommonContext):
|
||||||
@@ -86,9 +83,6 @@ class OoTContext(CommonContext):
|
|||||||
self.n64_status = CONNECTION_INITIAL_STATUS
|
self.n64_status = CONNECTION_INITIAL_STATUS
|
||||||
self.awaiting_rom = False
|
self.awaiting_rom = False
|
||||||
self.location_table = {}
|
self.location_table = {}
|
||||||
self.collectible_table = {}
|
|
||||||
self.collectible_override_flags_address = 0
|
|
||||||
self.collectible_offsets = {}
|
|
||||||
self.deathlink_enabled = False
|
self.deathlink_enabled = False
|
||||||
self.deathlink_pending = False
|
self.deathlink_pending = False
|
||||||
self.deathlink_sent_this_death = False
|
self.deathlink_sent_this_death = False
|
||||||
@@ -100,7 +94,7 @@ class OoTContext(CommonContext):
|
|||||||
await super(OoTContext, self).server_auth(password_requested)
|
await super(OoTContext, self).server_auth(password_requested)
|
||||||
if not self.auth:
|
if not self.auth:
|
||||||
self.awaiting_rom = True
|
self.awaiting_rom = True
|
||||||
logger.info('Awaiting connection to EmuHawk to get player information')
|
logger.info('Awaiting connection to Bizhawk to get player information')
|
||||||
return
|
return
|
||||||
|
|
||||||
await self.send_connect()
|
await self.send_connect()
|
||||||
@@ -121,13 +115,6 @@ class OoTContext(CommonContext):
|
|||||||
self.ui = OoTManager(self)
|
self.ui = OoTManager(self)
|
||||||
self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI")
|
self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI")
|
||||||
|
|
||||||
def on_package(self, cmd, args):
|
|
||||||
if cmd == 'Connected':
|
|
||||||
slot_data = args.get('slot_data', None)
|
|
||||||
if slot_data:
|
|
||||||
self.collectible_override_flags_address = slot_data.get('collectible_override_flags', 0)
|
|
||||||
self.collectible_offsets = slot_data.get('collectible_flag_offsets', {})
|
|
||||||
|
|
||||||
|
|
||||||
def get_payload(ctx: OoTContext):
|
def get_payload(ctx: OoTContext):
|
||||||
if ctx.deathlink_enabled and ctx.deathlink_pending:
|
if ctx.deathlink_enabled and ctx.deathlink_pending:
|
||||||
@@ -136,32 +123,15 @@ def get_payload(ctx: OoTContext):
|
|||||||
else:
|
else:
|
||||||
trigger_death = False
|
trigger_death = False
|
||||||
|
|
||||||
payload = json.dumps({
|
return json.dumps({
|
||||||
"items": [get_item_value(item.item) for item in ctx.items_received],
|
"items": [get_item_value(item.item) for item in ctx.items_received],
|
||||||
"playerNames": [name for (i, name) in ctx.player_names.items() if i != 0],
|
"playerNames": [name for (i, name) in ctx.player_names.items() if i != 0],
|
||||||
"triggerDeath": trigger_death,
|
"triggerDeath": trigger_death
|
||||||
"collectibleOverrides": ctx.collectible_override_flags_address,
|
|
||||||
"collectibleOffsets": ctx.collectible_offsets
|
|
||||||
})
|
})
|
||||||
return payload
|
|
||||||
|
|
||||||
|
|
||||||
async def parse_payload(payload: dict, ctx: OoTContext, force: bool):
|
async def parse_payload(payload: dict, ctx: OoTContext, force: bool):
|
||||||
|
|
||||||
# Refuse to do anything if ROM is detected as changed
|
|
||||||
if ctx.auth and payload['playerName'] != ctx.auth:
|
|
||||||
logger.warning("ROM change detected. Disconnecting and reconnecting...")
|
|
||||||
ctx.deathlink_enabled = False
|
|
||||||
ctx.deathlink_client_override = False
|
|
||||||
ctx.finished_game = False
|
|
||||||
ctx.location_table = {}
|
|
||||||
ctx.collectible_table = {}
|
|
||||||
ctx.deathlink_pending = False
|
|
||||||
ctx.deathlink_sent_this_death = False
|
|
||||||
ctx.auth = payload['playerName']
|
|
||||||
await ctx.send_connect()
|
|
||||||
return
|
|
||||||
|
|
||||||
# Turn on deathlink if it is on, and if the client hasn't overriden it
|
# Turn on deathlink if it is on, and if the client hasn't overriden it
|
||||||
if payload['deathlinkActive'] and not ctx.deathlink_enabled and not ctx.deathlink_client_override:
|
if payload['deathlinkActive'] and not ctx.deathlink_enabled and not ctx.deathlink_client_override:
|
||||||
await ctx.update_death_link(True)
|
await ctx.update_death_link(True)
|
||||||
@@ -176,23 +146,11 @@ async def parse_payload(payload: dict, ctx: OoTContext, force: bool):
|
|||||||
ctx.finished_game = True
|
ctx.finished_game = True
|
||||||
|
|
||||||
# Locations handling
|
# Locations handling
|
||||||
locations = payload['locations']
|
if ctx.location_table != payload['locations']:
|
||||||
collectibles = payload['collectibles']
|
ctx.location_table = payload['locations']
|
||||||
|
|
||||||
# The Lua JSON library serializes an empty table into a list instead of a dict. Verify types for safety:
|
|
||||||
if isinstance(locations, list):
|
|
||||||
locations = {}
|
|
||||||
if isinstance(collectibles, list):
|
|
||||||
collectibles = {}
|
|
||||||
|
|
||||||
if ctx.location_table != locations or ctx.collectible_table != collectibles:
|
|
||||||
ctx.location_table = locations
|
|
||||||
ctx.collectible_table = collectibles
|
|
||||||
locs1 = [oot_loc_name_to_id[loc] for loc, b in ctx.location_table.items() if b]
|
|
||||||
locs2 = [int(loc) for loc, b in ctx.collectible_table.items() if b]
|
|
||||||
await ctx.send_msgs([{
|
await ctx.send_msgs([{
|
||||||
"cmd": "LocationChecks",
|
"cmd": "LocationChecks",
|
||||||
"locations": locs1 + locs2
|
"locations": [oot_loc_name_to_id[loc] for loc in ctx.location_table if ctx.location_table[loc]]
|
||||||
}])
|
}])
|
||||||
|
|
||||||
# Deathlink handling
|
# Deathlink handling
|
||||||
@@ -218,13 +176,20 @@ async def n64_sync_task(ctx: OoTContext):
|
|||||||
try:
|
try:
|
||||||
await asyncio.wait_for(writer.drain(), timeout=1.5)
|
await asyncio.wait_for(writer.drain(), timeout=1.5)
|
||||||
try:
|
try:
|
||||||
|
# Data will return a dict with up to six fields:
|
||||||
|
# 1. str: player name (always)
|
||||||
|
# 2. int: script version (always)
|
||||||
|
# 3. bool: deathlink active (always)
|
||||||
|
# 4. dict[str, bool]: checked locations
|
||||||
|
# 5. bool: whether Link is currently at 0 HP
|
||||||
|
# 6. bool: whether the game currently registers as complete
|
||||||
data = await asyncio.wait_for(reader.readline(), timeout=10)
|
data = await asyncio.wait_for(reader.readline(), timeout=10)
|
||||||
data_decoded = json.loads(data.decode())
|
data_decoded = json.loads(data.decode())
|
||||||
reported_version = data_decoded.get('scriptVersion', 0)
|
reported_version = data_decoded.get('scriptVersion', 0)
|
||||||
if reported_version >= script_version:
|
if reported_version >= script_version:
|
||||||
if ctx.game is not None and 'locations' in data_decoded:
|
if ctx.game is not None and 'locations' in data_decoded:
|
||||||
# Not just a keep alive ping, parse
|
# Not just a keep alive ping, parse
|
||||||
async_start(parse_payload(data_decoded, ctx, False))
|
asyncio.create_task(parse_payload(data_decoded, ctx, False))
|
||||||
if not ctx.auth:
|
if not ctx.auth:
|
||||||
ctx.auth = data_decoded['playerName']
|
ctx.auth = data_decoded['playerName']
|
||||||
if ctx.awaiting_rom:
|
if ctx.awaiting_rom:
|
||||||
@@ -290,27 +255,17 @@ async def run_game(romfile):
|
|||||||
|
|
||||||
|
|
||||||
async def patch_and_run_game(apz5_file):
|
async def patch_and_run_game(apz5_file):
|
||||||
apz5_file = os.path.abspath(apz5_file)
|
|
||||||
base_name = os.path.splitext(apz5_file)[0]
|
base_name = os.path.splitext(apz5_file)[0]
|
||||||
decomp_path = base_name + '-decomp.z64'
|
decomp_path = base_name + '-decomp.z64'
|
||||||
comp_path = base_name + '.z64'
|
comp_path = base_name + '.z64'
|
||||||
# Load vanilla ROM, patch file, compress ROM
|
# Load vanilla ROM, patch file, compress ROM
|
||||||
rom_file_name = Utils.get_options()["oot_options"]["rom_file"]
|
rom = Rom(Utils.local_path(Utils.get_options()["oot_options"]["rom_file"]))
|
||||||
rom = Rom(rom_file_name)
|
apply_patch_file(rom, apz5_file)
|
||||||
|
|
||||||
sub_file = None
|
|
||||||
if zipfile.is_zipfile(apz5_file):
|
|
||||||
for name in zipfile.ZipFile(apz5_file).namelist():
|
|
||||||
if name.endswith('.zpf'):
|
|
||||||
sub_file = name
|
|
||||||
break
|
|
||||||
|
|
||||||
apply_patch_file(rom, apz5_file, sub_file=sub_file)
|
|
||||||
rom.write_to_file(decomp_path)
|
rom.write_to_file(decomp_path)
|
||||||
os.chdir(data_path("Compress"))
|
os.chdir(data_path("Compress"))
|
||||||
compress_rom_file(decomp_path, comp_path)
|
compress_rom_file(decomp_path, comp_path)
|
||||||
os.remove(decomp_path)
|
os.remove(decomp_path)
|
||||||
async_start(run_game(comp_path))
|
asyncio.create_task(run_game(comp_path))
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
@@ -326,7 +281,7 @@ if __name__ == '__main__':
|
|||||||
|
|
||||||
if args.apz5_file:
|
if args.apz5_file:
|
||||||
logger.info("APZ5 file supplied, beginning patching process...")
|
logger.info("APZ5 file supplied, beginning patching process...")
|
||||||
async_start(patch_and_run_game(args.apz5_file))
|
asyncio.create_task(patch_and_run_game(args.apz5_file))
|
||||||
|
|
||||||
ctx = OoTContext(args.connect, args.password)
|
ctx = OoTContext(args.connect, args.password)
|
||||||
ctx.server_task = asyncio.create_task(server_loop(ctx), name="Server Loop")
|
ctx.server_task = asyncio.create_task(server_loop(ctx), name="Server Loop")
|
||||||
|
|||||||
594
Options.py
594
Options.py
@@ -1,37 +1,12 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import abc
|
import abc
|
||||||
import functools
|
|
||||||
import logging
|
|
||||||
import math
|
import math
|
||||||
import numbers
|
import numbers
|
||||||
import random
|
|
||||||
import typing
|
import typing
|
||||||
import enum
|
import random
|
||||||
from copy import deepcopy
|
|
||||||
from dataclasses import dataclass
|
|
||||||
|
|
||||||
from schema import And, Optional, Or, Schema
|
from schema import Schema, And, Or, Optional
|
||||||
|
from Utils import get_fuzzy_results
|
||||||
from Utils import get_fuzzy_results, is_iterable_except_str
|
|
||||||
|
|
||||||
if typing.TYPE_CHECKING:
|
|
||||||
from BaseClasses import PlandoOptions
|
|
||||||
from worlds.AutoWorld import World
|
|
||||||
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):
|
||||||
@@ -51,37 +26,15 @@ class AssembleOptions(abc.ABCMeta):
|
|||||||
|
|
||||||
attrs["name_lookup"].update({option_id: name for name, option_id in new_options.items()})
|
attrs["name_lookup"].update({option_id: name for name, option_id in new_options.items()})
|
||||||
options.update(new_options)
|
options.update(new_options)
|
||||||
|
|
||||||
# apply aliases, without name_lookup
|
# apply aliases, without name_lookup
|
||||||
aliases = {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
|
|
||||||
if "off" in options:
|
|
||||||
options["false"] = options["off"]
|
|
||||||
if "on" in options:
|
|
||||||
options["true"] = options["on"]
|
|
||||||
|
|
||||||
options.update(aliases)
|
options.update(aliases)
|
||||||
|
|
||||||
if "verify" not in attrs:
|
|
||||||
# not overridden by class -> look up bases
|
|
||||||
verifiers = [f for f in (getattr(base, "verify", None) for base in bases) if f]
|
|
||||||
if len(verifiers) > 1: # verify multiple bases/mixins
|
|
||||||
def verify(self, *args, **kwargs) -> None:
|
|
||||||
for f in verifiers:
|
|
||||||
f(self, *args, **kwargs)
|
|
||||||
|
|
||||||
attrs["verify"] = verify
|
|
||||||
else:
|
|
||||||
assert verifiers, "class Option is supposed to implement def verify"
|
|
||||||
|
|
||||||
# auto-validate schema on __init__
|
# auto-validate schema on __init__
|
||||||
if "schema" in attrs.keys():
|
if "schema" in attrs.keys():
|
||||||
|
|
||||||
@@ -115,8 +68,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,12 +78,11 @@ 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[int, 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.get_current_option_name()})"
|
||||||
|
|
||||||
def __hash__(self) -> int:
|
def __hash__(self) -> int:
|
||||||
return hash(self.value)
|
return hash(self.value)
|
||||||
@@ -140,9 +91,8 @@ 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]
|
||||||
|
|
||||||
@property
|
def get_current_option_name(self) -> str:
|
||||||
def current_option_name(self) -> str:
|
"""For display purposes."""
|
||||||
"""For display purposes. Worlds should be using current_key."""
|
|
||||||
return self.get_option_name(self.value)
|
return self.get_option_name(self.value)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -159,56 +109,11 @@ class Option(typing.Generic[T], metaclass=AssembleOptions):
|
|||||||
return bool(self.value)
|
return bool(self.value)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@abc.abstractmethod
|
|
||||||
def from_any(cls, data: typing.Any) -> Option[T]:
|
def from_any(cls, data: typing.Any) -> Option[T]:
|
||||||
...
|
raise NotImplementedError
|
||||||
|
|
||||||
if typing.TYPE_CHECKING:
|
|
||||||
def verify(self, world: typing.Type[World], player_name: str, plando_options: PlandoOptions) -> None:
|
|
||||||
pass
|
|
||||||
else:
|
|
||||||
def verify(self, *args, **kwargs) -> None:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class FreeText(Option[str]):
|
class NumericOption(Option[int], numbers.Integral):
|
||||||
"""Text option that allows users to enter strings.
|
|
||||||
Needs to be validated by the world or option definition."""
|
|
||||||
|
|
||||||
default = ""
|
|
||||||
|
|
||||||
def __init__(self, value: str):
|
|
||||||
assert isinstance(value, str), "value of FreeText must be a string"
|
|
||||||
self.value = value
|
|
||||||
|
|
||||||
@property
|
|
||||||
def current_key(self) -> str:
|
|
||||||
return self.value
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_text(cls, text: str) -> FreeText:
|
|
||||||
return cls(text)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_any(cls, data: typing.Any) -> FreeText:
|
|
||||||
return cls.from_text(str(data))
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def get_option_name(cls, value: str) -> str:
|
|
||||||
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):
|
|
||||||
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 +145,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 +281,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
|
||||||
@@ -400,7 +298,7 @@ class Toggle(NumericOption):
|
|||||||
if type(data) == str:
|
if type(data) == str:
|
||||||
return cls.from_text(data)
|
return cls.from_text(data)
|
||||||
else:
|
else:
|
||||||
return cls(int(data))
|
return cls(data)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_option_name(cls, value):
|
def get_option_name(cls, value):
|
||||||
@@ -470,169 +368,6 @@ class Choice(NumericOption):
|
|||||||
__hash__ = Option.__hash__ # see https://docs.python.org/3/reference/datamodel.html#object.__hash__
|
__hash__ = Option.__hash__ # see https://docs.python.org/3/reference/datamodel.html#object.__hash__
|
||||||
|
|
||||||
|
|
||||||
class TextChoice(Choice):
|
|
||||||
"""Allows custom string input and offers choices. Choices will resolve to int and text will resolve to string"""
|
|
||||||
value: typing.Union[str, int]
|
|
||||||
|
|
||||||
def __init__(self, value: typing.Union[str, int]):
|
|
||||||
assert isinstance(value, str) or isinstance(value, int), \
|
|
||||||
f"{value} is not a valid option for {self.__class__.__name__}"
|
|
||||||
self.value = value
|
|
||||||
|
|
||||||
@property
|
|
||||||
def current_key(self) -> str:
|
|
||||||
if isinstance(self.value, str):
|
|
||||||
return self.value
|
|
||||||
return super().current_key
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_text(cls, text: str) -> TextChoice:
|
|
||||||
if text.lower() == "random": # chooses a random defined option but won't use any free text options
|
|
||||||
return cls(random.choice(list(cls.name_lookup)))
|
|
||||||
for option_name, value in cls.options.items():
|
|
||||||
if option_name.lower() == text.lower():
|
|
||||||
return cls(value)
|
|
||||||
return cls(text)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def get_option_name(cls, value: T) -> str:
|
|
||||||
if isinstance(value, str):
|
|
||||||
return value
|
|
||||||
return super().get_option_name(value)
|
|
||||||
|
|
||||||
def __eq__(self, other: typing.Any):
|
|
||||||
if isinstance(other, self.__class__):
|
|
||||||
return other.value == self.value
|
|
||||||
elif isinstance(other, str):
|
|
||||||
if other in self.options:
|
|
||||||
return other == self.current_key
|
|
||||||
return other == self.value
|
|
||||||
elif isinstance(other, int):
|
|
||||||
assert other in self.name_lookup, f"compared against an int that could never be equal. {self} == {other}"
|
|
||||||
return other == self.value
|
|
||||||
elif isinstance(other, bool):
|
|
||||||
return other == bool(self.value)
|
|
||||||
else:
|
|
||||||
raise TypeError(f"Can't compare {self.__class__.__name__} with {other.__class__.__name__}")
|
|
||||||
|
|
||||||
|
|
||||||
class BossMeta(AssembleOptions):
|
|
||||||
def __new__(mcs, name, bases, attrs):
|
|
||||||
if name != "PlandoBosses":
|
|
||||||
assert "bosses" in attrs, f"Please define valid bosses for {name}"
|
|
||||||
attrs["bosses"] = frozenset((boss.lower() for boss in attrs["bosses"]))
|
|
||||||
assert "locations" in attrs, f"Please define valid locations for {name}"
|
|
||||||
attrs["locations"] = frozenset((location.lower() for location in attrs["locations"]))
|
|
||||||
cls = super().__new__(mcs, name, bases, attrs)
|
|
||||||
assert not cls.duplicate_bosses or "singularity" in cls.options, f"Please define option_singularity for {name}"
|
|
||||||
return cls
|
|
||||||
|
|
||||||
|
|
||||||
class PlandoBosses(TextChoice, metaclass=BossMeta):
|
|
||||||
"""Generic boss shuffle option that supports plando. Format expected is
|
|
||||||
'location1-boss1;location2-boss2;shuffle_mode'.
|
|
||||||
If shuffle_mode is not provided in the string, this will be the default shuffle mode. Must override can_place_boss,
|
|
||||||
which passes a plando boss and location. Check if the placement is valid for your game here."""
|
|
||||||
bosses: typing.ClassVar[typing.Union[typing.Set[str], typing.FrozenSet[str]]]
|
|
||||||
locations: typing.ClassVar[typing.Union[typing.Set[str], typing.FrozenSet[str]]]
|
|
||||||
|
|
||||||
duplicate_bosses: bool = False
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_text(cls, text: str):
|
|
||||||
# set all of our text to lower case for name checking
|
|
||||||
text = text.lower()
|
|
||||||
if text == "random":
|
|
||||||
return cls(random.choice(list(cls.options.values())))
|
|
||||||
for option_name, value in cls.options.items():
|
|
||||||
if option_name == text:
|
|
||||||
return cls(value)
|
|
||||||
options = text.split(";")
|
|
||||||
|
|
||||||
# since plando exists in the option verify the plando values given are valid
|
|
||||||
cls.validate_plando_bosses(options)
|
|
||||||
return cls.get_shuffle_mode(options)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def get_shuffle_mode(cls, option_list: typing.List[str]):
|
|
||||||
# find out what mode of boss shuffle we should use for placing bosses after plando
|
|
||||||
# and add as a string to look nice in the spoiler
|
|
||||||
if "random" in option_list:
|
|
||||||
shuffle = random.choice(list(cls.options))
|
|
||||||
option_list.remove("random")
|
|
||||||
options = ";".join(option_list) + f";{shuffle}"
|
|
||||||
boss_class = cls(options)
|
|
||||||
else:
|
|
||||||
for option in option_list:
|
|
||||||
if option in cls.options:
|
|
||||||
options = ";".join(option_list)
|
|
||||||
break
|
|
||||||
else:
|
|
||||||
if cls.duplicate_bosses and len(option_list) == 1:
|
|
||||||
if cls.valid_boss_name(option_list[0]):
|
|
||||||
# this doesn't exist in this class but it's a forced option for classes where this is called
|
|
||||||
options = option_list[0] + ";singularity"
|
|
||||||
else:
|
|
||||||
options = option_list[0] + f";{cls.name_lookup[cls.default]}"
|
|
||||||
else:
|
|
||||||
options = ";".join(option_list) + f";{cls.name_lookup[cls.default]}"
|
|
||||||
boss_class = cls(options)
|
|
||||||
return boss_class
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def validate_plando_bosses(cls, options: typing.List[str]) -> None:
|
|
||||||
used_locations = []
|
|
||||||
used_bosses = []
|
|
||||||
for option in options:
|
|
||||||
# check if a shuffle mode was provided in the incorrect location
|
|
||||||
if option == "random" or option in cls.options:
|
|
||||||
if option != options[-1]:
|
|
||||||
raise ValueError(f"{option} option must be at the end of the boss_shuffle options!")
|
|
||||||
elif "-" in option:
|
|
||||||
location, boss = option.split("-")
|
|
||||||
if location in used_locations:
|
|
||||||
raise ValueError(f"Duplicate Boss Location {location} not allowed.")
|
|
||||||
if not cls.duplicate_bosses and boss in used_bosses:
|
|
||||||
raise ValueError(f"Duplicate Boss {boss} not allowed.")
|
|
||||||
used_locations.append(location)
|
|
||||||
used_bosses.append(boss)
|
|
||||||
if not cls.valid_boss_name(boss):
|
|
||||||
raise ValueError(f"{boss.title()} is not a valid boss name.")
|
|
||||||
if not cls.valid_location_name(location):
|
|
||||||
raise ValueError(f"{location.title()} is not a valid boss location name.")
|
|
||||||
if not cls.can_place_boss(boss, location):
|
|
||||||
raise ValueError(f"{location.title()} is not a valid location for {boss.title()} to be placed.")
|
|
||||||
else:
|
|
||||||
if cls.duplicate_bosses:
|
|
||||||
if not cls.valid_boss_name(option):
|
|
||||||
raise ValueError(f"{option} is not a valid boss name.")
|
|
||||||
else:
|
|
||||||
raise ValueError(f"{option.title()} is not formatted correctly.")
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def can_place_boss(cls, boss: str, location: str) -> bool:
|
|
||||||
raise NotImplementedError
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def valid_boss_name(cls, value: str) -> bool:
|
|
||||||
return value in cls.bosses
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def valid_location_name(cls, value: str) -> bool:
|
|
||||||
return value in cls.locations
|
|
||||||
|
|
||||||
def verify(self, world: typing.Type[World], player_name: str, plando_options: "PlandoOptions") -> None:
|
|
||||||
if isinstance(self.value, int):
|
|
||||||
return
|
|
||||||
from BaseClasses import PlandoOptions
|
|
||||||
if not (PlandoOptions.bosses & plando_options):
|
|
||||||
# plando is disabled but plando options were given so pull the option and change it to an int
|
|
||||||
option = self.value.split(";")[-1]
|
|
||||||
self.value = self.options[option]
|
|
||||||
logging.warning(f"The plando bosses module is turned off, so {self.name_lookup[self.value].title()} "
|
|
||||||
f"boss shuffle will be used for player {player_name}.")
|
|
||||||
|
|
||||||
|
|
||||||
class Range(NumericOption):
|
class Range(NumericOption):
|
||||||
range_start = 0
|
range_start = 0
|
||||||
range_end = 1
|
range_end = 1
|
||||||
@@ -650,7 +385,7 @@ class Range(NumericOption):
|
|||||||
if text.startswith("random"):
|
if text.startswith("random"):
|
||||||
return cls.weighted_range(text)
|
return cls.weighted_range(text)
|
||||||
elif text == "default" and hasattr(cls, "default"):
|
elif text == "default" and hasattr(cls, "default"):
|
||||||
return cls.from_any(cls.default)
|
return cls(cls.default)
|
||||||
elif text == "high":
|
elif text == "high":
|
||||||
return cls(cls.range_end)
|
return cls(cls.range_end)
|
||||||
elif text == "low":
|
elif text == "low":
|
||||||
@@ -661,7 +396,7 @@ class Range(NumericOption):
|
|||||||
and text in ("true", "false"):
|
and text in ("true", "false"):
|
||||||
# these are the conditions where "true" and "false" make sense
|
# these are the conditions where "true" and "false" make sense
|
||||||
if text == "true":
|
if text == "true":
|
||||||
return cls.from_any(cls.default)
|
return cls(cls.default)
|
||||||
else: # "false"
|
else: # "false"
|
||||||
return cls(0)
|
return cls(0)
|
||||||
return cls(int(text))
|
return cls(int(text))
|
||||||
@@ -723,19 +458,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,17 +470,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
|
||||||
class FreezeValidKeys(AssembleOptions):
|
def weighted_range(cls, text) -> Range:
|
||||||
def __new__(mcs, name, bases, attrs):
|
if text == "random-low":
|
||||||
if "valid_keys" in attrs:
|
return cls(cls.triangular(cls.special_range_cutoff, cls.range_end, cls.special_range_cutoff))
|
||||||
attrs["_valid_keys"] = frozenset(attrs["valid_keys"])
|
elif text == "random-high":
|
||||||
return super(FreezeValidKeys, mcs).__new__(mcs, name, bases, attrs)
|
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 VerifyKeys(metaclass=FreezeValidKeys):
|
class VerifyKeys:
|
||||||
valid_keys: typing.Iterable = []
|
valid_keys = frozenset()
|
||||||
_valid_keys: frozenset # gets created by AssembleOptions from valid_keys
|
|
||||||
valid_keys_casefold: bool = False
|
valid_keys_casefold: bool = False
|
||||||
convert_name_groups: bool = False
|
convert_name_groups: bool = False
|
||||||
verify_item_name: bool = False
|
verify_item_name: bool = False
|
||||||
@@ -761,26 +498,21 @@ 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):
|
||||||
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)
|
||||||
extra = dataset - cls._valid_keys
|
extra = dataset - cls.valid_keys
|
||||||
if extra:
|
if extra:
|
||||||
raise Exception(f"Found unexpected key {', '.join(extra)} in {cls}. "
|
raise Exception(f"Found unexpected key {', '.join(extra)} in {cls}. "
|
||||||
f"Allowed keys: {cls._valid_keys}.")
|
f"Allowed keys: {cls.valid_keys}.")
|
||||||
|
|
||||||
def verify(self, world: typing.Type[World], player_name: str, plando_options: "PlandoOptions") -> None:
|
def verify(self, world):
|
||||||
if self.convert_name_groups and self.verify_item_name:
|
if self.convert_name_groups and self.verify_item_name:
|
||||||
new_value = type(self.value)() # empty container of whatever value is
|
new_value = type(self.value)() # empty container of whatever value is
|
||||||
for item_name in self.value:
|
for item_name in self.value:
|
||||||
new_value |= world.item_name_groups.get(item_name, {item_name})
|
new_value |= world.item_name_groups.get(item_name, {item_name})
|
||||||
self.value = new_value
|
self.value = new_value
|
||||||
elif self.convert_name_groups and self.verify_location_name:
|
|
||||||
new_value = type(self.value)()
|
|
||||||
for loc_name in self.value:
|
|
||||||
new_value |= world.location_name_groups.get(loc_name, {loc_name})
|
|
||||||
self.value = new_value
|
|
||||||
if self.verify_item_name:
|
if self.verify_item_name:
|
||||||
for item_name in self.value:
|
for item_name in self.value:
|
||||||
if item_name not in world.item_names:
|
if item_name not in world.item_names:
|
||||||
@@ -797,12 +529,12 @@ class VerifyKeys(metaclass=FreezeValidKeys):
|
|||||||
f"Did you mean '{picks[0][0]}' ({picks[0][1]}% sure)")
|
f"Did you mean '{picks[0][0]}' ({picks[0][1]}% sure)")
|
||||||
|
|
||||||
|
|
||||||
class OptionDict(Option[typing.Dict[str, typing.Any]], VerifyKeys, typing.Mapping[str, typing.Any]):
|
class OptionDict(Option[typing.Dict[str, typing.Any]], VerifyKeys):
|
||||||
default = {}
|
default = {}
|
||||||
supports_weighting = False
|
supports_weighting = False
|
||||||
|
|
||||||
def __init__(self, value: typing.Dict[str, typing.Any]):
|
def __init__(self, value: typing.Dict[str, typing.Any]):
|
||||||
self.value = deepcopy(value)
|
self.value = value
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_any(cls, data: typing.Dict[str, typing.Any]) -> OptionDict:
|
def from_any(cls, data: typing.Dict[str, typing.Any]) -> OptionDict:
|
||||||
@@ -815,14 +547,8 @@ class OptionDict(Option[typing.Dict[str, typing.Any]], VerifyKeys, typing.Mappin
|
|||||||
def get_option_name(self, value):
|
def get_option_name(self, value):
|
||||||
return ", ".join(f"{key}: {v}" for key, v in value.items())
|
return ", ".join(f"{key}: {v}" for key, v in value.items())
|
||||||
|
|
||||||
def __getitem__(self, item: str) -> typing.Any:
|
def __contains__(self, item):
|
||||||
return self.value.__getitem__(item)
|
return item in self.value
|
||||||
|
|
||||||
def __iter__(self) -> typing.Iterator[str]:
|
|
||||||
return self.value.__iter__()
|
|
||||||
|
|
||||||
def __len__(self) -> int:
|
|
||||||
return self.value.__len__()
|
|
||||||
|
|
||||||
|
|
||||||
class ItemDict(OptionDict):
|
class ItemDict(OptionDict):
|
||||||
@@ -835,15 +561,11 @@ class ItemDict(OptionDict):
|
|||||||
|
|
||||||
|
|
||||||
class OptionList(Option[typing.List[typing.Any]], VerifyKeys):
|
class OptionList(Option[typing.List[typing.Any]], VerifyKeys):
|
||||||
# Supports duplicate entries and ordering.
|
default = []
|
||||||
# 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.
|
|
||||||
|
|
||||||
default = ()
|
|
||||||
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 = value or []
|
||||||
super(OptionList, self).__init__()
|
super(OptionList, self).__init__()
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -852,7 +574,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))
|
||||||
@@ -868,8 +590,8 @@ class OptionSet(Option[typing.Set[str]], VerifyKeys):
|
|||||||
default = frozenset()
|
default = frozenset()
|
||||||
supports_weighting = False
|
supports_weighting = False
|
||||||
|
|
||||||
def __init__(self, value: typing.Iterable[str]):
|
def __init__(self, value: typing.Union[typing.Set[str, typing.Any], typing.List[str, typing.Any]]):
|
||||||
self.value = set(deepcopy(value))
|
self.value = set(value)
|
||||||
super(OptionSet, self).__init__()
|
super(OptionSet, self).__init__()
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -878,7 +600,10 @@ 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 type(data) == list:
|
||||||
|
cls.verify_keys(data)
|
||||||
|
return cls(data)
|
||||||
|
elif type(data) == set:
|
||||||
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))
|
||||||
@@ -890,9 +615,7 @@ class OptionSet(Option[typing.Set[str]], VerifyKeys):
|
|||||||
return item in self.value
|
return item in self.value
|
||||||
|
|
||||||
|
|
||||||
class ItemSet(OptionSet):
|
local_objective = Toggle # local triforce pieces, local dungeon prizes etc.
|
||||||
verify_item_name = True
|
|
||||||
convert_name_groups = True
|
|
||||||
|
|
||||||
|
|
||||||
class Accessibility(Choice):
|
class Accessibility(Choice):
|
||||||
@@ -908,9 +631,9 @@ 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."""
|
[0-99, default 50] A lower setting means more getting stuck. A higher setting means less getting stuck."""
|
||||||
default = 50
|
default = 50
|
||||||
range_start = 0
|
range_start = 0
|
||||||
range_end = 99
|
range_end = 99
|
||||||
@@ -922,58 +645,15 @@ 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 ItemSet(OptionSet):
|
||||||
class CommonOptions(metaclass=OptionsMetaProperty):
|
verify_item_name = True
|
||||||
progression_balancing: ProgressionBalancing
|
convert_name_groups = True
|
||||||
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):
|
||||||
@@ -992,36 +672,27 @@ class StartInventory(ItemDict):
|
|||||||
display_name = "Start Inventory"
|
display_name = "Start Inventory"
|
||||||
|
|
||||||
|
|
||||||
class StartInventoryPool(StartInventory):
|
|
||||||
"""Start with these items and don't place them in the world.
|
|
||||||
The game decides what the replacement items will be."""
|
|
||||||
verify_item_name = True
|
|
||||||
display_name = "Start Inventory from Pool"
|
|
||||||
|
|
||||||
|
|
||||||
class StartHints(ItemSet):
|
class StartHints(ItemSet):
|
||||||
"""Start with these item's locations prefilled into the !hint command."""
|
"""Start with these item's locations prefilled into the !hint command."""
|
||||||
display_name = "Start Hints"
|
display_name = "Start Hints"
|
||||||
|
|
||||||
|
|
||||||
class LocationSet(OptionSet):
|
class StartLocationHints(OptionSet):
|
||||||
verify_location_name = True
|
|
||||||
convert_name_groups = True
|
|
||||||
|
|
||||||
|
|
||||||
class StartLocationHints(LocationSet):
|
|
||||||
"""Start with these locations and their item prefilled into the !hint command"""
|
"""Start with these locations and their item prefilled into the !hint command"""
|
||||||
display_name = "Start Location Hints"
|
display_name = "Start Location Hints"
|
||||||
|
verify_location_name = True
|
||||||
|
|
||||||
|
|
||||||
class ExcludeLocations(LocationSet):
|
class ExcludeLocations(OptionSet):
|
||||||
"""Prevent these locations from having an important item"""
|
"""Prevent these locations from having an important item"""
|
||||||
display_name = "Excluded Locations"
|
display_name = "Excluded Locations"
|
||||||
|
verify_location_name = True
|
||||||
|
|
||||||
|
|
||||||
class PriorityLocations(LocationSet):
|
class PriorityLocations(OptionSet):
|
||||||
"""Prevent these locations from having an unimportant item"""
|
"""Prevent these locations from having an unimportant item"""
|
||||||
display_name = "Priority Locations"
|
display_name = "Priority Locations"
|
||||||
|
verify_location_name = True
|
||||||
|
|
||||||
|
|
||||||
class DeathLink(Toggle):
|
class DeathLink(Toggle):
|
||||||
@@ -1031,7 +702,6 @@ class DeathLink(Toggle):
|
|||||||
|
|
||||||
class ItemLinks(OptionList):
|
class ItemLinks(OptionList):
|
||||||
"""Share part of your item pool with other players."""
|
"""Share part of your item pool with other players."""
|
||||||
display_name = "Item Links"
|
|
||||||
default = []
|
default = []
|
||||||
schema = Schema([
|
schema = Schema([
|
||||||
{
|
{
|
||||||
@@ -1040,8 +710,7 @@ class ItemLinks(OptionList):
|
|||||||
Optional("exclude"): [And(str, len)],
|
Optional("exclude"): [And(str, len)],
|
||||||
"replacement_item": Or(And(str, len), None),
|
"replacement_item": Or(And(str, len), None),
|
||||||
Optional("local_items"): [And(str, len)],
|
Optional("local_items"): [And(str, len)],
|
||||||
Optional("non_local_items"): [And(str, len)],
|
Optional("non_local_items"): [And(str, len)]
|
||||||
Optional("link_replacement"): Or(None, bool),
|
|
||||||
}
|
}
|
||||||
])
|
])
|
||||||
|
|
||||||
@@ -1063,9 +732,8 @@ class ItemLinks(OptionList):
|
|||||||
pool |= {item_name}
|
pool |= {item_name}
|
||||||
return pool
|
return pool
|
||||||
|
|
||||||
def verify(self, world: typing.Type[World], player_name: str, plando_options: "PlandoOptions") -> None:
|
def verify(self, world):
|
||||||
link: dict
|
super(ItemLinks, self).verify(world)
|
||||||
super(ItemLinks, self).verify(world, player_name, plando_options)
|
|
||||||
existing_links = set()
|
existing_links = set()
|
||||||
for link in self.value:
|
for link in self.value:
|
||||||
if link["name"] in existing_links:
|
if link["name"] in existing_links:
|
||||||
@@ -1089,110 +757,20 @@ class ItemLinks(OptionList):
|
|||||||
|
|
||||||
intersection = local_items.intersection(non_local_items)
|
intersection = local_items.intersection(non_local_items)
|
||||||
if intersection:
|
if intersection:
|
||||||
raise Exception(f"item_link {link['name']} has {intersection} "
|
raise Exception(f"item_link {link['name']} has {intersection} 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["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):
|
|
||||||
import os
|
|
||||||
|
|
||||||
import yaml
|
|
||||||
from jinja2 import Template
|
|
||||||
|
|
||||||
from worlds import AutoWorldRegister
|
|
||||||
from Utils import local_path, __version__
|
|
||||||
|
|
||||||
full_path: str
|
|
||||||
|
|
||||||
os.makedirs(target_folder, exist_ok=True)
|
|
||||||
|
|
||||||
# clean out old
|
|
||||||
for file in os.listdir(target_folder):
|
|
||||||
full_path = os.path.join(target_folder, file)
|
|
||||||
if os.path.isfile(full_path) and full_path.endswith(".yaml"):
|
|
||||||
os.unlink(full_path)
|
|
||||||
|
|
||||||
def dictify_range(option: Range):
|
|
||||||
data = {option.default: 50}
|
|
||||||
for sub_option in ["random", "random-low", "random-high"]:
|
|
||||||
if sub_option != option.default:
|
|
||||||
data[sub_option] = 0
|
|
||||||
|
|
||||||
notes = {}
|
|
||||||
for name, number in getattr(option, "special_range_names", {}).items():
|
|
||||||
notes[name] = f"equivalent to {number}"
|
|
||||||
if number in data:
|
|
||||||
data[name] = data[number]
|
|
||||||
del data[number]
|
|
||||||
else:
|
|
||||||
data[name] = 0
|
|
||||||
|
|
||||||
return data, notes
|
|
||||||
|
|
||||||
for game_name, world in AutoWorldRegister.world_types.items():
|
|
||||||
if not world.hidden or generate_hidden:
|
|
||||||
|
|
||||||
option_groups = {option: option_group.name
|
|
||||||
for option_group in world.web.option_groups
|
|
||||||
for option in option_group.options}
|
|
||||||
ordered_groups = ["Game Options"]
|
|
||||||
[ordered_groups.append(group) for group in option_groups.values() if group not in ordered_groups]
|
|
||||||
grouped_options = {group: {} for group in ordered_groups}
|
|
||||||
for option_name, option in world.options_dataclass.type_hints.items():
|
|
||||||
if option.visibility >= Visibility.template:
|
|
||||||
grouped_options[option_groups.get(option, "Game Options")][option_name] = option
|
|
||||||
|
|
||||||
with open(local_path("data", "options.yaml")) as f:
|
|
||||||
file_data = f.read()
|
|
||||||
res = Template(file_data).render(
|
|
||||||
option_groups=grouped_options,
|
|
||||||
__version__=__version__, game=game_name, yaml_dump=yaml.dump,
|
|
||||||
dictify_range=dictify_range,
|
|
||||||
)
|
|
||||||
|
|
||||||
del file_data
|
|
||||||
|
|
||||||
with open(os.path.join(target_folder, game_name + ".yaml"), "w", encoding="utf-8-sig") as f:
|
|
||||||
f.write(res)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|
||||||
|
|||||||
429
Patch.py
429
Patch.py
@@ -1,35 +1,438 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import shutil
|
||||||
|
import json
|
||||||
|
import bsdiff4
|
||||||
|
import yaml
|
||||||
import os
|
import os
|
||||||
|
import lzma
|
||||||
|
import threading
|
||||||
|
import concurrent.futures
|
||||||
|
import zipfile
|
||||||
import sys
|
import sys
|
||||||
from typing import Tuple, Optional, TypedDict
|
from typing import Tuple, Optional, Dict, Any, Union, BinaryIO
|
||||||
|
|
||||||
if __name__ == "__main__":
|
import ModuleUpdate
|
||||||
import ModuleUpdate
|
ModuleUpdate.update()
|
||||||
ModuleUpdate.update()
|
|
||||||
|
|
||||||
from worlds.Files import AutoPatchRegister, APAutoPatchInterface
|
import Utils
|
||||||
|
|
||||||
|
current_patch_version = 4
|
||||||
|
|
||||||
|
|
||||||
class RomMeta(TypedDict):
|
class AutoPatchRegister(type):
|
||||||
server: str
|
patch_types: Dict[str, APDeltaPatch] = {}
|
||||||
|
file_endings: Dict[str, APDeltaPatch] = {}
|
||||||
|
|
||||||
|
def __new__(cls, name: str, bases, dct: Dict[str, Any]):
|
||||||
|
# construct class
|
||||||
|
new_class = super().__new__(cls, name, bases, dct)
|
||||||
|
if "game" in dct:
|
||||||
|
AutoPatchRegister.patch_types[dct["game"]] = new_class
|
||||||
|
if not dct["patch_file_ending"]:
|
||||||
|
raise Exception(f"Need an expected file ending for {name}")
|
||||||
|
AutoPatchRegister.file_endings[dct["patch_file_ending"]] = new_class
|
||||||
|
return new_class
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_handler(file: str) -> Optional[type(APDeltaPatch)]:
|
||||||
|
for file_ending, handler in AutoPatchRegister.file_endings.items():
|
||||||
|
if file.endswith(file_ending):
|
||||||
|
return handler
|
||||||
|
|
||||||
|
|
||||||
|
class APContainer:
|
||||||
|
"""A zipfile containing at least archipelago.json"""
|
||||||
|
version: int = current_patch_version
|
||||||
|
compression_level: int = 9
|
||||||
|
compression_method: int = zipfile.ZIP_DEFLATED
|
||||||
|
game: Optional[str] = None
|
||||||
|
|
||||||
|
# instance attributes:
|
||||||
|
path: Optional[str]
|
||||||
player: Optional[int]
|
player: Optional[int]
|
||||||
player_name: str
|
player_name: str
|
||||||
|
server: str
|
||||||
|
|
||||||
|
def __init__(self, path: Optional[str] = None, player: Optional[int] = None,
|
||||||
|
player_name: str = "", server: str = ""):
|
||||||
|
self.path = path
|
||||||
|
self.player = player
|
||||||
|
self.player_name = player_name
|
||||||
|
self.server = server
|
||||||
|
|
||||||
|
def write(self, file: Optional[Union[str, BinaryIO]] = None):
|
||||||
|
if not self.path and not file:
|
||||||
|
raise FileNotFoundError(f"Cannot write {self.__class__.__name__} due to no path provided.")
|
||||||
|
with zipfile.ZipFile(file if file else self.path, "w", self.compression_method, True, self.compression_level) \
|
||||||
|
as zf:
|
||||||
|
if file:
|
||||||
|
self.path = zf.filename
|
||||||
|
self.write_contents(zf)
|
||||||
|
|
||||||
|
def write_contents(self, opened_zipfile: zipfile.ZipFile):
|
||||||
|
manifest = self.get_manifest()
|
||||||
|
try:
|
||||||
|
manifest = json.dumps(manifest)
|
||||||
|
except Exception as e:
|
||||||
|
raise Exception(f"Manifest {manifest} did not convert to json.") from e
|
||||||
|
else:
|
||||||
|
opened_zipfile.writestr("archipelago.json", manifest)
|
||||||
|
|
||||||
|
def read(self, file: Optional[Union[str, BinaryIO]] = None):
|
||||||
|
"""Read data into patch object. file can be file-like, such as an outer zip file's stream."""
|
||||||
|
if not self.path and not file:
|
||||||
|
raise FileNotFoundError(f"Cannot read {self.__class__.__name__} due to no path provided.")
|
||||||
|
with zipfile.ZipFile(file if file else self.path, "r") as zf:
|
||||||
|
if file:
|
||||||
|
self.path = zf.filename
|
||||||
|
self.read_contents(zf)
|
||||||
|
|
||||||
|
def read_contents(self, opened_zipfile: zipfile.ZipFile):
|
||||||
|
with opened_zipfile.open("archipelago.json", "r") as f:
|
||||||
|
manifest = json.load(f)
|
||||||
|
if manifest["compatible_version"] > self.version:
|
||||||
|
raise Exception(f"File (version: {manifest['compatible_version']}) too new "
|
||||||
|
f"for this handler (version: {self.version})")
|
||||||
|
self.player = manifest["player"]
|
||||||
|
self.server = manifest["server"]
|
||||||
|
self.player_name = manifest["player_name"]
|
||||||
|
|
||||||
|
def get_manifest(self) -> dict:
|
||||||
|
return {
|
||||||
|
"server": self.server, # allow immediate connection to server in multiworld. Empty string otherwise
|
||||||
|
"player": self.player,
|
||||||
|
"player_name": self.player_name,
|
||||||
|
"game": self.game,
|
||||||
|
# minimum version of patch system expected for patching to be successful
|
||||||
|
"compatible_version": 4,
|
||||||
|
"version": current_patch_version,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def create_rom_file(patch_file: str) -> Tuple[RomMeta, str]:
|
class APDeltaPatch(APContainer, metaclass=AutoPatchRegister):
|
||||||
|
"""An APContainer that additionally has delta.bsdiff4
|
||||||
|
containing a delta patch to get the desired file, often a rom."""
|
||||||
|
|
||||||
|
hash = Optional[str] # base checksum of source file
|
||||||
|
patch_file_ending: str = ""
|
||||||
|
delta: Optional[bytes] = None
|
||||||
|
result_file_ending: str = ".sfc"
|
||||||
|
source_data: bytes
|
||||||
|
|
||||||
|
def __init__(self, *args, patched_path: str = "", **kwargs):
|
||||||
|
self.patched_path = patched_path
|
||||||
|
super(APDeltaPatch, self).__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
def get_manifest(self) -> dict:
|
||||||
|
manifest = super(APDeltaPatch, self).get_manifest()
|
||||||
|
manifest["base_checksum"] = self.hash
|
||||||
|
manifest["result_file_ending"] = self.result_file_ending
|
||||||
|
return manifest
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_source_data(cls) -> bytes:
|
||||||
|
"""Get Base data"""
|
||||||
|
raise NotImplementedError()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_source_data_with_cache(cls) -> bytes:
|
||||||
|
if not hasattr(cls, "source_data"):
|
||||||
|
cls.source_data = cls.get_source_data()
|
||||||
|
return cls.source_data
|
||||||
|
|
||||||
|
def write_contents(self, opened_zipfile: zipfile.ZipFile):
|
||||||
|
super(APDeltaPatch, self).write_contents(opened_zipfile)
|
||||||
|
# write Delta
|
||||||
|
opened_zipfile.writestr("delta.bsdiff4",
|
||||||
|
bsdiff4.diff(self.get_source_data_with_cache(), open(self.patched_path, "rb").read()),
|
||||||
|
compress_type=zipfile.ZIP_STORED) # bsdiff4 is a format with integrated compression
|
||||||
|
|
||||||
|
def read_contents(self, opened_zipfile: zipfile.ZipFile):
|
||||||
|
super(APDeltaPatch, self).read_contents(opened_zipfile)
|
||||||
|
self.delta = opened_zipfile.read("delta.bsdiff4")
|
||||||
|
|
||||||
|
def patch(self, target: str):
|
||||||
|
"""Base + Delta -> Patched"""
|
||||||
|
if not self.delta:
|
||||||
|
self.read()
|
||||||
|
result = bsdiff4.patch(self.get_source_data_with_cache(), self.delta)
|
||||||
|
with open(target, "wb") as f:
|
||||||
|
f.write(result)
|
||||||
|
|
||||||
|
|
||||||
|
# legacy patch handling follows:
|
||||||
|
GAME_ALTTP = "A Link to the Past"
|
||||||
|
GAME_SM = "Super Metroid"
|
||||||
|
GAME_SOE = "Secret of Evermore"
|
||||||
|
GAME_SMZ3 = "SMZ3"
|
||||||
|
GAME_DKC3 = "Donkey Kong Country 3"
|
||||||
|
supported_games = {"A Link to the Past", "Super Metroid", "Secret of Evermore", "SMZ3", "Donkey Kong Country 3"}
|
||||||
|
|
||||||
|
preferred_endings = {
|
||||||
|
GAME_ALTTP: "apbp",
|
||||||
|
GAME_SM: "apm3",
|
||||||
|
GAME_SOE: "apsoe",
|
||||||
|
GAME_SMZ3: "apsmz",
|
||||||
|
GAME_DKC3: "apdkc3"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def generate_yaml(patch: bytes, metadata: Optional[dict] = None, game: str = GAME_ALTTP) -> bytes:
|
||||||
|
if game == GAME_ALTTP:
|
||||||
|
from worlds.alttp.Rom import LTTPJPN10HASH as HASH
|
||||||
|
elif game == GAME_SM:
|
||||||
|
from worlds.sm.Rom import SMJUHASH as HASH
|
||||||
|
elif game == GAME_SOE:
|
||||||
|
from worlds.soe.Patch import USHASH as HASH
|
||||||
|
elif game == GAME_SMZ3:
|
||||||
|
from worlds.alttp.Rom import LTTPJPN10HASH as ALTTPHASH
|
||||||
|
from worlds.sm.Rom import SMJUHASH as SMHASH
|
||||||
|
HASH = ALTTPHASH + SMHASH
|
||||||
|
elif game == GAME_DKC3:
|
||||||
|
from worlds.dkc3.Rom import USHASH as HASH
|
||||||
|
else:
|
||||||
|
raise RuntimeError(f"Selected game {game} for base rom not found.")
|
||||||
|
|
||||||
|
patch = yaml.dump({"meta": metadata,
|
||||||
|
"patch": patch,
|
||||||
|
"game": game,
|
||||||
|
# minimum version of patch system expected for patching to be successful
|
||||||
|
"compatible_version": 3,
|
||||||
|
"version": current_patch_version,
|
||||||
|
"base_checksum": HASH})
|
||||||
|
return patch.encode(encoding="utf-8-sig")
|
||||||
|
|
||||||
|
|
||||||
|
def generate_patch(rom: bytes, metadata: Optional[dict] = None, game: str = GAME_ALTTP) -> bytes:
|
||||||
|
if metadata is None:
|
||||||
|
metadata = {}
|
||||||
|
patch = bsdiff4.diff(get_base_rom_data(game), rom)
|
||||||
|
return generate_yaml(patch, metadata, game)
|
||||||
|
|
||||||
|
|
||||||
|
def create_patch_file(rom_file_to_patch: str, server: str = "", destination: str = None,
|
||||||
|
player: int = 0, player_name: str = "", game: str = GAME_ALTTP) -> str:
|
||||||
|
meta = {"server": server, # allow immediate connection to server in multiworld. Empty string otherwise
|
||||||
|
"player_id": player,
|
||||||
|
"player_name": player_name}
|
||||||
|
bytes = generate_patch(load_bytes(rom_file_to_patch),
|
||||||
|
meta,
|
||||||
|
game)
|
||||||
|
target = destination if destination else os.path.splitext(rom_file_to_patch)[0] + (
|
||||||
|
".apbp" if game == GAME_ALTTP
|
||||||
|
else ".apsmz" if game == GAME_SMZ3
|
||||||
|
else ".apdkc3" if game == GAME_DKC3
|
||||||
|
else ".apm3")
|
||||||
|
write_lzma(bytes, target)
|
||||||
|
return target
|
||||||
|
|
||||||
|
|
||||||
|
def create_rom_bytes(patch_file: str, ignore_version: bool = False) -> Tuple[dict, str, bytearray]:
|
||||||
|
data = Utils.parse_yaml(lzma.decompress(load_bytes(patch_file)).decode("utf-8-sig"))
|
||||||
|
game_name = data["game"]
|
||||||
|
if not ignore_version and data["compatible_version"] > current_patch_version:
|
||||||
|
raise RuntimeError("Patch file is incompatible with this patcher, likely an update is required.")
|
||||||
|
patched_data = bsdiff4.patch(get_base_rom_data(game_name), data["patch"])
|
||||||
|
rom_hash = patched_data[int(0x7FC0):int(0x7FD5)]
|
||||||
|
data["meta"]["hash"] = "".join(chr(x) for x in rom_hash)
|
||||||
|
target = os.path.splitext(patch_file)[0] + ".sfc"
|
||||||
|
return data["meta"], target, patched_data
|
||||||
|
|
||||||
|
|
||||||
|
def get_base_rom_data(game: str):
|
||||||
|
if game == GAME_ALTTP:
|
||||||
|
from worlds.alttp.Rom import get_base_rom_bytes
|
||||||
|
elif game == "alttp": # old version for A Link to the Past
|
||||||
|
from worlds.alttp.Rom import get_base_rom_bytes
|
||||||
|
elif game == GAME_SM:
|
||||||
|
from worlds.sm.Rom import get_base_rom_bytes
|
||||||
|
elif game == GAME_SOE:
|
||||||
|
from worlds.soe.Patch import get_base_rom_path
|
||||||
|
get_base_rom_bytes = lambda: bytes(read_rom(open(get_base_rom_path(), "rb")))
|
||||||
|
elif game == GAME_SMZ3:
|
||||||
|
from worlds.smz3.Rom import get_base_rom_bytes
|
||||||
|
elif game == GAME_DKC3:
|
||||||
|
from worlds.dkc3.Rom import get_base_rom_bytes
|
||||||
|
else:
|
||||||
|
raise RuntimeError("Selected game for base rom not found.")
|
||||||
|
return get_base_rom_bytes()
|
||||||
|
|
||||||
|
|
||||||
|
def create_rom_file(patch_file: str) -> Tuple[dict, str]:
|
||||||
auto_handler = AutoPatchRegister.get_handler(patch_file)
|
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,
|
||||||
"player": handler.player,
|
"player": handler.player,
|
||||||
"player_name": handler.player_name}, target
|
"player_name": handler.player_name}, target
|
||||||
raise NotImplementedError(f"No Handler for {patch_file} found.")
|
else:
|
||||||
|
data, target, patched_data = create_rom_bytes(patch_file)
|
||||||
|
with open(target, "wb") as f:
|
||||||
|
f.write(patched_data)
|
||||||
|
return data, target
|
||||||
|
|
||||||
|
|
||||||
|
def update_patch_data(patch_data: bytes, server: str = "") -> bytes:
|
||||||
|
data = Utils.parse_yaml(lzma.decompress(patch_data).decode("utf-8-sig"))
|
||||||
|
data["meta"]["server"] = server
|
||||||
|
bytes = generate_yaml(data["patch"], data["meta"], data["game"])
|
||||||
|
return lzma.compress(bytes)
|
||||||
|
|
||||||
|
|
||||||
|
def load_bytes(path: str) -> bytes:
|
||||||
|
with open(path, "rb") as f:
|
||||||
|
return f.read()
|
||||||
|
|
||||||
|
|
||||||
|
def write_lzma(data: bytes, path: str):
|
||||||
|
with lzma.LZMAFile(path, 'wb') as f:
|
||||||
|
f.write(data)
|
||||||
|
|
||||||
|
|
||||||
|
def read_rom(stream, strip_header=True) -> bytearray:
|
||||||
|
"""Reads rom into bytearray and optionally strips off any smc header"""
|
||||||
|
buffer = bytearray(stream.read())
|
||||||
|
if strip_header and len(buffer) % 0x400 == 0x200:
|
||||||
|
return buffer[0x200:]
|
||||||
|
return buffer
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
for file in sys.argv[1:]:
|
host = Utils.get_public_ipv4()
|
||||||
meta_data, result_file = create_rom_file(file)
|
options = Utils.get_options()['server_options']
|
||||||
print(f"Patch with meta-data {meta_data} was written to {result_file}")
|
if options['host']:
|
||||||
|
host = options['host']
|
||||||
|
|
||||||
|
address = f"{host}:{options['port']}"
|
||||||
|
ziplock = threading.Lock()
|
||||||
|
print(f"Host for patches to be created is {address}")
|
||||||
|
with concurrent.futures.ThreadPoolExecutor() as pool:
|
||||||
|
for rom in sys.argv:
|
||||||
|
try:
|
||||||
|
if rom.endswith(".sfc"):
|
||||||
|
print(f"Creating patch for {rom}")
|
||||||
|
result = pool.submit(create_patch_file, rom, address)
|
||||||
|
result.add_done_callback(lambda task: print(f"Created patch {task.result()}"))
|
||||||
|
|
||||||
|
elif rom.endswith(".apbp"):
|
||||||
|
print(f"Applying patch {rom}")
|
||||||
|
data, target = create_rom_file(rom)
|
||||||
|
#romfile, adjusted = Utils.get_adjuster_settings(target)
|
||||||
|
adjuster_settings = Utils.get_adjuster_settings(GAME_ALTTP)
|
||||||
|
adjusted = False
|
||||||
|
if adjuster_settings:
|
||||||
|
import pprint
|
||||||
|
from worlds.alttp.Rom import get_base_rom_path
|
||||||
|
adjuster_settings.rom = target
|
||||||
|
adjuster_settings.baserom = get_base_rom_path()
|
||||||
|
adjuster_settings.world = None
|
||||||
|
whitelist = {"music", "menuspeed", "heartbeep", "heartcolor", "ow_palettes", "quickswap",
|
||||||
|
"uw_palettes", "sprite", "sword_palettes", "shield_palettes", "hud_palettes",
|
||||||
|
"reduceflashing", "deathlink"}
|
||||||
|
printed_options = {name: value for name, value in vars(adjuster_settings).items() if name in whitelist}
|
||||||
|
if hasattr(adjuster_settings, "sprite_pool"):
|
||||||
|
sprite_pool = {}
|
||||||
|
for sprite in getattr(adjuster_settings, "sprite_pool"):
|
||||||
|
if sprite in sprite_pool:
|
||||||
|
sprite_pool[sprite] += 1
|
||||||
|
else:
|
||||||
|
sprite_pool[sprite] = 1
|
||||||
|
if sprite_pool:
|
||||||
|
printed_options["sprite_pool"] = sprite_pool
|
||||||
|
|
||||||
|
adjust_wanted = str('no')
|
||||||
|
if not hasattr(adjuster_settings, 'auto_apply') or 'ask' in adjuster_settings.auto_apply:
|
||||||
|
adjust_wanted = input(f"Last used adjuster settings were found. Would you like to apply these? \n"
|
||||||
|
f"{pprint.pformat(printed_options)}\n"
|
||||||
|
f"Enter yes, no, always or never: ")
|
||||||
|
if adjuster_settings.auto_apply == 'never': # never adjust, per user request
|
||||||
|
adjust_wanted = 'no'
|
||||||
|
elif adjuster_settings.auto_apply == 'always':
|
||||||
|
adjust_wanted = 'yes'
|
||||||
|
|
||||||
|
if adjust_wanted and "never" in adjust_wanted:
|
||||||
|
adjuster_settings.auto_apply = 'never'
|
||||||
|
Utils.persistent_store("adjuster", GAME_ALTTP, adjuster_settings)
|
||||||
|
|
||||||
|
elif adjust_wanted and "always" in adjust_wanted:
|
||||||
|
adjuster_settings.auto_apply = 'always'
|
||||||
|
Utils.persistent_store("adjuster", GAME_ALTTP, adjuster_settings)
|
||||||
|
|
||||||
|
if adjust_wanted and adjust_wanted.startswith("y"):
|
||||||
|
if hasattr(adjuster_settings, "sprite_pool"):
|
||||||
|
from LttPAdjuster import AdjusterWorld
|
||||||
|
adjuster_settings.world = AdjusterWorld(getattr(adjuster_settings, "sprite_pool"))
|
||||||
|
|
||||||
|
adjusted = True
|
||||||
|
import LttPAdjuster
|
||||||
|
_, romfile = LttPAdjuster.adjust(adjuster_settings)
|
||||||
|
|
||||||
|
if hasattr(adjuster_settings, "world"):
|
||||||
|
delattr(adjuster_settings, "world")
|
||||||
|
else:
|
||||||
|
adjusted = False
|
||||||
|
if adjusted:
|
||||||
|
try:
|
||||||
|
shutil.move(romfile, target)
|
||||||
|
romfile = target
|
||||||
|
except Exception as e:
|
||||||
|
print(e)
|
||||||
|
print(f"Created rom {romfile if adjusted else target}.")
|
||||||
|
if 'server' in data:
|
||||||
|
Utils.persistent_store("servers", data['hash'], data['server'])
|
||||||
|
print(f"Host is {data['server']}")
|
||||||
|
elif rom.endswith(".apm3"):
|
||||||
|
print(f"Applying patch {rom}")
|
||||||
|
data, target = create_rom_file(rom)
|
||||||
|
print(f"Created rom {target}.")
|
||||||
|
if 'server' in data:
|
||||||
|
Utils.persistent_store("servers", data['hash'], data['server'])
|
||||||
|
print(f"Host is {data['server']}")
|
||||||
|
elif rom.endswith(".apsmz"):
|
||||||
|
print(f"Applying patch {rom}")
|
||||||
|
data, target = create_rom_file(rom)
|
||||||
|
print(f"Created rom {target}.")
|
||||||
|
if 'server' in data:
|
||||||
|
Utils.persistent_store("servers", data['hash'], data['server'])
|
||||||
|
print(f"Host is {data['server']}")
|
||||||
|
elif rom.endswith(".apdkc3"):
|
||||||
|
print(f"Applying patch {rom}")
|
||||||
|
data, target = create_rom_file(rom)
|
||||||
|
print(f"Created rom {target}.")
|
||||||
|
if 'server' in data:
|
||||||
|
Utils.persistent_store("servers", data['hash'], data['server'])
|
||||||
|
print(f"Host is {data['server']}")
|
||||||
|
|
||||||
|
elif rom.endswith(".zip"):
|
||||||
|
print(f"Updating host in patch files contained in {rom}")
|
||||||
|
|
||||||
|
|
||||||
|
def _handle_zip_file_entry(zfinfo: zipfile.ZipInfo, server: str):
|
||||||
|
data = zfr.read(zfinfo)
|
||||||
|
if zfinfo.filename.endswith(".apbp") or \
|
||||||
|
zfinfo.filename.endswith(".apm3") or \
|
||||||
|
zfinfo.filename.endswith(".apdkc3"):
|
||||||
|
data = update_patch_data(data, server)
|
||||||
|
with ziplock:
|
||||||
|
zfw.writestr(zfinfo, data)
|
||||||
|
return zfinfo.filename
|
||||||
|
|
||||||
|
|
||||||
|
futures = []
|
||||||
|
with zipfile.ZipFile(rom, "r") as zfr:
|
||||||
|
updated_zip = os.path.splitext(rom)[0] + "_updated.zip"
|
||||||
|
with zipfile.ZipFile(updated_zip, "w", compression=zipfile.ZIP_DEFLATED,
|
||||||
|
compresslevel=9) as zfw:
|
||||||
|
for zfname in zfr.namelist():
|
||||||
|
futures.append(pool.submit(_handle_zip_file_entry, zfr.getinfo(zfname), address))
|
||||||
|
for future in futures:
|
||||||
|
print(f"File {future.result()} added to {os.path.split(updated_zip)[1]}")
|
||||||
|
|
||||||
|
except:
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
traceback.print_exc()
|
||||||
|
input("Press enter to close.")
|
||||||
|
|||||||
68
README.md
68
README.md
@@ -25,49 +25,9 @@ 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
|
|
||||||
* Pokémon Red and Blue
|
|
||||||
* Hylics 2
|
|
||||||
* Overcooked! 2
|
|
||||||
* Zillion
|
|
||||||
* Lufia II Ancient Cave
|
|
||||||
* Blasphemous
|
|
||||||
* Wargroove
|
|
||||||
* Stardew Valley
|
|
||||||
* The Legend of Zelda
|
|
||||||
* The Messenger
|
|
||||||
* Kingdom Hearts 2
|
|
||||||
* The Legend of Zelda: Link's Awakening DX
|
|
||||||
* Clique
|
|
||||||
* Adventure
|
|
||||||
* DLC Quest
|
|
||||||
* Noita
|
|
||||||
* Undertale
|
|
||||||
* Bumper Stickers
|
|
||||||
* Mega Man Battle Network 3: Blue Version
|
|
||||||
* Muse Dash
|
|
||||||
* DOOM 1993
|
|
||||||
* Terraria
|
|
||||||
* Lingo
|
|
||||||
* Pokémon Emerald
|
|
||||||
* DOOM II
|
|
||||||
* Shivers
|
|
||||||
* Heretic
|
|
||||||
* Landstalker: The Treasures of King Nole
|
|
||||||
* Final Fantasy Mystic Quest
|
|
||||||
* TUNIC
|
|
||||||
* Kirby's Dream Land 3
|
|
||||||
* Celeste 64
|
|
||||||
* Zork Grand Inquisitor
|
|
||||||
* Castlevania 64
|
|
||||||
* A Short Hike
|
|
||||||
* Yoshi's Island
|
|
||||||
* Mario & Luigi: Superstar Saga
|
|
||||||
* Bomb Rush Cyberfunk
|
|
||||||
* 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 +49,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.
|
||||||
@@ -101,10 +61,26 @@ This project makes use of multiple other projects. We wouldn't be here without t
|
|||||||
* [Ocarina of Time Randomizer](https://github.com/TestRunnerSRL/OoT-Randomizer)
|
* [Ocarina of Time Randomizer](https://github.com/TestRunnerSRL/OoT-Randomizer)
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
For contribution guidelines, please see our [Contributing doc.](/docs/contributing.md)
|
Contributions are welcome. We have a few asks of any new contributors.
|
||||||
|
|
||||||
|
* Ensure that all changes which affect logic are covered by unit tests.
|
||||||
|
* Do not introduce any unit test failures/regressions.
|
||||||
|
|
||||||
|
Otherwise, we tend to judge code on a case to case basis. It is a generally good idea to stick to PEP-8 guidelines to ensure consistency with existing code. (And to make the linter happy.)
|
||||||
|
|
||||||
|
For adding a new game to Archipelago and other documentation on how Archipelago functions, please see [the docs folder](docs/) for the relevant information and feel free to ask any questions in the #archipelago-dev channel in our discord.
|
||||||
|
|
||||||
## FAQ
|
## FAQ
|
||||||
For Frequently asked questions, please see the website's [FAQ Page.](https://archipelago.gg/faq/en/)
|
For frequently asked questions see the website's [FAQ Page](https://archipelago.gg/faq/en/)
|
||||||
|
|
||||||
## Code of Conduct
|
## Code of Conduct
|
||||||
Please refer to our [code of conduct.](/docs/code_of_conduct.md)
|
We conduct ourselves openly and inclusively here. Please do not contribute to an environment which makes other people uncomfortable. This means that we expect all contributors or participants here to:
|
||||||
|
|
||||||
|
* Be welcoming and inclusive in tone and language.
|
||||||
|
* Be respectful of others and their abilities.
|
||||||
|
* Show empathy when speaking with others.
|
||||||
|
* Be gracious and accept feedback and constructive criticism.
|
||||||
|
|
||||||
|
These guidelines apply to all channels of communication within this GitHub repository. Please be respectful in both public channels, such as issues, and private, such as private messaging or emails.
|
||||||
|
|
||||||
|
Any incidents of abuse may be reported directly to Ijwu at hmfarran@gmail.com.
|
||||||
|
|||||||
1206
SNIClient.py
1206
SNIClient.py
File diff suppressed because it is too large
Load Diff
@@ -1,11 +1,952 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import ModuleUpdate
|
import multiprocessing
|
||||||
ModuleUpdate.update()
|
import logging
|
||||||
|
import asyncio
|
||||||
|
import os.path
|
||||||
|
|
||||||
from worlds.sc2.Client import launch
|
import nest_asyncio
|
||||||
import Utils
|
import sc2
|
||||||
|
|
||||||
|
from sc2.main import run_game
|
||||||
|
from sc2.data import Race
|
||||||
|
from sc2.bot_ai import BotAI
|
||||||
|
from sc2.player import Bot
|
||||||
|
|
||||||
|
from worlds.sc2wol.Regions import MissionInfo
|
||||||
|
from worlds.sc2wol.MissionTables import lookup_id_to_mission
|
||||||
|
from worlds.sc2wol.Items import lookup_id_to_name, item_table
|
||||||
|
from worlds.sc2wol.Locations import SC2WOL_LOC_ID_OFFSET
|
||||||
|
from worlds.sc2wol import SC2WoLWorld
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
from MultiServer import mark_raw
|
||||||
|
import ctypes
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from Utils import init_logging, is_windows
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
Utils.init_logging("Starcraft2Client", exception_logger="Client")
|
init_logging("SC2Client", exception_logger="Client")
|
||||||
launch()
|
|
||||||
|
logger = logging.getLogger("Client")
|
||||||
|
sc2_logger = logging.getLogger("Starcraft2")
|
||||||
|
|
||||||
|
import colorama
|
||||||
|
|
||||||
|
from NetUtils import *
|
||||||
|
from CommonClient import CommonContext, server_loop, ClientCommandProcessor, gui_enabled, get_base_parser
|
||||||
|
|
||||||
|
nest_asyncio.apply()
|
||||||
|
|
||||||
|
|
||||||
|
class StarcraftClientProcessor(ClientCommandProcessor):
|
||||||
|
ctx: SC2Context
|
||||||
|
|
||||||
|
def _cmd_difficulty(self, difficulty: str = "") -> bool:
|
||||||
|
"""Overrides the current difficulty set for the seed. Takes the argument casual, normal, hard, or brutal"""
|
||||||
|
options = difficulty.split()
|
||||||
|
num_options = len(options)
|
||||||
|
difficulty_choice = options[0].lower()
|
||||||
|
|
||||||
|
if num_options > 0:
|
||||||
|
if difficulty_choice == "casual":
|
||||||
|
self.ctx.difficulty_override = 0
|
||||||
|
elif difficulty_choice == "normal":
|
||||||
|
self.ctx.difficulty_override = 1
|
||||||
|
elif difficulty_choice == "hard":
|
||||||
|
self.ctx.difficulty_override = 2
|
||||||
|
elif difficulty_choice == "brutal":
|
||||||
|
self.ctx.difficulty_override = 3
|
||||||
|
else:
|
||||||
|
self.output("Unable to parse difficulty '" + options[0] + "'")
|
||||||
|
return False
|
||||||
|
|
||||||
|
self.output("Difficulty set to " + options[0])
|
||||||
|
return True
|
||||||
|
|
||||||
|
else:
|
||||||
|
self.output("Difficulty needs to be specified in the command.")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _cmd_disable_mission_check(self) -> bool:
|
||||||
|
"""Disables the check to see if a mission is available to play. Meant for co-op runs where one player can play
|
||||||
|
the next mission in a chain the other player is doing."""
|
||||||
|
self.ctx.missions_unlocked = True
|
||||||
|
sc2_logger.info("Mission check has been disabled")
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _cmd_play(self, mission_id: str = "") -> bool:
|
||||||
|
"""Start a Starcraft 2 mission"""
|
||||||
|
|
||||||
|
options = mission_id.split()
|
||||||
|
num_options = len(options)
|
||||||
|
|
||||||
|
if num_options > 0:
|
||||||
|
mission_number = int(options[0])
|
||||||
|
|
||||||
|
self.ctx.play_mission(mission_number)
|
||||||
|
|
||||||
|
else:
|
||||||
|
sc2_logger.info(
|
||||||
|
"Mission ID needs to be specified. Use /unfinished or /available to view ids for available missions.")
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _cmd_available(self) -> bool:
|
||||||
|
"""Get what missions are currently available to play"""
|
||||||
|
|
||||||
|
request_available_missions(self.ctx.checked_locations, self.ctx.mission_req_table, self.ctx.ui)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _cmd_unfinished(self) -> bool:
|
||||||
|
"""Get what missions are currently available to play and have not had all locations checked"""
|
||||||
|
|
||||||
|
request_unfinished_missions(self.ctx.checked_locations, self.ctx.mission_req_table, self.ctx.ui, self.ctx)
|
||||||
|
return True
|
||||||
|
|
||||||
|
@mark_raw
|
||||||
|
def _cmd_set_path(self, path: str = '') -> bool:
|
||||||
|
"""Manually set the SC2 install directory (if the automatic detection fails)."""
|
||||||
|
if path:
|
||||||
|
os.environ["SC2PATH"] = path
|
||||||
|
check_mod_install()
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
sc2_logger.warning("When using set_path, you must type the path to your SC2 install directory.")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
class SC2Context(CommonContext):
|
||||||
|
command_processor = StarcraftClientProcessor
|
||||||
|
game = "Starcraft 2 Wings of Liberty"
|
||||||
|
items_handling = 0b111
|
||||||
|
difficulty = -1
|
||||||
|
all_in_choice = 0
|
||||||
|
mission_req_table = None
|
||||||
|
items_rec_to_announce = []
|
||||||
|
rec_announce_pos = 0
|
||||||
|
items_sent_to_announce = []
|
||||||
|
sent_announce_pos = 0
|
||||||
|
announcements = []
|
||||||
|
announcement_pos = 0
|
||||||
|
sc2_run_task: typing.Optional[asyncio.Task] = None
|
||||||
|
missions_unlocked = False
|
||||||
|
current_tooltip = None
|
||||||
|
last_loc_list = None
|
||||||
|
difficulty_override = -1
|
||||||
|
|
||||||
|
async def server_auth(self, password_requested: bool = False):
|
||||||
|
if password_requested and not self.password:
|
||||||
|
await super(SC2Context, self).server_auth(password_requested)
|
||||||
|
await self.get_username()
|
||||||
|
await self.send_connect()
|
||||||
|
|
||||||
|
def on_package(self, cmd: str, args: dict):
|
||||||
|
if cmd in {"Connected"}:
|
||||||
|
self.difficulty = args["slot_data"]["game_difficulty"]
|
||||||
|
self.all_in_choice = args["slot_data"]["all_in_map"]
|
||||||
|
slot_req_table = args["slot_data"]["mission_req"]
|
||||||
|
self.mission_req_table = {}
|
||||||
|
# Compatibility for 0.3.2 server data.
|
||||||
|
if "category" not in next(iter(slot_req_table)):
|
||||||
|
for i, mission_data in enumerate(slot_req_table.values()):
|
||||||
|
mission_data["category"] = wol_default_categories[i]
|
||||||
|
for mission in slot_req_table:
|
||||||
|
self.mission_req_table[mission] = MissionInfo(**slot_req_table[mission])
|
||||||
|
|
||||||
|
# Look for and set SC2PATH.
|
||||||
|
# check_game_install_path() returns True if and only if it finds + sets SC2PATH.
|
||||||
|
if "SC2PATH" not in os.environ and check_game_install_path():
|
||||||
|
check_mod_install()
|
||||||
|
|
||||||
|
if cmd in {"PrintJSON"}:
|
||||||
|
if "receiving" in args:
|
||||||
|
if self.slot_concerns_self(args["receiving"]):
|
||||||
|
self.announcements.append(args["data"])
|
||||||
|
return
|
||||||
|
if "item" in args:
|
||||||
|
if self.slot_concerns_self(args["item"].player):
|
||||||
|
self.announcements.append(args["data"])
|
||||||
|
|
||||||
|
def run_gui(self):
|
||||||
|
from kvui import GameManager, HoverBehavior, ServerToolTip, fade_in_animation
|
||||||
|
from kivy.app import App
|
||||||
|
from kivy.clock import Clock
|
||||||
|
from kivy.uix.tabbedpanel import TabbedPanelItem
|
||||||
|
from kivy.uix.gridlayout import GridLayout
|
||||||
|
from kivy.lang import Builder
|
||||||
|
from kivy.uix.label import Label
|
||||||
|
from kivy.uix.button import Button
|
||||||
|
from kivy.uix.floatlayout import FloatLayout
|
||||||
|
from kivy.properties import StringProperty
|
||||||
|
|
||||||
|
import Utils
|
||||||
|
|
||||||
|
class HoverableButton(HoverBehavior, Button):
|
||||||
|
pass
|
||||||
|
|
||||||
|
class MissionButton(HoverableButton):
|
||||||
|
tooltip_text = StringProperty("Test")
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super(HoverableButton, self).__init__(*args, **kwargs)
|
||||||
|
self.layout = FloatLayout()
|
||||||
|
self.popuplabel = ServerToolTip(text=self.text)
|
||||||
|
self.layout.add_widget(self.popuplabel)
|
||||||
|
|
||||||
|
def on_enter(self):
|
||||||
|
self.popuplabel.text = self.tooltip_text
|
||||||
|
|
||||||
|
if self.ctx.current_tooltip:
|
||||||
|
App.get_running_app().root.remove_widget(self.ctx.current_tooltip)
|
||||||
|
|
||||||
|
if self.tooltip_text == "":
|
||||||
|
self.ctx.current_tooltip = None
|
||||||
|
else:
|
||||||
|
App.get_running_app().root.add_widget(self.layout)
|
||||||
|
self.ctx.current_tooltip = self.layout
|
||||||
|
|
||||||
|
def on_leave(self):
|
||||||
|
if self.ctx.current_tooltip:
|
||||||
|
App.get_running_app().root.remove_widget(self.ctx.current_tooltip)
|
||||||
|
|
||||||
|
self.ctx.current_tooltip = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def ctx(self) -> CommonContext:
|
||||||
|
return App.get_running_app().ctx
|
||||||
|
|
||||||
|
class MissionLayout(GridLayout):
|
||||||
|
pass
|
||||||
|
|
||||||
|
class MissionCategory(GridLayout):
|
||||||
|
pass
|
||||||
|
|
||||||
|
class SC2Manager(GameManager):
|
||||||
|
logging_pairs = [
|
||||||
|
("Client", "Archipelago"),
|
||||||
|
("Starcraft2", "Starcraft2"),
|
||||||
|
]
|
||||||
|
base_title = "Archipelago Starcraft 2 Client"
|
||||||
|
|
||||||
|
mission_panel = None
|
||||||
|
last_checked_locations = {}
|
||||||
|
mission_id_to_button = {}
|
||||||
|
launching = False
|
||||||
|
refresh_from_launching = True
|
||||||
|
first_check = True
|
||||||
|
|
||||||
|
def __init__(self, ctx):
|
||||||
|
super().__init__(ctx)
|
||||||
|
|
||||||
|
def build(self):
|
||||||
|
container = super().build()
|
||||||
|
|
||||||
|
panel = TabbedPanelItem(text="Starcraft 2 Launcher")
|
||||||
|
self.mission_panel = panel.content = MissionLayout()
|
||||||
|
|
||||||
|
self.tabs.add_widget(panel)
|
||||||
|
|
||||||
|
Clock.schedule_interval(self.build_mission_table, 0.5)
|
||||||
|
|
||||||
|
return container
|
||||||
|
|
||||||
|
def build_mission_table(self, dt):
|
||||||
|
if (not self.launching and (not self.last_checked_locations == self.ctx.checked_locations or
|
||||||
|
not self.refresh_from_launching)) or self.first_check:
|
||||||
|
self.refresh_from_launching = True
|
||||||
|
|
||||||
|
self.mission_panel.clear_widgets()
|
||||||
|
|
||||||
|
if self.ctx.mission_req_table:
|
||||||
|
self.last_checked_locations = self.ctx.checked_locations.copy()
|
||||||
|
self.first_check = False
|
||||||
|
|
||||||
|
self.mission_id_to_button = {}
|
||||||
|
categories = {}
|
||||||
|
available_missions = []
|
||||||
|
unfinished_locations = initialize_blank_mission_dict(self.ctx.mission_req_table)
|
||||||
|
unfinished_missions = calc_unfinished_missions(self.ctx.checked_locations,
|
||||||
|
self.ctx.mission_req_table,
|
||||||
|
self.ctx, available_missions=available_missions,
|
||||||
|
unfinished_locations=unfinished_locations)
|
||||||
|
|
||||||
|
# separate missions into categories
|
||||||
|
for mission in self.ctx.mission_req_table:
|
||||||
|
if not self.ctx.mission_req_table[mission].category in categories:
|
||||||
|
categories[self.ctx.mission_req_table[mission].category] = []
|
||||||
|
|
||||||
|
categories[self.ctx.mission_req_table[mission].category].append(mission)
|
||||||
|
|
||||||
|
for category in categories:
|
||||||
|
category_panel = MissionCategory()
|
||||||
|
category_panel.add_widget(Label(text=category, size_hint_y=None, height=50, outline_width=1))
|
||||||
|
|
||||||
|
# Map is completed
|
||||||
|
for mission in categories[category]:
|
||||||
|
text = mission
|
||||||
|
tooltip = ""
|
||||||
|
|
||||||
|
# Map has uncollected locations
|
||||||
|
if mission in unfinished_missions:
|
||||||
|
text = f"[color=6495ED]{text}[/color]"
|
||||||
|
|
||||||
|
tooltip = f"Uncollected locations:\n"
|
||||||
|
tooltip += "\n".join(location for location in unfinished_locations[mission])
|
||||||
|
elif mission in available_missions:
|
||||||
|
text = f"[color=FFFFFF]{text}[/color]"
|
||||||
|
# Map requirements not met
|
||||||
|
else:
|
||||||
|
text = f"[color=a9a9a9]{text}[/color]"
|
||||||
|
tooltip = f"Requires: "
|
||||||
|
if len(self.ctx.mission_req_table[mission].required_world) > 0:
|
||||||
|
tooltip += ", ".join(list(self.ctx.mission_req_table)[req_mission-1] for
|
||||||
|
req_mission in
|
||||||
|
self.ctx.mission_req_table[mission].required_world)
|
||||||
|
|
||||||
|
if self.ctx.mission_req_table[mission].number > 0:
|
||||||
|
tooltip += " and "
|
||||||
|
if self.ctx.mission_req_table[mission].number > 0:
|
||||||
|
tooltip += f"{self.ctx.mission_req_table[mission].number} missions completed"
|
||||||
|
|
||||||
|
mission_button = MissionButton(text=text, size_hint_y=None, height=50)
|
||||||
|
mission_button.tooltip_text = tooltip
|
||||||
|
mission_button.bind(on_press=self.mission_callback)
|
||||||
|
self.mission_id_to_button[self.ctx.mission_req_table[mission].id] = mission_button
|
||||||
|
category_panel.add_widget(mission_button)
|
||||||
|
|
||||||
|
category_panel.add_widget(Label(text=""))
|
||||||
|
self.mission_panel.add_widget(category_panel)
|
||||||
|
|
||||||
|
elif self.launching:
|
||||||
|
self.refresh_from_launching = False
|
||||||
|
|
||||||
|
self.mission_panel.clear_widgets()
|
||||||
|
self.mission_panel.add_widget(Label(text="Launching Mission"))
|
||||||
|
|
||||||
|
def mission_callback(self, button):
|
||||||
|
if not self.launching:
|
||||||
|
self.ctx.play_mission(list(self.mission_id_to_button.keys())
|
||||||
|
[list(self.mission_id_to_button.values()).index(button)])
|
||||||
|
self.launching = True
|
||||||
|
Clock.schedule_once(self.finish_launching, 10)
|
||||||
|
|
||||||
|
def finish_launching(self, dt):
|
||||||
|
self.launching = False
|
||||||
|
|
||||||
|
self.ui = SC2Manager(self)
|
||||||
|
self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI")
|
||||||
|
|
||||||
|
Builder.load_file(Utils.local_path(os.path.dirname(SC2WoLWorld.__file__), "Starcraft2.kv"))
|
||||||
|
|
||||||
|
async def shutdown(self):
|
||||||
|
await super(SC2Context, self).shutdown()
|
||||||
|
if self.sc2_run_task:
|
||||||
|
self.sc2_run_task.cancel()
|
||||||
|
|
||||||
|
def play_mission(self, mission_id):
|
||||||
|
if self.missions_unlocked or \
|
||||||
|
is_mission_available(mission_id, self.checked_locations, self.mission_req_table):
|
||||||
|
if self.sc2_run_task:
|
||||||
|
if not self.sc2_run_task.done():
|
||||||
|
sc2_logger.warning("Starcraft 2 Client is still running!")
|
||||||
|
self.sc2_run_task.cancel() # doesn't actually close the game, just stops the python task
|
||||||
|
if self.slot is None:
|
||||||
|
sc2_logger.warning("Launching Mission without Archipelago authentication, "
|
||||||
|
"checks will not be registered to server.")
|
||||||
|
self.sc2_run_task = asyncio.create_task(starcraft_launch(self, mission_id),
|
||||||
|
name="Starcraft 2 Launch")
|
||||||
|
else:
|
||||||
|
sc2_logger.info(
|
||||||
|
f"{lookup_id_to_mission[mission_id]} is not currently unlocked. "
|
||||||
|
f"Use /unfinished or /available to see what is available.")
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
multiprocessing.freeze_support()
|
||||||
|
parser = get_base_parser()
|
||||||
|
parser.add_argument('--name', default=None, help="Slot Name to connect as.")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
ctx = SC2Context(args.connect, args.password)
|
||||||
|
ctx.auth = args.name
|
||||||
|
if ctx.server_task is None:
|
||||||
|
ctx.server_task = asyncio.create_task(server_loop(ctx), name="ServerLoop")
|
||||||
|
|
||||||
|
if gui_enabled:
|
||||||
|
ctx.run_gui()
|
||||||
|
ctx.run_cli()
|
||||||
|
|
||||||
|
await ctx.exit_event.wait()
|
||||||
|
|
||||||
|
await ctx.shutdown()
|
||||||
|
|
||||||
|
|
||||||
|
maps_table = [
|
||||||
|
"ap_traynor01", "ap_traynor02", "ap_traynor03",
|
||||||
|
"ap_thanson01", "ap_thanson02", "ap_thanson03a", "ap_thanson03b",
|
||||||
|
"ap_ttychus01", "ap_ttychus02", "ap_ttychus03", "ap_ttychus04", "ap_ttychus05",
|
||||||
|
"ap_ttosh01", "ap_ttosh02", "ap_ttosh03a", "ap_ttosh03b",
|
||||||
|
"ap_thorner01", "ap_thorner02", "ap_thorner03", "ap_thorner04", "ap_thorner05s",
|
||||||
|
"ap_tzeratul01", "ap_tzeratul02", "ap_tzeratul03", "ap_tzeratul04",
|
||||||
|
"ap_tvalerian01", "ap_tvalerian02a", "ap_tvalerian02b", "ap_tvalerian03"
|
||||||
|
]
|
||||||
|
|
||||||
|
wol_default_categories = [
|
||||||
|
"Mar Sara", "Mar Sara", "Mar Sara", "Colonist", "Colonist", "Colonist", "Colonist",
|
||||||
|
"Artifact", "Artifact", "Artifact", "Artifact", "Artifact", "Covert", "Covert", "Covert", "Covert",
|
||||||
|
"Rebellion", "Rebellion", "Rebellion", "Rebellion", "Rebellion", "Prophecy", "Prophecy", "Prophecy", "Prophecy",
|
||||||
|
"Char", "Char", "Char", "Char"
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def calculate_items(items):
|
||||||
|
unit_unlocks = 0
|
||||||
|
armory1_unlocks = 0
|
||||||
|
armory2_unlocks = 0
|
||||||
|
upgrade_unlocks = 0
|
||||||
|
building_unlocks = 0
|
||||||
|
merc_unlocks = 0
|
||||||
|
lab_unlocks = 0
|
||||||
|
protoss_unlock = 0
|
||||||
|
minerals = 0
|
||||||
|
vespene = 0
|
||||||
|
supply = 0
|
||||||
|
|
||||||
|
for item in items:
|
||||||
|
data = lookup_id_to_name[item.item]
|
||||||
|
|
||||||
|
if item_table[data].type == "Unit":
|
||||||
|
unit_unlocks += (1 << item_table[data].number)
|
||||||
|
elif item_table[data].type == "Upgrade":
|
||||||
|
upgrade_unlocks += (1 << item_table[data].number)
|
||||||
|
elif item_table[data].type == "Armory 1":
|
||||||
|
armory1_unlocks += (1 << item_table[data].number)
|
||||||
|
elif item_table[data].type == "Armory 2":
|
||||||
|
armory2_unlocks += (1 << item_table[data].number)
|
||||||
|
elif item_table[data].type == "Building":
|
||||||
|
building_unlocks += (1 << item_table[data].number)
|
||||||
|
elif item_table[data].type == "Mercenary":
|
||||||
|
merc_unlocks += (1 << item_table[data].number)
|
||||||
|
elif item_table[data].type == "Laboratory":
|
||||||
|
lab_unlocks += (1 << item_table[data].number)
|
||||||
|
elif item_table[data].type == "Protoss":
|
||||||
|
protoss_unlock += (1 << item_table[data].number)
|
||||||
|
elif item_table[data].type == "Minerals":
|
||||||
|
minerals += item_table[data].number
|
||||||
|
elif item_table[data].type == "Vespene":
|
||||||
|
vespene += item_table[data].number
|
||||||
|
elif item_table[data].type == "Supply":
|
||||||
|
supply += item_table[data].number
|
||||||
|
|
||||||
|
return [unit_unlocks, upgrade_unlocks, armory1_unlocks, armory2_unlocks, building_unlocks, merc_unlocks,
|
||||||
|
lab_unlocks, protoss_unlock, minerals, vespene, supply]
|
||||||
|
|
||||||
|
|
||||||
|
def calc_difficulty(difficulty):
|
||||||
|
if difficulty == 0:
|
||||||
|
return 'C'
|
||||||
|
elif difficulty == 1:
|
||||||
|
return 'N'
|
||||||
|
elif difficulty == 2:
|
||||||
|
return 'H'
|
||||||
|
elif difficulty == 3:
|
||||||
|
return 'B'
|
||||||
|
|
||||||
|
return 'X'
|
||||||
|
|
||||||
|
|
||||||
|
async def starcraft_launch(ctx: SC2Context, mission_id):
|
||||||
|
ctx.rec_announce_pos = len(ctx.items_rec_to_announce)
|
||||||
|
ctx.sent_announce_pos = len(ctx.items_sent_to_announce)
|
||||||
|
ctx.announcements_pos = len(ctx.announcements)
|
||||||
|
|
||||||
|
sc2_logger.info(f"Launching {lookup_id_to_mission[mission_id]}. If game does not launch check log file for errors.")
|
||||||
|
|
||||||
|
with DllDirectory(None):
|
||||||
|
run_game(sc2.maps.get(maps_table[mission_id - 1]), [Bot(Race.Terran, ArchipelagoBot(ctx, mission_id),
|
||||||
|
name="Archipelago", fullscreen=True)], realtime=True)
|
||||||
|
|
||||||
|
|
||||||
|
class ArchipelagoBot(sc2.bot_ai.BotAI):
|
||||||
|
game_running = False
|
||||||
|
mission_completed = False
|
||||||
|
first_bonus = False
|
||||||
|
second_bonus = False
|
||||||
|
third_bonus = False
|
||||||
|
fourth_bonus = False
|
||||||
|
fifth_bonus = False
|
||||||
|
sixth_bonus = False
|
||||||
|
seventh_bonus = False
|
||||||
|
eight_bonus = False
|
||||||
|
ctx: SC2Context = None
|
||||||
|
mission_id = 0
|
||||||
|
|
||||||
|
can_read_game = False
|
||||||
|
|
||||||
|
last_received_update = 0
|
||||||
|
|
||||||
|
def __init__(self, ctx: SC2Context, mission_id):
|
||||||
|
self.ctx = ctx
|
||||||
|
self.mission_id = mission_id
|
||||||
|
|
||||||
|
super(ArchipelagoBot, self).__init__()
|
||||||
|
|
||||||
|
async def on_step(self, iteration: int):
|
||||||
|
game_state = 0
|
||||||
|
if iteration == 0:
|
||||||
|
start_items = calculate_items(self.ctx.items_received)
|
||||||
|
if self.ctx.difficulty_override >= 0:
|
||||||
|
difficulty = calc_difficulty(self.ctx.difficulty_override)
|
||||||
|
else:
|
||||||
|
difficulty = calc_difficulty(self.ctx.difficulty)
|
||||||
|
await self.chat_send("ArchipelagoLoad {} {} {} {} {} {} {} {} {} {} {} {} {}".format(
|
||||||
|
difficulty,
|
||||||
|
start_items[0], start_items[1], start_items[2], start_items[3], start_items[4],
|
||||||
|
start_items[5], start_items[6], start_items[7], start_items[8], start_items[9],
|
||||||
|
self.ctx.all_in_choice, start_items[10]))
|
||||||
|
self.last_received_update = len(self.ctx.items_received)
|
||||||
|
|
||||||
|
else:
|
||||||
|
if self.ctx.announcement_pos < len(self.ctx.announcements):
|
||||||
|
index = 0
|
||||||
|
message = ""
|
||||||
|
while index < len(self.ctx.announcements[self.ctx.announcement_pos]):
|
||||||
|
message += self.ctx.announcements[self.ctx.announcement_pos][index]["text"]
|
||||||
|
index += 1
|
||||||
|
|
||||||
|
index = 0
|
||||||
|
start_rem_pos = -1
|
||||||
|
# Remove unneeded [Color] tags
|
||||||
|
while index < len(message):
|
||||||
|
if message[index] == '[':
|
||||||
|
start_rem_pos = index
|
||||||
|
index += 1
|
||||||
|
elif message[index] == ']' and start_rem_pos > -1:
|
||||||
|
temp_msg = ""
|
||||||
|
|
||||||
|
if start_rem_pos > 0:
|
||||||
|
temp_msg = message[:start_rem_pos]
|
||||||
|
if index < len(message) - 1:
|
||||||
|
temp_msg += message[index + 1:]
|
||||||
|
|
||||||
|
message = temp_msg
|
||||||
|
index += start_rem_pos - index
|
||||||
|
start_rem_pos = -1
|
||||||
|
else:
|
||||||
|
index += 1
|
||||||
|
|
||||||
|
await self.chat_send("SendMessage " + message)
|
||||||
|
self.ctx.announcement_pos += 1
|
||||||
|
|
||||||
|
# Archipelago reads the health
|
||||||
|
for unit in self.all_own_units():
|
||||||
|
if unit.health_max == 38281:
|
||||||
|
game_state = int(38281 - unit.health)
|
||||||
|
self.can_read_game = True
|
||||||
|
|
||||||
|
if iteration == 160 and not game_state & 1:
|
||||||
|
await self.chat_send("SendMessage Warning: Archipelago unable to connect or has lost connection to " +
|
||||||
|
"Starcraft 2 (This is likely a map issue)")
|
||||||
|
|
||||||
|
if self.last_received_update < len(self.ctx.items_received):
|
||||||
|
current_items = calculate_items(self.ctx.items_received)
|
||||||
|
await self.chat_send("UpdateTech {} {} {} {} {} {} {} {}".format(
|
||||||
|
current_items[0], current_items[1], current_items[2], current_items[3], current_items[4],
|
||||||
|
current_items[5], current_items[6], current_items[7]))
|
||||||
|
self.last_received_update = len(self.ctx.items_received)
|
||||||
|
|
||||||
|
if game_state & 1:
|
||||||
|
if not self.game_running:
|
||||||
|
print("Archipelago Connected")
|
||||||
|
self.game_running = True
|
||||||
|
|
||||||
|
if self.can_read_game:
|
||||||
|
if game_state & (1 << 1) and not self.mission_completed:
|
||||||
|
if self.mission_id != 29:
|
||||||
|
print("Mission Completed")
|
||||||
|
await self.ctx.send_msgs([
|
||||||
|
{"cmd": 'LocationChecks', "locations": [SC2WOL_LOC_ID_OFFSET + 100 * self.mission_id]}])
|
||||||
|
self.mission_completed = True
|
||||||
|
else:
|
||||||
|
print("Game Complete")
|
||||||
|
await self.ctx.send_msgs([{"cmd": 'StatusUpdate', "status": ClientStatus.CLIENT_GOAL}])
|
||||||
|
self.mission_completed = True
|
||||||
|
|
||||||
|
if game_state & (1 << 2) and not self.first_bonus:
|
||||||
|
print("1st Bonus Collected")
|
||||||
|
await self.ctx.send_msgs(
|
||||||
|
[{"cmd": 'LocationChecks',
|
||||||
|
"locations": [SC2WOL_LOC_ID_OFFSET + 100 * self.mission_id + 1]}])
|
||||||
|
self.first_bonus = True
|
||||||
|
|
||||||
|
if not self.second_bonus and game_state & (1 << 3):
|
||||||
|
print("2nd Bonus Collected")
|
||||||
|
await self.ctx.send_msgs(
|
||||||
|
[{"cmd": 'LocationChecks',
|
||||||
|
"locations": [SC2WOL_LOC_ID_OFFSET + 100 * self.mission_id + 2]}])
|
||||||
|
self.second_bonus = True
|
||||||
|
|
||||||
|
if not self.third_bonus and game_state & (1 << 4):
|
||||||
|
print("3rd Bonus Collected")
|
||||||
|
await self.ctx.send_msgs(
|
||||||
|
[{"cmd": 'LocationChecks',
|
||||||
|
"locations": [SC2WOL_LOC_ID_OFFSET + 100 * self.mission_id + 3]}])
|
||||||
|
self.third_bonus = True
|
||||||
|
|
||||||
|
if not self.fourth_bonus and game_state & (1 << 5):
|
||||||
|
print("4th Bonus Collected")
|
||||||
|
await self.ctx.send_msgs(
|
||||||
|
[{"cmd": 'LocationChecks',
|
||||||
|
"locations": [SC2WOL_LOC_ID_OFFSET + 100 * self.mission_id + 4]}])
|
||||||
|
self.fourth_bonus = True
|
||||||
|
|
||||||
|
if not self.fifth_bonus and game_state & (1 << 6):
|
||||||
|
print("5th Bonus Collected")
|
||||||
|
await self.ctx.send_msgs(
|
||||||
|
[{"cmd": 'LocationChecks',
|
||||||
|
"locations": [SC2WOL_LOC_ID_OFFSET + 100 * self.mission_id + 5]}])
|
||||||
|
self.fifth_bonus = True
|
||||||
|
|
||||||
|
if not self.sixth_bonus and game_state & (1 << 7):
|
||||||
|
print("6th Bonus Collected")
|
||||||
|
await self.ctx.send_msgs(
|
||||||
|
[{"cmd": 'LocationChecks',
|
||||||
|
"locations": [SC2WOL_LOC_ID_OFFSET + 100 * self.mission_id + 6]}])
|
||||||
|
self.sixth_bonus = True
|
||||||
|
|
||||||
|
if not self.seventh_bonus and game_state & (1 << 8):
|
||||||
|
print("6th Bonus Collected")
|
||||||
|
await self.ctx.send_msgs(
|
||||||
|
[{"cmd": 'LocationChecks',
|
||||||
|
"locations": [SC2WOL_LOC_ID_OFFSET + 100 * self.mission_id + 7]}])
|
||||||
|
self.seventh_bonus = True
|
||||||
|
|
||||||
|
if not self.eight_bonus and game_state & (1 << 9):
|
||||||
|
print("6th Bonus Collected")
|
||||||
|
await self.ctx.send_msgs(
|
||||||
|
[{"cmd": 'LocationChecks',
|
||||||
|
"locations": [SC2WOL_LOC_ID_OFFSET + 100 * self.mission_id + 8]}])
|
||||||
|
self.eight_bonus = True
|
||||||
|
|
||||||
|
else:
|
||||||
|
await self.chat_send("LostConnection - Lost connection to game.")
|
||||||
|
|
||||||
|
|
||||||
|
def calc_objectives_completed(mission, missions_info, locations_done, unfinished_locations, ctx):
|
||||||
|
objectives_complete = 0
|
||||||
|
|
||||||
|
if missions_info[mission].extra_locations > 0:
|
||||||
|
for i in range(missions_info[mission].extra_locations):
|
||||||
|
if (missions_info[mission].id * 100 + SC2WOL_LOC_ID_OFFSET + i) in locations_done:
|
||||||
|
objectives_complete += 1
|
||||||
|
else:
|
||||||
|
unfinished_locations[mission].append(ctx.location_names[
|
||||||
|
missions_info[mission].id * 100 + SC2WOL_LOC_ID_OFFSET + i])
|
||||||
|
|
||||||
|
return objectives_complete
|
||||||
|
|
||||||
|
else:
|
||||||
|
return -1
|
||||||
|
|
||||||
|
|
||||||
|
def request_unfinished_missions(locations_done, location_table, ui, ctx):
|
||||||
|
if location_table:
|
||||||
|
message = "Unfinished Missions: "
|
||||||
|
unlocks = initialize_blank_mission_dict(location_table)
|
||||||
|
unfinished_locations = initialize_blank_mission_dict(location_table)
|
||||||
|
|
||||||
|
unfinished_missions = calc_unfinished_missions(locations_done, location_table, ctx, unlocks=unlocks,
|
||||||
|
unfinished_locations=unfinished_locations)
|
||||||
|
|
||||||
|
message += ", ".join(f"{mark_up_mission_name(mission, location_table, ui,unlocks)}[{location_table[mission].id}] " +
|
||||||
|
mark_up_objectives(
|
||||||
|
f"[{unfinished_missions[mission]}/{location_table[mission].extra_locations}]",
|
||||||
|
ctx, unfinished_locations, mission)
|
||||||
|
for mission in unfinished_missions)
|
||||||
|
|
||||||
|
if ui:
|
||||||
|
ui.log_panels['All'].on_message_markup(message)
|
||||||
|
ui.log_panels['Starcraft2'].on_message_markup(message)
|
||||||
|
else:
|
||||||
|
sc2_logger.info(message)
|
||||||
|
else:
|
||||||
|
sc2_logger.warning("No mission table found, you are likely not connected to a server.")
|
||||||
|
|
||||||
|
|
||||||
|
def calc_unfinished_missions(locations_done, locations, ctx, unlocks=None, unfinished_locations=None,
|
||||||
|
available_missions=[]):
|
||||||
|
unfinished_missions = []
|
||||||
|
locations_completed = []
|
||||||
|
|
||||||
|
if not unlocks:
|
||||||
|
unlocks = initialize_blank_mission_dict(locations)
|
||||||
|
|
||||||
|
if not unfinished_locations:
|
||||||
|
unfinished_locations = initialize_blank_mission_dict(locations)
|
||||||
|
|
||||||
|
if len(available_missions) > 0:
|
||||||
|
available_missions = []
|
||||||
|
|
||||||
|
available_missions.extend(calc_available_missions(locations_done, locations, unlocks))
|
||||||
|
|
||||||
|
for name in available_missions:
|
||||||
|
if not locations[name].extra_locations == -1:
|
||||||
|
objectives_completed = calc_objectives_completed(name, locations, locations_done, unfinished_locations, ctx)
|
||||||
|
|
||||||
|
if objectives_completed < locations[name].extra_locations:
|
||||||
|
unfinished_missions.append(name)
|
||||||
|
locations_completed.append(objectives_completed)
|
||||||
|
|
||||||
|
else:
|
||||||
|
unfinished_missions.append(name)
|
||||||
|
locations_completed.append(-1)
|
||||||
|
|
||||||
|
return {unfinished_missions[i]: locations_completed[i] for i in range(len(unfinished_missions))}
|
||||||
|
|
||||||
|
|
||||||
|
def is_mission_available(mission_id_to_check, locations_done, locations):
|
||||||
|
unfinished_missions = calc_available_missions(locations_done, locations)
|
||||||
|
|
||||||
|
return any(mission_id_to_check == locations[mission].id for mission in unfinished_missions)
|
||||||
|
|
||||||
|
|
||||||
|
def mark_up_mission_name(mission, location_table, ui, unlock_table):
|
||||||
|
"""Checks if the mission is required for game completion and adds '*' to the name to mark that."""
|
||||||
|
|
||||||
|
if location_table[mission].completion_critical:
|
||||||
|
if ui:
|
||||||
|
message = "[color=AF99EF]" + mission + "[/color]"
|
||||||
|
else:
|
||||||
|
message = "*" + mission + "*"
|
||||||
|
else:
|
||||||
|
message = mission
|
||||||
|
|
||||||
|
if ui:
|
||||||
|
unlocks = unlock_table[mission]
|
||||||
|
|
||||||
|
if len(unlocks) > 0:
|
||||||
|
pre_message = f"[ref={list(location_table).index(mission)}|Unlocks: "
|
||||||
|
pre_message += ", ".join(f"{unlock}({location_table[unlock].id})" for unlock in unlocks)
|
||||||
|
pre_message += f"]"
|
||||||
|
message = pre_message + message + "[/ref]"
|
||||||
|
|
||||||
|
return message
|
||||||
|
|
||||||
|
|
||||||
|
def mark_up_objectives(message, ctx, unfinished_locations, mission):
|
||||||
|
formatted_message = message
|
||||||
|
|
||||||
|
if ctx.ui:
|
||||||
|
locations = unfinished_locations[mission]
|
||||||
|
|
||||||
|
pre_message = f"[ref={list(ctx.mission_req_table).index(mission)+30}|"
|
||||||
|
pre_message += "<br>".join(location for location in locations)
|
||||||
|
pre_message += f"]"
|
||||||
|
formatted_message = pre_message + message + "[/ref]"
|
||||||
|
|
||||||
|
return formatted_message
|
||||||
|
|
||||||
|
|
||||||
|
def request_available_missions(locations_done, location_table, ui):
|
||||||
|
if location_table:
|
||||||
|
message = "Available Missions: "
|
||||||
|
|
||||||
|
# Initialize mission unlock table
|
||||||
|
unlocks = initialize_blank_mission_dict(location_table)
|
||||||
|
|
||||||
|
missions = calc_available_missions(locations_done, location_table, unlocks)
|
||||||
|
message += \
|
||||||
|
", ".join(f"{mark_up_mission_name(mission, location_table, ui, unlocks)}[{location_table[mission].id}]"
|
||||||
|
for mission in missions)
|
||||||
|
|
||||||
|
if ui:
|
||||||
|
ui.log_panels['All'].on_message_markup(message)
|
||||||
|
ui.log_panels['Starcraft2'].on_message_markup(message)
|
||||||
|
else:
|
||||||
|
sc2_logger.info(message)
|
||||||
|
else:
|
||||||
|
sc2_logger.warning("No mission table found, you are likely not connected to a server.")
|
||||||
|
|
||||||
|
|
||||||
|
def calc_available_missions(locations_done, locations, unlocks=None):
|
||||||
|
available_missions = []
|
||||||
|
missions_complete = 0
|
||||||
|
|
||||||
|
# Get number of missions completed
|
||||||
|
for loc in locations_done:
|
||||||
|
if loc % 100 == 0:
|
||||||
|
missions_complete += 1
|
||||||
|
|
||||||
|
for name in locations:
|
||||||
|
# Go through the required missions for each mission and fill up unlock table used later for hover-over tooltips
|
||||||
|
if unlocks:
|
||||||
|
for unlock in locations[name].required_world:
|
||||||
|
unlocks[list(locations)[unlock-1]].append(name)
|
||||||
|
|
||||||
|
if mission_reqs_completed(name, missions_complete, locations_done, locations):
|
||||||
|
available_missions.append(name)
|
||||||
|
|
||||||
|
return available_missions
|
||||||
|
|
||||||
|
|
||||||
|
def mission_reqs_completed(location_to_check, missions_complete, locations_done, locations):
|
||||||
|
"""Returns a bool signifying if the mission has all requirements complete and can be done
|
||||||
|
|
||||||
|
Keyword arguments:
|
||||||
|
locations_to_check -- the mission string name to check
|
||||||
|
missions_complete -- an int of how many missions have been completed
|
||||||
|
locations_done -- a list of the location ids that have been complete
|
||||||
|
locations -- a dict of MissionInfo for mission requirements for this world"""
|
||||||
|
if len(locations[location_to_check].required_world) >= 1:
|
||||||
|
# A check for when the requirements are being or'd
|
||||||
|
or_success = False
|
||||||
|
|
||||||
|
# Loop through required missions
|
||||||
|
for req_mission in locations[location_to_check].required_world:
|
||||||
|
req_success = True
|
||||||
|
|
||||||
|
# Check if required mission has been completed
|
||||||
|
if not (locations[list(locations)[req_mission-1]].id * 100 + SC2WOL_LOC_ID_OFFSET) in locations_done:
|
||||||
|
if not locations[location_to_check].or_requirements:
|
||||||
|
return False
|
||||||
|
else:
|
||||||
|
req_success = False
|
||||||
|
|
||||||
|
# Recursively check required mission to see if it's requirements are met, in case !collect has been done
|
||||||
|
if not mission_reqs_completed(list(locations)[req_mission-1], missions_complete, locations_done,
|
||||||
|
locations):
|
||||||
|
if not locations[location_to_check].or_requirements:
|
||||||
|
return False
|
||||||
|
else:
|
||||||
|
req_success = False
|
||||||
|
|
||||||
|
# If requirement check succeeded mark or as satisfied
|
||||||
|
if locations[location_to_check].or_requirements and req_success:
|
||||||
|
or_success = True
|
||||||
|
|
||||||
|
if locations[location_to_check].or_requirements:
|
||||||
|
# Return false if or requirements not met
|
||||||
|
if not or_success:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Check number of missions
|
||||||
|
if missions_complete >= locations[location_to_check].number:
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
return False
|
||||||
|
else:
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def initialize_blank_mission_dict(location_table):
|
||||||
|
unlocks = {}
|
||||||
|
|
||||||
|
for mission in list(location_table):
|
||||||
|
unlocks[mission] = []
|
||||||
|
|
||||||
|
return unlocks
|
||||||
|
|
||||||
|
|
||||||
|
def check_game_install_path() -> bool:
|
||||||
|
# First thing: go to the default location for ExecuteInfo.
|
||||||
|
# An exception for Windows is included because it's very difficult to find ~\Documents if the user moved it.
|
||||||
|
if is_windows:
|
||||||
|
# The next five lines of utterly inscrutable code are brought to you by copy-paste from Stack Overflow.
|
||||||
|
# https://stackoverflow.com/questions/6227590/finding-the-users-my-documents-path/30924555#
|
||||||
|
import ctypes.wintypes
|
||||||
|
CSIDL_PERSONAL = 5 # My Documents
|
||||||
|
SHGFP_TYPE_CURRENT = 0 # Get current, not default value
|
||||||
|
|
||||||
|
buf = ctypes.create_unicode_buffer(ctypes.wintypes.MAX_PATH)
|
||||||
|
ctypes.windll.shell32.SHGetFolderPathW(None, CSIDL_PERSONAL, None, SHGFP_TYPE_CURRENT, buf)
|
||||||
|
documentspath = buf.value
|
||||||
|
einfo = str(documentspath / Path("StarCraft II\\ExecuteInfo.txt"))
|
||||||
|
else:
|
||||||
|
einfo = str(sc2.paths.get_home() / Path(sc2.paths.USERPATH[sc2.paths.PF]))
|
||||||
|
|
||||||
|
# Check if the file exists.
|
||||||
|
if os.path.isfile(einfo):
|
||||||
|
|
||||||
|
# Open the file and read it, picking out the latest executable's path.
|
||||||
|
with open(einfo) as f:
|
||||||
|
content = f.read()
|
||||||
|
if content:
|
||||||
|
base = re.search(r" = (.*)Versions", content).group(1)
|
||||||
|
if os.path.exists(base):
|
||||||
|
executable = sc2.paths.latest_executeble(Path(base).expanduser() / "Versions")
|
||||||
|
|
||||||
|
# Finally, check the path for an actual executable.
|
||||||
|
# If we find one, great. Set up the SC2PATH.
|
||||||
|
if os.path.isfile(executable):
|
||||||
|
sc2_logger.info(f"Found an SC2 install at {base}!")
|
||||||
|
sc2_logger.debug(f"Latest executable at {executable}.")
|
||||||
|
os.environ["SC2PATH"] = base
|
||||||
|
sc2_logger.debug(f"SC2PATH set to {base}.")
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
sc2_logger.warning(f"We may have found an SC2 install at {base}, but couldn't find {executable}.")
|
||||||
|
else:
|
||||||
|
sc2_logger.warning(f"{einfo} pointed to {base}, but we could not find an SC2 install there.")
|
||||||
|
else:
|
||||||
|
sc2_logger.warning(f"Couldn't find {einfo}. Please run /set_path with your SC2 install directory.")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def check_mod_install() -> bool:
|
||||||
|
# Pull up the SC2PATH if set. If not, encourage the user to manually run /set_path.
|
||||||
|
try:
|
||||||
|
# Check inside the Mods folder for Archipelago.SC2Mod. If found, tell user. If not, tell user.
|
||||||
|
if os.path.isfile(modfile := (os.environ["SC2PATH"] / Path("Mods") / Path("Archipelago.SC2Mod"))):
|
||||||
|
sc2_logger.info(f"Archipelago mod found at {modfile}.")
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
sc2_logger.warning(f"Archipelago mod could not be found at {modfile}. Please install the mod file there.")
|
||||||
|
except KeyError:
|
||||||
|
sc2_logger.warning(f"SC2PATH isn't set. Please run /set_path with the path to your SC2 install.")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
class DllDirectory:
|
||||||
|
# Credit to Black Sliver for this code.
|
||||||
|
# More info: https://docs.microsoft.com/en-us/windows/win32/api/winbase/nf-winbase-setdlldirectoryw
|
||||||
|
_old: typing.Optional[str] = None
|
||||||
|
_new: typing.Optional[str] = None
|
||||||
|
|
||||||
|
def __init__(self, new: typing.Optional[str]):
|
||||||
|
self._new = new
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
old = self.get()
|
||||||
|
if self.set(self._new):
|
||||||
|
self._old = old
|
||||||
|
|
||||||
|
def __exit__(self, *args):
|
||||||
|
if self._old is not None:
|
||||||
|
self.set(self._old)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get() -> str:
|
||||||
|
if sys.platform == "win32":
|
||||||
|
n = ctypes.windll.kernel32.GetDllDirectoryW(0, None)
|
||||||
|
buf = ctypes.create_unicode_buffer(n)
|
||||||
|
ctypes.windll.kernel32.GetDllDirectoryW(n, buf)
|
||||||
|
return buf.value
|
||||||
|
# NOTE: other OS may support os.environ["LD_LIBRARY_PATH"], but this fix is windows-specific
|
||||||
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def set(s: typing.Optional[str]) -> bool:
|
||||||
|
if sys.platform == "win32":
|
||||||
|
return ctypes.windll.kernel32.SetDllDirectoryW(s) != 0
|
||||||
|
# NOTE: other OS may support os.environ["LD_LIBRARY_PATH"], but this fix is windows-specific
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
colorama.init()
|
||||||
|
asyncio.run(main())
|
||||||
|
colorama.deinit()
|
||||||
|
|||||||
@@ -1,512 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
import asyncio
|
|
||||||
import typing
|
|
||||||
import bsdiff4
|
|
||||||
import shutil
|
|
||||||
|
|
||||||
import Utils
|
|
||||||
|
|
||||||
from NetUtils import NetworkItem, ClientStatus
|
|
||||||
from worlds import undertale
|
|
||||||
from MultiServer import mark_raw
|
|
||||||
from CommonClient import CommonContext, server_loop, \
|
|
||||||
gui_enabled, ClientCommandProcessor, logger, get_base_parser
|
|
||||||
from Utils import async_start
|
|
||||||
|
|
||||||
|
|
||||||
class UndertaleCommandProcessor(ClientCommandProcessor):
|
|
||||||
def __init__(self, ctx):
|
|
||||||
super().__init__(ctx)
|
|
||||||
|
|
||||||
def _cmd_resync(self):
|
|
||||||
"""Manually trigger a resync."""
|
|
||||||
if isinstance(self.ctx, UndertaleContext):
|
|
||||||
self.output(f"Syncing items.")
|
|
||||||
self.ctx.syncing = True
|
|
||||||
|
|
||||||
def _cmd_patch(self):
|
|
||||||
"""Patch the game. Only use this command if /auto_patch fails."""
|
|
||||||
if isinstance(self.ctx, UndertaleContext):
|
|
||||||
os.makedirs(name=os.path.join(os.getcwd(), "Undertale"), exist_ok=True)
|
|
||||||
self.ctx.patch_game()
|
|
||||||
self.output("Patched.")
|
|
||||||
|
|
||||||
def _cmd_savepath(self, directory: str):
|
|
||||||
"""Redirect to proper save data folder. This is necessary for Linux users to use before connecting."""
|
|
||||||
if isinstance(self.ctx, UndertaleContext):
|
|
||||||
self.ctx.save_game_folder = directory
|
|
||||||
self.output("Changed to the following directory: " + self.ctx.save_game_folder)
|
|
||||||
|
|
||||||
@mark_raw
|
|
||||||
def _cmd_auto_patch(self, steaminstall: typing.Optional[str] = None):
|
|
||||||
"""Patch the game automatically."""
|
|
||||||
if isinstance(self.ctx, UndertaleContext):
|
|
||||||
os.makedirs(name=os.path.join(os.getcwd(), "Undertale"), exist_ok=True)
|
|
||||||
tempInstall = steaminstall
|
|
||||||
if not os.path.isfile(os.path.join(tempInstall, "data.win")):
|
|
||||||
tempInstall = None
|
|
||||||
if tempInstall is None:
|
|
||||||
tempInstall = "C:\\Program Files (x86)\\Steam\\steamapps\\common\\Undertale"
|
|
||||||
if not os.path.exists(tempInstall):
|
|
||||||
tempInstall = "C:\\Program Files\\Steam\\steamapps\\common\\Undertale"
|
|
||||||
elif not os.path.exists(tempInstall):
|
|
||||||
tempInstall = "C:\\Program Files (x86)\\Steam\\steamapps\\common\\Undertale"
|
|
||||||
if not os.path.exists(tempInstall):
|
|
||||||
tempInstall = "C:\\Program Files\\Steam\\steamapps\\common\\Undertale"
|
|
||||||
if not os.path.exists(tempInstall) or not os.path.exists(tempInstall) or not os.path.isfile(os.path.join(tempInstall, "data.win")):
|
|
||||||
self.output("ERROR: Cannot find Undertale. Please rerun the command with the correct folder."
|
|
||||||
" command. \"/auto_patch (Steam directory)\".")
|
|
||||||
else:
|
|
||||||
for file_name in os.listdir(tempInstall):
|
|
||||||
if file_name != "steam_api.dll":
|
|
||||||
shutil.copy(os.path.join(tempInstall, file_name),
|
|
||||||
os.path.join(os.getcwd(), "Undertale", file_name))
|
|
||||||
self.ctx.patch_game()
|
|
||||||
self.output("Patching successful!")
|
|
||||||
|
|
||||||
def _cmd_online(self):
|
|
||||||
"""Toggles seeing other Undertale players."""
|
|
||||||
if isinstance(self.ctx, UndertaleContext):
|
|
||||||
self.ctx.update_online_mode(not ("Online" in self.ctx.tags))
|
|
||||||
if "Online" in self.ctx.tags:
|
|
||||||
self.output(f"Now online.")
|
|
||||||
else:
|
|
||||||
self.output(f"Now offline.")
|
|
||||||
|
|
||||||
def _cmd_deathlink(self):
|
|
||||||
"""Toggles deathlink"""
|
|
||||||
if isinstance(self.ctx, UndertaleContext):
|
|
||||||
self.ctx.deathlink_status = not self.ctx.deathlink_status
|
|
||||||
if self.ctx.deathlink_status:
|
|
||||||
self.output(f"Deathlink enabled.")
|
|
||||||
else:
|
|
||||||
self.output(f"Deathlink disabled.")
|
|
||||||
|
|
||||||
|
|
||||||
class UndertaleContext(CommonContext):
|
|
||||||
tags = {"AP", "Online"}
|
|
||||||
game = "Undertale"
|
|
||||||
command_processor = UndertaleCommandProcessor
|
|
||||||
items_handling = 0b111
|
|
||||||
route = None
|
|
||||||
pieces_needed = None
|
|
||||||
completed_routes = None
|
|
||||||
completed_count = 0
|
|
||||||
save_game_folder = os.path.expandvars(r"%localappdata%/UNDERTALE")
|
|
||||||
|
|
||||||
def __init__(self, server_address, password):
|
|
||||||
super().__init__(server_address, password)
|
|
||||||
self.pieces_needed = 0
|
|
||||||
self.finished_game = False
|
|
||||||
self.game = "Undertale"
|
|
||||||
self.got_deathlink = False
|
|
||||||
self.syncing = False
|
|
||||||
self.deathlink_status = False
|
|
||||||
self.tem_armor = False
|
|
||||||
self.completed_count = 0
|
|
||||||
self.completed_routes = {"pacifist": 0, "genocide": 0, "neutral": 0}
|
|
||||||
# self.save_game_folder: files go in this path to pass data between us and the actual game
|
|
||||||
self.save_game_folder = os.path.expandvars(r"%localappdata%/UNDERTALE")
|
|
||||||
|
|
||||||
def patch_game(self):
|
|
||||||
with open(os.path.join(os.getcwd(), "Undertale", "data.win"), "rb") as f:
|
|
||||||
patchedFile = bsdiff4.patch(f.read(), undertale.data_path("patch.bsdiff"))
|
|
||||||
with open(os.path.join(os.getcwd(), "Undertale", "data.win"), "wb") as f:
|
|
||||||
f.write(patchedFile)
|
|
||||||
os.makedirs(name=os.path.join(os.getcwd(), "Undertale", "Custom Sprites"), exist_ok=True)
|
|
||||||
with open(os.path.expandvars(os.path.join(os.getcwd(), "Undertale", "Custom Sprites",
|
|
||||||
"Which Character.txt")), "w") as f:
|
|
||||||
f.writelines(["// Put the folder name of the sprites you want to play as, make sure it is the only "
|
|
||||||
"line other than this one.\n", "frisk"])
|
|
||||||
f.close()
|
|
||||||
|
|
||||||
async def server_auth(self, password_requested: bool = False):
|
|
||||||
if password_requested and not self.password:
|
|
||||||
await super().server_auth(password_requested)
|
|
||||||
await self.get_username()
|
|
||||||
await self.send_connect()
|
|
||||||
|
|
||||||
def clear_undertale_files(self):
|
|
||||||
path = self.save_game_folder
|
|
||||||
self.finished_game = False
|
|
||||||
for root, dirs, files in os.walk(path):
|
|
||||||
for file in files:
|
|
||||||
if "check.spot" == file or "scout" == file:
|
|
||||||
os.remove(os.path.join(root, file))
|
|
||||||
elif file.endswith((".item", ".victory", ".route", ".playerspot", ".mad",
|
|
||||||
".youDied", ".LV", ".mine", ".flag", ".hint")):
|
|
||||||
os.remove(os.path.join(root, file))
|
|
||||||
|
|
||||||
async def connect(self, address: typing.Optional[str] = None):
|
|
||||||
self.clear_undertale_files()
|
|
||||||
await super().connect(address)
|
|
||||||
|
|
||||||
async def disconnect(self, allow_autoreconnect: bool = False):
|
|
||||||
self.clear_undertale_files()
|
|
||||||
await super().disconnect(allow_autoreconnect)
|
|
||||||
|
|
||||||
async def connection_closed(self):
|
|
||||||
self.clear_undertale_files()
|
|
||||||
await super().connection_closed()
|
|
||||||
|
|
||||||
async def shutdown(self):
|
|
||||||
self.clear_undertale_files()
|
|
||||||
await super().shutdown()
|
|
||||||
|
|
||||||
def update_online_mode(self, online):
|
|
||||||
old_tags = self.tags.copy()
|
|
||||||
if online:
|
|
||||||
self.tags.add("Online")
|
|
||||||
else:
|
|
||||||
self.tags -= {"Online"}
|
|
||||||
if old_tags != self.tags and self.server and not self.server.socket.closed:
|
|
||||||
async_start(self.send_msgs([{"cmd": "ConnectUpdate", "tags": self.tags}]))
|
|
||||||
|
|
||||||
def on_package(self, cmd: str, args: dict):
|
|
||||||
if cmd == "Connected":
|
|
||||||
self.game = self.slot_info[self.slot].game
|
|
||||||
async_start(process_undertale_cmd(self, cmd, args))
|
|
||||||
|
|
||||||
def run_gui(self):
|
|
||||||
from kvui import GameManager
|
|
||||||
|
|
||||||
class UTManager(GameManager):
|
|
||||||
logging_pairs = [
|
|
||||||
("Client", "Archipelago")
|
|
||||||
]
|
|
||||||
base_title = "Archipelago Undertale Client"
|
|
||||||
|
|
||||||
self.ui = UTManager(self)
|
|
||||||
self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI")
|
|
||||||
|
|
||||||
def on_deathlink(self, data: typing.Dict[str, typing.Any]):
|
|
||||||
self.got_deathlink = True
|
|
||||||
super().on_deathlink(data)
|
|
||||||
|
|
||||||
|
|
||||||
def to_room_name(place_name: str):
|
|
||||||
if place_name == "Old Home Exit":
|
|
||||||
return "room_ruinsexit"
|
|
||||||
elif place_name == "Snowdin Forest":
|
|
||||||
return "room_tundra1"
|
|
||||||
elif place_name == "Snowdin Town Exit":
|
|
||||||
return "room_fogroom"
|
|
||||||
elif place_name == "Waterfall":
|
|
||||||
return "room_water1"
|
|
||||||
elif place_name == "Waterfall Exit":
|
|
||||||
return "room_fire2"
|
|
||||||
elif place_name == "Hotland":
|
|
||||||
return "room_fire_prelab"
|
|
||||||
elif place_name == "Hotland Exit":
|
|
||||||
return "room_fire_precore"
|
|
||||||
elif place_name == "Core":
|
|
||||||
return "room_fire_core1"
|
|
||||||
|
|
||||||
|
|
||||||
async def process_undertale_cmd(ctx: UndertaleContext, cmd: str, args: dict):
|
|
||||||
if cmd == "Connected":
|
|
||||||
if not os.path.exists(ctx.save_game_folder):
|
|
||||||
os.mkdir(ctx.save_game_folder)
|
|
||||||
ctx.route = args["slot_data"]["route"]
|
|
||||||
ctx.pieces_needed = args["slot_data"]["key_pieces"]
|
|
||||||
ctx.tem_armor = args["slot_data"]["temy_armor_include"]
|
|
||||||
|
|
||||||
await ctx.send_msgs([{"cmd": "Get", "keys": [str(ctx.slot)+" RoutesDone neutral",
|
|
||||||
str(ctx.slot)+" RoutesDone pacifist",
|
|
||||||
str(ctx.slot)+" RoutesDone genocide"]}])
|
|
||||||
await ctx.send_msgs([{"cmd": "SetNotify", "keys": [str(ctx.slot)+" RoutesDone neutral",
|
|
||||||
str(ctx.slot)+" RoutesDone pacifist",
|
|
||||||
str(ctx.slot)+" RoutesDone genocide"]}])
|
|
||||||
if args["slot_data"]["only_flakes"]:
|
|
||||||
with open(os.path.join(ctx.save_game_folder, "GenoNoChest.flag"), "w") as f:
|
|
||||||
f.close()
|
|
||||||
if not args["slot_data"]["key_hunt"]:
|
|
||||||
ctx.pieces_needed = 0
|
|
||||||
if args["slot_data"]["rando_love"]:
|
|
||||||
filename = f"LOVErando.LV"
|
|
||||||
with open(os.path.join(ctx.save_game_folder, filename), "w") as f:
|
|
||||||
f.close()
|
|
||||||
if args["slot_data"]["rando_stats"]:
|
|
||||||
filename = f"STATrando.LV"
|
|
||||||
with open(os.path.join(ctx.save_game_folder, filename), "w") as f:
|
|
||||||
f.close()
|
|
||||||
filename = f"{ctx.route}.route"
|
|
||||||
with open(os.path.join(ctx.save_game_folder, filename), "w") as f:
|
|
||||||
f.close()
|
|
||||||
filename = f"check.spot"
|
|
||||||
with open(os.path.join(ctx.save_game_folder, filename), "a") as f:
|
|
||||||
for ss in set(args["checked_locations"]):
|
|
||||||
f.write(str(ss-12000)+"\n")
|
|
||||||
f.close()
|
|
||||||
elif cmd == "LocationInfo":
|
|
||||||
for l in args["locations"]:
|
|
||||||
locationid = l.location
|
|
||||||
filename = f"{str(locationid-12000)}.hint"
|
|
||||||
with open(os.path.join(ctx.save_game_folder, filename), "w") as f:
|
|
||||||
toDraw = ""
|
|
||||||
for i in range(20):
|
|
||||||
if i < len(str(ctx.item_names[l.item])):
|
|
||||||
toDraw += str(ctx.item_names[l.item])[i]
|
|
||||||
else:
|
|
||||||
break
|
|
||||||
f.write(toDraw)
|
|
||||||
f.close()
|
|
||||||
elif cmd == "Retrieved":
|
|
||||||
if str(ctx.slot)+" RoutesDone neutral" in args["keys"]:
|
|
||||||
if args["keys"][str(ctx.slot)+" RoutesDone neutral"] is not None:
|
|
||||||
ctx.completed_routes["neutral"] = args["keys"][str(ctx.slot)+" RoutesDone neutral"]
|
|
||||||
if str(ctx.slot)+" RoutesDone genocide" in args["keys"]:
|
|
||||||
if args["keys"][str(ctx.slot)+" RoutesDone genocide"] is not None:
|
|
||||||
ctx.completed_routes["genocide"] = args["keys"][str(ctx.slot)+" RoutesDone genocide"]
|
|
||||||
if str(ctx.slot)+" RoutesDone pacifist" in args["keys"]:
|
|
||||||
if args["keys"][str(ctx.slot) + " RoutesDone pacifist"] is not None:
|
|
||||||
ctx.completed_routes["pacifist"] = args["keys"][str(ctx.slot)+" RoutesDone pacifist"]
|
|
||||||
elif cmd == "SetReply":
|
|
||||||
if args["value"] is not None:
|
|
||||||
if str(ctx.slot)+" RoutesDone pacifist" == args["key"]:
|
|
||||||
ctx.completed_routes["pacifist"] = args["value"]
|
|
||||||
elif str(ctx.slot)+" RoutesDone genocide" == args["key"]:
|
|
||||||
ctx.completed_routes["genocide"] = args["value"]
|
|
||||||
elif str(ctx.slot)+" RoutesDone neutral" == args["key"]:
|
|
||||||
ctx.completed_routes["neutral"] = args["value"]
|
|
||||||
elif cmd == "ReceivedItems":
|
|
||||||
start_index = args["index"]
|
|
||||||
|
|
||||||
if start_index == 0:
|
|
||||||
ctx.items_received = []
|
|
||||||
elif start_index != len(ctx.items_received):
|
|
||||||
sync_msg = [{"cmd": "Sync"}]
|
|
||||||
if ctx.locations_checked:
|
|
||||||
sync_msg.append({"cmd": "LocationChecks",
|
|
||||||
"locations": list(ctx.locations_checked)})
|
|
||||||
await ctx.send_msgs(sync_msg)
|
|
||||||
if start_index == len(ctx.items_received):
|
|
||||||
counter = -1
|
|
||||||
placedWeapon = 0
|
|
||||||
placedArmor = 0
|
|
||||||
for item in args["items"]:
|
|
||||||
id = NetworkItem(*item).location
|
|
||||||
while NetworkItem(*item).location < 0 and \
|
|
||||||
counter <= id:
|
|
||||||
id -= 1
|
|
||||||
if NetworkItem(*item).location < 0:
|
|
||||||
counter -= 1
|
|
||||||
filename = f"{str(id)}PLR{str(NetworkItem(*item).player)}.item"
|
|
||||||
with open(os.path.join(ctx.save_game_folder, filename), "w") as f:
|
|
||||||
if NetworkItem(*item).item == 77701:
|
|
||||||
if placedWeapon == 0:
|
|
||||||
f.write(str(77013-11000))
|
|
||||||
elif placedWeapon == 1:
|
|
||||||
f.write(str(77014-11000))
|
|
||||||
elif placedWeapon == 2:
|
|
||||||
f.write(str(77025-11000))
|
|
||||||
elif placedWeapon == 3:
|
|
||||||
f.write(str(77045-11000))
|
|
||||||
elif placedWeapon == 4:
|
|
||||||
f.write(str(77049-11000))
|
|
||||||
elif placedWeapon == 5:
|
|
||||||
f.write(str(77047-11000))
|
|
||||||
elif placedWeapon == 6:
|
|
||||||
if str(ctx.route) == "genocide" or str(ctx.route) == "all_routes":
|
|
||||||
f.write(str(77052-11000))
|
|
||||||
else:
|
|
||||||
f.write(str(77051-11000))
|
|
||||||
else:
|
|
||||||
f.write(str(77003-11000))
|
|
||||||
placedWeapon += 1
|
|
||||||
elif NetworkItem(*item).item == 77702:
|
|
||||||
if placedArmor == 0:
|
|
||||||
f.write(str(77012-11000))
|
|
||||||
elif placedArmor == 1:
|
|
||||||
f.write(str(77015-11000))
|
|
||||||
elif placedArmor == 2:
|
|
||||||
f.write(str(77024-11000))
|
|
||||||
elif placedArmor == 3:
|
|
||||||
f.write(str(77044-11000))
|
|
||||||
elif placedArmor == 4:
|
|
||||||
f.write(str(77048-11000))
|
|
||||||
elif placedArmor == 5:
|
|
||||||
if str(ctx.route) == "genocide":
|
|
||||||
f.write(str(77053-11000))
|
|
||||||
else:
|
|
||||||
f.write(str(77046-11000))
|
|
||||||
elif placedArmor == 6 and ((not str(ctx.route) == "genocide") or ctx.tem_armor):
|
|
||||||
if str(ctx.route) == "all_routes":
|
|
||||||
f.write(str(77053-11000))
|
|
||||||
elif str(ctx.route) == "genocide":
|
|
||||||
f.write(str(77064-11000))
|
|
||||||
else:
|
|
||||||
f.write(str(77050-11000))
|
|
||||||
elif placedArmor == 7 and ctx.tem_armor and not str(ctx.route) == "genocide":
|
|
||||||
f.write(str(77064-11000))
|
|
||||||
else:
|
|
||||||
f.write(str(77004-11000))
|
|
||||||
placedArmor += 1
|
|
||||||
else:
|
|
||||||
f.write(str(NetworkItem(*item).item-11000))
|
|
||||||
f.close()
|
|
||||||
ctx.items_received.append(NetworkItem(*item))
|
|
||||||
if [item.item for item in ctx.items_received].count(77000) >= ctx.pieces_needed > 0:
|
|
||||||
filename = f"{str(-99999)}PLR{str(0)}.item"
|
|
||||||
with open(os.path.join(ctx.save_game_folder, filename), "w") as f:
|
|
||||||
f.write(str(77787 - 11000))
|
|
||||||
f.close()
|
|
||||||
filename = f"{str(-99998)}PLR{str(0)}.item"
|
|
||||||
with open(os.path.join(ctx.save_game_folder, filename), "w") as f:
|
|
||||||
f.write(str(77789 - 11000))
|
|
||||||
f.close()
|
|
||||||
ctx.watcher_event.set()
|
|
||||||
|
|
||||||
elif cmd == "RoomUpdate":
|
|
||||||
if "checked_locations" in args:
|
|
||||||
filename = f"check.spot"
|
|
||||||
with open(os.path.join(ctx.save_game_folder, filename), "a") as f:
|
|
||||||
for ss in set(args["checked_locations"]):
|
|
||||||
f.write(str(ss-12000)+"\n")
|
|
||||||
f.close()
|
|
||||||
|
|
||||||
elif cmd == "Bounced":
|
|
||||||
tags = args.get("tags", [])
|
|
||||||
if "Online" in tags:
|
|
||||||
data = args.get("data", {})
|
|
||||||
if data["player"] != ctx.slot and data["player"] is not None:
|
|
||||||
filename = f"FRISK" + str(data["player"]) + ".playerspot"
|
|
||||||
with open(os.path.join(ctx.save_game_folder, filename), "w") as f:
|
|
||||||
f.write(str(data["x"]) + str(data["y"]) + str(data["room"]) + str(
|
|
||||||
data["spr"]) + str(data["frm"]))
|
|
||||||
f.close()
|
|
||||||
|
|
||||||
|
|
||||||
async def multi_watcher(ctx: UndertaleContext):
|
|
||||||
while not ctx.exit_event.is_set():
|
|
||||||
path = ctx.save_game_folder
|
|
||||||
for root, dirs, files in os.walk(path):
|
|
||||||
for file in files:
|
|
||||||
if "spots.mine" in file and "Online" in ctx.tags:
|
|
||||||
with open(os.path.join(root, file), "r") as mine:
|
|
||||||
this_x = mine.readline()
|
|
||||||
this_y = mine.readline()
|
|
||||||
this_room = mine.readline()
|
|
||||||
this_sprite = mine.readline()
|
|
||||||
this_frame = mine.readline()
|
|
||||||
mine.close()
|
|
||||||
message = [{"cmd": "Bounce", "tags": ["Online"],
|
|
||||||
"data": {"player": ctx.slot, "x": this_x, "y": this_y, "room": this_room,
|
|
||||||
"spr": this_sprite, "frm": this_frame}}]
|
|
||||||
await ctx.send_msgs(message)
|
|
||||||
|
|
||||||
await asyncio.sleep(0.1)
|
|
||||||
|
|
||||||
|
|
||||||
async def game_watcher(ctx: UndertaleContext):
|
|
||||||
while not ctx.exit_event.is_set():
|
|
||||||
await ctx.update_death_link(ctx.deathlink_status)
|
|
||||||
path = ctx.save_game_folder
|
|
||||||
if ctx.syncing:
|
|
||||||
for root, dirs, files in os.walk(path):
|
|
||||||
for file in files:
|
|
||||||
if ".item" in file:
|
|
||||||
os.remove(os.path.join(root, file))
|
|
||||||
sync_msg = [{"cmd": "Sync"}]
|
|
||||||
if ctx.locations_checked:
|
|
||||||
sync_msg.append({"cmd": "LocationChecks", "locations": list(ctx.locations_checked)})
|
|
||||||
await ctx.send_msgs(sync_msg)
|
|
||||||
ctx.syncing = False
|
|
||||||
if ctx.got_deathlink:
|
|
||||||
ctx.got_deathlink = False
|
|
||||||
with open(os.path.join(ctx.save_game_folder, "WelcomeToTheDead.youDied"), "w") as f:
|
|
||||||
f.close()
|
|
||||||
sending = []
|
|
||||||
victory = False
|
|
||||||
found_routes = 0
|
|
||||||
for root, dirs, files in os.walk(path):
|
|
||||||
for file in files:
|
|
||||||
if "DontBeMad.mad" in file:
|
|
||||||
os.remove(os.path.join(root, file))
|
|
||||||
if "DeathLink" in ctx.tags:
|
|
||||||
await ctx.send_death()
|
|
||||||
if "scout" == file:
|
|
||||||
sending = []
|
|
||||||
try:
|
|
||||||
with open(os.path.join(root, file), "r") as f:
|
|
||||||
lines = f.readlines()
|
|
||||||
for l in lines:
|
|
||||||
if ctx.server_locations.__contains__(int(l)+12000):
|
|
||||||
sending = sending + [int(l.rstrip('\n'))+12000]
|
|
||||||
finally:
|
|
||||||
await ctx.send_msgs([{"cmd": "LocationScouts", "locations": sending,
|
|
||||||
"create_as_hint": int(2)}])
|
|
||||||
os.remove(os.path.join(root, file))
|
|
||||||
if "check.spot" in file:
|
|
||||||
sending = []
|
|
||||||
try:
|
|
||||||
with open(os.path.join(root, file), "r") as f:
|
|
||||||
lines = f.readlines()
|
|
||||||
for l in lines:
|
|
||||||
sending = sending+[(int(l.rstrip('\n')))+12000]
|
|
||||||
finally:
|
|
||||||
await ctx.send_msgs([{"cmd": "LocationChecks", "locations": sending}])
|
|
||||||
if "victory" in file and str(ctx.route) in file:
|
|
||||||
victory = True
|
|
||||||
if ".playerspot" in file and "Online" not in ctx.tags:
|
|
||||||
os.remove(os.path.join(root, file))
|
|
||||||
if "victory" in file:
|
|
||||||
if str(ctx.route) == "all_routes":
|
|
||||||
if "neutral" in file and ctx.completed_routes["neutral"] != 1:
|
|
||||||
await ctx.send_msgs([{"cmd": "Set", "key": str(ctx.slot)+" RoutesDone neutral",
|
|
||||||
"default": 0, "want_reply": True, "operations": [{"operation": "max",
|
|
||||||
"value": 1}]}])
|
|
||||||
elif "pacifist" in file and ctx.completed_routes["pacifist"] != 1:
|
|
||||||
await ctx.send_msgs([{"cmd": "Set", "key": str(ctx.slot)+" RoutesDone pacifist",
|
|
||||||
"default": 0, "want_reply": True, "operations": [{"operation": "max",
|
|
||||||
"value": 1}]}])
|
|
||||||
elif "genocide" in file and ctx.completed_routes["genocide"] != 1:
|
|
||||||
await ctx.send_msgs([{"cmd": "Set", "key": str(ctx.slot)+" RoutesDone genocide",
|
|
||||||
"default": 0, "want_reply": True, "operations": [{"operation": "max",
|
|
||||||
"value": 1}]}])
|
|
||||||
if str(ctx.route) == "all_routes":
|
|
||||||
found_routes += ctx.completed_routes["neutral"]
|
|
||||||
found_routes += ctx.completed_routes["pacifist"]
|
|
||||||
found_routes += ctx.completed_routes["genocide"]
|
|
||||||
if str(ctx.route) == "all_routes" and found_routes >= 3:
|
|
||||||
victory = True
|
|
||||||
ctx.locations_checked = sending
|
|
||||||
if (not ctx.finished_game) and victory:
|
|
||||||
await ctx.send_msgs([{"cmd": "StatusUpdate", "status": ClientStatus.CLIENT_GOAL}])
|
|
||||||
ctx.finished_game = True
|
|
||||||
await asyncio.sleep(0.1)
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
Utils.init_logging("UndertaleClient", exception_logger="Client")
|
|
||||||
|
|
||||||
async def _main():
|
|
||||||
ctx = UndertaleContext(None, None)
|
|
||||||
ctx.server_task = asyncio.create_task(server_loop(ctx), name="server loop")
|
|
||||||
asyncio.create_task(
|
|
||||||
game_watcher(ctx), name="UndertaleProgressionWatcher")
|
|
||||||
|
|
||||||
asyncio.create_task(
|
|
||||||
multi_watcher(ctx), name="UndertaleMultiplayerWatcher")
|
|
||||||
|
|
||||||
if gui_enabled:
|
|
||||||
ctx.run_gui()
|
|
||||||
ctx.run_cli()
|
|
||||||
|
|
||||||
await ctx.exit_event.wait()
|
|
||||||
await ctx.shutdown()
|
|
||||||
|
|
||||||
import colorama
|
|
||||||
|
|
||||||
colorama.init()
|
|
||||||
|
|
||||||
asyncio.run(_main())
|
|
||||||
colorama.deinit()
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
parser = get_base_parser(description="Undertale Client, for text interfacing.")
|
|
||||||
args = parser.parse_args()
|
|
||||||
main()
|
|
||||||
642
Utils.py
642
Utils.py
@@ -1,11 +1,8 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
|
||||||
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 +11,18 @@ import io
|
|||||||
import collections
|
import collections
|
||||||
import importlib
|
import importlib
|
||||||
import logging
|
import logging
|
||||||
import warnings
|
from yaml import load, load_all, dump, SafeLoader
|
||||||
|
|
||||||
from argparse import Namespace
|
|
||||||
from settings import Settings, get_settings
|
|
||||||
from typing import BinaryIO, Coroutine, Optional, Set, Dict, Any, Union
|
|
||||||
from typing_extensions import TypeGuard
|
|
||||||
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:
|
||||||
@@ -42,11 +34,8 @@ class Version(typing.NamedTuple):
|
|||||||
minor: int
|
minor: int
|
||||||
build: int
|
build: int
|
||||||
|
|
||||||
def as_simple_string(self) -> str:
|
|
||||||
return ".".join(str(item) for item in self)
|
|
||||||
|
|
||||||
|
__version__ = "0.3.4"
|
||||||
__version__ = "0.4.6"
|
|
||||||
version_tuple = tuplize_version(__version__)
|
version_tuple = tuplize_version(__version__)
|
||||||
|
|
||||||
is_linux = sys.platform.startswith("linux")
|
is_linux = sys.platform.startswith("linux")
|
||||||
@@ -73,8 +62,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,40 +79,12 @@ 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))
|
||||||
|
|
||||||
|
|
||||||
def local_path(*path: str) -> str:
|
def local_path(*path: str) -> str:
|
||||||
"""
|
"""Returns path to a file in the local Archipelago installation or source."""
|
||||||
Returns path to a file in the local Archipelago installation or source.
|
|
||||||
This might be read-only and user_path should be used instead for ROMs, configuration, etc.
|
|
||||||
"""
|
|
||||||
if hasattr(local_path, 'cached_path'):
|
if hasattr(local_path, 'cached_path'):
|
||||||
pass
|
pass
|
||||||
elif is_frozen():
|
elif is_frozen():
|
||||||
@@ -137,7 +96,7 @@ def local_path(*path: str) -> str:
|
|||||||
local_path.cached_path = os.path.dirname(os.path.abspath(sys.argv[0]))
|
local_path.cached_path = os.path.dirname(os.path.abspath(sys.argv[0]))
|
||||||
else:
|
else:
|
||||||
import __main__
|
import __main__
|
||||||
if hasattr(__main__, "__file__") and os.path.isfile(__main__.__file__):
|
if hasattr(__main__, "__file__"):
|
||||||
# we are running in a normal Python environment
|
# we are running in a normal Python environment
|
||||||
local_path.cached_path = os.path.dirname(os.path.abspath(__main__.__file__))
|
local_path.cached_path = os.path.dirname(os.path.abspath(__main__.__file__))
|
||||||
else:
|
else:
|
||||||
@@ -169,39 +128,21 @@ def user_path(*path: str) -> str:
|
|||||||
user_path.cached_path = local_path()
|
user_path.cached_path = local_path()
|
||||||
else:
|
else:
|
||||||
user_path.cached_path = home_path()
|
user_path.cached_path = home_path()
|
||||||
# populate home from local
|
# populate home from local - TODO: upgrade feature
|
||||||
if user_path.cached_path != local_path():
|
if user_path.cached_path != local_path() and not os.path.exists(user_path("host.yaml")):
|
||||||
import filecmp
|
import shutil
|
||||||
if not os.path.exists(user_path("manifest.json")) or \
|
for dn in ("Players", "data/sprites"):
|
||||||
not os.path.exists(local_path("manifest.json")) or \
|
shutil.copytree(local_path(dn), user_path(dn), dirs_exist_ok=True)
|
||||||
not filecmp.cmp(local_path("manifest.json"), user_path("manifest.json"), shallow=True):
|
for fn in ("manifest.json", "host.yaml"):
|
||||||
import shutil
|
shutil.copy2(local_path(fn), user_path(fn))
|
||||||
for dn in ("Players", "data/sprites", "data/lua"):
|
|
||||||
shutil.copytree(local_path(dn), user_path(dn), dirs_exist_ok=True)
|
|
||||||
if not os.path.exists(local_path("manifest.json")):
|
|
||||||
warnings.warn(f"Upgrading {user_path()} from something that is not a proper install")
|
|
||||||
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)
|
||||||
|
|
||||||
|
|
||||||
def cache_path(*path: str) -> str:
|
def output_path(*path: str):
|
||||||
"""Returns path to a file in the user's Archipelago cache directory."""
|
|
||||||
if hasattr(cache_path, "cached_path"):
|
|
||||||
pass
|
|
||||||
else:
|
|
||||||
import platformdirs
|
|
||||||
cache_path.cached_path = platformdirs.user_cache_dir("Archipelago", False)
|
|
||||||
|
|
||||||
return os.path.join(cache_path.cached_path, *path)
|
|
||||||
|
|
||||||
|
|
||||||
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 +166,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,20 +189,14 @@ 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).read().decode("utf8").strip()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# noinspection PyBroadException
|
# noinspection PyBroadException
|
||||||
try:
|
try:
|
||||||
ip = urllib.request.urlopen("https://v4.ident.me", context=ctx, timeout=10).read().decode("utf8").strip()
|
ip = urllib.request.urlopen("https://v4.ident.me", context=ctx).read().decode("utf8").strip()
|
||||||
except Exception:
|
except Exception:
|
||||||
logging.exception(e)
|
logging.exception(e)
|
||||||
pass # we could be offline, in a local game, so no point in erroring out
|
pass # we could be offline, in a local game, so no point in erroring out
|
||||||
@@ -275,29 +207,123 @@ 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).read().decode("utf8").strip()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.exception(e)
|
logging.exception(e)
|
||||||
pass # we could be offline, in a local game, or ipv6 may not be available
|
pass # we could be offline, in a local game, or ipv6 may not be available
|
||||||
return ip
|
return ip
|
||||||
|
|
||||||
|
|
||||||
OptionsType = Settings # TODO: remove when removing get_options
|
@cache_argsless
|
||||||
|
def get_default_options() -> dict:
|
||||||
|
# Refer to host.yaml for comments as to what all these options mean.
|
||||||
|
options = {
|
||||||
|
"general_options": {
|
||||||
|
"output_path": "output",
|
||||||
|
},
|
||||||
|
"factorio_options": {
|
||||||
|
"executable": os.path.join("factorio", "bin", "x64", "factorio"),
|
||||||
|
},
|
||||||
|
"sm_options": {
|
||||||
|
"rom_file": "Super Metroid (JU).sfc",
|
||||||
|
"sni": "SNI",
|
||||||
|
"rom_start": True,
|
||||||
|
},
|
||||||
|
"soe_options": {
|
||||||
|
"rom_file": "Secret of Evermore (USA).sfc",
|
||||||
|
},
|
||||||
|
"lttp_options": {
|
||||||
|
"rom_file": "Zelda no Densetsu - Kamigami no Triforce (Japan).sfc",
|
||||||
|
"sni": "SNI",
|
||||||
|
"rom_start": True,
|
||||||
|
|
||||||
|
},
|
||||||
|
"server_options": {
|
||||||
|
"host": None,
|
||||||
|
"port": 38281,
|
||||||
|
"password": None,
|
||||||
|
"multidata": None,
|
||||||
|
"savefile": None,
|
||||||
|
"disable_save": False,
|
||||||
|
"loglevel": "info",
|
||||||
|
"server_password": None,
|
||||||
|
"disable_item_cheat": False,
|
||||||
|
"location_check_points": 1,
|
||||||
|
"hint_cost": 10,
|
||||||
|
"forfeit_mode": "goal",
|
||||||
|
"collect_mode": "disabled",
|
||||||
|
"remaining_mode": "goal",
|
||||||
|
"auto_shutdown": 0,
|
||||||
|
"compatibility": 2,
|
||||||
|
"log_network": 0
|
||||||
|
},
|
||||||
|
"generator": {
|
||||||
|
"teams": 1,
|
||||||
|
"enemizer_path": os.path.join("EnemizerCLI", "EnemizerCLI.Core"),
|
||||||
|
"player_files_path": "Players",
|
||||||
|
"players": 0,
|
||||||
|
"weights_file_path": "weights.yaml",
|
||||||
|
"meta_file_path": "meta.yaml",
|
||||||
|
"spoiler": 2,
|
||||||
|
"glitch_triforce_room": 1,
|
||||||
|
"race": 0,
|
||||||
|
"plando_options": "bosses",
|
||||||
|
},
|
||||||
|
"minecraft_options": {
|
||||||
|
"forge_directory": "Minecraft Forge server",
|
||||||
|
"max_heap_size": "2G",
|
||||||
|
"release_channel": "release"
|
||||||
|
},
|
||||||
|
"oot_options": {
|
||||||
|
"rom_file": "The Legend of Zelda - Ocarina of Time.z64",
|
||||||
|
},
|
||||||
|
"dkc3_options": {
|
||||||
|
"rom_file": "Donkey Kong Country 3 - Dixie Kong's Double Trouble! (USA) (En,Fr).sfc",
|
||||||
|
"sni": "SNI",
|
||||||
|
"rom_start": True,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return options
|
||||||
|
|
||||||
|
|
||||||
def get_options() -> Settings:
|
def update_options(src: dict, dest: dict, filename: str, keys: list) -> dict:
|
||||||
# TODO: switch to Utils.deprecate after 0.4.4
|
for key, value in src.items():
|
||||||
warnings.warn("Utils.get_options() is deprecated. Use the settings API instead.", DeprecationWarning)
|
new_keys = keys.copy()
|
||||||
return get_settings()
|
new_keys.append(key)
|
||||||
|
option_name = '.'.join(new_keys)
|
||||||
|
if key not in dest:
|
||||||
|
dest[key] = value
|
||||||
|
if filename.endswith("options.yaml"):
|
||||||
|
logging.info(f"Warning: {filename} is missing {option_name}")
|
||||||
|
elif isinstance(value, dict):
|
||||||
|
if not isinstance(dest.get(key, None), dict):
|
||||||
|
if filename.endswith("options.yaml"):
|
||||||
|
logging.info(f"Warning: {filename} has {option_name}, but it is not a dictionary. overwriting.")
|
||||||
|
dest[key] = value
|
||||||
|
else:
|
||||||
|
dest[key] = update_options(value, dest[key], filename, new_keys)
|
||||||
|
return dest
|
||||||
|
|
||||||
|
|
||||||
|
@cache_argsless
|
||||||
|
def get_options() -> dict:
|
||||||
|
filenames = ("options.yaml", "host.yaml")
|
||||||
|
locations = []
|
||||||
|
if os.path.join(os.getcwd()) != local_path():
|
||||||
|
locations += filenames # use files from cwd only if it's not the local_path
|
||||||
|
locations += [user_path(filename) for filename in filenames]
|
||||||
|
|
||||||
|
for location in locations:
|
||||||
|
if os.path.exists(location):
|
||||||
|
with open(location) as f:
|
||||||
|
options = parse_yaml(f.read())
|
||||||
|
return update_options(get_default_options(), options, location, list())
|
||||||
|
|
||||||
|
raise FileNotFoundError(f"Could not find {filenames[1]} to load options.")
|
||||||
|
|
||||||
|
|
||||||
def persistent_store(category: str, key: typing.Any, value: typing.Any):
|
def persistent_store(category: str, key: typing.Any, value: typing.Any):
|
||||||
@@ -327,65 +353,11 @@ def persistent_load() -> typing.Dict[str, dict]:
|
|||||||
return storage
|
return storage
|
||||||
|
|
||||||
|
|
||||||
def get_file_safe_name(name: str) -> str:
|
def get_adjuster_settings(game_name: str):
|
||||||
return "".join(c for c in name if c not in '<>:"/\\|?*')
|
adjuster_settings = persistent_load().get("adjuster", {}).get(game_name, {})
|
||||||
|
|
||||||
|
|
||||||
def load_data_package_for_checksum(game: str, checksum: typing.Optional[str]) -> Dict[str, Any]:
|
|
||||||
if checksum and game:
|
|
||||||
if checksum != get_file_safe_name(checksum):
|
|
||||||
raise ValueError(f"Bad symbols in checksum: {checksum}")
|
|
||||||
path = cache_path("datapackage", get_file_safe_name(game), f"{checksum}.json")
|
|
||||||
if os.path.exists(path):
|
|
||||||
try:
|
|
||||||
with open(path, "r", encoding="utf-8-sig") as f:
|
|
||||||
return json.load(f)
|
|
||||||
except Exception as e:
|
|
||||||
logging.debug(f"Could not load data package: {e}")
|
|
||||||
|
|
||||||
# fall back to old cache
|
|
||||||
cache = persistent_load().get("datapackage", {}).get("games", {}).get(game, {})
|
|
||||||
if cache.get("checksum") == checksum:
|
|
||||||
return cache
|
|
||||||
|
|
||||||
# cache does not match
|
|
||||||
return {}
|
|
||||||
|
|
||||||
|
|
||||||
def store_data_package_for_checksum(game: str, data: typing.Dict[str, Any]) -> None:
|
|
||||||
checksum = data.get("checksum")
|
|
||||||
if checksum and game:
|
|
||||||
if checksum != get_file_safe_name(checksum):
|
|
||||||
raise ValueError(f"Bad symbols in checksum: {checksum}")
|
|
||||||
game_folder = cache_path("datapackage", get_file_safe_name(game))
|
|
||||||
os.makedirs(game_folder, exist_ok=True)
|
|
||||||
try:
|
|
||||||
with open(os.path.join(game_folder, f"{checksum}.json"), "w", encoding="utf-8-sig") as f:
|
|
||||||
json.dump(data, f, ensure_ascii=False, separators=(",", ":"))
|
|
||||||
except Exception as e:
|
|
||||||
logging.debug(f"Could not store data package: {e}")
|
|
||||||
|
|
||||||
def get_default_adjuster_settings(game_name: str) -> Namespace:
|
|
||||||
import LttPAdjuster
|
|
||||||
adjuster_settings = Namespace()
|
|
||||||
if game_name == LttPAdjuster.GAME_ALTTP:
|
|
||||||
return LttPAdjuster.get_argparser().parse_known_args(args=[])[0]
|
|
||||||
|
|
||||||
return adjuster_settings
|
return adjuster_settings
|
||||||
|
|
||||||
|
|
||||||
def get_adjuster_settings_no_defaults(game_name: str) -> Namespace:
|
|
||||||
return persistent_load().get("adjuster", {}).get(game_name, Namespace())
|
|
||||||
|
|
||||||
|
|
||||||
def get_adjuster_settings(game_name: str) -> Namespace:
|
|
||||||
adjuster_settings = get_adjuster_settings_no_defaults(game_name)
|
|
||||||
default_settings = get_default_adjuster_settings(game_name)
|
|
||||||
|
|
||||||
# Fill in any arguments from the argparser that we haven't seen before
|
|
||||||
return Namespace(**vars(adjuster_settings), **{k:v for k,v in vars(default_settings).items() if k not in vars(adjuster_settings)})
|
|
||||||
|
|
||||||
|
|
||||||
@cache_argsless
|
@cache_argsless
|
||||||
def get_unique_identifier():
|
def get_unique_identifier():
|
||||||
uuid = persistent_load().get("client", {}).get("uuid", None)
|
uuid = persistent_load().get("client", {}).get("uuid", None)
|
||||||
@@ -405,13 +377,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,11 +391,8 @@ 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)
|
if module.endswith("Options"):
|
||||||
if module.lower().endswith("options"):
|
|
||||||
if module == "Options":
|
if module == "Options":
|
||||||
mod = self.options_module
|
mod = self.options_module
|
||||||
else:
|
else:
|
||||||
@@ -442,15 +409,6 @@ def restricted_loads(s):
|
|||||||
return RestrictedUnpickler(io.BytesIO(s)).load()
|
return RestrictedUnpickler(io.BytesIO(s)).load()
|
||||||
|
|
||||||
|
|
||||||
class ByValue:
|
|
||||||
"""
|
|
||||||
Mixin for enums to pickle value instead of name (restores pre-3.11 behavior). Use as left-most parent.
|
|
||||||
See https://github.com/python/cpython/pull/26658 for why this exists.
|
|
||||||
"""
|
|
||||||
def __reduce_ex__(self, prot):
|
|
||||||
return self.__class__, (self._value_, )
|
|
||||||
|
|
||||||
|
|
||||||
class KeyedDefaultDict(collections.defaultdict):
|
class KeyedDefaultDict(collections.defaultdict):
|
||||||
"""defaultdict variant that uses the missing key as argument to default_factory"""
|
"""defaultdict variant that uses the missing key as argument to default_factory"""
|
||||||
default_factory: typing.Callable[[typing.Any], typing.Any]
|
default_factory: typing.Callable[[typing.Any], typing.Any]
|
||||||
@@ -474,7 +432,6 @@ loglevel_mapping = {'error': logging.ERROR, 'info': logging.INFO, 'warning': log
|
|||||||
def init_logging(name: str, loglevel: typing.Union[str, int] = logging.INFO, write_mode: str = "w",
|
def init_logging(name: str, loglevel: typing.Union[str, int] = logging.INFO, write_mode: str = "w",
|
||||||
log_format: str = "[%(name)s at %(asctime)s]: %(message)s",
|
log_format: str = "[%(name)s at %(asctime)s]: %(message)s",
|
||||||
exception_logger: typing.Optional[str] = None):
|
exception_logger: typing.Optional[str] = None):
|
||||||
import datetime
|
|
||||||
loglevel: int = loglevel_mapping.get(loglevel, loglevel)
|
loglevel: int = loglevel_mapping.get(loglevel, loglevel)
|
||||||
log_folder = user_path("logs")
|
log_folder = user_path("logs")
|
||||||
os.makedirs(log_folder, exist_ok=True)
|
os.makedirs(log_folder, exist_ok=True)
|
||||||
@@ -483,29 +440,16 @@ def init_logging(name: str, loglevel: typing.Union[str, int] = logging.INFO, wri
|
|||||||
root_logger.removeHandler(handler)
|
root_logger.removeHandler(handler)
|
||||||
handler.close()
|
handler.close()
|
||||||
root_logger.setLevel(loglevel)
|
root_logger.setLevel(loglevel)
|
||||||
logging.getLogger("websockets").setLevel(loglevel) # make sure level is applied for websockets
|
|
||||||
if "a" not in write_mode:
|
|
||||||
name += f"_{datetime.datetime.now().strftime('%Y_%m_%d_%H_%M_%S')}"
|
|
||||||
file_handler = logging.FileHandler(
|
file_handler = logging.FileHandler(
|
||||||
os.path.join(log_folder, f"{name}.txt"),
|
os.path.join(log_folder, f"{name}.txt"),
|
||||||
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
|
||||||
@@ -523,25 +467,7 @@ def init_logging(name: str, loglevel: typing.Union[str, int] = logging.INFO, wri
|
|||||||
|
|
||||||
sys.excepthook = handle_exception
|
sys.excepthook = handle_exception
|
||||||
|
|
||||||
def _cleanup():
|
logging.info(f"Archipelago ({__version__}) logging initialized.")
|
||||||
for file in os.scandir(log_folder):
|
|
||||||
if file.name.endswith(".txt"):
|
|
||||||
last_change = datetime.datetime.fromtimestamp(file.stat().st_mtime)
|
|
||||||
if datetime.datetime.now() - last_change > datetime.timedelta(days=7):
|
|
||||||
try:
|
|
||||||
os.unlink(file.path)
|
|
||||||
except Exception as e:
|
|
||||||
logging.exception(e)
|
|
||||||
else:
|
|
||||||
logging.debug(f"Deleted old logfile {file.path}")
|
|
||||||
import threading
|
|
||||||
threading.Thread(target=_cleanup, name="LogCleaner").start()
|
|
||||||
import platform
|
|
||||||
logging.info(
|
|
||||||
f"Archipelago ({__version__}) logging initialized"
|
|
||||||
f" on {platform.platform()}"
|
|
||||||
f" running Python {sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def stream_input(stream, queue):
|
def stream_input(stream, queue):
|
||||||
@@ -617,10 +543,8 @@ 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]]]) \
|
||||||
-> 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
|
||||||
|
|
||||||
@@ -630,12 +554,11 @@ def open_filename(title: str, filetypes: typing.Sequence[typing.Tuple[str, typin
|
|||||||
kdialog = which("kdialog")
|
kdialog = which("kdialog")
|
||||||
if kdialog:
|
if kdialog:
|
||||||
k_filters = '|'.join((f'{text} (*{" *".join(ext)})' for (text, ext) in filetypes))
|
k_filters = '|'.join((f'{text} (*{" *".join(ext)})' for (text, ext) in filetypes))
|
||||||
return run(kdialog, f"--title={title}", "--getopenfilename", suggest or ".", k_filters)
|
return run(kdialog, f"--title={title}", "--getopenfilename", ".", k_filters)
|
||||||
zenity = which("zenity")
|
zenity = which("zenity")
|
||||||
if zenity:
|
if zenity:
|
||||||
z_filters = (f'--file-filter={text} ({", ".join(ext)}) | *{" *".join(ext)}' for (text, ext) in filetypes)
|
z_filters = (f'--file-filter={text} ({", ".join(ext)}) | *{" *".join(ext)}' for (text, ext) in filetypes)
|
||||||
selection = (f"--filename={suggest}",) if suggest else ()
|
return run(zenity, f"--title={title}", "--file-selection", *z_filters)
|
||||||
return run(zenity, f"--title={title}", "--file-selection", *z_filters, *selection)
|
|
||||||
|
|
||||||
# fall back to tk
|
# fall back to tk
|
||||||
try:
|
try:
|
||||||
@@ -646,47 +569,9 @@ 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)
|
|
||||||
|
|
||||||
|
|
||||||
def open_directory(title: str, suggest: str = "") -> typing.Optional[str]:
|
|
||||||
def run(*args: str):
|
|
||||||
return subprocess.run(args, capture_output=True, text=True).stdout.split("\n", 1)[0] or None
|
|
||||||
|
|
||||||
if is_linux:
|
|
||||||
# prefer native dialog
|
|
||||||
from shutil import which
|
|
||||||
kdialog = which("kdialog")
|
|
||||||
if kdialog:
|
|
||||||
return run(kdialog, f"--title={title}", "--getexistingdirectory",
|
|
||||||
os.path.abspath(suggest) if suggest else ".")
|
|
||||||
zenity = which("zenity")
|
|
||||||
if zenity:
|
|
||||||
z_filters = ("--directory",)
|
|
||||||
selection = (f"--filename={os.path.abspath(suggest)}/",) if suggest else ()
|
|
||||||
return run(zenity, f"--title={title}", "--file-selection", *z_filters, *selection)
|
|
||||||
|
|
||||||
# fall back to tk
|
|
||||||
try:
|
|
||||||
import tkinter
|
|
||||||
import tkinter.filedialog
|
|
||||||
except Exception as e:
|
|
||||||
logging.error('Could not load tkinter, which is likely not installed. '
|
|
||||||
f'This attempt was made because open_filename was used for "{title}".')
|
|
||||||
raise e
|
|
||||||
else:
|
|
||||||
try:
|
|
||||||
root = tkinter.Tk()
|
|
||||||
except tkinter.TclError:
|
|
||||||
return None # GUI not available. None is the same as a user clicking "cancel"
|
|
||||||
root.withdraw()
|
|
||||||
return tkinter.filedialog.askdirectory(title=title, mustexist=True, initialdir=suggest or None)
|
|
||||||
|
|
||||||
|
|
||||||
def messagebox(title: str, text: str, error: bool = False) -> None:
|
def messagebox(title: str, text: str, error: bool = False) -> None:
|
||||||
@@ -714,11 +599,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
|
||||||
@@ -736,246 +616,10 @@ def messagebox(title: str, text: str, error: bool = False) -> None:
|
|||||||
|
|
||||||
def title_sorted(data: typing.Sequence, key=None, ignore: typing.Set = frozenset(("a", "the"))):
|
def title_sorted(data: typing.Sequence, key=None, ignore: typing.Set = frozenset(("a", "the"))):
|
||||||
"""Sorts a sequence of text ignoring typical articles like "a" or "the" in the beginning."""
|
"""Sorts a sequence of text ignoring typical articles like "a" or "the" in the beginning."""
|
||||||
def sorter(element: Union[str, Dict[str, Any]]) -> str:
|
def sorter(element: str) -> str:
|
||||||
if (not isinstance(element, str)):
|
|
||||||
element = element["title"]
|
|
||||||
|
|
||||||
parts = element.split(maxsplit=1)
|
parts = element.split(maxsplit=1)
|
||||||
if parts[0].lower() in ignore:
|
if parts[0].lower() in ignore:
|
||||||
return parts[1].lower()
|
return parts[1]
|
||||||
else:
|
else:
|
||||||
return element.lower()
|
return element
|
||||||
return sorted(data, key=lambda i: sorter(key(i)) if key else sorter(i))
|
return sorted(data, key=lambda i: sorter(key(i)) if key else sorter(i))
|
||||||
|
|
||||||
|
|
||||||
def read_snes_rom(stream: BinaryIO, strip_header: bool = True) -> bytearray:
|
|
||||||
"""Reads rom into bytearray and optionally strips off any smc header"""
|
|
||||||
buffer = bytearray(stream.read())
|
|
||||||
if strip_header and len(buffer) % 0x400 == 0x200:
|
|
||||||
return buffer[0x200:]
|
|
||||||
return buffer
|
|
||||||
|
|
||||||
|
|
||||||
_faf_tasks: "Set[asyncio.Task[typing.Any]]" = set()
|
|
||||||
|
|
||||||
|
|
||||||
def async_start(co: Coroutine[None, None, typing.Any], name: Optional[str] = None) -> None:
|
|
||||||
"""
|
|
||||||
Use this to start a task when you don't keep a reference to it or immediately await it,
|
|
||||||
to prevent early garbage collection. "fire-and-forget"
|
|
||||||
"""
|
|
||||||
# https://docs.python.org/3.10/library/asyncio-task.html#asyncio.create_task
|
|
||||||
# Python docs:
|
|
||||||
# ```
|
|
||||||
# Important: Save a reference to the result of [asyncio.create_task],
|
|
||||||
# to avoid a task disappearing mid-execution.
|
|
||||||
# ```
|
|
||||||
# This implementation follows the pattern given in that documentation.
|
|
||||||
|
|
||||||
task: asyncio.Task[typing.Any] = asyncio.create_task(co, name=name)
|
|
||||||
_faf_tasks.add(task)
|
|
||||||
task.add_done_callback(_faf_tasks.discard)
|
|
||||||
|
|
||||||
|
|
||||||
def deprecate(message: str):
|
|
||||||
if __debug__:
|
|
||||||
raise Exception(message)
|
|
||||||
import warnings
|
|
||||||
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:
|
|
||||||
"""Extend multiprocessing.freeze_support() to also work on Non-Windows for spawn."""
|
|
||||||
# upstream issue: https://github.com/python/cpython/issues/76327
|
|
||||||
# code based on https://github.com/pyinstaller/pyinstaller/blob/develop/PyInstaller/hooks/rthooks/pyi_rth_multiprocessing.py#L26
|
|
||||||
import multiprocessing
|
|
||||||
import multiprocessing.spawn
|
|
||||||
|
|
||||||
def _freeze_support() -> None:
|
|
||||||
"""Minimal freeze_support. Only apply this if frozen."""
|
|
||||||
from subprocess import _args_from_interpreter_flags
|
|
||||||
|
|
||||||
# Prevent `spawn` from trying to read `__main__` in from the main script
|
|
||||||
multiprocessing.process.ORIGINAL_DIR = None
|
|
||||||
|
|
||||||
# Handle the first process that MP will create
|
|
||||||
if (
|
|
||||||
len(sys.argv) >= 2 and sys.argv[-2] == '-c' and sys.argv[-1].startswith((
|
|
||||||
'from multiprocessing.semaphore_tracker import main', # Py<3.8
|
|
||||||
'from multiprocessing.resource_tracker import main', # Py>=3.8
|
|
||||||
'from multiprocessing.forkserver import main'
|
|
||||||
)) and set(sys.argv[1:-2]) == set(_args_from_interpreter_flags())
|
|
||||||
):
|
|
||||||
exec(sys.argv[-1])
|
|
||||||
sys.exit()
|
|
||||||
|
|
||||||
# Handle the second process that MP will create
|
|
||||||
if multiprocessing.spawn.is_forking(sys.argv):
|
|
||||||
kwargs = {}
|
|
||||||
for arg in sys.argv[2:]:
|
|
||||||
name, value = arg.split('=')
|
|
||||||
if value == 'None':
|
|
||||||
kwargs[name] = None
|
|
||||||
else:
|
|
||||||
kwargs[name] = int(value)
|
|
||||||
multiprocessing.spawn.spawn_main(**kwargs)
|
|
||||||
sys.exit()
|
|
||||||
|
|
||||||
if not is_windows and is_frozen():
|
|
||||||
multiprocessing.freeze_support = multiprocessing.spawn.freeze_support = _freeze_support
|
|
||||||
|
|
||||||
|
|
||||||
def freeze_support() -> None:
|
|
||||||
"""This behaves like multiprocessing.freeze_support but also works on Non-Windows."""
|
|
||||||
import multiprocessing
|
|
||||||
_extend_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)
|
|
||||||
|
|||||||
@@ -1,453 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import atexit
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
import asyncio
|
|
||||||
import random
|
|
||||||
import shutil
|
|
||||||
from typing import Tuple, List, Iterable, Dict
|
|
||||||
|
|
||||||
from worlds.wargroove import WargrooveWorld
|
|
||||||
from worlds.wargroove.Items import item_table, faction_table, CommanderData, ItemData
|
|
||||||
|
|
||||||
import ModuleUpdate
|
|
||||||
ModuleUpdate.update()
|
|
||||||
|
|
||||||
import Utils
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
Utils.init_logging("WargrooveClient", exception_logger="Client")
|
|
||||||
|
|
||||||
from NetUtils import NetworkItem, ClientStatus
|
|
||||||
from CommonClient import gui_enabled, logger, get_base_parser, ClientCommandProcessor, \
|
|
||||||
CommonContext, server_loop
|
|
||||||
|
|
||||||
wg_logger = logging.getLogger("WG")
|
|
||||||
|
|
||||||
|
|
||||||
class WargrooveClientCommandProcessor(ClientCommandProcessor):
|
|
||||||
def _cmd_resync(self):
|
|
||||||
"""Manually trigger a resync."""
|
|
||||||
self.output(f"Syncing items.")
|
|
||||||
self.ctx.syncing = True
|
|
||||||
|
|
||||||
def _cmd_commander(self, *commander_name: Iterable[str]):
|
|
||||||
"""Set the current commander to the given commander."""
|
|
||||||
if commander_name:
|
|
||||||
self.ctx.set_commander(' '.join(commander_name))
|
|
||||||
else:
|
|
||||||
if self.ctx.can_choose_commander:
|
|
||||||
commanders = self.ctx.get_commanders()
|
|
||||||
wg_logger.info('Unlocked commanders: ' +
|
|
||||||
', '.join((commander.name for commander, unlocked in commanders if unlocked)))
|
|
||||||
wg_logger.info('Locked commanders: ' +
|
|
||||||
', '.join((commander.name for commander, unlocked in commanders if not unlocked)))
|
|
||||||
else:
|
|
||||||
wg_logger.error('Cannot set commanders in this game mode.')
|
|
||||||
|
|
||||||
|
|
||||||
class WargrooveContext(CommonContext):
|
|
||||||
command_processor: int = WargrooveClientCommandProcessor
|
|
||||||
game = "Wargroove"
|
|
||||||
items_handling = 0b111 # full remote
|
|
||||||
current_commander: CommanderData = faction_table["Starter"][0]
|
|
||||||
can_choose_commander: bool = False
|
|
||||||
commander_defense_boost_multiplier: int = 0
|
|
||||||
income_boost_multiplier: int = 0
|
|
||||||
starting_groove_multiplier: float
|
|
||||||
faction_item_ids = {
|
|
||||||
'Starter': 0,
|
|
||||||
'Cherrystone': 52025,
|
|
||||||
'Felheim': 52026,
|
|
||||||
'Floran': 52027,
|
|
||||||
'Heavensong': 52028,
|
|
||||||
'Requiem': 52029,
|
|
||||||
'Outlaw': 52030
|
|
||||||
}
|
|
||||||
buff_item_ids = {
|
|
||||||
'Income Boost': 52023,
|
|
||||||
'Commander Defense Boost': 52024,
|
|
||||||
}
|
|
||||||
|
|
||||||
def __init__(self, server_address, password):
|
|
||||||
super(WargrooveContext, self).__init__(server_address, password)
|
|
||||||
self.send_index: int = 0
|
|
||||||
self.syncing = False
|
|
||||||
self.awaiting_bridge = False
|
|
||||||
# self.game_communication_path: files go in this path to pass data between us and the actual game
|
|
||||||
if "appdata" in os.environ:
|
|
||||||
options = Utils.get_options()
|
|
||||||
root_directory = os.path.join(options["wargroove_options"]["root_directory"])
|
|
||||||
data_directory = os.path.join("lib", "worlds", "wargroove", "data")
|
|
||||||
dev_data_directory = os.path.join("worlds", "wargroove", "data")
|
|
||||||
appdata_wargroove = os.path.expandvars(os.path.join("%APPDATA%", "Chucklefish", "Wargroove"))
|
|
||||||
if not os.path.isfile(os.path.join(root_directory, "win64_bin", "wargroove64.exe")):
|
|
||||||
print_error_and_close("WargrooveClient couldn't find wargroove64.exe. "
|
|
||||||
"Unable to infer required game_communication_path")
|
|
||||||
self.game_communication_path = os.path.join(root_directory, "AP")
|
|
||||||
if not os.path.exists(self.game_communication_path):
|
|
||||||
os.makedirs(self.game_communication_path)
|
|
||||||
self.remove_communication_files()
|
|
||||||
atexit.register(self.remove_communication_files)
|
|
||||||
if not os.path.isdir(appdata_wargroove):
|
|
||||||
print_error_and_close("WargrooveClient couldn't find Wargoove in appdata!"
|
|
||||||
"Boot Wargroove and then close it to attempt to fix this error")
|
|
||||||
if not os.path.isdir(data_directory):
|
|
||||||
data_directory = dev_data_directory
|
|
||||||
if not os.path.isdir(data_directory):
|
|
||||||
print_error_and_close("WargrooveClient couldn't find Wargoove mod and save files in install!")
|
|
||||||
shutil.copytree(data_directory, appdata_wargroove, dirs_exist_ok=True)
|
|
||||||
else:
|
|
||||||
print_error_and_close("WargrooveClient couldn't detect system type. "
|
|
||||||
"Unable to infer required game_communication_path")
|
|
||||||
|
|
||||||
async def server_auth(self, password_requested: bool = False):
|
|
||||||
if password_requested and not self.password:
|
|
||||||
await super(WargrooveContext, self).server_auth(password_requested)
|
|
||||||
await self.get_username()
|
|
||||||
await self.send_connect()
|
|
||||||
|
|
||||||
async def connection_closed(self):
|
|
||||||
await super(WargrooveContext, self).connection_closed()
|
|
||||||
self.remove_communication_files()
|
|
||||||
self.checked_locations.clear()
|
|
||||||
self.server_locations.clear()
|
|
||||||
self.finished_game = False
|
|
||||||
|
|
||||||
@property
|
|
||||||
def endpoints(self):
|
|
||||||
if self.server:
|
|
||||||
return [self.server]
|
|
||||||
else:
|
|
||||||
return []
|
|
||||||
|
|
||||||
async def shutdown(self):
|
|
||||||
await super(WargrooveContext, self).shutdown()
|
|
||||||
self.remove_communication_files()
|
|
||||||
self.checked_locations.clear()
|
|
||||||
self.server_locations.clear()
|
|
||||||
self.finished_game = False
|
|
||||||
|
|
||||||
def remove_communication_files(self):
|
|
||||||
for root, dirs, files in os.walk(self.game_communication_path):
|
|
||||||
for file in files:
|
|
||||||
os.remove(root + "/" + file)
|
|
||||||
|
|
||||||
def on_package(self, cmd: str, args: dict):
|
|
||||||
if cmd in {"Connected"}:
|
|
||||||
filename = f"AP_settings.json"
|
|
||||||
with open(os.path.join(self.game_communication_path, filename), 'w') as f:
|
|
||||||
slot_data = args["slot_data"]
|
|
||||||
json.dump(args["slot_data"], f)
|
|
||||||
self.can_choose_commander = slot_data["can_choose_commander"]
|
|
||||||
print('can choose commander:', self.can_choose_commander)
|
|
||||||
self.starting_groove_multiplier = slot_data["starting_groove_multiplier"]
|
|
||||||
self.income_boost_multiplier = slot_data["income_boost"]
|
|
||||||
self.commander_defense_boost_multiplier = slot_data["commander_defense_boost"]
|
|
||||||
f.close()
|
|
||||||
for ss in self.checked_locations:
|
|
||||||
filename = f"send{ss}"
|
|
||||||
with open(os.path.join(self.game_communication_path, filename), 'w') as f:
|
|
||||||
f.close()
|
|
||||||
self.update_commander_data()
|
|
||||||
self.ui.update_tracker()
|
|
||||||
|
|
||||||
random.seed(self.seed_name + str(self.slot))
|
|
||||||
# Our indexes start at 1 and we have 24 levels
|
|
||||||
for i in range(1, 25):
|
|
||||||
filename = f"seed{i}"
|
|
||||||
with open(os.path.join(self.game_communication_path, filename), 'w') as f:
|
|
||||||
f.write(str(random.randint(0, 4294967295)))
|
|
||||||
f.close()
|
|
||||||
|
|
||||||
if cmd in {"RoomInfo"}:
|
|
||||||
self.seed_name = args["seed_name"]
|
|
||||||
|
|
||||||
if cmd in {"ReceivedItems"}:
|
|
||||||
received_ids = [item.item for item in self.items_received]
|
|
||||||
for network_item in self.items_received:
|
|
||||||
filename = f"AP_{str(network_item.item)}.item"
|
|
||||||
path = os.path.join(self.game_communication_path, filename)
|
|
||||||
|
|
||||||
# Newly-obtained items
|
|
||||||
if not os.path.isfile(path):
|
|
||||||
open(path, 'w').close()
|
|
||||||
# Announcing commander unlocks
|
|
||||||
item_name = self.item_names[network_item.item]
|
|
||||||
if item_name in faction_table.keys():
|
|
||||||
for commander in faction_table[item_name]:
|
|
||||||
logger.info(f"{commander.name} has been unlocked!")
|
|
||||||
|
|
||||||
with open(path, 'w') as f:
|
|
||||||
item_count = received_ids.count(network_item.item)
|
|
||||||
if self.buff_item_ids["Income Boost"] == network_item.item:
|
|
||||||
f.write(f"{item_count * self.income_boost_multiplier}")
|
|
||||||
elif self.buff_item_ids["Commander Defense Boost"] == network_item.item:
|
|
||||||
f.write(f"{item_count * self.commander_defense_boost_multiplier}")
|
|
||||||
else:
|
|
||||||
f.write(f"{item_count}")
|
|
||||||
f.close()
|
|
||||||
|
|
||||||
print_filename = f"AP_{str(network_item.item)}.item.print"
|
|
||||||
print_path = os.path.join(self.game_communication_path, print_filename)
|
|
||||||
if not os.path.isfile(print_path):
|
|
||||||
open(print_path, 'w').close()
|
|
||||||
with open(print_path, 'w') as f:
|
|
||||||
f.write("Received " +
|
|
||||||
self.item_names[network_item.item] +
|
|
||||||
" from " +
|
|
||||||
self.player_names[network_item.player])
|
|
||||||
f.close()
|
|
||||||
self.update_commander_data()
|
|
||||||
self.ui.update_tracker()
|
|
||||||
|
|
||||||
if cmd in {"RoomUpdate"}:
|
|
||||||
if "checked_locations" in args:
|
|
||||||
for ss in self.checked_locations:
|
|
||||||
filename = f"send{ss}"
|
|
||||||
with open(os.path.join(self.game_communication_path, filename), 'w') as f:
|
|
||||||
f.close()
|
|
||||||
|
|
||||||
def run_gui(self):
|
|
||||||
"""Import kivy UI system and start running it as self.ui_task."""
|
|
||||||
from kvui import GameManager, HoverBehavior, ServerToolTip
|
|
||||||
from kivy.uix.tabbedpanel import TabbedPanelItem
|
|
||||||
from kivy.lang import Builder
|
|
||||||
from kivy.uix.button import Button
|
|
||||||
from kivy.uix.togglebutton import ToggleButton
|
|
||||||
from kivy.uix.boxlayout import BoxLayout
|
|
||||||
from kivy.uix.gridlayout import GridLayout
|
|
||||||
from kivy.uix.image import AsyncImage, Image
|
|
||||||
from kivy.uix.stacklayout import StackLayout
|
|
||||||
from kivy.uix.label import Label
|
|
||||||
from kivy.properties import ColorProperty
|
|
||||||
from kivy.uix.image import Image
|
|
||||||
import pkgutil
|
|
||||||
|
|
||||||
class TrackerLayout(BoxLayout):
|
|
||||||
pass
|
|
||||||
|
|
||||||
class CommanderSelect(BoxLayout):
|
|
||||||
pass
|
|
||||||
|
|
||||||
class CommanderButton(ToggleButton):
|
|
||||||
pass
|
|
||||||
|
|
||||||
class FactionBox(BoxLayout):
|
|
||||||
pass
|
|
||||||
|
|
||||||
class CommanderGroup(BoxLayout):
|
|
||||||
pass
|
|
||||||
|
|
||||||
class ItemTracker(BoxLayout):
|
|
||||||
pass
|
|
||||||
|
|
||||||
class ItemLabel(Label):
|
|
||||||
pass
|
|
||||||
|
|
||||||
class WargrooveManager(GameManager):
|
|
||||||
logging_pairs = [
|
|
||||||
("Client", "Archipelago"),
|
|
||||||
("WG", "WG Console"),
|
|
||||||
]
|
|
||||||
base_title = "Archipelago Wargroove Client"
|
|
||||||
ctx: WargrooveContext
|
|
||||||
unit_tracker: ItemTracker
|
|
||||||
trigger_tracker: BoxLayout
|
|
||||||
boost_tracker: BoxLayout
|
|
||||||
commander_buttons: Dict[int, List[CommanderButton]]
|
|
||||||
tracker_items = {
|
|
||||||
"Swordsman": ItemData(None, "Unit", False),
|
|
||||||
"Dog": ItemData(None, "Unit", False),
|
|
||||||
**item_table
|
|
||||||
}
|
|
||||||
|
|
||||||
def build(self):
|
|
||||||
container = super().build()
|
|
||||||
panel = TabbedPanelItem(text="Wargroove")
|
|
||||||
panel.content = self.build_tracker()
|
|
||||||
self.tabs.add_widget(panel)
|
|
||||||
return container
|
|
||||||
|
|
||||||
def build_tracker(self) -> TrackerLayout:
|
|
||||||
try:
|
|
||||||
tracker = TrackerLayout(orientation="horizontal")
|
|
||||||
commander_select = CommanderSelect(orientation="vertical")
|
|
||||||
self.commander_buttons = {}
|
|
||||||
|
|
||||||
for faction, commanders in faction_table.items():
|
|
||||||
faction_box = FactionBox(size_hint=(None, None), width=100 * len(commanders), height=70)
|
|
||||||
commander_group = CommanderGroup()
|
|
||||||
commander_buttons = []
|
|
||||||
for commander in commanders:
|
|
||||||
commander_button = CommanderButton(text=commander.name, group="commanders")
|
|
||||||
if faction == "Starter":
|
|
||||||
commander_button.disabled = False
|
|
||||||
commander_button.bind(on_press=lambda instance: self.ctx.set_commander(instance.text))
|
|
||||||
commander_buttons.append(commander_button)
|
|
||||||
commander_group.add_widget(commander_button)
|
|
||||||
self.commander_buttons[faction] = commander_buttons
|
|
||||||
faction_box.add_widget(Label(text=faction, size_hint_x=None, pos_hint={'left': 1}, size_hint_y=None, height=10))
|
|
||||||
faction_box.add_widget(commander_group)
|
|
||||||
commander_select.add_widget(faction_box)
|
|
||||||
item_tracker = ItemTracker(padding=[0,20])
|
|
||||||
self.unit_tracker = BoxLayout(orientation="vertical")
|
|
||||||
other_tracker = BoxLayout(orientation="vertical")
|
|
||||||
self.trigger_tracker = BoxLayout(orientation="vertical")
|
|
||||||
self.boost_tracker = BoxLayout(orientation="vertical")
|
|
||||||
other_tracker.add_widget(self.trigger_tracker)
|
|
||||||
other_tracker.add_widget(self.boost_tracker)
|
|
||||||
item_tracker.add_widget(self.unit_tracker)
|
|
||||||
item_tracker.add_widget(other_tracker)
|
|
||||||
tracker.add_widget(commander_select)
|
|
||||||
tracker.add_widget(item_tracker)
|
|
||||||
self.update_tracker()
|
|
||||||
return tracker
|
|
||||||
except Exception as e:
|
|
||||||
print(e)
|
|
||||||
|
|
||||||
def update_tracker(self):
|
|
||||||
received_ids = [item.item for item in self.ctx.items_received]
|
|
||||||
for faction, item_id in self.ctx.faction_item_ids.items():
|
|
||||||
for commander_button in self.commander_buttons[faction]:
|
|
||||||
commander_button.disabled = not (faction == "Starter" or item_id in received_ids)
|
|
||||||
self.unit_tracker.clear_widgets()
|
|
||||||
self.trigger_tracker.clear_widgets()
|
|
||||||
for name, item in self.tracker_items.items():
|
|
||||||
if item.type in ("Unit", "Trigger"):
|
|
||||||
status_color = (1, 1, 1, 1) if item.code is None or item.code in received_ids else (0.6, 0.2, 0.2, 1)
|
|
||||||
label = ItemLabel(text=name, color=status_color)
|
|
||||||
if item.type == "Unit":
|
|
||||||
self.unit_tracker.add_widget(label)
|
|
||||||
else:
|
|
||||||
self.trigger_tracker.add_widget(label)
|
|
||||||
self.boost_tracker.clear_widgets()
|
|
||||||
extra_income = received_ids.count(52023) * self.ctx.income_boost_multiplier
|
|
||||||
extra_defense = received_ids.count(52024) * self.ctx.commander_defense_boost_multiplier
|
|
||||||
income_boost = ItemLabel(text="Extra Income: " + str(extra_income))
|
|
||||||
defense_boost = ItemLabel(text="Comm Defense: " + str(100 + extra_defense))
|
|
||||||
self.boost_tracker.add_widget(income_boost)
|
|
||||||
self.boost_tracker.add_widget(defense_boost)
|
|
||||||
|
|
||||||
self.ui = WargrooveManager(self)
|
|
||||||
data = pkgutil.get_data(WargrooveWorld.__module__, "Wargroove.kv").decode()
|
|
||||||
Builder.load_string(data)
|
|
||||||
self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI")
|
|
||||||
|
|
||||||
def update_commander_data(self):
|
|
||||||
if self.can_choose_commander:
|
|
||||||
faction_items = 0
|
|
||||||
faction_item_names = [faction + ' Commanders' for faction in faction_table.keys()]
|
|
||||||
for network_item in self.items_received:
|
|
||||||
if self.item_names[network_item.item] in faction_item_names:
|
|
||||||
faction_items += 1
|
|
||||||
starting_groove = (faction_items - 1) * self.starting_groove_multiplier
|
|
||||||
# Must be an integer larger than 0
|
|
||||||
starting_groove = int(max(starting_groove, 0))
|
|
||||||
data = {
|
|
||||||
"commander": self.current_commander.internal_name,
|
|
||||||
"starting_groove": starting_groove
|
|
||||||
}
|
|
||||||
else:
|
|
||||||
data = {
|
|
||||||
"commander": "seed",
|
|
||||||
"starting_groove": 0
|
|
||||||
}
|
|
||||||
filename = 'commander.json'
|
|
||||||
with open(os.path.join(self.game_communication_path, filename), 'w') as f:
|
|
||||||
json.dump(data, f)
|
|
||||||
if self.ui:
|
|
||||||
self.ui.update_tracker()
|
|
||||||
|
|
||||||
def set_commander(self, commander_name: str) -> bool:
|
|
||||||
"""Sets the current commander to the given one, if possible"""
|
|
||||||
if not self.can_choose_commander:
|
|
||||||
wg_logger.error("Cannot set commanders in this game mode.")
|
|
||||||
return
|
|
||||||
match_name = commander_name.lower()
|
|
||||||
for commander, unlocked in self.get_commanders():
|
|
||||||
if commander.name.lower() == match_name or commander.alt_name and commander.alt_name.lower() == match_name:
|
|
||||||
if unlocked:
|
|
||||||
self.current_commander = commander
|
|
||||||
self.syncing = True
|
|
||||||
wg_logger.info(f"Commander set to {commander.name}.")
|
|
||||||
self.update_commander_data()
|
|
||||||
return True
|
|
||||||
else:
|
|
||||||
wg_logger.error(f"Commander {commander.name} has not been unlocked.")
|
|
||||||
return False
|
|
||||||
else:
|
|
||||||
wg_logger.error(f"{commander_name} is not a recognized Wargroove commander.")
|
|
||||||
|
|
||||||
def get_commanders(self) -> List[Tuple[CommanderData, bool]]:
|
|
||||||
"""Gets a list of commanders with their unlocked status"""
|
|
||||||
commanders = []
|
|
||||||
received_ids = [item.item for item in self.items_received]
|
|
||||||
for faction in faction_table.keys():
|
|
||||||
unlocked = faction == 'Starter' or self.faction_item_ids[faction] in received_ids
|
|
||||||
commanders += [(commander, unlocked) for commander in faction_table[faction]]
|
|
||||||
return commanders
|
|
||||||
|
|
||||||
|
|
||||||
async def game_watcher(ctx: WargrooveContext):
|
|
||||||
from worlds.wargroove.Locations import location_table
|
|
||||||
while not ctx.exit_event.is_set():
|
|
||||||
if ctx.syncing == True:
|
|
||||||
sync_msg = [{'cmd': 'Sync'}]
|
|
||||||
if ctx.locations_checked:
|
|
||||||
sync_msg.append({"cmd": "LocationChecks", "locations": list(ctx.locations_checked)})
|
|
||||||
await ctx.send_msgs(sync_msg)
|
|
||||||
ctx.syncing = False
|
|
||||||
sending = []
|
|
||||||
victory = False
|
|
||||||
for root, dirs, files in os.walk(ctx.game_communication_path):
|
|
||||||
for file in files:
|
|
||||||
if file.find("send") > -1:
|
|
||||||
st = file.split("send", -1)[1]
|
|
||||||
sending = sending+[(int(st))]
|
|
||||||
os.remove(os.path.join(ctx.game_communication_path, file))
|
|
||||||
if file.find("victory") > -1:
|
|
||||||
victory = True
|
|
||||||
os.remove(os.path.join(ctx.game_communication_path, file))
|
|
||||||
ctx.locations_checked = sending
|
|
||||||
message = [{"cmd": 'LocationChecks', "locations": sending}]
|
|
||||||
await ctx.send_msgs(message)
|
|
||||||
if not ctx.finished_game and victory:
|
|
||||||
await ctx.send_msgs([{"cmd": "StatusUpdate", "status": ClientStatus.CLIENT_GOAL}])
|
|
||||||
ctx.finished_game = True
|
|
||||||
await asyncio.sleep(0.1)
|
|
||||||
|
|
||||||
|
|
||||||
def print_error_and_close(msg):
|
|
||||||
logger.error("Error: " + msg)
|
|
||||||
Utils.messagebox("Error", msg, error=True)
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
async def main(args):
|
|
||||||
ctx = WargrooveContext(args.connect, args.password)
|
|
||||||
ctx.server_task = asyncio.create_task(server_loop(ctx), name="server loop")
|
|
||||||
if gui_enabled:
|
|
||||||
ctx.run_gui()
|
|
||||||
ctx.run_cli()
|
|
||||||
progression_watcher = asyncio.create_task(
|
|
||||||
game_watcher(ctx), name="WargrooveProgressionWatcher")
|
|
||||||
|
|
||||||
await ctx.exit_event.wait()
|
|
||||||
ctx.server_address = None
|
|
||||||
|
|
||||||
await progression_watcher
|
|
||||||
|
|
||||||
await ctx.shutdown()
|
|
||||||
|
|
||||||
import colorama
|
|
||||||
|
|
||||||
parser = get_base_parser(description="Wargroove Client, for text interfacing.")
|
|
||||||
|
|
||||||
args, rest = parser.parse_known_args()
|
|
||||||
colorama.init()
|
|
||||||
asyncio.run(main(args))
|
|
||||||
colorama.deinit()
|
|
||||||
36
WebHost.py
36
WebHost.py
@@ -1,4 +1,5 @@
|
|||||||
import os
|
import os
|
||||||
|
import sys
|
||||||
import multiprocessing
|
import multiprocessing
|
||||||
import logging
|
import logging
|
||||||
import typing
|
import typing
|
||||||
@@ -10,31 +11,29 @@ ModuleUpdate.update()
|
|||||||
|
|
||||||
# in case app gets imported by something like gunicorn
|
# in case app gets imported by something like gunicorn
|
||||||
import Utils
|
import Utils
|
||||||
import settings
|
|
||||||
|
|
||||||
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__)
|
||||||
settings.no_gui = True
|
|
||||||
|
from WebHostLib import register, app as raw_app
|
||||||
|
from waitress import serve
|
||||||
|
|
||||||
|
from WebHostLib.models import db
|
||||||
|
from WebHostLib.autolauncher import autohost, autogen
|
||||||
|
from WebHostLib.lttpsprites import update_sprites_lttp
|
||||||
|
from WebHostLib.options import create as create_options_files
|
||||||
|
|
||||||
configpath = os.path.abspath("config.yaml")
|
configpath = os.path.abspath("config.yaml")
|
||||||
if not os.path.exists(configpath): # fall back to config.yaml in home
|
if not os.path.exists(configpath): # fall back to config.yaml in home
|
||||||
configpath = os.path.abspath(Utils.user_path('config.yaml'))
|
configpath = os.path.abspath(Utils.user_path('config.yaml'))
|
||||||
|
|
||||||
|
|
||||||
def get_app():
|
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):
|
||||||
import yaml
|
import yaml
|
||||||
app.config.from_file(configpath, yaml.safe_load)
|
app.config.from_file(configpath, yaml.safe_load)
|
||||||
logging.info(f"Updated config from {configpath}")
|
logging.info(f"Updated config from {configpath}")
|
||||||
if not app.config["HOST_ADDRESS"]:
|
|
||||||
logging.info("Getting public IP, as HOST_ADDRESS is empty.")
|
|
||||||
app.config["HOST_ADDRESS"] = Utils.get_public_ipv4()
|
|
||||||
logging.info(f"HOST_ADDRESS was set to {app.config['HOST_ADDRESS']}")
|
|
||||||
|
|
||||||
register()
|
|
||||||
cache.init_app(app)
|
|
||||||
db.bind(**app.config["PONY"])
|
db.bind(**app.config["PONY"])
|
||||||
db.generate_mapping(create_tables=True)
|
db.generate_mapping(create_tables=True)
|
||||||
return app
|
return app
|
||||||
@@ -69,7 +68,6 @@ def create_ordered_tutorials_file() -> typing.List[typing.Dict[str, typing.Any]]
|
|||||||
with zipfile.ZipFile(zipfile_path) as zf:
|
with zipfile.ZipFile(zipfile_path) as zf:
|
||||||
for zfile in zf.infolist():
|
for zfile in zf.infolist():
|
||||||
if not zfile.is_dir() and "/docs/" in zfile.filename:
|
if not zfile.is_dir() and "/docs/" in zfile.filename:
|
||||||
zfile.filename = os.path.basename(zfile.filename)
|
|
||||||
zf.extract(zfile, target_path)
|
zf.extract(zfile, target_path)
|
||||||
else:
|
else:
|
||||||
source_path = Utils.local_path(os.path.dirname(world.__file__), "docs")
|
source_path = Utils.local_path(os.path.dirname(world.__file__), "docs")
|
||||||
@@ -106,7 +104,7 @@ def create_ordered_tutorials_file() -> typing.List[typing.Dict[str, typing.Any]]
|
|||||||
for games in data:
|
for games in data:
|
||||||
if 'Archipelago' in games['gameTitle']:
|
if 'Archipelago' in games['gameTitle']:
|
||||||
generic_data = data.pop(data.index(games))
|
generic_data = data.pop(data.index(games))
|
||||||
sorted_data = [generic_data] + Utils.title_sorted(data, key=lambda entry: entry["gameTitle"])
|
sorted_data = [generic_data] + Utils.title_sorted(data, key=lambda entry: entry["gameTitle"].lower())
|
||||||
json.dump(sorted_data, json_target, indent=2, ensure_ascii=False)
|
json.dump(sorted_data, json_target, indent=2, ensure_ascii=False)
|
||||||
return sorted_data
|
return sorted_data
|
||||||
|
|
||||||
@@ -115,11 +113,6 @@ if __name__ == "__main__":
|
|||||||
multiprocessing.freeze_support()
|
multiprocessing.freeze_support()
|
||||||
multiprocessing.set_start_method('spawn')
|
multiprocessing.set_start_method('spawn')
|
||||||
logging.basicConfig(format='[%(asctime)s] %(message)s', level=logging.INFO)
|
logging.basicConfig(format='[%(asctime)s] %(message)s', level=logging.INFO)
|
||||||
|
|
||||||
from WebHostLib.lttpsprites import update_sprites_lttp
|
|
||||||
from WebHostLib.autolauncher import autohost, autogen
|
|
||||||
from WebHostLib.options import create as create_options_files
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
update_sprites_lttp()
|
update_sprites_lttp()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -136,5 +129,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"])
|
||||||
|
|||||||
@@ -1,15 +1,16 @@
|
|||||||
import base64
|
|
||||||
import os
|
import os
|
||||||
import socket
|
|
||||||
import uuid
|
import uuid
|
||||||
|
import base64
|
||||||
|
import socket
|
||||||
|
|
||||||
|
from pony.flask import Pony
|
||||||
from flask import Flask
|
from flask import Flask
|
||||||
from flask_caching import Cache
|
from flask_caching import Cache
|
||||||
from flask_compress import Compress
|
from flask_compress import Compress
|
||||||
from pony.flask import Pony
|
|
||||||
from werkzeug.routing import BaseConverter
|
from werkzeug.routing import BaseConverter
|
||||||
|
|
||||||
from Utils import title_sorted
|
from Utils import title_sorted
|
||||||
|
from .models import *
|
||||||
|
|
||||||
UPLOAD_FOLDER = os.path.relpath('uploads')
|
UPLOAD_FOLDER = os.path.relpath('uploads')
|
||||||
LOGS_FOLDER = os.path.relpath('logs')
|
LOGS_FOLDER = os.path.relpath('logs')
|
||||||
@@ -23,10 +24,7 @@ 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["SELFLAUNCHKEY"] = None # can point to a SSL Certificate Key to encrypt Room websocket connections
|
|
||||||
app.config["SELFGEN"] = True # application process is in charge of scheduling Generations.
|
app.config["SELFGEN"] = True # application process is in charge of scheduling Generations.
|
||||||
app.config["DEBUG"] = False
|
app.config["DEBUG"] = False
|
||||||
app.config["PORT"] = 80
|
app.config["PORT"] = 80
|
||||||
@@ -34,10 +32,8 @@ app.config['UPLOAD_FOLDER'] = UPLOAD_FOLDER
|
|||||||
app.config['MAX_CONTENT_LENGTH'] = 64 * 1024 * 1024 # 64 megabyte limit
|
app.config['MAX_CONTENT_LENGTH'] = 64 * 1024 * 1024 # 64 megabyte limit
|
||||||
# if you want to deploy, make sure you have a non-guessable secret key
|
# if you want to deploy, make sure you have a non-guessable secret key
|
||||||
app.config["SECRET_KEY"] = bytes(socket.gethostname(), encoding="utf-8")
|
app.config["SECRET_KEY"] = bytes(socket.gethostname(), encoding="utf-8")
|
||||||
# at what amount of worlds should scheduling be used, instead of rolling in the web-thread
|
# at what amount of worlds should scheduling be used, instead of rolling in the webthread
|
||||||
app.config["JOB_THRESHOLD"] = 1
|
app.config["JOB_THRESHOLD"] = 2
|
||||||
# after what time in seconds should generation be aborted, freeing the queue slot. Can be set to None to disable.
|
|
||||||
app.config["JOB_TIME"] = 600
|
|
||||||
app.config['SESSION_PERMANENT'] = True
|
app.config['SESSION_PERMANENT'] = True
|
||||||
|
|
||||||
# waitress uses one thread for I/O, these are for processing of views that then get sent
|
# waitress uses one thread for I/O, these are for processing of views that then get sent
|
||||||
@@ -50,11 +46,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["HOST_ADDRESS"] = ""
|
app.config["JSON_AS_ASCII"] = False
|
||||||
app.config["ASSET_RIGHTS"] = False
|
app.config["PATCH_TARGET"] = "archipelago.gg"
|
||||||
|
|
||||||
cache = Cache()
|
cache = Cache(app)
|
||||||
Compress(app)
|
Compress(app)
|
||||||
|
|
||||||
|
|
||||||
@@ -77,13 +73,11 @@ def register():
|
|||||||
"""Import submodules, triggering their registering on flask routing.
|
"""Import submodules, triggering their registering on flask routing.
|
||||||
Note: initializes worlds subsystem."""
|
Note: initializes worlds subsystem."""
|
||||||
# has automatic patch integration
|
# has automatic patch integration
|
||||||
import worlds.AutoWorld
|
import Patch
|
||||||
import worlds.Files
|
app.jinja_env.filters['supports_apdeltapatch'] = lambda game_name: game_name in Patch.AutoPatchRegister.patch_types
|
||||||
app.jinja_env.filters['supports_apdeltapatch'] = lambda game_name: \
|
|
||||||
game_name in worlds.Files.AutoPatchRegister.patch_types
|
|
||||||
|
|
||||||
from WebHostLib.customserver import run_server_process
|
from WebHostLib.customserver import run_server_process
|
||||||
# to trigger app routing picking up on it
|
# 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)
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
"""API endpoints package."""
|
"""API endpoints package."""
|
||||||
from typing import List, Tuple
|
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
from typing import List, Tuple
|
||||||
|
|
||||||
from flask import Blueprint, abort, url_for
|
from flask import Blueprint, abort
|
||||||
|
|
||||||
import worlds.Files
|
|
||||||
from .. import cache
|
|
||||||
from ..models import Room, Seed
|
from ..models import Room, Seed
|
||||||
|
from .. import cache
|
||||||
|
|
||||||
api_endpoints = Blueprint('api', __name__, url_prefix="/api")
|
api_endpoints = Blueprint('api', __name__, url_prefix="/api")
|
||||||
|
|
||||||
@@ -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,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -59,19 +40,9 @@ def get_datapackage():
|
|||||||
@api_endpoints.route('/datapackage_version')
|
@api_endpoints.route('/datapackage_version')
|
||||||
@cache.cached()
|
@cache.cached()
|
||||||
def get_datapackage_versions():
|
def get_datapackage_versions():
|
||||||
from worlds import AutoWorldRegister
|
from worlds import network_data_package, AutoWorldRegister
|
||||||
|
|
||||||
version_package = {game: world.data_version for game, world in AutoWorldRegister.world_types.items()}
|
version_package = {game: world.data_version for game, world in AutoWorldRegister.world_types.items()}
|
||||||
return version_package
|
version_package["version"] = network_data_package["version"]
|
||||||
|
|
||||||
|
|
||||||
@api_endpoints.route('/datapackage_checksum')
|
|
||||||
@cache.cached()
|
|
||||||
def get_datapackage_checksums():
|
|
||||||
from worlds import network_data_package
|
|
||||||
version_package = {
|
|
||||||
game: game_data["checksum"] for game, game_data in network_data_package["games"].items()
|
|
||||||
}
|
|
||||||
return version_package
|
return version_package
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,16 +1,15 @@
|
|||||||
import json
|
import json
|
||||||
import pickle
|
import pickle
|
||||||
|
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
|
from . import api_endpoints
|
||||||
from flask import request, session, url_for
|
from flask import request, session, url_for
|
||||||
from markupsafe import Markup
|
|
||||||
from pony.orm import commit
|
from pony.orm import commit
|
||||||
|
|
||||||
from WebHostLib import app
|
from WebHostLib import app, Generation, STATE_QUEUED, Seed, STATE_ERROR
|
||||||
from WebHostLib.check import get_yaml_data, roll_options
|
from WebHostLib.check import get_yaml_data, roll_options
|
||||||
from WebHostLib.generate import get_meta
|
from WebHostLib.generate import get_meta
|
||||||
from WebHostLib.models import Generation, STATE_QUEUED, Seed, STATE_ERROR
|
|
||||||
from . import api_endpoints
|
|
||||||
|
|
||||||
|
|
||||||
@api_endpoints.route('/generate', methods=['POST'])
|
@api_endpoints.route('/generate', methods=['POST'])
|
||||||
@@ -20,20 +19,15 @@ 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 type(options) == str:
|
||||||
return {"text": options.striptags()}, 400
|
|
||||||
if isinstance(options, str):
|
|
||||||
return {"text": options}, 400
|
return {"text": options}, 400
|
||||||
if "race" in request.form:
|
if "race" in request.form:
|
||||||
race = bool(0 if request.form["race"] in {"false"} else int(request.form["race"]))
|
race = bool(0 if request.form["race"] in {"false"} else int(request.form["race"]))
|
||||||
meta_options_source = request.form
|
meta_options_source = request.form
|
||||||
|
|
||||||
# json_data is optional, we can have it silently fall to None as it used to do.
|
json_data = request.get_json()
|
||||||
# See https://flask.palletsprojects.com/en/2.2.x/api/#flask.Request.get_json -> Changelog -> 2.1
|
|
||||||
json_data = request.get_json(silent=True)
|
|
||||||
|
|
||||||
if json_data:
|
if json_data:
|
||||||
meta_options_source = json_data
|
meta_options_source = json_data
|
||||||
if 'weights' in json_data:
|
if 'weights' in json_data:
|
||||||
@@ -49,8 +43,9 @@ def generate_api():
|
|||||||
if len(options) > app.config["MAX_ROLL"]:
|
if len(options) > app.config["MAX_ROLL"]:
|
||||||
return {"text": "Max size of multiworld exceeded",
|
return {"text": "Max size of multiworld exceeded",
|
||||||
"detail": app.config["MAX_ROLL"]}, 409
|
"detail": app.config["MAX_ROLL"]}, 409
|
||||||
meta = get_meta(meta_options_source, race)
|
meta = get_meta(meta_options_source)
|
||||||
results, gen_options = roll_options(options, set(meta["plando_options"]))
|
meta["race"] = race
|
||||||
|
results, gen_options = roll_options(options, meta["plando_options"])
|
||||||
if any(type(result) == str for result in results.values()):
|
if any(type(result) == str for result in results.values()):
|
||||||
return {"text": str(results),
|
return {"text": str(results),
|
||||||
"detail": results}, 400
|
"detail": results}, 400
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
from flask import session, jsonify
|
from flask import session, jsonify
|
||||||
from pony.orm import select
|
|
||||||
|
|
||||||
from WebHostLib.models import Room, Seed
|
from WebHostLib.models import *
|
||||||
from . import api_endpoints, get_players
|
from . import api_endpoints, get_players
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,17 +1,77 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
|
||||||
import logging
|
import logging
|
||||||
|
import json
|
||||||
import multiprocessing
|
import multiprocessing
|
||||||
import time
|
import threading
|
||||||
import typing
|
|
||||||
from uuid import UUID
|
|
||||||
from datetime import timedelta, datetime
|
from datetime import timedelta, datetime
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import typing
|
||||||
|
import time
|
||||||
|
import os
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
|
class CommonLocker():
|
||||||
|
"""Uses a file lock to signal that something is already running"""
|
||||||
|
lock_folder = "file_locks"
|
||||||
|
|
||||||
|
def __init__(self, lockname: str, folder=None):
|
||||||
|
if folder:
|
||||||
|
self.lock_folder = folder
|
||||||
|
os.makedirs(self.lock_folder, exist_ok=True)
|
||||||
|
self.lockname = lockname
|
||||||
|
self.lockfile = os.path.join(self.lock_folder, f"{self.lockname}.lck")
|
||||||
|
|
||||||
|
|
||||||
|
class AlreadyRunningException(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
if sys.platform == 'win32':
|
||||||
|
class Locker(CommonLocker):
|
||||||
|
def __enter__(self):
|
||||||
|
try:
|
||||||
|
if os.path.exists(self.lockfile):
|
||||||
|
os.unlink(self.lockfile)
|
||||||
|
self.fp = os.open(
|
||||||
|
self.lockfile, os.O_CREAT | os.O_EXCL | os.O_RDWR)
|
||||||
|
except OSError as e:
|
||||||
|
raise AlreadyRunningException() from e
|
||||||
|
|
||||||
|
def __exit__(self, _type, value, tb):
|
||||||
|
fp = getattr(self, "fp", None)
|
||||||
|
if fp:
|
||||||
|
os.close(self.fp)
|
||||||
|
os.unlink(self.lockfile)
|
||||||
|
else: # unix
|
||||||
|
import fcntl
|
||||||
|
|
||||||
|
|
||||||
|
class Locker(CommonLocker):
|
||||||
|
def __enter__(self):
|
||||||
|
try:
|
||||||
|
self.fp = open(self.lockfile, "wb")
|
||||||
|
fcntl.flock(self.fp.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||||
|
except OSError as e:
|
||||||
|
raise AlreadyRunningException() from e
|
||||||
|
|
||||||
|
def __exit__(self, _type, value, tb):
|
||||||
|
fcntl.flock(self.fp.fileno(), fcntl.LOCK_UN)
|
||||||
|
self.fp.close()
|
||||||
|
|
||||||
|
|
||||||
|
def launch_room(room: Room, config: dict):
|
||||||
|
# requires db_session!
|
||||||
|
if room.last_activity >= datetime.utcnow() - timedelta(seconds=room.timeout):
|
||||||
|
multiworld = multiworlds.get(room.id, None)
|
||||||
|
if not multiworld:
|
||||||
|
multiworld = MultiworldInstance(room, config)
|
||||||
|
|
||||||
|
multiworld.start()
|
||||||
|
|
||||||
|
|
||||||
def handle_generation_success(seed_id):
|
def handle_generation_success(seed_id):
|
||||||
@@ -48,30 +108,11 @@ 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():
|
||||||
try:
|
try:
|
||||||
with Locker("autohost"):
|
with Locker("autohost"):
|
||||||
cleanup()
|
run_guardian()
|
||||||
hosters = []
|
|
||||||
for x in range(config["HOSTERS"]):
|
|
||||||
hoster = MultiworldInstance(config, x)
|
|
||||||
hosters.append(hoster)
|
|
||||||
hoster.start()
|
|
||||||
|
|
||||||
while 1:
|
while 1:
|
||||||
time.sleep(0.1)
|
time.sleep(0.1)
|
||||||
with db_session:
|
with db_session:
|
||||||
@@ -79,9 +120,7 @@ def autohost(config: dict):
|
|||||||
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):
|
|
||||||
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.")
|
||||||
@@ -96,7 +135,7 @@ def autogen(config: dict):
|
|||||||
with Locker("autogen"):
|
with Locker("autogen"):
|
||||||
|
|
||||||
with multiprocessing.Pool(config["GENERATORS"], initializer=init_db,
|
with multiprocessing.Pool(config["GENERATORS"], initializer=init_db,
|
||||||
initargs=(config["PONY"],), maxtasksperchild=10) as generator_pool:
|
initargs=(config["PONY"],)) as generator_pool:
|
||||||
with db_session:
|
with db_session:
|
||||||
to_start = select(generation for generation in Generation if generation.state == STATE_STARTED)
|
to_start = select(generation for generation in Generation if generation.state == STATE_STARTED)
|
||||||
|
|
||||||
@@ -132,38 +171,25 @@ 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.key = config["SELFLAUNCHKEY"]
|
|
||||||
self.host = config["HOST_ADDRESS"]
|
|
||||||
self.rooms_to_start = multiprocessing.Queue()
|
|
||||||
self.rooms_shutting_down = multiprocessing.Queue()
|
|
||||||
self.name = f"MultiHoster{id}"
|
|
||||||
|
|
||||||
def start(self):
|
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,
|
name="MultiHost")
|
||||||
self.rooms_to_start, self.rooms_shutting_down),
|
|
||||||
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()
|
||||||
@@ -177,6 +203,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,15 +1,18 @@
|
|||||||
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, session, render_template
|
||||||
from markupsafe import Markup
|
|
||||||
|
|
||||||
from WebHostLib import app
|
from WebHostLib import app
|
||||||
from WebHostLib.upload import allowed_options, allowed_options_extensions, banned_file
|
|
||||||
|
|
||||||
from Generate import roll_settings, PlandoOptions
|
banned_zip_contents = (".sfc",)
|
||||||
|
|
||||||
|
|
||||||
|
def allowed_file(filename):
|
||||||
|
return filename.endswith(('.txt', ".yaml", ".zip"))
|
||||||
|
|
||||||
|
|
||||||
|
from Generate import roll_settings, PlandoSettings
|
||||||
from Utils import parse_yamls
|
from Utils import parse_yamls
|
||||||
|
|
||||||
|
|
||||||
@@ -20,21 +23,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 type(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,51 +38,34 @@ 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]:
|
||||||
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"):
|
|
||||||
return Markup("Error: Your .zip file contains an .archipelago file. "
|
|
||||||
'Did you mean to <a href="/uploads">host a game</a>?')
|
|
||||||
elif base_filename.endswith(".zip"):
|
|
||||||
return "Nested .zip files inside a .zip are not supported."
|
|
||||||
elif banned_file(base_filename):
|
|
||||||
return ("Uploaded data contained a rom file, which is likely to contain copyrighted "
|
|
||||||
"material. Your file was deleted.")
|
|
||||||
# Ignore dot-files.
|
|
||||||
elif not base_filename.startswith(".") and allowed_options(base_filename):
|
|
||||||
options[file.filename] = zfile.open(file, "r").read()
|
|
||||||
else:
|
|
||||||
options[uploaded_file.filename] = uploaded_file.read()
|
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
def roll_options(options: Dict[str, Union[dict, str]],
|
def roll_options(options: Dict[str, Union[dict, str]],
|
||||||
plando_options: Set[str] = frozenset({"bosses", "items", "connections", "texts"})) -> \
|
plando_options: Set[str] = frozenset({"bosses", "items", "connections", "texts"})) -> \
|
||||||
Tuple[Dict[str, Union[str, bool]], Dict[str, dict]]:
|
Tuple[Dict[str, Union[str, bool]], Dict[str, dict]]:
|
||||||
plando_options = PlandoOptions.from_set(set(plando_options))
|
plando_options = PlandoSettings.from_set(set(plando_options))
|
||||||
results = {}
|
results = {}
|
||||||
rolled_results = {}
|
rolled_results = {}
|
||||||
for filename, text in options.items():
|
for filename, text in options.items():
|
||||||
@@ -108,10 +86,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 mystery 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
|
||||||
|
|||||||
@@ -1,37 +1,28 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import collections
|
|
||||||
import datetime
|
|
||||||
import functools
|
import functools
|
||||||
import logging
|
import websockets
|
||||||
import multiprocessing
|
import asyncio
|
||||||
import pickle
|
|
||||||
import random
|
|
||||||
import socket
|
import socket
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
import typing
|
import random
|
||||||
import sys
|
import pickle
|
||||||
|
import logging
|
||||||
import websockets
|
import datetime
|
||||||
from pony.orm import commit, db_session, select
|
|
||||||
|
|
||||||
import Utils
|
import Utils
|
||||||
|
from .models import db_session, Room, select, commit, Command, db
|
||||||
|
|
||||||
from MultiServer import Context, server, auto_shutdown, ServerCommandProcessor, ClientMessageProcessor, load_server_cert
|
from MultiServer import Context, server, auto_shutdown, ServerCommandProcessor, ClientMessageProcessor
|
||||||
from Utils import restricted_loads, cache_argsless
|
from Utils import get_public_ipv4, get_public_ipv6, restricted_loads, cache_argsless
|
||||||
from .locker import Locker
|
|
||||||
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 +45,15 @@ 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
|
def __init__(self, static_server_data: dict):
|
||||||
|
|
||||||
def __init__(self, static_server_data: dict, logger: logging.Logger):
|
|
||||||
# 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 = {}
|
||||||
@@ -75,7 +62,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():
|
||||||
setattr(self, key, value)
|
setattr(self, key, value)
|
||||||
self.non_hintable_names = collections.defaultdict(frozenset, self.non_hintable_names)
|
|
||||||
|
|
||||||
def listen_to_db_commands(self):
|
def listen_to_db_commands(self):
|
||||||
cmdprocessor = DBCommandProcessor(self)
|
cmdprocessor = DBCommandProcessor(self)
|
||||||
@@ -99,21 +85,7 @@ class WebHostContext(Context):
|
|||||||
else:
|
else:
|
||||||
self.port = get_random_port()
|
self.port = get_random_port()
|
||||||
|
|
||||||
multidata = self.decompress(room.seed.multidata)
|
return self._load(self.decompress(room.seed.multidata), True)
|
||||||
game_data_packages = {}
|
|
||||||
for game in list(multidata.get("datapackage", {})):
|
|
||||||
game_data = multidata["datapackage"][game]
|
|
||||||
if "checksum" in game_data:
|
|
||||||
if self.gamespackage.get(game, {}).get("checksum") == game_data["checksum"]:
|
|
||||||
# non-custom. remove from multidata
|
|
||||||
# games package could be dropped from static data once all rooms embed data package
|
|
||||||
del multidata["datapackage"][game]
|
|
||||||
else:
|
|
||||||
row = GameDataPackage.get(checksum=game_data["checksum"])
|
|
||||||
if row: # None if rolled on >= 0.3.9 but uploaded to <= 0.3.8. multidata should be complete
|
|
||||||
game_data_packages[game] = Utils.restricted_loads(row.data)
|
|
||||||
|
|
||||||
return self._load(multidata, game_data_packages, True)
|
|
||||||
|
|
||||||
@db_session
|
@db_session
|
||||||
def init_save(self, enabled: bool = True):
|
def init_save(self, enabled: bool = True):
|
||||||
@@ -131,7 +103,7 @@ class WebHostContext(Context):
|
|||||||
room.multisave = pickle.dumps(self.get_save())
|
room.multisave = pickle.dumps(self.get_save())
|
||||||
# saving only occurs on activity, so we can "abuse" this information to mark this as last_activity
|
# saving only occurs on activity, so we can "abuse" this information to mark this as last_activity
|
||||||
if not exit_save: # we don't want to count a shutdown as activity, which would restart the server again
|
if not exit_save: # we don't want to count a shutdown as activity, which would restart the server again
|
||||||
room.last_activity = datetime.datetime.utcnow()
|
room.last_activity = datetime.utcnow()
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def get_save(self) -> dict:
|
def get_save(self) -> dict:
|
||||||
@@ -148,132 +120,62 @@ def get_random_port():
|
|||||||
def get_static_server_data() -> dict:
|
def get_static_server_data() -> dict:
|
||||||
import worlds
|
import worlds
|
||||||
data = {
|
data = {
|
||||||
|
"forced_auto_forfeits": {},
|
||||||
"non_hintable_names": {},
|
"non_hintable_names": {},
|
||||||
"gamespackage": worlds.network_data_package["games"],
|
"gamespackage": worlds.network_data_package["games"],
|
||||||
"item_name_groups": {world_name: world.item_name_groups for world_name, world in
|
"item_name_groups": {world_name: world.item_name_groups for world_name, world in
|
||||||
worlds.AutoWorldRegister.world_types.items()},
|
worlds.AutoWorldRegister.world_types.items()},
|
||||||
"location_name_groups": {world_name: world.location_name_groups for world_name, world in
|
|
||||||
worlds.AutoWorldRegister.world_types.items()},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for world_name, world in worlds.AutoWorldRegister.world_types.items():
|
for world_name, world in worlds.AutoWorldRegister.world_types.items():
|
||||||
|
data["forced_auto_forfeits"][world_name] = world.forced_auto_forfeit
|
||||||
data["non_hintable_names"][world_name] = world.hint_blacklist
|
data["non_hintable_names"][world_name] = world.hint_blacklist
|
||||||
|
|
||||||
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],
|
|
||||||
host: str, rooms_to_run: multiprocessing.Queue, rooms_shutting_down: multiprocessing.Queue):
|
|
||||||
Utils.init_logging(name)
|
|
||||||
try:
|
|
||||||
import resource
|
|
||||||
except ModuleNotFoundError:
|
|
||||||
pass # unix only module
|
|
||||||
else:
|
|
||||||
# Each Server is another file handle, so request as many as we can from the system
|
|
||||||
file_limit = resource.getrlimit(resource.RLIMIT_NOFILE)[1]
|
|
||||||
# set soft limit to hard limit
|
|
||||||
resource.setrlimit(resource.RLIMIT_NOFILE, (file_limit, file_limit))
|
|
||||||
del resource, file_limit
|
|
||||||
|
|
||||||
# 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()
|
||||||
|
|
||||||
import gc
|
|
||||||
ssl_context = load_server_cert(cert_file, cert_key_file) if cert_file else None
|
|
||||||
del cert_file, cert_key_file, ponyconfig
|
|
||||||
gc.collect() # free intermediate objects used during setup
|
|
||||||
|
|
||||||
loop = asyncio.get_event_loop()
|
|
||||||
|
|
||||||
async def start_room(room_id):
|
|
||||||
try:
|
try:
|
||||||
logger = set_up_logging(room_id)
|
ctx.server = websockets.serve(functools.partial(server, ctx=ctx), ctx.host, ctx.port, ping_timeout=None,
|
||||||
ctx = WebHostContext(static_server_data, logger)
|
ping_interval=None)
|
||||||
ctx.load(room_id)
|
|
||||||
ctx.init_save()
|
|
||||||
try:
|
|
||||||
ctx.server = websockets.serve(functools.partial(server, ctx=ctx), ctx.host, ctx.port, ssl=ssl_context)
|
|
||||||
|
|
||||||
await ctx.server
|
await ctx.server
|
||||||
except OSError: # likely port in use
|
except Exception: # likely port in use - in windows this is OSError, but I didn't check the others
|
||||||
ctx.server = websockets.serve(functools.partial(server, ctx=ctx), ctx.host, 0, ssl=ssl_context)
|
ctx.server = websockets.serve(functools.partial(server, ctx=ctx), ctx.host, 0, ping_timeout=None,
|
||||||
|
ping_interval=None)
|
||||||
|
|
||||||
await ctx.server
|
await ctx.server
|
||||||
port = 0
|
port = 0
|
||||||
for wssocket in ctx.server.ws_server.sockets:
|
for wssocket in ctx.server.ws_server.sockets:
|
||||||
socketname = wssocket.getsockname()
|
socketname = wssocket.getsockname()
|
||||||
if wssocket.family == socket.AF_INET6:
|
if wssocket.family == socket.AF_INET6:
|
||||||
# Prefer IPv4, as most users seem to not have working ipv6 support
|
logging.info(f'Hosting game at [{get_public_ipv6()}]:{socketname[1]}')
|
||||||
if not port:
|
# Prefer IPv4, as most users seem to not have working ipv6 support
|
||||||
port = socketname[1]
|
if not port:
|
||||||
elif wssocket.family == socket.AF_INET:
|
|
||||||
port = socketname[1]
|
port = socketname[1]
|
||||||
if port:
|
elif wssocket.family == socket.AF_INET:
|
||||||
ctx.logger.info(f'Hosting game at {host}:{port}')
|
logging.info(f'Hosting game at {get_public_ipv4()}:{socketname[1]}')
|
||||||
with db_session:
|
port = socketname[1]
|
||||||
room = Room.get(id=ctx.room_id)
|
if port:
|
||||||
room.last_port = port
|
|
||||||
else:
|
|
||||||
ctx.logger.exception("Could not determine port. Likely hosting failure.")
|
|
||||||
with db_session:
|
with db_session:
|
||||||
ctx.auto_shutdown = Room.get(id=room_id).timeout
|
room = Room.get(id=ctx.room_id)
|
||||||
ctx.shutdown_task = asyncio.create_task(auto_shutdown(ctx, []))
|
room.last_port = port
|
||||||
await ctx.shutdown_task
|
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")
|
||||||
|
|
||||||
# ensure auto launch is on the same page in regard to room activity.
|
from .autolauncher import Locker
|
||||||
with db_session:
|
with Locker(room_id):
|
||||||
room: Room = Room.get(id=ctx.room_id)
|
asyncio.run(main())
|
||||||
room.last_activity = datetime.datetime.utcnow() - datetime.timedelta(seconds=room.timeout + 60)
|
|
||||||
|
|
||||||
except (KeyboardInterrupt, SystemExit):
|
|
||||||
with db_session:
|
|
||||||
room = Room.get(id=room_id)
|
|
||||||
# ensure the Room does not spin up again on its own, minute of safety buffer
|
|
||||||
room.last_activity = datetime.datetime.utcnow() - datetime.timedelta(minutes=1, seconds=room.timeout)
|
|
||||||
except Exception:
|
|
||||||
with db_session:
|
|
||||||
room = Room.get(id=room_id)
|
|
||||||
room.last_port = -1
|
|
||||||
# ensure the Room does not spin up again on its own, minute of safety buffer
|
|
||||||
room.last_activity = datetime.datetime.utcnow() - datetime.timedelta(minutes=1, seconds=room.timeout)
|
|
||||||
raise
|
|
||||||
finally:
|
|
||||||
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()
|
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
import json
|
|
||||||
import zipfile
|
import zipfile
|
||||||
|
import json
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
|
|
||||||
from flask import send_file, Response, render_template
|
from flask import send_file, Response, render_template
|
||||||
from pony.orm import select
|
from pony.orm import select
|
||||||
|
|
||||||
from worlds.Files import AutoPatchRegister
|
from Patch import update_patch_data, preferred_endings, AutoPatchRegister
|
||||||
from . import app, cache
|
from WebHostLib import app, Slot, Room, Seed, cache
|
||||||
from .models import Slot, Room, Seed
|
|
||||||
|
|
||||||
|
|
||||||
@app.route("/dl_patch/<suuid:room_id>/<int:patch_id>")
|
@app.route("/dl_patch/<suuid:room_id>/<int:patch_id>")
|
||||||
@@ -26,23 +25,25 @@ def download_patch(room_id, patch_id):
|
|||||||
with zipfile.ZipFile(filelike, "a") as zf:
|
with zipfile.ZipFile(filelike, "a") as zf:
|
||||||
with zf.open("archipelago.json", "r") as f:
|
with zf.open("archipelago.json", "r") as f:
|
||||||
manifest = json.load(f)
|
manifest = json.load(f)
|
||||||
manifest["server"] = f"{app.config['HOST_ADDRESS']}:{last_port}" if last_port else None
|
manifest["server"] = f"{app.config['PATCH_TARGET']}:{last_port}" if last_port else None
|
||||||
with zipfile.ZipFile(new_file, "w") as new_zip:
|
with zipfile.ZipFile(new_file, "w") as new_zip:
|
||||||
for file in zf.infolist():
|
for file in zf.infolist():
|
||||||
if file.filename == "archipelago.json":
|
if file.filename == "archipelago.json":
|
||||||
new_zip.writestr("archipelago.json", json.dumps(manifest))
|
new_zip.writestr("archipelago.json", json.dumps(manifest))
|
||||||
else:
|
else:
|
||||||
new_zip.writestr(file.filename, zf.read(file), file.compress_type, 9)
|
new_zip.writestr(file.filename, zf.read(file), file.compress_type, 9)
|
||||||
if "patch_file_ending" in manifest:
|
|
||||||
patch_file_ending = manifest["patch_file_ending"]
|
|
||||||
else:
|
|
||||||
patch_file_ending = AutoPatchRegister.patch_types[patch.game].patch_file_ending
|
|
||||||
fname = f"P{patch.player_id}_{patch.player_name}_{app.jinja_env.filters['suuid'](room_id)}" \
|
fname = f"P{patch.player_id}_{patch.player_name}_{app.jinja_env.filters['suuid'](room_id)}" \
|
||||||
f"{patch_file_ending}"
|
f"{AutoPatchRegister.patch_types[patch.game].patch_file_ending}"
|
||||||
new_file.seek(0)
|
new_file.seek(0)
|
||||||
return send_file(new_file, as_attachment=True, download_name=fname)
|
return send_file(new_file, as_attachment=True, download_name=fname)
|
||||||
else:
|
else:
|
||||||
return "Old Patch file, no longer compatible."
|
patch_data = update_patch_data(patch.data, server=f"{app.config['PATCH_TARGET']}:{last_port}")
|
||||||
|
patch_data = BytesIO(patch_data)
|
||||||
|
|
||||||
|
fname = f"P{patch.player_id}_{patch.player_name}_{app.jinja_env.filters['suuid'](room_id)}." \
|
||||||
|
f"{preferred_endings[patch.game]}"
|
||||||
|
return send_file(patch_data, as_attachment=True, download_name=fname)
|
||||||
|
|
||||||
|
|
||||||
@app.route("/dl_spoiler/<suuid:seed_id>")
|
@app.route("/dl_spoiler/<suuid:seed_id>")
|
||||||
@@ -64,7 +65,7 @@ def download_slot_file(room_id, player_id: int):
|
|||||||
if slot_data.game == "Minecraft":
|
if slot_data.game == "Minecraft":
|
||||||
from worlds.minecraft import mc_update_output
|
from worlds.minecraft import mc_update_output
|
||||||
fname = f"AP_{app.jinja_env.filters['suuid'](room_id)}_P{slot_data.player_id}_{slot_data.player_name}.apmc"
|
fname = f"AP_{app.jinja_env.filters['suuid'](room_id)}_P{slot_data.player_id}_{slot_data.player_name}.apmc"
|
||||||
data = mc_update_output(slot_data.data, server=app.config['HOST_ADDRESS'], port=room.last_port)
|
data = mc_update_output(slot_data.data, server=app.config['PATCH_TARGET'], port=room.last_port)
|
||||||
return send_file(io.BytesIO(data), as_attachment=True, download_name=fname)
|
return send_file(io.BytesIO(data), as_attachment=True, download_name=fname)
|
||||||
elif slot_data.game == "Factorio":
|
elif slot_data.game == "Factorio":
|
||||||
with zipfile.ZipFile(io.BytesIO(slot_data.data)) as zf:
|
with zipfile.ZipFile(io.BytesIO(slot_data.data)) as zf:
|
||||||
@@ -72,26 +73,13 @@ def download_slot_file(room_id, player_id: int):
|
|||||||
if name.endswith("info.json"):
|
if name.endswith("info.json"):
|
||||||
fname = name.rsplit("/", 1)[0] + ".zip"
|
fname = name.rsplit("/", 1)[0] + ".zip"
|
||||||
elif slot_data.game == "Ocarina of Time":
|
elif slot_data.game == "Ocarina of Time":
|
||||||
stream = io.BytesIO(slot_data.data)
|
fname = f"AP_{app.jinja_env.filters['suuid'](room_id)}_P{slot_data.player_id}_{slot_data.player_name}.apz5"
|
||||||
if zipfile.is_zipfile(stream):
|
|
||||||
with zipfile.ZipFile(stream) as zf:
|
|
||||||
for name in zf.namelist():
|
|
||||||
if name.endswith(".zpf"):
|
|
||||||
fname = name.rsplit(".", 1)[0] + ".apz5"
|
|
||||||
else: # pre-ootr-7.0 support
|
|
||||||
fname = f"AP_{app.jinja_env.filters['suuid'](room_id)}_P{slot_data.player_id}_{slot_data.player_name}.apz5"
|
|
||||||
elif slot_data.game == "VVVVVV":
|
elif slot_data.game == "VVVVVV":
|
||||||
fname = f"AP_{app.jinja_env.filters['suuid'](room_id)}_SP.apv6"
|
fname = f"AP_{app.jinja_env.filters['suuid'](room_id)}_SP.apv6"
|
||||||
elif slot_data.game == "Zillion":
|
|
||||||
fname = f"AP_{app.jinja_env.filters['suuid'](room_id)}_SP.apzl"
|
|
||||||
elif slot_data.game == "Super Mario 64":
|
elif slot_data.game == "Super Mario 64":
|
||||||
fname = f"AP_{app.jinja_env.filters['suuid'](room_id)}_SP.apsm64ex"
|
fname = f"AP_{app.jinja_env.filters['suuid'](room_id)}_SP.apsm64ex"
|
||||||
elif slot_data.game == "Dark Souls III":
|
elif slot_data.game == "Dark Souls III":
|
||||||
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":
|
|
||||||
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,28 +1,27 @@
|
|||||||
import concurrent.futures
|
|
||||||
import json
|
|
||||||
import os
|
import os
|
||||||
import pickle
|
|
||||||
import random
|
|
||||||
import tempfile
|
import tempfile
|
||||||
|
import random
|
||||||
|
import json
|
||||||
import zipfile
|
import zipfile
|
||||||
from collections import Counter
|
from collections import Counter
|
||||||
from typing import Any, Dict, List, Optional, Union
|
from typing import Dict, Optional, Any
|
||||||
|
|
||||||
from flask import flash, redirect, render_template, request, session, url_for
|
|
||||||
from pony.orm import commit, db_session
|
|
||||||
|
|
||||||
from BaseClasses import get_seed, seeddigits
|
|
||||||
from Generate import PlandoOptions, handle_name
|
|
||||||
from Main import main as ERmain
|
|
||||||
from Utils import __version__
|
from Utils import __version__
|
||||||
from WebHostLib import app
|
|
||||||
|
from flask import request, flash, redirect, url_for, session, render_template
|
||||||
|
|
||||||
from worlds.alttp.EntranceRandomizer import parse_arguments
|
from worlds.alttp.EntranceRandomizer import parse_arguments
|
||||||
|
from Main import main as ERmain
|
||||||
|
from BaseClasses import seeddigits, get_seed
|
||||||
|
from Generate import handle_name, PlandoSettings
|
||||||
|
import pickle
|
||||||
|
|
||||||
|
from .models import Generation, STATE_ERROR, STATE_QUEUED, commit, db_session, Seed, UUID
|
||||||
|
from WebHostLib import app
|
||||||
from .check import get_yaml_data, roll_options
|
from .check import get_yaml_data, roll_options
|
||||||
from .models import Generation, STATE_ERROR, STATE_QUEUED, Seed, UUID
|
|
||||||
from .upload import upload_zip_to_db
|
from .upload import upload_zip_to_db
|
||||||
|
|
||||||
|
|
||||||
def get_meta(options_source: dict, race: bool = False) -> Dict[str, Union[List[str], Dict[str, Any]]]:
|
def get_meta(options_source: dict) -> dict:
|
||||||
plando_options = {
|
plando_options = {
|
||||||
options_source.get("plando_bosses", ""),
|
options_source.get("plando_bosses", ""),
|
||||||
options_source.get("plando_items", ""),
|
options_source.get("plando_items", ""),
|
||||||
@@ -33,27 +32,13 @@ def get_meta(options_source: dict, race: bool = False) -> Dict[str, Union[List[s
|
|||||||
|
|
||||||
server_options = {
|
server_options = {
|
||||||
"hint_cost": int(options_source.get("hint_cost", 10)),
|
"hint_cost": int(options_source.get("hint_cost", 10)),
|
||||||
"release_mode": options_source.get("release_mode", "goal"),
|
"forfeit_mode": options_source.get("forfeit_mode", "goal"),
|
||||||
"remaining_mode": options_source.get("remaining_mode", "disabled"),
|
"remaining_mode": options_source.get("remaining_mode", "disabled"),
|
||||||
"collect_mode": options_source.get("collect_mode", "disabled"),
|
"collect_mode": options_source.get("collect_mode", "disabled"),
|
||||||
"item_cheat": bool(int(options_source.get("item_cheat", 1))),
|
"item_cheat": bool(int(options_source.get("item_cheat", 1))),
|
||||||
"server_password": options_source.get("server_password", None),
|
"server_password": options_source.get("server_password", None),
|
||||||
}
|
}
|
||||||
generator_options = {
|
return {"server_options": server_options, "plando_options": list(plando_options)}
|
||||||
"spoiler": int(options_source.get("spoiler", 0)),
|
|
||||||
"race": race
|
|
||||||
}
|
|
||||||
|
|
||||||
if race:
|
|
||||||
server_options["item_cheat"] = False
|
|
||||||
server_options["remaining_mode"] = "disabled"
|
|
||||||
generator_options["spoiler"] = 0
|
|
||||||
|
|
||||||
return {
|
|
||||||
"server_options": server_options,
|
|
||||||
"plando_options": list(plando_options),
|
|
||||||
"generator_options": generator_options,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@app.route('/generate', methods=['GET', 'POST'])
|
@app.route('/generate', methods=['GET', 'POST'])
|
||||||
@@ -64,13 +49,18 @@ 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 type(options) == str:
|
||||||
flash(options)
|
flash(options)
|
||||||
else:
|
else:
|
||||||
meta = get_meta(request.form, race)
|
meta = get_meta(request.form)
|
||||||
results, gen_options = roll_options(options, set(meta["plando_options"]))
|
meta["race"] = race
|
||||||
|
results, gen_options = roll_options(options, meta["plando_options"])
|
||||||
|
|
||||||
|
if race:
|
||||||
|
meta["server_options"]["item_cheat"] = False
|
||||||
|
meta["server_options"]["remaining_mode"] = "disabled"
|
||||||
|
|
||||||
if any(type(result) == str for result in results.values()):
|
if any(type(result) == str for result in results.values()):
|
||||||
return render_template("checkResult.html", results=results)
|
return render_template("checkResult.html", results=results)
|
||||||
@@ -101,14 +91,14 @@ def generate(race=False):
|
|||||||
return render_template("generate.html", race=race, version=__version__)
|
return render_template("generate.html", race=race, version=__version__)
|
||||||
|
|
||||||
|
|
||||||
def gen_game(gen_options: dict, meta: Optional[Dict[str, Any]] = None, owner=None, sid=None):
|
def gen_game(gen_options, meta: Optional[Dict[str, Any]] = None, owner=None, sid=None):
|
||||||
if not meta:
|
if not meta:
|
||||||
meta: Dict[str, Any] = {}
|
meta: Dict[str, Any] = {}
|
||||||
|
|
||||||
meta.setdefault("server_options", {}).setdefault("hint_cost", 10)
|
meta.setdefault("server_options", {}).setdefault("hint_cost", 10)
|
||||||
race = meta.setdefault("generator_options", {}).setdefault("race", False)
|
race = meta.setdefault("race", False)
|
||||||
|
|
||||||
def task():
|
try:
|
||||||
target = tempfile.TemporaryDirectory()
|
target = tempfile.TemporaryDirectory()
|
||||||
playercount = len(gen_options)
|
playercount = len(gen_options)
|
||||||
seed = get_seed()
|
seed = get_seed()
|
||||||
@@ -123,15 +113,13 @@ def gen_game(gen_options: dict, meta: Optional[Dict[str, Any]] = None, owner=Non
|
|||||||
erargs = parse_arguments(['--multi', str(playercount)])
|
erargs = parse_arguments(['--multi', str(playercount)])
|
||||||
erargs.seed = seed
|
erargs.seed = seed
|
||||||
erargs.name = {x: "" for x in range(1, playercount + 1)} # only so it can be overwritten in mystery
|
erargs.name = {x: "" for x in range(1, playercount + 1)} # only so it can be overwritten in mystery
|
||||||
erargs.spoiler = meta["generator_options"].get("spoiler", 0)
|
erargs.spoiler = 0 if race else 2
|
||||||
erargs.race = race
|
erargs.race = race
|
||||||
erargs.outputname = seedname
|
erargs.outputname = seedname
|
||||||
erargs.outputpath = target.name
|
erargs.outputpath = target.name
|
||||||
erargs.teams = 1
|
erargs.teams = 1
|
||||||
erargs.plando_options = PlandoOptions.from_set(meta.setdefault("plando_options",
|
erargs.plando_options = PlandoSettings.from_set(meta.setdefault("plando_options",
|
||||||
{"bosses", "items", "connections", "texts"}))
|
{"bosses", "items", "connections", "texts"}))
|
||||||
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):
|
||||||
@@ -150,23 +138,6 @@ def gen_game(gen_options: dict, meta: Optional[Dict[str, Any]] = None, owner=Non
|
|||||||
ERmain(erargs, seed, baked_server_options=meta["server_options"])
|
ERmain(erargs, seed, baked_server_options=meta["server_options"])
|
||||||
|
|
||||||
return upload_to_db(target.name, sid, owner, race)
|
return upload_to_db(target.name, sid, owner, race)
|
||||||
thread_pool = concurrent.futures.ThreadPoolExecutor(max_workers=1)
|
|
||||||
thread = thread_pool.submit(task)
|
|
||||||
|
|
||||||
try:
|
|
||||||
return thread.result(app.config["JOB_TIME"])
|
|
||||||
except concurrent.futures.TimeoutError as e:
|
|
||||||
if sid:
|
|
||||||
with db_session:
|
|
||||||
gen = Generation.get(id=sid)
|
|
||||||
if gen is not None:
|
|
||||||
gen.state = STATE_ERROR
|
|
||||||
meta = json.loads(gen.meta)
|
|
||||||
meta["error"] = (
|
|
||||||
"Allowed time for Generation exceeded, please consider generating locally instead. " +
|
|
||||||
e.__class__.__name__ + ": " + str(e))
|
|
||||||
gen.meta = json.dumps(meta)
|
|
||||||
commit()
|
|
||||||
except BaseException as e:
|
except BaseException as e:
|
||||||
if sid:
|
if sid:
|
||||||
with db_session:
|
with db_session:
|
||||||
|
|||||||
@@ -1,11 +1,7 @@
|
|||||||
from datetime import timedelta, datetime
|
|
||||||
|
|
||||||
from flask import render_template
|
from flask import render_template
|
||||||
from pony.orm import count
|
|
||||||
|
|
||||||
from WebHostLib import app, cache
|
from WebHostLib import app, cache
|
||||||
from .models import Room, Seed
|
from .models import *
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
@app.route('/', methods=['GET', 'POST'])
|
@app.route('/', methods=['GET', 'POST'])
|
||||||
@cache.cached(timeout=300) # cache has to appear under app route for caching to work
|
@cache.cached(timeout=300) # cache has to appear under app route for caching to work
|
||||||
|
|||||||
@@ -1,51 +0,0 @@
|
|||||||
import os
|
|
||||||
import sys
|
|
||||||
|
|
||||||
|
|
||||||
class CommonLocker:
|
|
||||||
"""Uses a file lock to signal that something is already running"""
|
|
||||||
lock_folder = "file_locks"
|
|
||||||
|
|
||||||
def __init__(self, lockname: str, folder=None):
|
|
||||||
if folder:
|
|
||||||
self.lock_folder = folder
|
|
||||||
os.makedirs(self.lock_folder, exist_ok=True)
|
|
||||||
self.lockname = lockname
|
|
||||||
self.lockfile = os.path.join(self.lock_folder, f"{self.lockname}.lck")
|
|
||||||
|
|
||||||
|
|
||||||
class AlreadyRunningException(Exception):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
if sys.platform == 'win32':
|
|
||||||
class Locker(CommonLocker):
|
|
||||||
def __enter__(self):
|
|
||||||
try:
|
|
||||||
if os.path.exists(self.lockfile):
|
|
||||||
os.unlink(self.lockfile)
|
|
||||||
self.fp = os.open(
|
|
||||||
self.lockfile, os.O_CREAT | os.O_EXCL | os.O_RDWR)
|
|
||||||
except OSError as e:
|
|
||||||
raise AlreadyRunningException() from e
|
|
||||||
|
|
||||||
def __exit__(self, _type, value, tb):
|
|
||||||
fp = getattr(self, "fp", None)
|
|
||||||
if fp:
|
|
||||||
os.close(self.fp)
|
|
||||||
os.unlink(self.lockfile)
|
|
||||||
else: # unix
|
|
||||||
import fcntl
|
|
||||||
|
|
||||||
|
|
||||||
class Locker(CommonLocker):
|
|
||||||
def __enter__(self):
|
|
||||||
try:
|
|
||||||
self.fp = open(self.lockfile, "wb")
|
|
||||||
fcntl.flock(self.fp.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
||||||
except OSError as e:
|
|
||||||
raise AlreadyRunningException() from e
|
|
||||||
|
|
||||||
def __exit__(self, _type, value, tb):
|
|
||||||
fcntl.flock(self.fp.fileno(), fcntl.LOCK_UN)
|
|
||||||
self.fp.close()
|
|
||||||
@@ -32,7 +32,7 @@ def update_sprites_lttp():
|
|||||||
|
|
||||||
spriteData = []
|
spriteData = []
|
||||||
|
|
||||||
for file in (file for file in os.listdir(input_dir) if not file.startswith(".")):
|
for file in os.listdir(input_dir):
|
||||||
sprite = Sprite(os.path.join(input_dir, file))
|
sprite = Sprite(os.path.join(input_dir, file))
|
||||||
|
|
||||||
if not sprite.name:
|
if not sprite.name:
|
||||||
|
|||||||
@@ -1,14 +1,12 @@
|
|||||||
import datetime
|
import datetime
|
||||||
import os
|
import os
|
||||||
from typing import List, Dict, Union
|
|
||||||
|
|
||||||
import jinja2.exceptions
|
import jinja2.exceptions
|
||||||
from flask import request, redirect, url_for, render_template, Response, session, abort, send_from_directory
|
from flask import request, redirect, url_for, render_template, Response, session, abort, send_from_directory
|
||||||
from pony.orm import count, commit, db_session
|
|
||||||
|
|
||||||
|
from .models import count, Seed, commit, Room, db_session, Command, UUID, uuid4
|
||||||
from worlds.AutoWorld import AutoWorldRegister
|
from worlds.AutoWorld import AutoWorldRegister
|
||||||
from . import app, cache
|
from . import app, cache
|
||||||
from .models import Seed, Room, Command, UUID, uuid4
|
|
||||||
|
|
||||||
|
|
||||||
def get_world_theme(game_name: str):
|
def get_world_theme(game_name: str):
|
||||||
@@ -32,21 +30,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 +62,25 @@ 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():
|
||||||
|
worlds = {}
|
||||||
|
for game, world in AutoWorldRegister.world_types.items():
|
||||||
|
if not world.hidden:
|
||||||
|
worlds[game] = world
|
||||||
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)
|
||||||
|
|
||||||
@@ -112,11 +118,7 @@ def display_log(room: UUID):
|
|||||||
if room is None:
|
if room is None:
|
||||||
return abort(404)
|
return abort(404)
|
||||||
if room.owner == session["_id"]:
|
if room.owner == session["_id"]:
|
||||||
file_path = os.path.join("logs", str(room.id) + ".txt")
|
return Response(_read_log(os.path.join("logs", str(room.id) + ".txt")), mimetype="text/plain;charset=UTF-8")
|
||||||
if os.path.exists(file_path):
|
|
||||||
return Response(_read_log(file_path), mimetype="text/plain;charset=UTF-8")
|
|
||||||
return "Log File does not exist."
|
|
||||||
|
|
||||||
return "Access Denied", 403
|
return "Access Denied", 403
|
||||||
|
|
||||||
|
|
||||||
@@ -131,7 +133,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,13 +145,13 @@ 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')
|
||||||
|
|
||||||
|
|
||||||
@app.route('/discord')
|
@app.route('/discord')
|
||||||
def discord():
|
def discord():
|
||||||
return redirect("https://discord.gg/8Z65BR2")
|
return redirect("https://discord.gg/archipelago")
|
||||||
|
|
||||||
|
|
||||||
@app.route('/datapackage')
|
@app.route('/datapackage')
|
||||||
@@ -164,11 +165,9 @@ 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 = []
|
||||||
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
|
available_games.append(game)
|
||||||
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,6 +1,6 @@
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from uuid import UUID, uuid4
|
from uuid import UUID, uuid4
|
||||||
from pony.orm import Database, PrimaryKey, Required, Set, Optional, buffer, LongStr
|
from pony.orm import *
|
||||||
|
|
||||||
db = Database()
|
db = Database()
|
||||||
|
|
||||||
@@ -21,7 +21,7 @@ class Slot(db.Entity):
|
|||||||
class Room(db.Entity):
|
class Room(db.Entity):
|
||||||
id = PrimaryKey(UUID, default=uuid4)
|
id = PrimaryKey(UUID, default=uuid4)
|
||||||
last_activity = Required(datetime, default=lambda: datetime.utcnow(), index=True)
|
last_activity = Required(datetime, default=lambda: datetime.utcnow(), index=True)
|
||||||
creation_time = Required(datetime, default=lambda: datetime.utcnow(), index=True) # index used by landing page
|
creation_time = Required(datetime, default=lambda: datetime.utcnow())
|
||||||
owner = Required(UUID, index=True)
|
owner = Required(UUID, index=True)
|
||||||
commands = Set('Command')
|
commands = Set('Command')
|
||||||
seed = Required('Seed', index=True)
|
seed = Required('Seed', index=True)
|
||||||
@@ -29,7 +29,6 @@ class Room(db.Entity):
|
|||||||
show_spoiler = Required(int, default=0) # 0 -> never, 1 -> after completion, -> 2 always
|
show_spoiler = Required(int, default=0) # 0 -> never, 1 -> after completion, -> 2 always
|
||||||
timeout = Required(int, default=lambda: 2 * 60 * 60) # seconds since last activity to shutdown
|
timeout = Required(int, default=lambda: 2 * 60 * 60) # seconds since last activity to shutdown
|
||||||
tracker = Optional(UUID, index=True)
|
tracker = Optional(UUID, index=True)
|
||||||
# Port special value -1 means the server errored out. Another attempt can be made with a page refresh
|
|
||||||
last_port = Optional(int, default=lambda: 0)
|
last_port = Optional(int, default=lambda: 0)
|
||||||
|
|
||||||
|
|
||||||
@@ -38,7 +37,7 @@ class Seed(db.Entity):
|
|||||||
rooms = Set(Room)
|
rooms = Set(Room)
|
||||||
multidata = Required(bytes, lazy=True)
|
multidata = Required(bytes, lazy=True)
|
||||||
owner = Required(UUID, index=True)
|
owner = Required(UUID, index=True)
|
||||||
creation_time = Required(datetime, default=lambda: datetime.utcnow(), index=True) # index used by landing page
|
creation_time = Required(datetime, default=lambda: datetime.utcnow())
|
||||||
slots = Set(Slot)
|
slots = Set(Slot)
|
||||||
spoiler = Optional(LongStr, lazy=True)
|
spoiler = Optional(LongStr, lazy=True)
|
||||||
meta = Required(LongStr, default=lambda: "{\"race\": false}") # additional meta information/tags
|
meta = Required(LongStr, default=lambda: "{\"race\": false}") # additional meta information/tags
|
||||||
@@ -56,8 +55,3 @@ class Generation(db.Entity):
|
|||||||
options = Required(buffer, lazy=True)
|
options = Required(buffer, lazy=True)
|
||||||
meta = Required(LongStr, default=lambda: "{\"race\": false}")
|
meta = Required(LongStr, default=lambda: "{\"race\": false}")
|
||||||
state = Required(int, default=0, index=True)
|
state = Required(int, default=0, index=True)
|
||||||
|
|
||||||
|
|
||||||
class GameDataPackage(db.Entity):
|
|
||||||
checksum = PrimaryKey(str)
|
|
||||||
data = Required(bytes)
|
|
||||||
|
|||||||
@@ -1,253 +1,171 @@
|
|||||||
import collections.abc
|
import logging
|
||||||
import os
|
import os
|
||||||
|
from Utils import __version__
|
||||||
|
from jinja2 import Template
|
||||||
import yaml
|
import yaml
|
||||||
import requests
|
|
||||||
import json
|
import json
|
||||||
import flask
|
import typing
|
||||||
from urllib.parse import urlparse
|
|
||||||
|
|
||||||
import Options
|
|
||||||
from Options import Visibility
|
|
||||||
from flask import redirect, render_template, request, Response
|
|
||||||
from worlds.AutoWorld import AutoWorldRegister
|
from worlds.AutoWorld import AutoWorldRegister
|
||||||
from Utils import local_path
|
import Options
|
||||||
from textwrap import dedent
|
|
||||||
from . import app, cache
|
target_folder = os.path.join("WebHostLib", "static", "generated")
|
||||||
|
|
||||||
|
handled_in_js = {"start_inventory", "local_items", "non_local_items", "start_hints", "start_location_hints",
|
||||||
|
"exclude_locations"}
|
||||||
|
|
||||||
|
|
||||||
def create():
|
def create():
|
||||||
target_folder = local_path("WebHostLib", "static", "generated")
|
os.makedirs(os.path.join(target_folder, 'configs'), exist_ok=True)
|
||||||
yaml_folder = os.path.join(target_folder, "configs")
|
|
||||||
|
|
||||||
Options.generate_yaml_templates(yaml_folder)
|
def dictify_range(option: typing.Union[Options.Range, Options.SpecialRange]):
|
||||||
|
data = {}
|
||||||
|
special = getattr(option, "special_range_cutoff", None)
|
||||||
|
if special is not None:
|
||||||
|
data[special] = 0
|
||||||
|
data.update({
|
||||||
|
option.range_start: 0,
|
||||||
|
option.range_end: 0,
|
||||||
|
"random": 0, "random-low": 0, "random-high": 0,
|
||||||
|
option.default: 50
|
||||||
|
})
|
||||||
|
notes = {
|
||||||
|
special: "minimum value without special meaning",
|
||||||
|
option.range_start: "minimum value",
|
||||||
|
option.range_end: "maximum value"
|
||||||
|
}
|
||||||
|
|
||||||
|
for name, number in getattr(option, "special_range_names", {}).items():
|
||||||
|
if number in data:
|
||||||
|
data[name] = data[number]
|
||||||
|
del data[number]
|
||||||
|
else:
|
||||||
|
data[name] = 0
|
||||||
|
|
||||||
def get_world_theme(game_name: str):
|
return data, notes
|
||||||
if game_name in AutoWorldRegister.world_types:
|
|
||||||
return AutoWorldRegister.world_types[game_name].web.theme
|
|
||||||
return 'grass'
|
|
||||||
|
|
||||||
|
def default_converter(default_value):
|
||||||
|
if isinstance(default_value, (set, frozenset)):
|
||||||
|
return list(default_value)
|
||||||
|
return default_value
|
||||||
|
|
||||||
def render_options_page(template: str, world_name: str, is_complex: bool = False):
|
weighted_settings = {
|
||||||
world = AutoWorldRegister.world_types[world_name]
|
"baseOptions": {
|
||||||
if world.hidden or world.web.options_page is False:
|
"description": "Generated by https://archipelago.gg/",
|
||||||
return redirect("games")
|
"name": "Player",
|
||||||
|
"game": {},
|
||||||
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 not is_complex and option.visibility < Visibility.simple_ui:
|
|
||||||
continue
|
|
||||||
|
|
||||||
if is_complex and option.visibility < Visibility.complex_ui:
|
|
||||||
continue
|
|
||||||
|
|
||||||
grouped_options[option_groups.get(option, "Game Options")][option_name] = option
|
|
||||||
|
|
||||||
return render_template(
|
|
||||||
template,
|
|
||||||
world_name=world_name,
|
|
||||||
world=world,
|
|
||||||
option_groups=grouped_options,
|
|
||||||
issubclass=issubclass,
|
|
||||||
Options=Options,
|
|
||||||
theme=get_world_theme(world_name),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def generate_game(player_name: str, formatted_options: dict):
|
|
||||||
payload = {
|
|
||||||
"race": 0,
|
|
||||||
"hint_cost": 10,
|
|
||||||
"forfeit_mode": "auto",
|
|
||||||
"remaining_mode": "disabled",
|
|
||||||
"collect_mode": "goal",
|
|
||||||
"weights": {
|
|
||||||
player_name: formatted_options,
|
|
||||||
},
|
},
|
||||||
|
"games": {},
|
||||||
}
|
}
|
||||||
|
|
||||||
url = urlparse(request.base_url)
|
for game_name, world in AutoWorldRegister.world_types.items():
|
||||||
port_string = f":{url.port}" if url.port else ""
|
|
||||||
r = requests.post(f"{url.scheme}://{url.hostname}{port_string}/api/generate", json=payload)
|
|
||||||
if 200 <= r.status_code <= 299:
|
|
||||||
response_data = r.json()
|
|
||||||
return redirect(response_data["url"])
|
|
||||||
else:
|
|
||||||
return r.text
|
|
||||||
|
|
||||||
|
all_options = {**Options.per_game_common_options, **world.option_definitions}
|
||||||
|
res = Template(open(os.path.join("WebHostLib", "templates", "options.yaml")).read()).render(
|
||||||
|
options=all_options,
|
||||||
|
__version__=__version__, game=game_name, yaml_dump=yaml.dump,
|
||||||
|
dictify_range=dictify_range, default_converter=default_converter,
|
||||||
|
)
|
||||||
|
|
||||||
def send_yaml(player_name: str, formatted_options: dict):
|
with open(os.path.join(target_folder, 'configs', game_name + ".yaml"), "w") as f:
|
||||||
response = Response(yaml.dump(formatted_options, sort_keys=False))
|
f.write(res)
|
||||||
response.headers["Content-Type"] = "text/yaml"
|
|
||||||
response.headers["Content-Disposition"] = f"attachment; filename={player_name}.yaml"
|
|
||||||
return response
|
|
||||||
|
|
||||||
|
# Generate JSON files for player-settings pages
|
||||||
@app.template_filter("dedent")
|
player_settings = {
|
||||||
def filter_dedent(text: str):
|
"baseOptions": {
|
||||||
return dedent(text).strip("\n ")
|
"description": "Generated by https://archipelago.gg/",
|
||||||
|
"game": game_name,
|
||||||
|
"name": "Player",
|
||||||
@app.template_test("ordered")
|
},
|
||||||
def test_ordered(obj):
|
|
||||||
return isinstance(obj, collections.abc.Sequence)
|
|
||||||
|
|
||||||
|
|
||||||
@app.route("/games/<string:game>/option-presets", methods=["GET"])
|
|
||||||
@cache.cached()
|
|
||||||
def option_presets(game: str) -> Response:
|
|
||||||
world = AutoWorldRegister.world_types[game]
|
|
||||||
presets = {}
|
|
||||||
|
|
||||||
if world.web.options_presets:
|
|
||||||
presets = presets | world.web.options_presets
|
|
||||||
|
|
||||||
class SetEncoder(json.JSONEncoder):
|
|
||||||
def default(self, obj):
|
|
||||||
from collections.abc import Set
|
|
||||||
if isinstance(obj, Set):
|
|
||||||
return list(obj)
|
|
||||||
return json.JSONEncoder.default(self, obj)
|
|
||||||
|
|
||||||
json_data = json.dumps(presets, cls=SetEncoder)
|
|
||||||
response = flask.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:
|
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 option.options:
|
||||||
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": option.__doc__ if option.__doc__ else "Please document me!",
|
||||||
|
"defaultValue": None,
|
||||||
|
"options": []
|
||||||
|
}
|
||||||
|
|
||||||
|
for sub_option_id, sub_option_name in option.name_lookup.items():
|
||||||
|
this_option["options"].append({
|
||||||
|
"name": option.get_option_name(sub_option_id),
|
||||||
|
"value": sub_option_name,
|
||||||
|
})
|
||||||
|
|
||||||
# Player options pages
|
if sub_option_id == option.default:
|
||||||
@app.route("/games/<string:game>/player-options")
|
this_option["defaultValue"] = sub_option_name
|
||||||
@cache.cached()
|
|
||||||
def player_options(game: str):
|
|
||||||
return render_options_page("playerOptions/playerOptions.html", game, is_complex=False)
|
|
||||||
|
|
||||||
|
this_option["options"].append({
|
||||||
|
"name": "Random",
|
||||||
|
"value": "random",
|
||||||
|
})
|
||||||
|
|
||||||
|
if option.default == "random":
|
||||||
|
this_option["defaultValue"] = "random"
|
||||||
|
|
||||||
|
elif issubclass(option, Options.Range):
|
||||||
|
game_options[option_name] = {
|
||||||
|
"type": "range",
|
||||||
|
"displayName": option.display_name if hasattr(option, "display_name") else option_name,
|
||||||
|
"description": option.__doc__ if option.__doc__ else "Please document me!",
|
||||||
|
"defaultValue": option.default if hasattr(
|
||||||
|
option, "default") and option.default != "random" else option.range_start,
|
||||||
|
"min": option.range_start,
|
||||||
|
"max": option.range_end,
|
||||||
|
}
|
||||||
|
|
||||||
|
if issubclass(option, Options.SpecialRange):
|
||||||
|
game_options[option_name]["type"] = 'special_range'
|
||||||
|
game_options[option_name]["value_names"] = {}
|
||||||
|
for key, val in option.special_range_names.items():
|
||||||
|
game_options[option_name]["value_names"][key] = val
|
||||||
|
|
||||||
|
elif getattr(option, "verify_item_name", False):
|
||||||
|
game_options[option_name] = {
|
||||||
|
"type": "items-list",
|
||||||
|
"displayName": option.display_name if hasattr(option, "display_name") else option_name,
|
||||||
|
"description": option.__doc__ if option.__doc__ else "Please document me!",
|
||||||
|
}
|
||||||
|
|
||||||
|
elif getattr(option, "verify_location_name", False):
|
||||||
|
game_options[option_name] = {
|
||||||
|
"type": "locations-list",
|
||||||
|
"displayName": option.display_name if hasattr(option, "display_name") else option_name,
|
||||||
|
"description": option.__doc__ if option.__doc__ else "Please document me!",
|
||||||
|
}
|
||||||
|
|
||||||
|
elif issubclass(option, Options.OptionList) or issubclass(option, Options.OptionSet):
|
||||||
|
if option.valid_keys:
|
||||||
|
game_options[option_name] = {
|
||||||
|
"type": "custom-list",
|
||||||
|
"displayName": option.display_name if hasattr(option, "display_name") else option_name,
|
||||||
|
"description": option.__doc__ if option.__doc__ else "Please document me!",
|
||||||
|
"options": list(option.valid_keys),
|
||||||
|
}
|
||||||
|
|
||||||
# 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:
|
else:
|
||||||
options[key] = val
|
logging.debug(f"{option} not exported to Web Settings.")
|
||||||
|
|
||||||
# Detect and build ItemDict options from their name pattern
|
player_settings["gameOptions"] = game_options
|
||||||
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
|
os.makedirs(os.path.join(target_folder, 'player-settings'), exist_ok=True)
|
||||||
for key, val in options.copy().items():
|
|
||||||
if key.startswith("random-"):
|
|
||||||
options[key.removeprefix("random-")] = "random"
|
|
||||||
del options[key]
|
|
||||||
|
|
||||||
# Error checking
|
with open(os.path.join(target_folder, 'player-settings', game_name + ".json"), "w") as f:
|
||||||
if not options["name"]:
|
json.dump(player_settings, f, indent=2, separators=(',', ': '))
|
||||||
return "Player name is required."
|
|
||||||
|
|
||||||
# Remove POST data irrelevant to YAML
|
if not world.hidden and world.web.settings_page is True:
|
||||||
preset_name = 'default'
|
weighted_settings["baseOptions"]["game"][game_name] = 0
|
||||||
if "intent-generate" in options:
|
weighted_settings["games"][game_name] = {}
|
||||||
intent_generate = True
|
weighted_settings["games"][game_name]["gameSettings"] = game_options
|
||||||
del options["intent-generate"]
|
weighted_settings["games"][game_name]["gameItems"] = tuple(world.item_names)
|
||||||
if "intent-export" in options:
|
weighted_settings["games"][game_name]["gameLocations"] = tuple(world.location_names)
|
||||||
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
|
with open(os.path.join(target_folder, 'weighted-settings.json'), "w") as f:
|
||||||
player_name = options["name"]
|
json.dump(weighted_settings, f, indent=2, separators=(',', ': '))
|
||||||
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:
|
|
||||||
return generate_game(player_name, formatted_options)
|
|
||||||
|
|
||||||
else:
|
|
||||||
return send_yaml(player_name, formatted_options)
|
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
flask>=3.0.0
|
flask>=2.1.3
|
||||||
pony>=0.7.17
|
pony>=0.7.16
|
||||||
waitress>=2.1.2
|
waitress>=2.1.1
|
||||||
Flask-Caching>=2.1.0
|
Flask-Caching>=2.0.1
|
||||||
Flask-Compress>=1.14
|
Flask-Compress>=1.12
|
||||||
Flask-Limiter>=3.5.0
|
Flask-Limiter>=2.5.0
|
||||||
bokeh>=3.1.1; python_version <= '3.8'
|
bokeh>=2.4.3
|
||||||
bokeh>=3.3.2; python_version >= '3.9'
|
|
||||||
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)
|
|
||||||
@@ -4,7 +4,6 @@ window.addEventListener('load', () => {
|
|||||||
"ordering": true,
|
"ordering": true,
|
||||||
"info": false,
|
"info": false,
|
||||||
"dom": "t",
|
"dom": "t",
|
||||||
"stateSave": true,
|
|
||||||
});
|
});
|
||||||
console.log(tables);
|
console.log(tables);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,40 +0,0 @@
|
|||||||
window.addEventListener('load', () => {
|
|
||||||
// Mobile menu handling
|
|
||||||
const menuButton = document.getElementById('base-header-mobile-menu-button');
|
|
||||||
const mobileMenu = document.getElementById('base-header-mobile-menu');
|
|
||||||
|
|
||||||
menuButton.addEventListener('click', (evt) => {
|
|
||||||
evt.preventDefault();
|
|
||||||
evt.stopPropagation();
|
|
||||||
|
|
||||||
if (!mobileMenu.style.display || mobileMenu.style.display === 'none') {
|
|
||||||
return mobileMenu.style.display = 'flex';
|
|
||||||
}
|
|
||||||
|
|
||||||
mobileMenu.style.display = 'none';
|
|
||||||
});
|
|
||||||
|
|
||||||
window.addEventListener('resize', () => {
|
|
||||||
mobileMenu.style.display = 'none';
|
|
||||||
});
|
|
||||||
|
|
||||||
// Popover handling
|
|
||||||
const popoverText = document.getElementById('base-header-popover-text');
|
|
||||||
const popoverMenu = document.getElementById('base-header-popover-menu');
|
|
||||||
|
|
||||||
popoverText.addEventListener('click', (evt) => {
|
|
||||||
evt.preventDefault();
|
|
||||||
evt.stopPropagation();
|
|
||||||
|
|
||||||
if (!popoverMenu.style.display || popoverMenu.style.display === 'none') {
|
|
||||||
return popoverMenu.style.display = 'flex';
|
|
||||||
}
|
|
||||||
|
|
||||||
popoverMenu.style.display = 'none';
|
|
||||||
});
|
|
||||||
|
|
||||||
document.body.addEventListener('click', () => {
|
|
||||||
mobileMenu.style.display = 'none';
|
|
||||||
popoverMenu.style.display = 'none';
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
window.addEventListener('load', () => {
|
|
||||||
// Reload tracker every 60 seconds
|
|
||||||
const url = window.location;
|
|
||||||
setInterval(() => {
|
|
||||||
const ajax = new XMLHttpRequest();
|
|
||||||
ajax.onreadystatechange = () => {
|
|
||||||
if (ajax.readyState !== 4) { return; }
|
|
||||||
|
|
||||||
// Create a fake DOM using the returned HTML
|
|
||||||
const domParser = new DOMParser();
|
|
||||||
const fakeDOM = domParser.parseFromString(ajax.responseText, 'text/html');
|
|
||||||
|
|
||||||
// Update item tracker
|
|
||||||
document.getElementById('inventory-table').innerHTML = fakeDOM.getElementById('inventory-table').innerHTML;
|
|
||||||
// Update only counters in the location-table
|
|
||||||
let counters = document.getElementsByClassName('counter');
|
|
||||||
const fakeCounters = fakeDOM.getElementsByClassName('counter');
|
|
||||||
for (let i = 0; i < counters.length; i++) {
|
|
||||||
counters[i].innerHTML = fakeCounters[i].innerHTML;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
ajax.open('GET', url);
|
|
||||||
ajax.send();
|
|
||||||
}, 60000)
|
|
||||||
|
|
||||||
// Collapsible advancement sections
|
|
||||||
const categories = document.getElementsByClassName("location-category");
|
|
||||||
for (let i = 0; i < categories.length; i++) {
|
|
||||||
let hide_id = categories[i].id.split('-')[0];
|
|
||||||
if (hide_id == 'Total') {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
categories[i].addEventListener('click', function() {
|
|
||||||
// Toggle the advancement list
|
|
||||||
document.getElementById(hide_id).classList.toggle("hide");
|
|
||||||
// Change text of the header
|
|
||||||
const tab_header = document.getElementById(hide_id+'-header').children[0];
|
|
||||||
const orig_text = tab_header.innerHTML;
|
|
||||||
let new_text;
|
|
||||||
if (orig_text.includes("▼")) {
|
|
||||||
new_text = orig_text.replace("▼", "▲");
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
new_text = orig_text.replace("▲", "▼");
|
|
||||||
}
|
|
||||||
tab_header.innerHTML = new_text;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -26,22 +26,24 @@ window.addEventListener('load', () => {
|
|||||||
adjustHeaderWidth();
|
adjustHeaderWidth();
|
||||||
|
|
||||||
// Reset the id of all header divs to something nicer
|
// Reset the id of all header divs to something nicer
|
||||||
for (const header of document.querySelectorAll('h1, h2, h3, h4, h5, h6')) {
|
const headers = Array.from(document.querySelectorAll('h1, h2, h3, h4, h5, h6'));
|
||||||
const headerId = header.innerText.replace(/\s+/g, '-').toLowerCase();
|
const scrollTargetIndex = window.location.href.search(/#[A-z0-9-_]*$/);
|
||||||
header.setAttribute('id', headerId);
|
for (let i=0; i < headers.length; i++){
|
||||||
header.addEventListener('click', () => {
|
const headerId = headers[i].innerText.replace(/[ ]/g,'-').toLowerCase()
|
||||||
window.location.hash = `#${headerId}`;
|
headers[i].setAttribute('id', headerId);
|
||||||
header.scrollIntoView();
|
headers[i].addEventListener('click', () =>
|
||||||
});
|
window.location.href = window.location.href.substring(0, scrollTargetIndex) + `#${headerId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Manually scroll the user to the appropriate header if anchor navigation is used
|
// Manually scroll the user to the appropriate header if anchor navigation is used
|
||||||
document.fonts.ready.finally(() => {
|
if (scrollTargetIndex > -1) {
|
||||||
if (window.location.hash) {
|
try{
|
||||||
const scrollTarget = document.getElementById(window.location.hash.substring(1));
|
const scrollTarget = window.location.href.substring(scrollTargetIndex + 1);
|
||||||
scrollTarget?.scrollIntoView();
|
document.getElementById(scrollTarget).scrollIntoView({ behavior: "smooth" });
|
||||||
|
} catch(error) {
|
||||||
|
console.error(error);
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
}).catch((error) => {
|
}).catch((error) => {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
tutorialWrapper.innerHTML =
|
tutorialWrapper.innerHTML =
|
||||||
|
|||||||
@@ -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 seeds. In each player's
|
||||||
|
game, they may find items which belong to the other player. If player A finds an item which belongs to player B, the
|
||||||
|
item will be sent to player B's world over the internet.
|
||||||
|
|
||||||
|
This creates a cooperative experience during multi-world games, requiring players to rely upon each other to complete
|
||||||
|
their game.
|
||||||
|
|
||||||
|
## What happens if a person has to leave early?
|
||||||
|
|
||||||
|
If a player must leave early, they can use Archipelago's forfeit system. When a player forfeits their game, all the
|
||||||
|
items in that game which belong to other players are sent out automatically, so other players can continue to play.
|
||||||
|
|
||||||
|
## What does multi-game mean?
|
||||||
|
|
||||||
|
While a multi-world game traditionally requires all players to be playing the same game, a multi-game multi-world allows
|
||||||
|
players to randomize any of a number of supported games, and send items between them. This allows players of different
|
||||||
|
games to interact with one another in a single multiplayer environment.
|
||||||
|
|
||||||
|
## Can I generate a single-player game with Archipelago?
|
||||||
|
|
||||||
|
Yes. All our supported games can be generated as single-player experiences, and so long as you download the software,
|
||||||
|
the website is not required to generate them.
|
||||||
|
|
||||||
|
## How do I get started?
|
||||||
|
|
||||||
|
If you are ready to start randomizing games, or want to start playing your favorite randomizer with others, please join
|
||||||
|
our discord server at the [Archipelago Discord](https://discord.gg/archipelago). There are always people ready to answer
|
||||||
|
any questions you might have.
|
||||||
|
|
||||||
|
## What are some common terms I should know?
|
||||||
|
|
||||||
|
As randomizers and multiworld randomizers have been around for a while now there are quite a lot of common terms
|
||||||
|
and jargon that is used in conjunction by the communities surrounding them. For a lot of the terms that are more common
|
||||||
|
to Archipelago and its specific systems please see the [Glossary](/glossary/en).
|
||||||
|
|
||||||
## I want to add a game to the Archipelago randomizer. How do I do that?
|
## 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.
|
||||||
|
|||||||
@@ -26,22 +26,24 @@ window.addEventListener('load', () => {
|
|||||||
adjustHeaderWidth();
|
adjustHeaderWidth();
|
||||||
|
|
||||||
// Reset the id of all header divs to something nicer
|
// Reset the id of all header divs to something nicer
|
||||||
for (const header of document.querySelectorAll('h1, h2, h3, h4, h5, h6')) {
|
const headers = Array.from(document.querySelectorAll('h1, h2, h3, h4, h5, h6'));
|
||||||
const headerId = header.innerText.replace(/\s+/g, '-').toLowerCase();
|
const scrollTargetIndex = window.location.href.search(/#[A-z0-9-_]*$/);
|
||||||
header.setAttribute('id', headerId);
|
for (let i=0; i < headers.length; i++){
|
||||||
header.addEventListener('click', () => {
|
const headerId = headers[i].innerText.replace(/[ ]/g,'-').toLowerCase()
|
||||||
window.location.hash = `#${headerId}`;
|
headers[i].setAttribute('id', headerId);
|
||||||
header.scrollIntoView();
|
headers[i].addEventListener('click', () =>
|
||||||
});
|
window.location.href = window.location.href.substring(0, scrollTargetIndex) + `#${headerId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Manually scroll the user to the appropriate header if anchor navigation is used
|
// Manually scroll the user to the appropriate header if anchor navigation is used
|
||||||
document.fonts.ready.finally(() => {
|
if (scrollTargetIndex > -1) {
|
||||||
if (window.location.hash) {
|
try{
|
||||||
const scrollTarget = document.getElementById(window.location.hash.substring(1));
|
const scrollTarget = window.location.href.substring(scrollTargetIndex + 1);
|
||||||
scrollTarget?.scrollIntoView();
|
document.getElementById(scrollTarget).scrollIntoView({ behavior: "smooth" });
|
||||||
|
} catch(error) {
|
||||||
|
console.error(error);
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
}).catch((error) => {
|
}).catch((error) => {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
gameInfo.innerHTML =
|
gameInfo.innerHTML =
|
||||||
|
|||||||
@@ -26,22 +26,24 @@ window.addEventListener('load', () => {
|
|||||||
adjustHeaderWidth();
|
adjustHeaderWidth();
|
||||||
|
|
||||||
// Reset the id of all header divs to something nicer
|
// Reset the id of all header divs to something nicer
|
||||||
for (const header of document.querySelectorAll('h1, h2, h3, h4, h5, h6')) {
|
const headers = Array.from(document.querySelectorAll('h1, h2, h3, h4, h5, h6'));
|
||||||
const headerId = header.innerText.replace(/\s+/g, '-').toLowerCase();
|
const scrollTargetIndex = window.location.href.search(/#[A-z0-9-_]*$/);
|
||||||
header.setAttribute('id', headerId);
|
for (let i=0; i < headers.length; i++){
|
||||||
header.addEventListener('click', () => {
|
const headerId = headers[i].innerText.replace(/[ ]/g,'-').toLowerCase()
|
||||||
window.location.hash = `#${headerId}`;
|
headers[i].setAttribute('id', headerId);
|
||||||
header.scrollIntoView();
|
headers[i].addEventListener('click', () =>
|
||||||
});
|
window.location.href = window.location.href.substring(0, scrollTargetIndex) + `#${headerId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Manually scroll the user to the appropriate header if anchor navigation is used
|
// Manually scroll the user to the appropriate header if anchor navigation is used
|
||||||
document.fonts.ready.finally(() => {
|
if (scrollTargetIndex > -1) {
|
||||||
if (window.location.hash) {
|
try{
|
||||||
const scrollTarget = document.getElementById(window.location.hash.substring(1));
|
const scrollTarget = window.location.href.substring(scrollTargetIndex + 1);
|
||||||
scrollTarget?.scrollIntoView();
|
document.getElementById(scrollTarget).scrollIntoView({ behavior: "smooth" });
|
||||||
|
} catch(error) {
|
||||||
|
console.error(error);
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
}).catch((error) => {
|
}).catch((error) => {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
tutorialWrapper.innerHTML =
|
tutorialWrapper.innerHTML =
|
||||||
|
|||||||
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)
|
||||||
|
});
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
window.addEventListener('load', () => {
|
|
||||||
$(".table-wrapper").scrollsync({
|
|
||||||
y_sync: true,
|
|
||||||
x_sync: true
|
|
||||||
});
|
|
||||||
});
|
|
||||||
312
WebHostLib/static/assets/player-settings.js
Normal file
312
WebHostLib/static/assets/player-settings.js
Normal file
@@ -0,0 +1,312 @@
|
|||||||
|
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.setAttribute('for', setting);
|
||||||
|
label.setAttribute('data-tooltip', settings[setting].description);
|
||||||
|
label.innerText = `${settings[setting].displayName}:`;
|
||||||
|
tdl.appendChild(label);
|
||||||
|
tr.appendChild(tdl);
|
||||||
|
|
||||||
|
// td Right
|
||||||
|
const tdr = document.createElement('td');
|
||||||
|
let element = null;
|
||||||
|
|
||||||
|
switch(settings[setting].type){
|
||||||
|
case 'select':
|
||||||
|
element = document.createElement('div');
|
||||||
|
element.classList.add('select-container');
|
||||||
|
let select = document.createElement('select');
|
||||||
|
select.setAttribute('id', setting);
|
||||||
|
select.setAttribute('data-key', setting);
|
||||||
|
if (romOpts) { select.setAttribute('data-romOpt', '1'); }
|
||||||
|
settings[setting].options.forEach((opt) => {
|
||||||
|
const option = document.createElement('option');
|
||||||
|
option.setAttribute('value', opt.value);
|
||||||
|
option.innerText = opt.name;
|
||||||
|
if ((isNaN(currentSettings[gameName][setting]) &&
|
||||||
|
(parseInt(opt.value, 10) === parseInt(currentSettings[gameName][setting]))) ||
|
||||||
|
(opt.value === currentSettings[gameName][setting]))
|
||||||
|
{
|
||||||
|
option.selected = true;
|
||||||
|
}
|
||||||
|
select.appendChild(option);
|
||||||
|
});
|
||||||
|
select.addEventListener('change', (event) => updateGameSetting(event));
|
||||||
|
element.appendChild(select);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'range':
|
||||||
|
element = document.createElement('div');
|
||||||
|
element.classList.add('range-container');
|
||||||
|
|
||||||
|
let range = document.createElement('input');
|
||||||
|
range.setAttribute('type', 'range');
|
||||||
|
range.setAttribute('data-key', setting);
|
||||||
|
range.setAttribute('min', settings[setting].min);
|
||||||
|
range.setAttribute('max', settings[setting].max);
|
||||||
|
range.value = currentSettings[gameName][setting];
|
||||||
|
range.addEventListener('change', (event) => {
|
||||||
|
document.getElementById(`${setting}-value`).innerText = event.target.value;
|
||||||
|
updateGameSetting(event);
|
||||||
|
});
|
||||||
|
element.appendChild(range);
|
||||||
|
|
||||||
|
let rangeVal = document.createElement('span');
|
||||||
|
rangeVal.classList.add('range-value');
|
||||||
|
rangeVal.setAttribute('id', `${setting}-value`);
|
||||||
|
rangeVal.innerText = currentSettings[gameName][setting] ?? settings[setting].defaultValue;
|
||||||
|
element.appendChild(rangeVal);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'special_range':
|
||||||
|
element = document.createElement('div');
|
||||||
|
element.classList.add('special-range-container');
|
||||||
|
|
||||||
|
// Build the select element
|
||||||
|
let specialRangeSelect = document.createElement('select');
|
||||||
|
specialRangeSelect.setAttribute('data-key', setting);
|
||||||
|
Object.keys(settings[setting].value_names).forEach((presetName) => {
|
||||||
|
let presetOption = document.createElement('option');
|
||||||
|
presetOption.innerText = presetName;
|
||||||
|
presetOption.value = settings[setting].value_names[presetName];
|
||||||
|
specialRangeSelect.appendChild(presetOption);
|
||||||
|
});
|
||||||
|
let customOption = document.createElement('option');
|
||||||
|
customOption.innerText = 'Custom';
|
||||||
|
customOption.value = 'custom';
|
||||||
|
customOption.selected = true;
|
||||||
|
specialRangeSelect.appendChild(customOption);
|
||||||
|
if (Object.values(settings[setting].value_names).includes(Number(currentSettings[gameName][setting]))) {
|
||||||
|
specialRangeSelect.value = Number(currentSettings[gameName][setting]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build range element
|
||||||
|
let specialRangeWrapper = document.createElement('div');
|
||||||
|
specialRangeWrapper.classList.add('special-range-wrapper');
|
||||||
|
let specialRange = document.createElement('input');
|
||||||
|
specialRange.setAttribute('type', 'range');
|
||||||
|
specialRange.setAttribute('data-key', setting);
|
||||||
|
specialRange.setAttribute('min', settings[setting].min);
|
||||||
|
specialRange.setAttribute('max', settings[setting].max);
|
||||||
|
specialRange.value = currentSettings[gameName][setting];
|
||||||
|
|
||||||
|
// Build rage value element
|
||||||
|
let specialRangeVal = document.createElement('span');
|
||||||
|
specialRangeVal.classList.add('range-value');
|
||||||
|
specialRangeVal.setAttribute('id', `${setting}-value`);
|
||||||
|
specialRangeVal.innerText = currentSettings[gameName][setting] ?? settings[setting].defaultValue;
|
||||||
|
|
||||||
|
// Configure select event listener
|
||||||
|
specialRangeSelect.addEventListener('change', (event) => {
|
||||||
|
if (event.target.value === 'custom') { return; }
|
||||||
|
|
||||||
|
// Update range slider
|
||||||
|
specialRange.value = event.target.value;
|
||||||
|
document.getElementById(`${setting}-value`).innerText = event.target.value;
|
||||||
|
updateGameSetting(event);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Configure range event handler
|
||||||
|
specialRange.addEventListener('change', (event) => {
|
||||||
|
// Update select element
|
||||||
|
specialRangeSelect.value =
|
||||||
|
(Object.values(settings[setting].value_names).includes(parseInt(event.target.value))) ?
|
||||||
|
parseInt(event.target.value) : 'custom';
|
||||||
|
document.getElementById(`${setting}-value`).innerText = event.target.value;
|
||||||
|
updateGameSetting(event);
|
||||||
|
});
|
||||||
|
|
||||||
|
element.appendChild(specialRangeSelect);
|
||||||
|
specialRangeWrapper.appendChild(specialRange);
|
||||||
|
specialRangeWrapper.appendChild(specialRangeVal);
|
||||||
|
element.appendChild(specialRangeWrapper);
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
console.error(`Ignoring unknown setting type: ${settings[setting].type} with name ${setting}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
tdr.appendChild(element);
|
||||||
|
tr.appendChild(tdr);
|
||||||
|
tbody.appendChild(tr);
|
||||||
|
});
|
||||||
|
|
||||||
|
table.appendChild(tbody);
|
||||||
|
return table;
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateBaseSetting = (event) => {
|
||||||
|
const options = JSON.parse(localStorage.getItem(gameName));
|
||||||
|
options[event.target.getAttribute('data-key')] = isNaN(event.target.value) ?
|
||||||
|
event.target.value : parseInt(event.target.value);
|
||||||
|
localStorage.setItem(gameName, JSON.stringify(options));
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateGameSetting = (event) => {
|
||||||
|
const options = JSON.parse(localStorage.getItem(gameName));
|
||||||
|
options[gameName][event.target.getAttribute('data-key')] = isNaN(event.target.value) ?
|
||||||
|
event.target.value : parseInt(event.target.value, 10);
|
||||||
|
localStorage.setItem(gameName, JSON.stringify(options));
|
||||||
|
};
|
||||||
|
|
||||||
|
const exportSettings = () => {
|
||||||
|
const settings = JSON.parse(localStorage.getItem(gameName));
|
||||||
|
if (!settings.name || settings.name.toLowerCase() === 'player' || settings.name.trim().length === 0) {
|
||||||
|
return showUserMessage('You must enter a player name!');
|
||||||
|
}
|
||||||
|
const yamlText = jsyaml.safeDump(settings, { noCompatMode: true }).replaceAll(/'(\d+)':/g, (x, y) => `${y}:`);
|
||||||
|
download(`${document.getElementById('player-name').value}.yaml`, yamlText);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Create an anchor and trigger a download of a text file. */
|
||||||
|
const download = (filename, text) => {
|
||||||
|
const downloadLink = document.createElement('a');
|
||||||
|
downloadLink.setAttribute('href','data:text/yaml;charset=utf-8,'+ encodeURIComponent(text))
|
||||||
|
downloadLink.setAttribute('download', filename);
|
||||||
|
downloadLink.style.display = 'none';
|
||||||
|
document.body.appendChild(downloadLink);
|
||||||
|
downloadLink.click();
|
||||||
|
document.body.removeChild(downloadLink);
|
||||||
|
};
|
||||||
|
|
||||||
|
const generateGame = (raceMode = false) => {
|
||||||
|
const settings = JSON.parse(localStorage.getItem(gameName));
|
||||||
|
if (!settings.name || settings.name.toLowerCase() === 'player' || settings.name.trim().length === 0) {
|
||||||
|
return showUserMessage('You must enter a player name!');
|
||||||
|
}
|
||||||
|
|
||||||
|
axios.post('/api/generate', {
|
||||||
|
weights: { player: settings },
|
||||||
|
presetData: { player: settings },
|
||||||
|
playerCount: 1,
|
||||||
|
race: raceMode ? '1' : '0',
|
||||||
|
}).then((response) => {
|
||||||
|
window.location.href = response.data.url;
|
||||||
|
}).catch((error) => {
|
||||||
|
let userMessage = 'Something went wrong and your game could not be generated.';
|
||||||
|
if (error.response.data.text) {
|
||||||
|
userMessage += ' ' + error.response.data.text;
|
||||||
|
}
|
||||||
|
showUserMessage(userMessage);
|
||||||
|
console.error(error);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const showUserMessage = (message) => {
|
||||||
|
const userMessage = document.getElementById('user-message');
|
||||||
|
userMessage.innerText = message;
|
||||||
|
userMessage.classList.add('visible');
|
||||||
|
window.scrollTo(0, 0);
|
||||||
|
userMessage.addEventListener('click', () => {
|
||||||
|
userMessage.classList.remove('visible');
|
||||||
|
userMessage.addEventListener('click', hideUserMessage);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const hideUserMessage = () => {
|
||||||
|
const userMessage = document.getElementById('user-message');
|
||||||
|
userMessage.classList.remove('visible');
|
||||||
|
userMessage.removeEventListener('click', hideUserMessage);
|
||||||
|
};
|
||||||
@@ -1,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';
|
|
||||||
};
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
window.addEventListener('load', () => {
|
|
||||||
// Reload tracker every 15 seconds
|
|
||||||
const url = window.location;
|
|
||||||
setInterval(() => {
|
|
||||||
const ajax = new XMLHttpRequest();
|
|
||||||
ajax.onreadystatechange = () => {
|
|
||||||
if (ajax.readyState !== 4) { return; }
|
|
||||||
|
|
||||||
// Create a fake DOM using the returned HTML
|
|
||||||
const domParser = new DOMParser();
|
|
||||||
const fakeDOM = domParser.parseFromString(ajax.responseText, 'text/html');
|
|
||||||
|
|
||||||
// Update item tracker
|
|
||||||
document.getElementById('inventory-table').innerHTML = fakeDOM.getElementById('inventory-table').innerHTML;
|
|
||||||
// Update only counters in the location-table
|
|
||||||
let counters = document.getElementsByClassName('counter');
|
|
||||||
const fakeCounters = fakeDOM.getElementsByClassName('counter');
|
|
||||||
for (let i = 0; i < counters.length; i++) {
|
|
||||||
counters[i].innerHTML = fakeCounters[i].innerHTML;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
ajax.open('GET', url);
|
|
||||||
ajax.send();
|
|
||||||
}, 15000)
|
|
||||||
|
|
||||||
// Collapsible advancement sections
|
|
||||||
const categories = document.getElementsByClassName("location-category");
|
|
||||||
for (let category of categories) {
|
|
||||||
let hide_id = category.id.split('_')[0];
|
|
||||||
if (hide_id === 'Total') {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
category.addEventListener('click', function() {
|
|
||||||
// Toggle the advancement list
|
|
||||||
document.getElementById(hide_id).classList.toggle("hide");
|
|
||||||
// Change text of the header
|
|
||||||
const tab_header = document.getElementById(hide_id+'_header').children[0];
|
|
||||||
const orig_text = tab_header.innerHTML;
|
|
||||||
let new_text;
|
|
||||||
if (orig_text.includes("▼")) {
|
|
||||||
new_text = orig_text.replace("▼", "▲");
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
new_text = orig_text.replace("▲", "▼");
|
|
||||||
}
|
|
||||||
tab_header.innerHTML = new_text;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
window.addEventListener('load', () => {
|
|
||||||
// Add toggle listener to all elements with .collapse-toggle
|
|
||||||
const toggleButtons = document.querySelectorAll('details');
|
|
||||||
|
|
||||||
// Handle game filter input
|
|
||||||
const gameSearch = document.getElementById('game-search');
|
|
||||||
gameSearch.value = '';
|
|
||||||
gameSearch.addEventListener('input', (evt) => {
|
|
||||||
if (!evt.target.value.trim()) {
|
|
||||||
// If input is empty, display all games as collapsed
|
|
||||||
return toggleButtons.forEach((header) => {
|
|
||||||
header.style.display = null;
|
|
||||||
header.removeAttribute('open');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Loop over all the games
|
|
||||||
toggleButtons.forEach((header) => {
|
|
||||||
// If the game name includes the search string, display the game. If not, hide it
|
|
||||||
if (header.getAttribute('data-game').toLowerCase().includes(evt.target.value.toLowerCase())) {
|
|
||||||
header.style.display = null;
|
|
||||||
header.setAttribute('open', '1');
|
|
||||||
} else {
|
|
||||||
header.style.display = 'none';
|
|
||||||
header.removeAttribute('open');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById('expand-all').addEventListener('click', expandAll);
|
|
||||||
document.getElementById('collapse-all').addEventListener('click', collapseAll);
|
|
||||||
});
|
|
||||||
|
|
||||||
const expandAll = () => {
|
|
||||||
document.querySelectorAll('details').forEach((detail) => {
|
|
||||||
detail.setAttribute('open', '1');
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const collapseAll = () => {
|
|
||||||
document.querySelectorAll('details').forEach((detail) => {
|
|
||||||
detail.removeAttribute('open');
|
|
||||||
});
|
|
||||||
};
|
|
||||||
108
WebHostLib/static/assets/tracker.js
Normal file
108
WebHostLib/static/assets/tracker.js
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
const adjustTableHeight = () => {
|
||||||
|
const tablesContainer = document.getElementById('tables-container');
|
||||||
|
const upperDistance = tablesContainer.getBoundingClientRect().top;
|
||||||
|
|
||||||
|
const containerHeight = window.innerHeight - upperDistance;
|
||||||
|
tablesContainer.style.maxHeight = `calc(${containerHeight}px - 1rem)`;
|
||||||
|
|
||||||
|
const tableWrappers = document.getElementsByClassName('table-wrapper');
|
||||||
|
for(let i=0; i < tableWrappers.length; i++){
|
||||||
|
const maxHeight = (window.innerHeight - upperDistance) / 2;
|
||||||
|
tableWrappers[i].style.maxHeight = `calc(${maxHeight}px - 1rem)`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('load', () => {
|
||||||
|
const tables = $(".table").DataTable({
|
||||||
|
paging: false,
|
||||||
|
info: false,
|
||||||
|
dom: "t",
|
||||||
|
columnDefs: [
|
||||||
|
{
|
||||||
|
targets: 'hours',
|
||||||
|
render: function (data, type, row) {
|
||||||
|
if (type === "sort" || type === 'type') {
|
||||||
|
if (data === "None")
|
||||||
|
return -1;
|
||||||
|
|
||||||
|
return parseInt(data);
|
||||||
|
}
|
||||||
|
if (data === "None")
|
||||||
|
return data;
|
||||||
|
|
||||||
|
let hours = Math.floor(data / 3600);
|
||||||
|
let minutes = Math.floor((data - (hours * 3600)) / 60);
|
||||||
|
|
||||||
|
if (minutes < 10) {minutes = "0"+minutes;}
|
||||||
|
return hours+':'+minutes;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
targets: 'number',
|
||||||
|
render: function (data, type, row) {
|
||||||
|
if (type === "sort" || type === 'type') {
|
||||||
|
return parseFloat(data);
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
targets: 'fraction',
|
||||||
|
render: function (data, type, row) {
|
||||||
|
let splitted = data.split("/", 1);
|
||||||
|
let current = splitted[0]
|
||||||
|
if (type === "sort" || type === 'type') {
|
||||||
|
return parseInt(current);
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
// DO NOT use the scrollX or scrollY options. They cause DataTables to split the thead from
|
||||||
|
// the tbody and render two separate tables.
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('search').addEventListener('keyup', (event) => {
|
||||||
|
tables.search(event.target.value);
|
||||||
|
console.info(tables.search());
|
||||||
|
tables.draw();
|
||||||
|
});
|
||||||
|
|
||||||
|
const update = () => {
|
||||||
|
const target = $("<div></div>");
|
||||||
|
const tracker = document.getElementById('tracker-wrapper').getAttribute('data-tracker');
|
||||||
|
target.load("/tracker/" + tracker, function (response, status) {
|
||||||
|
if (status === "success") {
|
||||||
|
target.find(".table").each(function (i, new_table) {
|
||||||
|
const new_trs = $(new_table).find("tbody>tr");
|
||||||
|
const old_table = tables.eq(i);
|
||||||
|
const topscroll = $(old_table.settings()[0].nScrollBody).scrollTop();
|
||||||
|
const leftscroll = $(old_table.settings()[0].nScrollBody).scrollLeft();
|
||||||
|
old_table.clear();
|
||||||
|
old_table.rows.add(new_trs).draw();
|
||||||
|
$(old_table.settings()[0].nScrollBody).scrollTop(topscroll);
|
||||||
|
$(old_table.settings()[0].nScrollBody).scrollLeft(leftscroll);
|
||||||
|
});
|
||||||
|
$("#multi-stream-link").replaceWith(target.find("#multi-stream-link"));
|
||||||
|
} else {
|
||||||
|
console.log("Failed to connect to Server, in order to update Table Data.");
|
||||||
|
console.log(response);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
setInterval(update, 30000);
|
||||||
|
|
||||||
|
window.addEventListener('resize', () => {
|
||||||
|
adjustTableHeight();
|
||||||
|
tables.draw();
|
||||||
|
});
|
||||||
|
|
||||||
|
$(".table-wrapper").scrollsync({
|
||||||
|
y_sync: true,
|
||||||
|
x_sync: true
|
||||||
|
});
|
||||||
|
|
||||||
|
adjustTableHeight();
|
||||||
|
});
|
||||||
@@ -1,171 +0,0 @@
|
|||||||
const adjustTableHeight = () => {
|
|
||||||
const tablesContainer = document.getElementById('tables-container');
|
|
||||||
if (!tablesContainer)
|
|
||||||
return;
|
|
||||||
const upperDistance = tablesContainer.getBoundingClientRect().top;
|
|
||||||
|
|
||||||
const tableWrappers = document.getElementsByClassName('table-wrapper');
|
|
||||||
for (let i = 0; i < tableWrappers.length; i++) {
|
|
||||||
// Ensure we are starting from maximum size prior to calculation.
|
|
||||||
tableWrappers[i].style.height = null;
|
|
||||||
tableWrappers[i].style.maxHeight = null;
|
|
||||||
|
|
||||||
// Set as a reasonable height, but still allows the user to resize element if they desire.
|
|
||||||
const currentHeight = tableWrappers[i].offsetHeight;
|
|
||||||
const maxHeight = (window.innerHeight - upperDistance) / Math.min(tableWrappers.length, 4);
|
|
||||||
if (currentHeight > maxHeight) {
|
|
||||||
tableWrappers[i].style.height = `calc(${maxHeight}px - 1rem)`;
|
|
||||||
}
|
|
||||||
|
|
||||||
tableWrappers[i].style.maxHeight = `${currentHeight}px`;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Convert an integer number of seconds into a human readable HH:MM format
|
|
||||||
* @param {Number} seconds
|
|
||||||
* @returns {string}
|
|
||||||
*/
|
|
||||||
const secondsToHours = (seconds) => {
|
|
||||||
let hours = Math.floor(seconds / 3600);
|
|
||||||
let minutes = Math.floor((seconds - (hours * 3600)) / 60).toString().padStart(2, '0');
|
|
||||||
return `${hours}:${minutes}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener('load', () => {
|
|
||||||
const tables = $(".table").DataTable({
|
|
||||||
paging: false,
|
|
||||||
info: false,
|
|
||||||
dom: "t",
|
|
||||||
stateSave: true,
|
|
||||||
stateSaveCallback: function(settings, data) {
|
|
||||||
delete data.search;
|
|
||||||
localStorage.setItem(`DataTables_${settings.sInstance}_/tracker`, JSON.stringify(data));
|
|
||||||
},
|
|
||||||
stateLoadCallback: function(settings) {
|
|
||||||
return JSON.parse(localStorage.getItem(`DataTables_${settings.sInstance}_/tracker`));
|
|
||||||
},
|
|
||||||
footerCallback: function(tfoot, data, start, end, display) {
|
|
||||||
if (tfoot) {
|
|
||||||
const activityData = this.api().column('lastActivity:name').data().toArray().filter(x => !isNaN(x));
|
|
||||||
Array.from(tfoot?.children).find(td => td.classList.contains('last-activity')).innerText =
|
|
||||||
(activityData.length) ? secondsToHours(Math.min(...activityData)) : 'None';
|
|
||||||
}
|
|
||||||
},
|
|
||||||
columnDefs: [
|
|
||||||
{
|
|
||||||
targets: 'last-activity',
|
|
||||||
name: 'lastActivity'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
targets: 'hours',
|
|
||||||
render: function (data, type, row) {
|
|
||||||
if (type === "sort" || type === 'type') {
|
|
||||||
if (data === "None")
|
|
||||||
return Number.MAX_VALUE;
|
|
||||||
|
|
||||||
return parseInt(data);
|
|
||||||
}
|
|
||||||
if (data === "None")
|
|
||||||
return data;
|
|
||||||
|
|
||||||
return secondsToHours(data);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
targets: 'number',
|
|
||||||
render: function (data, type, row) {
|
|
||||||
if (type === "sort" || type === 'type') {
|
|
||||||
return parseFloat(data);
|
|
||||||
}
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
targets: 'fraction',
|
|
||||||
render: function (data, type, row) {
|
|
||||||
let splitted = data.split("/", 1);
|
|
||||||
let current = splitted[0]
|
|
||||||
if (type === "sort" || type === 'type') {
|
|
||||||
return parseInt(current);
|
|
||||||
}
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
],
|
|
||||||
|
|
||||||
// DO NOT use the scrollX or scrollY options. They cause DataTables to split the thead from
|
|
||||||
// the tbody and render two separate tables.
|
|
||||||
});
|
|
||||||
|
|
||||||
const searchBox = document.getElementById("search");
|
|
||||||
searchBox.value = tables.search();
|
|
||||||
searchBox.focus();
|
|
||||||
searchBox.select();
|
|
||||||
const doSearch = () => {
|
|
||||||
tables.search(searchBox.value);
|
|
||||||
tables.draw();
|
|
||||||
};
|
|
||||||
searchBox.addEventListener("keyup", doSearch);
|
|
||||||
window.addEventListener("keydown", (event) => {
|
|
||||||
if (!event.ctrlKey && !event.altKey && event.key.length === 1 && document.activeElement !== searchBox) {
|
|
||||||
searchBox.focus();
|
|
||||||
searchBox.select();
|
|
||||||
}
|
|
||||||
if (!event.ctrlKey && !event.altKey && !event.shiftKey && event.key === "Escape") {
|
|
||||||
if (searchBox.value !== "") {
|
|
||||||
searchBox.value = "";
|
|
||||||
doSearch();
|
|
||||||
}
|
|
||||||
searchBox.blur();
|
|
||||||
if (!document.getElementById("tables-container"))
|
|
||||||
window.scroll(0, 0);
|
|
||||||
event.preventDefault();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
const tracker = document.getElementById('tracker-wrapper').getAttribute('data-tracker');
|
|
||||||
const target_second = document.getElementById('tracker-wrapper').getAttribute('data-second') + 3;
|
|
||||||
|
|
||||||
function getSleepTimeSeconds(){
|
|
||||||
// -40 % 60 is -40, which is absolutely wrong and should burn
|
|
||||||
var sleepSeconds = (((target_second - new Date().getSeconds()) % 60) + 60) % 60;
|
|
||||||
return sleepSeconds || 60;
|
|
||||||
}
|
|
||||||
|
|
||||||
const update = () => {
|
|
||||||
const target = $("<div></div>");
|
|
||||||
console.log("Updating Tracker...");
|
|
||||||
target.load(location.href, function (response, status) {
|
|
||||||
if (status === "success") {
|
|
||||||
target.find(".table").each(function (i, new_table) {
|
|
||||||
const new_trs = $(new_table).find("tbody>tr");
|
|
||||||
const footer_tr = $(new_table).find("tfoot>tr");
|
|
||||||
const old_table = tables.eq(i);
|
|
||||||
const topscroll = $(old_table.settings()[0].nScrollBody).scrollTop();
|
|
||||||
const leftscroll = $(old_table.settings()[0].nScrollBody).scrollLeft();
|
|
||||||
old_table.clear();
|
|
||||||
if (footer_tr.length) {
|
|
||||||
$(old_table.table).find("tfoot").html(footer_tr);
|
|
||||||
}
|
|
||||||
old_table.rows.add(new_trs);
|
|
||||||
old_table.draw();
|
|
||||||
$(old_table.settings()[0].nScrollBody).scrollTop(topscroll);
|
|
||||||
$(old_table.settings()[0].nScrollBody).scrollLeft(leftscroll);
|
|
||||||
});
|
|
||||||
$("#multi-stream-link").replaceWith(target.find("#multi-stream-link"));
|
|
||||||
} else {
|
|
||||||
console.log("Failed to connect to Server, in order to update Table Data.");
|
|
||||||
console.log(response);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
setTimeout(update, getSleepTimeSeconds()*1000);
|
|
||||||
}
|
|
||||||
setTimeout(update, getSleepTimeSeconds()*1000);
|
|
||||||
|
|
||||||
window.addEventListener('resize', () => {
|
|
||||||
adjustTableHeight();
|
|
||||||
tables.draw();
|
|
||||||
});
|
|
||||||
|
|
||||||
adjustTableHeight();
|
|
||||||
});
|
|
||||||
@@ -27,28 +27,25 @@ window.addEventListener('load', () => {
|
|||||||
tutorialWrapper.innerHTML += (new showdown.Converter()).makeHtml(results);
|
tutorialWrapper.innerHTML += (new showdown.Converter()).makeHtml(results);
|
||||||
adjustHeaderWidth();
|
adjustHeaderWidth();
|
||||||
|
|
||||||
const title = document.querySelector('h1')
|
|
||||||
if (title) {
|
|
||||||
document.title = title.textContent;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reset the id of all header divs to something nicer
|
// Reset the id of all header divs to something nicer
|
||||||
for (const header of document.querySelectorAll('h1, h2, h3, h4, h5, h6')) {
|
const headers = Array.from(document.querySelectorAll('h1, h2, h3, h4, h5, h6'));
|
||||||
const headerId = header.innerText.replace(/\s+/g, '-').toLowerCase();
|
const scrollTargetIndex = window.location.href.search(/#[A-z0-9-_]*$/);
|
||||||
header.setAttribute('id', headerId);
|
for (let i=0; i < headers.length; i++){
|
||||||
header.addEventListener('click', () => {
|
const headerId = headers[i].innerText.replace(/[ ]/g,'-').toLowerCase()
|
||||||
window.location.hash = `#${headerId}`;
|
headers[i].setAttribute('id', headerId);
|
||||||
header.scrollIntoView();
|
headers[i].addEventListener('click', () =>
|
||||||
});
|
window.location.href = window.location.href.substring(0, scrollTargetIndex) + `#${headerId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Manually scroll the user to the appropriate header if anchor navigation is used
|
// Manually scroll the user to the appropriate header if anchor navigation is used
|
||||||
document.fonts.ready.finally(() => {
|
if (scrollTargetIndex > -1) {
|
||||||
if (window.location.hash) {
|
try{
|
||||||
const scrollTarget = document.getElementById(window.location.hash.substring(1));
|
const scrollTarget = window.location.href.substring(scrollTargetIndex + 1);
|
||||||
scrollTarget?.scrollIntoView();
|
document.getElementById(scrollTarget).scrollIntoView({ behavior: "smooth" });
|
||||||
|
} catch(error) {
|
||||||
|
console.error(error);
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
}).catch((error) => {
|
}).catch((error) => {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
tutorialWrapper.innerHTML =
|
tutorialWrapper.innerHTML =
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ window.addEventListener('load', () => {
|
|||||||
"order": [[ 3, "desc" ]],
|
"order": [[ 3, "desc" ]],
|
||||||
"info": false,
|
"info": false,
|
||||||
"dom": "t",
|
"dom": "t",
|
||||||
"stateSave": true,
|
|
||||||
});
|
});
|
||||||
$("#seeds-table").DataTable({
|
$("#seeds-table").DataTable({
|
||||||
"paging": false,
|
"paging": false,
|
||||||
@@ -14,6 +13,5 @@ window.addEventListener('load', () => {
|
|||||||
"order": [[ 2, "desc" ]],
|
"order": [[ 2, "desc" ]],
|
||||||
"info": false,
|
"info": false,
|
||||||
"dom": "t",
|
"dom": "t",
|
||||||
"stateSave": true,
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
1032
WebHostLib/static/assets/weighted-settings.js
Normal file
1032
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: /
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 5.5 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 9.6 KiB |
@@ -1,30 +0,0 @@
|
|||||||
#player-tracker-wrapper{
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
#inventory-table{
|
|
||||||
padding: 8px 10px 2px 6px;
|
|
||||||
background-color: #42b149;
|
|
||||||
border-radius: 4px;
|
|
||||||
border: 2px solid black;
|
|
||||||
}
|
|
||||||
|
|
||||||
#inventory-table tr.column-headers td {
|
|
||||||
font-size: 1rem;
|
|
||||||
padding: 0 5rem 0 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
#inventory-table td{
|
|
||||||
padding: 0 0.5rem 0.5rem;
|
|
||||||
font-family: LexendDeca-Light, monospace;
|
|
||||||
font-size: 2.5rem;
|
|
||||||
color: #ffffff;
|
|
||||||
}
|
|
||||||
|
|
||||||
#inventory-table td img{
|
|
||||||
vertical-align: middle;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hide {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
@@ -56,3 +56,7 @@
|
|||||||
#file-input{
|
#file-input{
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.interactive{
|
||||||
|
color: #ffef00;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,10 +105,3 @@ h5, h6{
|
|||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
background-color: #ffff00;
|
background-color: #ffff00;
|
||||||
}
|
}
|
||||||
.user-message a{
|
|
||||||
color: #ff7700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.interactive{
|
|
||||||
color: #ffef00;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -55,6 +55,4 @@
|
|||||||
border: 1px solid #2a6c2f;
|
border: 1px solid #2a6c2f;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
color: #000000;
|
color: #000000;
|
||||||
overflow-y: auto;
|
|
||||||
max-height: 400px;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,33 +15,3 @@
|
|||||||
padding-left: 0.5rem;
|
padding-left: 0.5rem;
|
||||||
color: #dfedc6;
|
color: #dfedc6;
|
||||||
}
|
}
|
||||||
@media all and (max-width: 900px) {
|
|
||||||
#island-footer{
|
|
||||||
font-size: 17px;
|
|
||||||
font-size: 2vw;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@media all and (max-width: 768px) {
|
|
||||||
#island-footer{
|
|
||||||
font-size: 15px;
|
|
||||||
font-size: 2vw;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@media all and (max-width: 650px) {
|
|
||||||
#island-footer{
|
|
||||||
font-size: 13px;
|
|
||||||
font-size: 2vw;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@media all and (max-width: 580px) {
|
|
||||||
#island-footer{
|
|
||||||
font-size: 11px;
|
|
||||||
font-size: 2vw;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@media all and (max-width: 512px) {
|
|
||||||
#island-footer{
|
|
||||||
font-size: 9px;
|
|
||||||
font-size: 2vw;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ html{
|
|||||||
margin-right: auto;
|
margin-right: auto;
|
||||||
margin-top: 10px;
|
margin-top: 10px;
|
||||||
height: 140px;
|
height: 140px;
|
||||||
|
z-index: 10;
|
||||||
}
|
}
|
||||||
|
|
||||||
#landing-header h4{
|
#landing-header h4{
|
||||||
@@ -222,7 +223,7 @@ html{
|
|||||||
}
|
}
|
||||||
|
|
||||||
#landing{
|
#landing{
|
||||||
max-width: 700px;
|
width: 700px;
|
||||||
min-height: 280px;
|
min-height: 280px;
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
margin-right: auto;
|
margin-right: auto;
|
||||||
@@ -235,6 +236,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
border-top-left-radius: 4px;
|
border-top-left-radius: 4px;
|
||||||
border-top-right-radius: 4px;
|
border-top-right-radius: 4px;
|
||||||
padding: 3px 3px 10px;
|
padding: 3px 3px 10px;
|
||||||
width: 480px;
|
width: 448px;
|
||||||
background-color: rgb(60, 114, 157);
|
background-color: rgb(60, 114, 157);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,7 +46,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
#location-table{
|
#location-table{
|
||||||
width: 480px;
|
width: 448px;
|
||||||
border-left: 2px solid #000000;
|
border-left: 2px solid #000000;
|
||||||
border-right: 2px solid #000000;
|
border-right: 2px solid #000000;
|
||||||
border-bottom: 2px solid #000000;
|
border-bottom: 2px solid #000000;
|
||||||
@@ -108,7 +108,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
#location-table td:first-child {
|
#location-table td:first-child {
|
||||||
width: 300px;
|
width: 272px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.location-category td:first-child {
|
.location-category td:first-child {
|
||||||
|
|||||||
175
WebHostLib/static/styles/player-settings.css
Normal file
175
WebHostLib/static/styles/player-settings.css
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
html{
|
||||||
|
background-image: url('../static/backgrounds/grass.png');
|
||||||
|
background-repeat: repeat;
|
||||||
|
background-size: 650px 650px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#player-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;
|
||||||
|
}
|
||||||
|
|
||||||
|
#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 .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;
|
||||||
|
}
|
||||||
|
|
||||||
|
#player-settings table .special-range-wrapper input[type=range]{
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
#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: 1000px), all and (orientation: portrait){
|
||||||
|
#player-settings #game-options{
|
||||||
|
justify-content: flex-start;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
#player-settings .left, #player-settings .right{
|
||||||
|
flex-grow: unset;
|
||||||
|
}
|
||||||
|
|
||||||
|
#game-options table label{
|
||||||
|
display: block;
|
||||||
|
min-width: 200px;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user