Compare commits

..

1 Commits

Author SHA1 Message Date
Exempt-Medic
6c5418d708 Include that an empty string is expected 2025-03-11 11:47:21 -04:00
1280 changed files with 51044 additions and 246276 deletions

View File

@@ -1,210 +0,0 @@
.git
.github
.run
docs
test
typings
*Client.py
.idea
.vscode
*_Spoiler.txt
*.bmbp
*.apbp
*.apl2ac
*.apm3
*.apmc
*.apz5
*.aptloz
*.apemerald
*.pyc
*.pyd
*.sfc
*.z64
*.n64
*.nes
*.smc
*.sms
*.gb
*.gbc
*.gba
*.wixobj
*.lck
*.db3
*multidata
*multisave
*.archipelago
*.apsave
*.BIN
*.puml
setups
build
bundle/components.wxs
dist
/prof/
README.html
.vs/
EnemizerCLI/
/Players/
/SNI/
/sni-*/
/appimagetool*
/host.yaml
/options.yaml
/config.yaml
/logs/
_persistent_storage.yaml
mystery_result_*.yaml
*-errors.txt
success.txt
output/
Output Logs/
/factorio/
/Minecraft Forge Server/
/WebHostLib/static/generated
/freeze_requirements.txt
/Archipelago.zip
/setup.ini
/installdelete.iss
/data/user.kv
/datapackage
/custom_worlds
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
*.dll
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
installer.log
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# vim editor
*.swp
# SageMath parsed files
*.sage.py
# Environments
.env
.venv*
env/
venv/
/venv*/
ENV/
env.bak/
venv.bak/
*.code-workspace
shell.nix
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# Cython intermediates
_speedups.c
_speedups.cpp
_speedups.html
# minecraft server stuff
jdk*/
minecraft*/
minecraft_versions.json
!worlds/minecraft/
# pyenv
.python-version
#undertale stuff
/Undertale/
# OS General Files
.DS_Store
.AppleDouble
.LSOverride
Thumbs.db
[Dd]esktop.ini

1
.github/labeler.yml vendored
View File

@@ -21,6 +21,7 @@
- '!data/**'
- '!.run/**'
- '!.github/**'
- '!worlds_disabled/**'
- '!worlds/**'
- '!WebHost.py'
- '!WebHostLib/**'

View File

@@ -2,7 +2,6 @@
"include": [
"../BizHawkClient.py",
"../Patch.py",
"../test/param.py",
"../test/general/test_groups.py",
"../test/general/test_helpers.py",
"../test/general/test_memory.py",
@@ -29,7 +28,7 @@
"reportMissingImports": true,
"reportMissingTypeStubs": true,
"pythonVersion": "3.11",
"pythonVersion": "3.10",
"pythonPlatform": "Windows",
"executionEnvironments": [

View File

@@ -53,7 +53,7 @@ jobs:
- uses: actions/setup-python@v5
if: env.diff != ''
with:
python-version: '3.11'
python-version: '3.10'
- name: "Install dependencies"
if: env.diff != ''
@@ -65,7 +65,7 @@ jobs:
continue-on-error: false
if: env.diff != '' && matrix.task == 'flake8'
run: |
flake8 --count --select=E9,F63,F7,F82 --ignore F824 --show-source --statistics ${{ env.diff }}
flake8 --count --select=E9,F63,F7,F82 --show-source --statistics ${{ env.diff }}
- name: "flake8: Lint modified files"
continue-on-error: true

View File

@@ -9,37 +9,24 @@ on:
- 'setup.py'
- 'requirements.txt'
- '*.iss'
- 'worlds/*/archipelago.json'
pull_request:
paths:
- '.github/workflows/build.yml'
- 'setup.py'
- 'requirements.txt'
- '*.iss'
- 'worlds/*/archipelago.json'
workflow_dispatch:
env:
ENEMIZER_VERSION: 7.1
# NOTE: since appimage/appimagetool and appimage/type2-runtime does not have tags anymore,
# we check the sha256 and require manual intervention if it was updated.
APPIMAGE_FORK: 'PopTracker'
APPIMAGETOOL_VERSION: 'r-2025-11-18'
APPIMAGETOOL_X86_64_HASH: '4577a452b30af2337123fbb383aea154b618e51ad5448c3b62085cbbbfbfd9a2'
APPIMAGE_RUNTIME_VERSION: 'r-2025-11-07'
APPIMAGE_RUNTIME_X86_64_HASH: '27ddd3f78e483fc5f7856e413d7c17092917f8c35bfe3318a0d378aa9435ad17'
permissions: # permissions required for attestation
id-token: 'write'
attestations: 'write'
APPIMAGETOOL_VERSION: 13
jobs:
# build-release-macos: # LF volunteer
build-win: # RCs and releases may still be built and signed by hand
build-win: # RCs will still be built and signed by hand
runs-on: windows-latest
steps:
# - copy code below to release.yml -
- uses: actions/checkout@v4
- name: Install python
uses: actions/setup-python@v5
@@ -78,18 +65,6 @@ jobs:
$contents = Get-ChildItem -Path setups/*.exe -Force -Recurse
$SETUP_NAME=$contents[0].Name
echo "SETUP_NAME=$SETUP_NAME" >> $Env:GITHUB_ENV
# - copy code above to release.yml -
- name: Attest Build
if: ${{ github.event_name == 'workflow_dispatch' }}
uses: actions/attest-build-provenance@v2
with:
subject-path: |
build/exe.*/ArchipelagoLauncher.exe
build/exe.*/ArchipelagoLauncherDebug.exe
build/exe.*/ArchipelagoGenerate.exe
build/exe.*/ArchipelagoServer.exe
dist/${{ env.ZIP_NAME }}
setups/${{ env.SETUP_NAME }}
- name: Check build loads expected worlds
shell: bash
run: |
@@ -106,7 +81,7 @@ jobs:
shell: bash
run: |
cd build/exe*
cp Players/Templates/VVVVVV.yaml Players/
cp Players/Templates/Clique.yaml Players/
timeout 30 ./ArchipelagoGenerate
- name: Store 7z
uses: actions/upload-artifact@v4
@@ -124,8 +99,8 @@ jobs:
if-no-files-found: error
retention-days: 7 # keep for 7 days, should be enough
build-ubuntu2204:
runs-on: ubuntu-22.04
build-ubuntu2004:
runs-on: ubuntu-20.04
steps:
# - copy code below to release.yml -
- uses: actions/checkout@v4
@@ -142,13 +117,10 @@ jobs:
- name: Install build-time dependencies
run: |
echo "PYTHON=python3.12" >> $GITHUB_ENV
wget -nv https://github.com/$APPIMAGE_FORK/appimagetool/releases/download/$APPIMAGETOOL_VERSION/appimagetool-x86_64.AppImage
echo "$APPIMAGETOOL_X86_64_HASH appimagetool-x86_64.AppImage" | sha256sum -c
wget -nv https://github.com/$APPIMAGE_FORK/type2-runtime/releases/download/$APPIMAGE_RUNTIME_VERSION/runtime-x86_64
echo "$APPIMAGE_RUNTIME_X86_64_HASH runtime-x86_64" | sha256sum -c
wget -nv https://github.com/AppImage/AppImageKit/releases/download/$APPIMAGETOOL_VERSION/appimagetool-x86_64.AppImage
chmod a+rx appimagetool-x86_64.AppImage
./appimagetool-x86_64.AppImage --appimage-extract
echo -e '#/bin/sh\n./squashfs-root/AppRun --runtime-file runtime-x86_64 "$@"' > appimagetool
echo -e '#/bin/sh\n./squashfs-root/AppRun "$@"' > appimagetool
chmod a+rx appimagetool
- name: Download run-time dependencies
run: |
@@ -170,16 +142,6 @@ jobs:
echo "APPIMAGE_NAME=$APPIMAGE_NAME" >> $GITHUB_ENV
echo "TAR_NAME=$TAR_NAME" >> $GITHUB_ENV
# - copy code above to release.yml -
- name: Attest Build
if: ${{ github.event_name == 'workflow_dispatch' }}
uses: actions/attest-build-provenance@v2
with:
subject-path: |
build/exe.*/ArchipelagoLauncher
build/exe.*/ArchipelagoGenerate
build/exe.*/ArchipelagoServer
dist/${{ env.APPIMAGE_NAME }}*
dist/${{ env.TAR_NAME }}
- name: Build Again
run: |
source venv/bin/activate
@@ -200,7 +162,7 @@ jobs:
shell: bash
run: |
cd build/exe*
cp Players/Templates/VVVVVV.yaml Players/
cp Players/Templates/Clique.yaml Players/
timeout 30 ./ArchipelagoGenerate
- name: Store AppImage
uses: actions/upload-artifact@v4

View File

@@ -36,9 +36,9 @@ jobs:
steps:
- uses: actions/checkout@v4
- uses: ilammy/msvc-dev-cmd@0b201ec74fa43914dc39ae48a89fd1d8cb592756
- uses: ilammy/msvc-dev-cmd@v1
if: startsWith(matrix.os,'windows')
- uses: Bacondish2023/setup-googletest@49065d1f7a6d21f6134864dd65980fe5dbe06c73
- uses: Bacondish2023/setup-googletest@v1
with:
build-type: 'Release'
- name: Build tests

View File

@@ -1,154 +0,0 @@
name: Build and Publish Docker Images
on:
push:
paths:
- "**"
- "!docs/**"
- "!deploy/**"
- "!setup.py"
- "!.gitignore"
- "!.github/workflows/**"
- ".github/workflows/docker.yml"
branches:
- "main"
tags:
- "v?[0-9]+.[0-9]+.[0-9]*"
workflow_dispatch:
env:
REGISTRY: ghcr.io
jobs:
prepare:
runs-on: ubuntu-latest
outputs:
image-name: ${{ steps.image.outputs.name }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
package-name: ${{ steps.package.outputs.name }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set lowercase image name
id: image
run: |
echo "name=${GITHUB_REPOSITORY,,}" >> $GITHUB_OUTPUT
- name: Set package name
id: package
run: |
echo "name=$(basename ${GITHUB_REPOSITORY,,})" >> $GITHUB_OUTPUT
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ steps.image.outputs.name }}
tags: |
type=ref,event=branch,enable={{is_not_default_branch}}
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=raw,value=nightly,enable={{is_default_branch}}
- name: Compute final tags
id: final-tags
run: |
readarray -t tags <<< "${{ steps.meta.outputs.tags }}"
if [[ "${{ github.ref_type }}" == "tag" ]]; then
tag="${{ github.ref_name }}"
if [[ "$tag" =~ ^v?[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
full_latest="${{ env.REGISTRY }}/${{ steps.image.outputs.name }}:latest"
# Check if latest is already in tags to avoid duplicates
if ! printf '%s\n' "${tags[@]}" | grep -q "^$full_latest$"; then
tags+=("$full_latest")
fi
fi
fi
# Set multiline output
echo "tags<<EOF" >> $GITHUB_OUTPUT
printf '%s\n' "${tags[@]}" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
build:
needs: prepare
runs-on: ${{ matrix.runner }}
permissions:
contents: read
packages: write
strategy:
matrix:
include:
- platform: amd64
runner: ubuntu-latest
suffix: amd64
cache-scope: amd64
- platform: arm64
runner: ubuntu-24.04-arm
suffix: arm64
cache-scope: arm64
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Compute suffixed tags
id: tags
run: |
readarray -t tags <<< "${{ needs.prepare.outputs.tags }}"
suffixed=()
for t in "${tags[@]}"; do
suffixed+=("$t-${{ matrix.suffix }}")
done
echo "tags=$(IFS=','; echo "${suffixed[*]}")" >> $GITHUB_OUTPUT
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: .
file: ./Dockerfile
platforms: linux/${{ matrix.platform }}
push: true
tags: ${{ steps.tags.outputs.tags }}
labels: ${{ needs.prepare.outputs.labels }}
cache-from: type=gha,scope=${{ matrix.cache-scope }}
cache-to: type=gha,mode=max,scope=${{ matrix.cache-scope }}
provenance: false
manifest:
needs: [prepare, build]
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Create and push multi-arch manifest
run: |
readarray -t tag_array <<< "${{ needs.prepare.outputs.tags }}"
for tag in "${tag_array[@]}"; do
docker manifest create "$tag" \
"$tag-amd64" \
"$tag-arm64"
docker manifest push "$tag"
done

View File

@@ -6,12 +6,11 @@ on:
permissions:
contents: read
pull-requests: write
env:
GH_REPO: ${{ github.repository }}
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

View File

@@ -5,22 +5,11 @@ name: Release
on:
push:
tags:
- 'v?[0-9]+.[0-9]+.[0-9]*'
- '*.*.*'
env:
ENEMIZER_VERSION: 7.1
# NOTE: since appimage/appimagetool and appimage/type2-runtime does not have tags anymore,
# we check the sha256 and require manual intervention if it was updated.
APPIMAGE_FORK: 'PopTracker'
APPIMAGETOOL_VERSION: 'r-2025-11-18'
APPIMAGETOOL_X86_64_HASH: '4577a452b30af2337123fbb383aea154b618e51ad5448c3b62085cbbbfbfd9a2'
APPIMAGE_RUNTIME_VERSION: 'r-2025-11-07'
APPIMAGE_RUNTIME_X86_64_HASH: '27ddd3f78e483fc5f7856e413d7c17092917f8c35bfe3318a0d378aa9435ad17'
permissions: # permissions required for attestation
id-token: 'write'
attestations: 'write'
contents: 'write' # additionally required for release
APPIMAGETOOL_VERSION: 13
jobs:
create-release:
@@ -37,79 +26,11 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# build-release-windows: # this is done by hand because of signing
# build-release-macos: # LF volunteer
build-release-win:
runs-on: windows-latest
if: ${{ true }} # change to false to skip if release is built by hand
needs: create-release
steps:
- name: Set env
shell: bash
run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV
# - code below copied from build.yml -
- uses: actions/checkout@v4
- name: Install python
uses: actions/setup-python@v5
with:
python-version: '~3.12.7'
check-latest: true
- name: Download run-time dependencies
run: |
Invoke-WebRequest -Uri https://github.com/Ijwu/Enemizer/releases/download/${Env:ENEMIZER_VERSION}/win-x64.zip -OutFile enemizer.zip
Expand-Archive -Path enemizer.zip -DestinationPath EnemizerCLI -Force
choco install innosetup --version=6.2.2 --allow-downgrade
- name: Build
run: |
python -m pip install --upgrade pip
python setup.py build_exe --yes
if ( $? -eq $false ) {
Write-Error "setup.py failed!"
exit 1
}
$NAME="$(ls build | Select-String -Pattern 'exe')".Split('.',2)[1]
$ZIP_NAME="Archipelago_$NAME.7z"
echo "$NAME -> $ZIP_NAME"
echo "ZIP_NAME=$ZIP_NAME" >> $Env:GITHUB_ENV
New-Item -Path dist -ItemType Directory -Force
cd build
Rename-Item "exe.$NAME" Archipelago
7z a -mx=9 -mhe=on -ms "../dist/$ZIP_NAME" Archipelago
Rename-Item Archipelago "exe.$NAME" # inno_setup.iss expects the original name
- name: Build Setup
run: |
& "${env:ProgramFiles(x86)}\Inno Setup 6\iscc.exe" inno_setup.iss /DNO_SIGNTOOL
if ( $? -eq $false ) {
Write-Error "Building setup failed!"
exit 1
}
$contents = Get-ChildItem -Path setups/*.exe -Force -Recurse
$SETUP_NAME=$contents[0].Name
echo "SETUP_NAME=$SETUP_NAME" >> $Env:GITHUB_ENV
# - code above copied from build.yml -
- name: Attest Build
uses: actions/attest-build-provenance@v2
with:
subject-path: |
build/exe.*/ArchipelagoLauncher.exe
build/exe.*/ArchipelagoLauncherDebug.exe
build/exe.*/ArchipelagoGenerate.exe
build/exe.*/ArchipelagoServer.exe
setups/*
- name: Add to Release
uses: softprops/action-gh-release@975c1b265e11dd76618af1c374e7981f9a6ff44a
with:
draft: true # see above
prerelease: false
name: Archipelago ${{ env.RELEASE_VERSION }}
files: |
setups/*
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
build-release-ubuntu2204:
runs-on: ubuntu-22.04
needs: create-release
build-release-ubuntu2004:
runs-on: ubuntu-20.04
steps:
- name: Set env
run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV
@@ -128,13 +49,10 @@ jobs:
- name: Install build-time dependencies
run: |
echo "PYTHON=python3.12" >> $GITHUB_ENV
wget -nv https://github.com/$APPIMAGE_FORK/appimagetool/releases/download/$APPIMAGETOOL_VERSION/appimagetool-x86_64.AppImage
echo "$APPIMAGETOOL_X86_64_HASH appimagetool-x86_64.AppImage" | sha256sum -c
wget -nv https://github.com/$APPIMAGE_FORK/type2-runtime/releases/download/$APPIMAGE_RUNTIME_VERSION/runtime-x86_64
echo "$APPIMAGE_RUNTIME_X86_64_HASH runtime-x86_64" | sha256sum -c
wget -nv https://github.com/AppImage/AppImageKit/releases/download/$APPIMAGETOOL_VERSION/appimagetool-x86_64.AppImage
chmod a+rx appimagetool-x86_64.AppImage
./appimagetool-x86_64.AppImage --appimage-extract
echo -e '#/bin/sh\n./squashfs-root/AppRun --runtime-file runtime-x86_64 "$@"' > appimagetool
echo -e '#/bin/sh\n./squashfs-root/AppRun "$@"' > appimagetool
chmod a+rx appimagetool
- name: Download run-time dependencies
run: |
@@ -156,14 +74,6 @@ jobs:
echo "APPIMAGE_NAME=$APPIMAGE_NAME" >> $GITHUB_ENV
echo "TAR_NAME=$TAR_NAME" >> $GITHUB_ENV
# - code above copied from build.yml -
- name: Attest Build
uses: actions/attest-build-provenance@v2
with:
subject-path: |
build/exe.*/ArchipelagoLauncher
build/exe.*/ArchipelagoGenerate
build/exe.*/ArchipelagoServer
dist/*
- name: Add to Release
uses: softprops/action-gh-release@975c1b265e11dd76618af1c374e7981f9a6ff44a
with:

View File

@@ -8,24 +8,18 @@ on:
paths:
- '**'
- '!docs/**'
- '!deploy/**'
- '!setup.py'
- '!Dockerfile'
- '!*.iss'
- '!.gitignore'
- '!.dockerignore'
- '!.github/workflows/**'
- '.github/workflows/unittests.yml'
pull_request:
paths:
- '**'
- '!docs/**'
- '!deploy/**'
- '!setup.py'
- '!Dockerfile'
- '!*.iss'
- '!.gitignore'
- '!.dockerignore'
- '!.github/workflows/**'
- '.github/workflows/unittests.yml'
@@ -39,15 +33,15 @@ jobs:
matrix:
os: [ubuntu-latest]
python:
- {version: '3.11.2'} # Change to '3.11' around 2026-06-10
- {version: '3.10'}
- {version: '3.11'}
- {version: '3.12'}
- {version: '3.13'}
include:
- python: {version: '3.11'} # old compat
- python: {version: '3.10'} # old compat
os: windows-latest
- python: {version: '3.13'} # current
- python: {version: '3.12'} # current
os: windows-latest
- python: {version: '3.13'} # current
- python: {version: '3.12'} # current
os: macos-latest
steps:
@@ -59,7 +53,7 @@ jobs:
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r ci-requirements.txt
pip install pytest pytest-subtests pytest-xdist
python ModuleUpdate.py --yes --force --append "WebHostLib/requirements.txt"
python Launcher.py --update_settings # make sure host.yaml exists for tests
- name: Unittests
@@ -75,7 +69,7 @@ jobs:
os:
- ubuntu-latest
python:
- {version: '3.13'} # current
- {version: '3.12'} # current
steps:
- uses: actions/checkout@v4

8
.gitignore vendored
View File

@@ -10,7 +10,6 @@
*.apmc
*.apz5
*.aptloz
*.aptww
*.apemerald
*.pyc
*.pyd
@@ -56,6 +55,7 @@ success.txt
output/
Output Logs/
/factorio/
/Minecraft Forge Server/
/WebHostLib/static/generated
/freeze_requirements.txt
/Archipelago.zip
@@ -183,6 +183,12 @@ _speedups.c
_speedups.cpp
_speedups.html
# minecraft server stuff
jdk*/
minecraft*/
minecraft_versions.json
!worlds/minecraft/
# pyenv
.python-version

View File

@@ -1,24 +0,0 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="Build APWorld" type="PythonConfigurationType" factoryName="Python">
<module name="Archipelago" />
<option name="ENV_FILES" value="" />
<option name="INTERPRETER_OPTIONS" value="" />
<option name="PARENT_ENVS" value="true" />
<envs>
<env name="PYTHONUNBUFFERED" value="1" />
</envs>
<option name="SDK_HOME" value="" />
<option name="WORKING_DIRECTORY" value="$PROJECT_DIR$/" />
<option name="IS_MODULE_SDK" value="true" />
<option name="ADD_CONTENT_ROOTS" value="true" />
<option name="ADD_SOURCE_ROOTS" value="true" />
<option name="SCRIPT_NAME" value="$PROJECT_DIR$/Launcher.py" />
<option name="PARAMETERS" value="&quot;Build APWorlds&quot;" />
<option name="SHOW_COMMAND_LINE" value="false" />
<option name="EMULATE_TERMINAL" value="false" />
<option name="MODULE_MODE" value="false" />
<option name="REDIRECT_INPUT" value="false" />
<option name="INPUT_FILE" value="" />
<method v="2" />
</configuration>
</component>

View File

@@ -1,4 +1,3 @@
import sys
from worlds.ahit.Client import launch
import Utils
import ModuleUpdate
@@ -6,4 +5,4 @@ ModuleUpdate.update()
if __name__ == "__main__":
Utils.init_logging("AHITClient", exception_logger="Client")
launch(*sys.argv[1:])
launch()

View File

@@ -11,7 +11,6 @@ from typing import List
import Utils
from settings import get_settings
from NetUtils import ClientStatus
from Utils import async_start
from CommonClient import CommonContext, server_loop, gui_enabled, ClientCommandProcessor, logger, \
@@ -81,8 +80,8 @@ class AdventureContext(CommonContext):
self.local_item_locations = {}
self.dragon_speed_info = {}
options = get_settings().adventure_options
self.display_msgs = options.display_msgs
options = Utils.get_settings()
self.display_msgs = options["adventure_options"]["display_msgs"]
async def server_auth(self, password_requested: bool = False):
if password_requested and not self.password:
@@ -103,7 +102,7 @@ class AdventureContext(CommonContext):
def on_package(self, cmd: str, args: dict):
if cmd == 'Connected':
self.locations_array = None
if get_settings().adventure_options.as_dict().get("death_link", False):
if Utils.get_settings()["adventure_options"].get("death_link", False):
self.set_deathlink = True
async_start(self.get_freeincarnates_used())
elif cmd == "RoomInfo":
@@ -407,7 +406,6 @@ async def atari_sync_task(ctx: AdventureContext):
except ConnectionRefusedError:
logger.debug("Connection Refused, Trying Again")
ctx.atari_status = CONNECTION_REFUSED_STATUS
await asyncio.sleep(1)
continue
except CancelledError:
pass
@@ -417,9 +415,8 @@ async def atari_sync_task(ctx: AdventureContext):
async def run_game(romfile):
options = get_settings().adventure_options
auto_start = options.rom_start
rom_args = options.rom_args
auto_start = Utils.get_settings()["adventure_options"].get("rom_start", True)
rom_args = Utils.get_settings()["adventure_options"].get("rom_args")
if auto_start is True:
import webbrowser
webbrowser.open(romfile)
@@ -514,7 +511,7 @@ if __name__ == '__main__':
import colorama
colorama.just_fix_windows_console()
colorama.init()
asyncio.run(main())
colorama.deinit()

View File

@@ -5,14 +5,12 @@ import functools
import logging
import random
import secrets
import warnings
from argparse import Namespace
from collections import Counter, deque, defaultdict
from collections import Counter, deque
from collections.abc import Collection, MutableSequence
from enum import IntEnum, IntFlag
from typing import (AbstractSet, Any, Callable, ClassVar, Dict, Iterable, Iterator, List, Literal, Mapping, NamedTuple,
Optional, Protocol, Set, Tuple, Union, TYPE_CHECKING, Literal, overload)
import dataclasses
from typing import (AbstractSet, Any, Callable, ClassVar, Dict, Iterable, Iterator, List, Mapping, NamedTuple,
Optional, Protocol, Set, Tuple, Union, TYPE_CHECKING)
from typing_extensions import NotRequired, TypedDict
@@ -56,21 +54,12 @@ class HasNameAndPlayer(Protocol):
player: int
@dataclasses.dataclass
class PlandoItemBlock:
player: int
from_pool: bool
force: bool | Literal["silent"]
worlds: set[int] = dataclasses.field(default_factory=set)
items: list[str] = dataclasses.field(default_factory=list)
locations: list[str] = dataclasses.field(default_factory=list)
resolved_locations: list[Location] = dataclasses.field(default_factory=list)
count: dict[str, int] = dataclasses.field(default_factory=dict)
class MultiWorld():
debug_types = False
player_name: Dict[int, str]
plando_texts: List[Dict[str, str]]
plando_items: List[List[Dict[str, Any]]]
plando_connections: List
worlds: Dict[int, "AutoWorld.World"]
groups: Dict[int, Group]
regions: RegionManager
@@ -94,8 +83,6 @@ class MultiWorld():
start_location_hints: Dict[int, Options.StartLocationHints]
item_links: Dict[int, Options.ItemLinks]
plando_item_blocks: Dict[int, List[PlandoItemBlock]]
game: Dict[int, str]
random: random.Random
@@ -154,11 +141,17 @@ class MultiWorld():
self.algorithm = 'balanced'
self.groups = {}
self.regions = self.RegionManager(players)
self.shops = []
self.itempool = []
self.seed = None
self.seed_name: str = "Unavailable"
self.precollected_items = {player: [] for player in self.player_ids}
self.required_locations = []
self.light_world_light_cone = False
self.dark_world_light_cone = False
self.rupoor_cost = 10
self.aga_randomness = True
self.save_and_quit_from_boss = True
self.custom = False
self.customitemarray = []
self.shuffle_ganon = True
@@ -167,17 +160,18 @@ class MultiWorld():
self.local_early_items = {player: {} for player in self.player_ids}
self.indirect_connections = {}
self.start_inventory_from_pool: Dict[int, Options.StartInventoryPool] = {}
self.plando_item_blocks = {}
for player in range(1, players + 1):
def set_player_attr(attr: str, val) -> None:
self.__dict__.setdefault(attr, {})[player] = val
set_player_attr('plando_item_blocks', [])
set_player_attr('plando_items', [])
set_player_attr('plando_texts', {})
set_player_attr('plando_connections', [])
set_player_attr('game', "Archipelago")
set_player_attr('completion_condition', lambda state: True)
self.worlds = {}
self.per_slot_randoms = Utils.DeprecateDict("Using per_slot_randoms is now deprecated. Please use the "
"world's random object instead (usually self.random)", True)
"world's random object instead (usually self.random)")
self.plando_options = PlandoOptions.none
def get_all_ids(self) -> Tuple[int, ...]:
@@ -222,8 +216,17 @@ class MultiWorld():
self.seed_name = name if name else str(self.seed)
def set_options(self, args: Namespace) -> None:
# TODO - remove this section once all worlds use options dataclasses
from worlds import AutoWorld
all_keys: Set[str] = {key for player in self.player_ids for key in
AutoWorld.AutoWorldRegister.world_types[self.game[player]].options_dataclass.type_hints}
for option_key in all_keys:
option = Utils.DeprecateDict(f"Getting options from multiworld is now deprecated. "
f"Please use `self.options.{option_key}` instead.")
option.update(getattr(args, option_key, {}))
setattr(self, option_key, option)
for player in self.player_ids:
world_type = AutoWorld.AutoWorldRegister.world_types[self.game[player]]
self.worlds[player] = world_type(self, player)
@@ -261,7 +264,6 @@ class MultiWorld():
"local_items": set(item_link.get("local_items", [])),
"non_local_items": set(item_link.get("non_local_items", [])),
"link_replacement": replacement_prio.index(item_link["link_replacement"]),
"skip_if_solo": item_link.get("skip_if_solo", False),
}
for _name, item_link in item_links.items():
@@ -285,8 +287,6 @@ class MultiWorld():
for group_name, item_link in item_links.items():
game = item_link["game"]
if item_link["skip_if_solo"] and len(item_link["players"]) == 1:
continue
group_id, group = self.add_group(group_name, game, set(item_link["players"]))
group["item_pool"] = item_link["item_pool"]
@@ -427,39 +427,23 @@ class MultiWorld():
def get_location(self, location_name: str, player: int) -> Location:
return self.regions.location_cache[player][location_name]
def get_all_state(self, use_cache: bool | None = None, allow_partial_entrances: bool = False,
collect_pre_fill_items: bool = True, perform_sweep: bool = True) -> CollectionState:
"""
Creates a new CollectionState, and collects all precollected items, all items in the multiworld itempool, those
specified in each worlds' `get_pre_fill_items()`, and then sweeps the multiworld collecting any other items
it is able to reach, building as complete of a completed game state as possible.
def get_all_state(self, use_cache: bool, allow_partial_entrances: bool = False) -> CollectionState:
cached = getattr(self, "_all_state", None)
if use_cache and cached:
return cached.copy()
:param use_cache: Deprecated and unused.
:param allow_partial_entrances: Whether the CollectionState should allow for disconnected entrances while
sweeping, such as before entrance randomization is complete.
:param collect_pre_fill_items: Whether the items in each worlds' `get_pre_fill_items()` should be added to this
state.
:param perform_sweep: Whether this state should perform a sweep for reachable locations, collecting any placed
items it can.
:return: The completed CollectionState.
"""
if __debug__ and use_cache is not None:
# TODO swap to Utils.deprecate when we want this to crash on source and warn on frozen
warnings.warn("multiworld.get_all_state no longer caches all_state and this argument will be removed.",
DeprecationWarning)
ret = CollectionState(self, allow_partial_entrances)
for item in self.itempool:
self.worlds[item.player].collect(ret, item)
if collect_pre_fill_items:
for player in self.player_ids:
subworld = self.worlds[player]
for item in subworld.get_pre_fill_items():
subworld.collect(ret, item)
if perform_sweep:
ret.sweep_for_advancements()
for player in self.player_ids:
subworld = self.worlds[player]
for item in subworld.get_pre_fill_items():
subworld.collect(ret, item)
ret.sweep_for_advancements()
if use_cache:
self._all_state = ret
return ret
def get_items(self) -> List[Item]:
@@ -561,9 +545,7 @@ class MultiWorld():
else:
return all((self.has_beaten_game(state, p) for p in range(1, self.players + 1)))
def can_beat_game(self,
starting_state: Optional[CollectionState] = None,
locations: Optional[Iterable[Location]] = None) -> bool:
def can_beat_game(self, starting_state: Optional[CollectionState] = None) -> bool:
if starting_state:
if self.has_beaten_game(starting_state):
return True
@@ -572,10 +554,25 @@ class MultiWorld():
state = CollectionState(self)
if self.has_beaten_game(state):
return True
prog_locations = {location for location in self.get_locations() if location.item
and location.item.advancement and location not in state.locations_checked}
while prog_locations:
sphere: Set[Location] = set()
# build up spheres of collection radius.
# Everything in each sphere is independent from each other in dependencies and only depends on lower spheres
for location in prog_locations:
if location.can_reach(state):
sphere.add(location)
if not sphere:
# ran out of places and did not finish yet, quit
return False
for location in sphere:
state.collect(location.item, True, location)
prog_locations -= sphere
for _ in state.sweep_for_advancements(locations,
yield_each_sweep=True,
checked_locations=state.locations_checked):
if self.has_beaten_game(state):
return True
@@ -619,7 +616,7 @@ class MultiWorld():
locations: Set[Location] = set()
events: Set[Location] = set()
for location in self.get_filled_locations():
if type(location.item.code) is int and type(location.address) is int:
if type(location.item.code) is int:
locations.add(location)
else:
events.add(location)
@@ -691,12 +688,6 @@ class MultiWorld():
sphere.append(locations.pop(n))
if not sphere:
if __debug__:
from Fill import FillError
raise FillError(
f"Could not access required locations for accessibility check. Missing: {locations}",
multiworld=self,
)
# ran out of places and did not finish yet, quit
logging.warning(f"Could not access required locations for accessibility check."
f" Missing: {locations}")
@@ -732,7 +723,6 @@ class CollectionState():
additional_copy_functions: List[Callable[[CollectionState, CollectionState], CollectionState]] = []
def __init__(self, parent: MultiWorld, allow_partial_entrances: bool = False):
assert parent.worlds, "CollectionState created without worlds initialized in parent"
self.prog_items = {player: Counter() for player in parent.get_all_ids()}
self.multiworld = parent
self.reachable_regions = {player: set() for player in parent.get_all_ids()}
@@ -860,133 +850,20 @@ class CollectionState():
"Please switch over to sweep_for_advancements.")
return self.sweep_for_advancements(locations)
def _sweep_for_advancements_impl(self, advancements_per_player: List[Tuple[int, List[Location]]],
yield_each_sweep: bool) -> Iterator[None]:
"""
The implementation for sweep_for_advancements is separated here because it returns a generator due to the use
of a yield statement.
"""
all_players = {player for player, _ in advancements_per_player}
players_to_check = all_players
# As an optimization, it is assumed that each player's world only logically depends on itself. However, worlds
# are allowed to logically depend on other worlds, so once there are no more players that should be checked
# under this assumption, an extra sweep iteration is performed that checks every player, to confirm that the
# sweep is finished.
checking_if_finished = False
while players_to_check:
next_advancements_per_player: List[Tuple[int, List[Location]]] = []
next_players_to_check = set()
for player, locations in advancements_per_player:
if player not in players_to_check:
next_advancements_per_player.append((player, locations))
continue
# Accessibility of each location is checked first because a player's region accessibility cache becomes
# stale whenever one of their own items is collected into the state.
reachable_locations: List[Location] = []
unreachable_locations: List[Location] = []
for location in locations:
if location.can_reach(self):
# Locations containing items that do not belong to `player` could be collected immediately
# because they won't stale `player`'s region accessibility cache, but, for simplicity, all the
# items at reachable locations are collected in a single loop.
reachable_locations.append(location)
else:
unreachable_locations.append(location)
if unreachable_locations:
next_advancements_per_player.append((player, unreachable_locations))
# A previous player's locations processed in the current `while players_to_check` iteration could have
# collected items belonging to `player`, but now that all of `player`'s reachable locations have been
# found, it can be assumed that `player` will not gain any more reachable locations until another one of
# their items is collected.
# It would be clearer to not add players to `next_players_to_check` in the first place if they have yet
# to be processed in the current `while players_to_check` iteration, but checking if a player should be
# added to `next_players_to_check` would need to be run once for every item that is collected, so it is
# more performant to instead discard `player` from `next_players_to_check` once their locations have
# been processed.
next_players_to_check.discard(player)
# Collect the items from the reachable locations.
for advancement in reachable_locations:
self.advancements.add(advancement)
item = advancement.item
assert isinstance(item, Item), "tried to collect advancement Location with no Item"
if self.collect(item, True, advancement):
# The player the item belongs to may be able to reach additional locations in the next sweep
# iteration.
next_players_to_check.add(item.player)
if not next_players_to_check:
if not checking_if_finished:
# It is assumed that each player's world only logically depends on itself, which may not be the
# case, so confirm that the sweep is finished by doing an extra iteration that checks every player.
checking_if_finished = True
next_players_to_check = all_players
else:
checking_if_finished = False
players_to_check = next_players_to_check
advancements_per_player = next_advancements_per_player
if yield_each_sweep:
yield
@overload
def sweep_for_advancements(self, locations: Optional[Iterable[Location]] = None, *,
yield_each_sweep: Literal[True],
checked_locations: Optional[Set[Location]] = None) -> Iterator[None]: ...
@overload
def sweep_for_advancements(self, locations: Optional[Iterable[Location]] = None,
yield_each_sweep: Literal[False] = False,
checked_locations: Optional[Set[Location]] = None) -> None: ...
def sweep_for_advancements(self, locations: Optional[Iterable[Location]] = None, yield_each_sweep: bool = False,
checked_locations: Optional[Set[Location]] = None) -> Optional[Iterator[None]]:
"""
Sweep through the locations that contain uncollected advancement items, collecting the items into the state
until there are no more reachable locations that contain uncollected advancement items.
:param locations: The locations to sweep through, defaulting to all locations in the multiworld.
:param yield_each_sweep: When True, return a generator that yields at the end of each sweep iteration.
:param checked_locations: Optional override of locations to filter out from the locations argument, defaults to
self.advancements when None.
"""
if checked_locations is None:
checked_locations = self.advancements
# Since the sweep loop usually performs many iterations, the locations are filtered in advance.
# A list of tuples is used, instead of a dictionary, because it is faster to iterate.
advancements_per_player: List[Tuple[int, List[Location]]]
def sweep_for_advancements(self, locations: Optional[Iterable[Location]] = None) -> None:
if locations is None:
# `location.advancement` can only be True for filled locations, so unfilled locations are filtered out.
advancements_per_player = []
for player, locations_dict in self.multiworld.regions.location_cache.items():
filtered_locations = [location for location in locations_dict.values()
if location.advancement and location not in checked_locations]
if filtered_locations:
advancements_per_player.append((player, filtered_locations))
else:
# Filter and separate the locations into a list for each player.
advancements_per_player_dict: Dict[int, List[Location]] = defaultdict(list)
for location in locations:
if location.advancement and location not in checked_locations:
advancements_per_player_dict[location.player].append(location)
# Convert to a list of tuples.
advancements_per_player = list(advancements_per_player_dict.items())
del advancements_per_player_dict
locations = self.multiworld.get_filled_locations()
reachable_advancements = True
# since the loop has a good chance to run more than once, only filter the advancements once
locations = {location for location in locations if location.advancement and location not in self.advancements}
if yield_each_sweep:
# Return a generator that will yield at the end of each sweep iteration.
return self._sweep_for_advancements_impl(advancements_per_player, True)
else:
# Create the generator, but tell it not to yield anything, so it will run to completion in zero iterations
# once started, then start and exhaust the generator by attempting to iterate it.
for _ in self._sweep_for_advancements_impl(advancements_per_player, False):
assert False, "Generator yielded when it should have run to completion without yielding"
return None
while reachable_advancements:
reachable_advancements = {location for location in locations if location.can_reach(self)}
locations -= reachable_advancements
for advancement in reachable_advancements:
self.advancements.add(advancement)
assert isinstance(advancement.item, Item), "tried to collect Event with no Item"
self.collect(advancement.item, True, advancement)
# item name related
def has(self, item: str, player: int, count: int = 1) -> bool:
@@ -1122,17 +999,6 @@ class CollectionState():
return changed
def add_item(self, item: str, player: int, count: int = 1) -> None:
"""
Adds the item to state.
:param item: The item to be added.
:param player: The player the item is for.
:param count: How many of the item to add.
"""
assert count > 0
self.prog_items[player][item] += count
def remove(self, item: Item):
changed = self.multiworld.worlds[item.player].remove(self, item)
if changed:
@@ -1141,33 +1007,6 @@ class CollectionState():
self.blocked_connections[item.player] = set()
self.stale[item.player] = True
def remove_item(self, item: str, player: int, count: int = 1) -> None:
"""
Removes the item from state.
:param item: The item to be removed.
:param player: The player the item is for.
:param count: How many of the item to remove.
"""
assert count > 0
self.prog_items[player][item] -= count
if self.prog_items[player][item] < 1:
del (self.prog_items[player][item])
def set_item(self, item: str, player: int, count: int) -> None:
"""
Sets the item in state equal to the provided count.
:param item: The item to modify.
:param player: The player the item is for.
:param count: How many of the item to now have.
"""
assert count >= 0
if count == 0:
del (self.prog_items[player][item])
else:
self.prog_items[player][item] = count
class EntranceType(IntEnum):
ONE_WAY = 1
@@ -1183,6 +1022,9 @@ class Entrance:
connected_region: Optional[Region] = None
randomization_group: int
randomization_type: EntranceType
# LttP specific, TODO: should make a LttPEntrance
addresses = None
target = None
def __init__(self, player: int, name: str = "", parent: Optional[Region] = None,
randomization_group: int = 0, randomization_type: EntranceType = EntranceType.ONE_WAY) -> None:
@@ -1201,8 +1043,10 @@ class Entrance:
return False
def connect(self, region: Region) -> None:
def connect(self, region: Region, addresses: Any = None, target: Any = None) -> None:
self.connected_region = region
self.target = target
self.addresses = addresses
region.entrances.append(self)
def is_valid_source_transition(self, er_state: "ERPlacementState") -> bool:
@@ -1254,16 +1098,13 @@ class Region:
self.region_manager = region_manager
def __getitem__(self, index: int) -> Location:
return self._list[index]
return self._list.__getitem__(index)
def __setitem__(self, index: int, value: Location) -> None:
raise NotImplementedError()
def __len__(self) -> int:
return len(self._list)
def __iter__(self):
return iter(self._list)
return self._list.__len__()
# This seems to not be needed, but that's a bit suspicious.
# def __del__(self):
@@ -1274,8 +1115,8 @@ class Region:
class LocationRegister(Register):
def __delitem__(self, index: int) -> None:
location: Location = self._list[index]
del self._list[index]
location: Location = self._list.__getitem__(index)
self._list.__delitem__(index)
del(self.region_manager.location_cache[location.player][location.name])
def insert(self, index: int, value: Location) -> None:
@@ -1286,8 +1127,8 @@ class Region:
class EntranceRegister(Register):
def __delitem__(self, index: int) -> None:
entrance: Entrance = self._list[index]
del self._list[index]
entrance: Entrance = self._list.__getitem__(index)
self._list.__delitem__(index)
del(self.region_manager.entrance_cache[entrance.player][entrance.name])
def insert(self, index: int, value: Entrance) -> None:
@@ -1346,7 +1187,8 @@ class Region:
for entrance in self.entrances: # BFS might be better here, trying DFS for now.
return entrance.parent_region.get_connecting_entrance(is_main_entrance)
def add_locations(self, locations: Mapping[str, int | None], location_type: type[Location] | None = None) -> None:
def add_locations(self, locations: Dict[str, Optional[int]],
location_type: Optional[type[Location]] = None) -> None:
"""
Adds locations to the Region object, where location_type is your Location class and locations is a dict of
location names to address.
@@ -1358,48 +1200,6 @@ class Region:
for location, address in locations.items():
self.locations.append(location_type(self.player, location, address, self))
def add_event(
self,
location_name: str,
item_name: str | None = None,
rule: Callable[[CollectionState], bool] | None = None,
location_type: type[Location] | None = None,
item_type: type[Item] | None = None,
show_in_spoiler: bool = True,
) -> Item:
"""
Adds an event location/item pair to the region.
:param location_name: Name for the event location.
:param item_name: Name for the event item. If not provided, defaults to location_name.
:param rule: Callable to determine access for this event location within its region.
:param location_type: Location class to create the event location with. Defaults to BaseClasses.Location.
:param item_type: Item class to create the event item with. Defaults to BaseClasses.Item.
:param show_in_spoiler: Will be passed along to the created event Location's show_in_spoiler attribute.
:return: The created Event Item
"""
if location_type is None:
location_type = Location
if item_name is None:
item_name = location_name
if item_type is None:
item_type = Item
event_location = location_type(self.player, location_name, None, self)
event_location.show_in_spoiler = show_in_spoiler
if rule is not None:
event_location.access_rule = rule
event_item = item_type(item_name, ItemClassification.progression, None, self.player)
event_location.place_locked_item(event_item)
self.locations.append(event_location)
return event_item
def connect(self, connecting_region: Region, name: Optional[str] = None,
rule: Optional[Callable[[CollectionState], bool]] = None) -> Entrance:
"""
@@ -1434,16 +1234,16 @@ class Region:
entrance.connect(self)
return entrance
def add_exits(self, exits: Iterable[str] | Mapping[str, str | None],
rules: Mapping[str, Callable[[CollectionState], bool]] | None = None) -> List[Entrance]:
def add_exits(self, exits: Union[Iterable[str], Dict[str, Optional[str]]],
rules: Dict[str, Callable[[CollectionState], bool]] = None) -> List[Entrance]:
"""
Connects current region to regions in exit dictionary. Passed region names must exist first.
:param exits: exits from the region. format is {"connecting_region": "exit_name"}. if a non dict is provided,
created entrances will be named "self.name -> connecting_region"
:param rules: rules for the exits from this region. format is {"connecting_region": rule}
created entrances will be named "self.name -> connecting_region"
:param rules: rules for the exits from this region. format is {"connecting_region", rule}
"""
if not isinstance(exits, Mapping):
if not isinstance(exits, Dict):
exits = dict.fromkeys(exits)
return [
self.connect(
@@ -1510,6 +1310,9 @@ class Location:
multiworld = self.parent_region.multiworld if self.parent_region and self.parent_region.multiworld else None
return multiworld.get_name_string_for_object(self) if multiworld else f'{self.name} (Player {self.player})'
def __hash__(self):
return hash((self.name, self.player))
def __lt__(self, other: Location):
return (self.player, self.name) < (other.player, other.name)
@@ -1533,47 +1336,31 @@ class Location:
class ItemClassification(IntFlag):
filler = 0b00000
filler = 0b0000
""" aka trash, as in filler items like ammo, currency etc """
progression = 0b00001
progression = 0b0001
""" Item that is logically relevant.
Protects this item from being placed on excluded or unreachable locations. """
useful = 0b00010
useful = 0b0010
""" Item that is especially useful.
Protects this item from being placed on excluded or unreachable locations.
When combined with another flag like "progression", it means "an especially useful progression item". """
trap = 0b00100
trap = 0b0100
""" Item that is detrimental in some way. """
skip_balancing = 0b01000
skip_balancing = 0b1000
""" should technically never occur on its own
Item that is logically relevant, but progression balancing should not touch.
Possible reasons for why an item should not be pulled ahead by progression balancing:
1. This item is quite insignificant, so pulling it earlier doesn't help (currency/etc.)
2. It is important for the player experience that this item is evenly distributed in the seed (e.g. goal items) """
Typically currency or other counted items. """
deprioritized = 0b10000
""" Should technically never occur on its own.
Will not be considered for priority locations,
unless Priority Locations Fill runs out of regular progression items before filling all priority locations.
Should be used for items that would feel bad for the player to find on a priority location.
Usually, these are items that are plentiful or insignificant. """
progression_deprioritized_skip_balancing = 0b11001
""" Since a common case of both skip_balancing and deprioritized is "insignificant progression",
these items often want both flags. """
progression_skip_balancing = 0b01001 # only progression gets balanced
progression_deprioritized = 0b10001 # only progression can be placed during priority fill
progression_skip_balancing = 0b1001 # only progression gets balanced
def as_flag(self) -> int:
"""As Network API flag int."""
return int(self & 0b00111)
return int(self & 0b0111)
class Item:
@@ -1617,10 +1404,6 @@ class Item:
def trap(self) -> bool:
return ItemClassification.trap in self.classification
@property
def deprioritized(self) -> bool:
return ItemClassification.deprioritized in self.classification
@property
def filler(self) -> bool:
return not (self.advancement or self.useful or self.trap)
@@ -1633,10 +1416,6 @@ class Item:
def flags(self) -> int:
return self.classification.as_flag()
@property
def is_event(self) -> bool:
return self.code is None
def __eq__(self, other: object) -> bool:
if not isinstance(other, Item):
return NotImplemented
@@ -1721,29 +1500,30 @@ class Spoiler:
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 not multiworld.has_beaten_game(state):
raise RuntimeError("During playthrough generation, the game was determined to be unbeatable. "
"Something went terribly wrong here. "
f"Unreachable progression items: {sphere_candidates}")
if any([multiworld.worlds[location.item.player].options.accessibility != 'minimal' for location in sphere_candidates]):
raise RuntimeError(f'Not all progression items reachable ({sphere_candidates}). '
f'Something went terribly wrong here.')
else:
self.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
required_locations = {location for sphere in collection_spheres for location in sphere}
restore_later: Dict[Location, Item] = {}
for num, sphere in reversed(tuple(enumerate(collection_spheres))):
to_delete: Set[Location] = set()
for location in sphere:
# we remove the location from required_locations to sweep from, and check if the game is still beatable
# we remove the item at location and check if game is still beatable
logging.debug('Checking if %s (Player %d) is required to beat the game.', location.item.name,
location.item.player)
required_locations.remove(location)
if multiworld.can_beat_game(state_cache[num], required_locations):
old_item = location.item
location.item = None
if multiworld.can_beat_game(state_cache[num]):
to_delete.add(location)
restore_later[location] = old_item
else:
# still required, got to keep it around
required_locations.add(location)
location.item = old_item
# cull entries in spheres for spoiler walkthrough at end
sphere -= to_delete
@@ -1760,7 +1540,7 @@ class Spoiler:
logging.debug('Checking if %s (Player %d) is required to beat the game.', item.name, item.player)
precollected_items.remove(item)
multiworld.state.remove(item)
if not multiworld.can_beat_game(multiworld.state, required_locations):
if not multiworld.can_beat_game():
# Add the item back into `precollected_items` and collect it into `multiworld.state`.
multiworld.push_precollected(item)
else:
@@ -1802,6 +1582,9 @@ class Spoiler:
self.create_paths(state, collection_spheres)
# repair the multiworld again
for location, item in restore_later.items():
location.item = item
for item in removed_precollected:
multiworld.push_precollected(item)
@@ -1858,9 +1641,6 @@ class Spoiler:
Utils.__version__, self.multiworld.seed))
outfile.write('Filling Algorithm: %s\n' % self.multiworld.algorithm)
outfile.write('Players: %d\n' % self.multiworld.players)
if self.multiworld.players > 1:
loc_count = len([loc for loc in self.multiworld.get_locations() if not loc.is_event])
outfile.write('Total Location Count: %d\n' % loc_count)
outfile.write(f'Plando Options: {self.multiworld.plando_options}\n')
AutoWorld.call_stage(self.multiworld, "write_spoiler_header", outfile)
@@ -1869,9 +1649,6 @@ class Spoiler:
outfile.write('\nPlayer %d: %s\n' % (player, self.multiworld.get_player_name(player)))
outfile.write('Game: %s\n' % self.multiworld.game[player])
loc_count = len([loc for loc in self.multiworld.get_locations(player) if not loc.is_event])
outfile.write('Location Count: %d\n' % loc_count)
for f_option, option in self.multiworld.worlds[player].options_dataclass.type_hints.items():
write_option(f_option, option)
@@ -1908,8 +1685,7 @@ class Spoiler:
if self.unreachables:
outfile.write('\n\nUnreachable Progression Items:\n\n')
outfile.write(
'\n'.join(['%s: %s' % (unreachable.item, unreachable)
for unreachable in sorted(self.unreachables)]))
'\n'.join(['%s: %s' % (unreachable.item, unreachable) for unreachable in self.unreachables]))
if self.paths:
outfile.write('\n\nPaths:\n\n')
@@ -1936,7 +1712,7 @@ class Tutorial(NamedTuple):
description: str
language: str
file_name: str
link: str # unused
link: str
authors: List[str]

277
CommonClient.py Executable file → Normal file
View File

@@ -21,7 +21,7 @@ import Utils
if __name__ == "__main__":
Utils.init_logging("TextClient", exception_logger="Client")
from MultiServer import CommandProcessor, mark_raw
from MultiServer import CommandProcessor
from NetUtils import (Endpoint, decode, NetworkItem, encode, JSONtoTextParser, ClientStatus, Permission, NetworkSlot,
RawJSONtoTextParser, add_json_text, add_json_location, add_json_item, JSONTypes, HintStatus, SlotType)
from Utils import Version, stream_input, async_start
@@ -107,9 +107,7 @@ class ClientCommandProcessor(CommandProcessor):
return False
count = 0
checked_count = 0
lookup = self.ctx.location_names[self.ctx.game]
for location_id, location in lookup.items():
for location, location_id in AutoWorldRegister.world_types[self.ctx.game].location_name_to_id.items():
if filter_text and filter_text not in location:
continue
if location_id < 0:
@@ -130,87 +128,43 @@ class ClientCommandProcessor(CommandProcessor):
self.output("No missing location checks found.")
return True
def output_datapackage_part(self, name: typing.Literal["Item Names", "Location Names"]) -> bool:
"""
Helper to digest a specific section of this game's datapackage.
:param name: Printed to the user as context for the part.
:return: Whether the process was successful.
"""
if not self.ctx.game:
self.output(f"No game set, cannot determine {name}.")
return False
lookup = self.ctx.item_names if name == "Item Names" else self.ctx.location_names
lookup = lookup[self.ctx.game]
self.output(f"{name} for {self.ctx.game}")
for name in lookup.values():
self.output(name)
return True
def _cmd_items(self) -> bool:
def _cmd_items(self):
"""List all item names for the currently running game."""
return self.output_datapackage_part("Item Names")
def _cmd_locations(self) -> bool:
"""List all location names for the currently running game."""
return self.output_datapackage_part("Location Names")
def output_group_part(self, group_key: typing.Literal["item_name_groups", "location_name_groups"],
filter_key: str,
name: str) -> bool:
"""
Logs an item or location group from the player's game's datapackage.
:param group_key: Either Item or Location group to be processed.
:param filter_key: Which group key to filter to. If an empty string is passed will log all item/location groups.
:param name: Printed to the user as context for the part.
:return: Whether the process was successful.
"""
if not self.ctx.game:
self.output(f"No game set, cannot determine existing {name} Groups.")
self.output("No game set, cannot determine existing items.")
return False
lookup = Utils.persistent_load().get("groups_by_checksum", {}).get(self.ctx.checksums[self.ctx.game], {})\
.get(self.ctx.game, {}).get(group_key, {})
if lookup is None:
self.output("datapackage not yet loaded, try again")
self.output(f"Item Names for {self.ctx.game}")
for item_name in AutoWorldRegister.world_types[self.ctx.game].item_name_to_id:
self.output(item_name)
def _cmd_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)
if filter_key:
if filter_key not in lookup:
self.output(f"Unknown {name} Group {filter_key}")
return False
def _cmd_locations(self):
"""List all location names for the currently running game."""
if not self.ctx.game:
self.output("No game set, cannot determine existing locations.")
return False
self.output(f"Location Names for {self.ctx.game}")
for location_name in AutoWorldRegister.world_types[self.ctx.game].location_name_to_id:
self.output(location_name)
self.output(f"{name}s for {name} Group \"{filter_key}\"")
for entry in lookup[filter_key]:
self.output(entry)
else:
self.output(f"{name} Groups for {self.ctx.game}")
for group in lookup:
self.output(group)
return True
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)
@mark_raw
def _cmd_item_groups(self, key: str = "") -> bool:
"""
List all item group names for the currently running game.
:param key: Which item group to filter to. Will log all groups if empty.
"""
return self.output_group_part("item_name_groups", key, "Item")
@mark_raw
def _cmd_location_groups(self, key: str = "") -> bool:
"""
List all location group names for the currently running game.
:param key: Which item group to filter to. Will log all groups if empty.
"""
return self.output_group_part("location_name_groups", key, "Location")
def _cmd_ready(self) -> bool:
def _cmd_ready(self):
"""Send ready status to server."""
self.ctx.ready = not self.ctx.ready
if self.ctx.ready:
@@ -220,7 +174,6 @@ class ClientCommandProcessor(CommandProcessor):
state = ClientStatus.CLIENT_CONNECTED
self.output("Unreadied.")
async_start(self.ctx.send_msgs([{"cmd": "StatusUpdate", "status": state}]), name="send StatusUpdate")
return True
def default(self, raw: str):
"""The default message parser to be used when parsing any messages that do not match a command"""
@@ -243,12 +196,25 @@ class CommonContext:
self.lookup_type: typing.Literal["item", "location"] = lookup_type
self._unknown_item: typing.Callable[[int], str] = lambda key: f"Unknown {lookup_type} (ID: {key})"
self._archipelago_lookup: typing.Dict[int, str] = {}
self._flat_store: typing.Dict[int, str] = Utils.KeyedDefaultDict(self._unknown_item)
self._game_store: typing.Dict[str, typing.ChainMap[int, str]] = collections.defaultdict(
lambda: collections.ChainMap(self._archipelago_lookup, Utils.KeyedDefaultDict(self._unknown_item)))
self.warned: bool = False
# noinspection PyTypeChecker
def __getitem__(self, key: str) -> typing.Mapping[int, str]:
assert isinstance(key, str), f"ctx.{self.lookup_type}_names used with an id, use the lookup_in_ helpers instead"
# TODO: In a future version (0.6.0?) this should be simplified by removing implicit id lookups support.
if isinstance(key, int):
if not self.warned:
# Use warnings instead of logger to avoid deprecation message from appearing on user side.
self.warned = True
warnings.warn(f"Implicit name lookup by id only is deprecated and only supported to maintain "
f"backwards compatibility for now. If multiple games share the same id for a "
f"{self.lookup_type}, name could be incorrect. Please use "
f"`{self.lookup_type}_names.lookup_in_game()` or "
f"`{self.lookup_type}_names.lookup_in_slot()` instead.")
return self._flat_store[key] # type: ignore
return self._game_store[key]
def __len__(self) -> int:
@@ -258,7 +224,7 @@ class CommonContext:
return iter(self._game_store)
def __repr__(self) -> str:
return repr(self._game_store)
return self._game_store.__repr__()
def lookup_in_game(self, code: int, game_name: typing.Optional[str] = None) -> str:
"""Returns the name for an item/location id in the context of a specific game or own game if `game` is
@@ -288,6 +254,7 @@ class CommonContext:
id_to_name_lookup_table = Utils.KeyedDefaultDict(self._unknown_item)
id_to_name_lookup_table.update({code: name for name, code in name_to_id_lookup_table.items()})
self._game_store[game] = collections.ChainMap(self._archipelago_lookup, id_to_name_lookup_table)
self._flat_store.update(id_to_name_lookup_table) # Only needed for legacy lookup method.
if game == "Archipelago":
# Keep track of the Archipelago data package separately so if it gets updated in a custom datapackage,
# it updates in all chain maps automatically.
@@ -314,71 +281,38 @@ class CommonContext:
last_death_link: float = time.time() # last send/received death link on AP layer
# remaining type info
slot_info: dict[int, NetworkSlot]
"""Slot Info from the server for the current connection"""
server_address: str | None
"""Autoconnect address provided by the ctx constructor"""
password: str | None
"""Password used for Connecting, expected by server_auth"""
hint_cost: int | None
"""Current Hint Cost per Hint from the server"""
hint_points: int | None
"""Current available Hint Points from the server"""
player_names: dict[int, str]
"""Current lookup of slot number to player display name from server (includes aliases)"""
slot_info: typing.Dict[int, NetworkSlot]
server_address: typing.Optional[str]
password: typing.Optional[str]
hint_cost: typing.Optional[int]
hint_points: typing.Optional[int]
player_names: typing.Dict[int, str]
finished_game: bool
"""
Bool to signal that status should be updated to Goal after reconnecting
to be used to ensure that a StatusUpdate packet does not get lost when disconnected
"""
ready: bool
"""Bool to keep track of state for the /ready command"""
team: int | None
"""Team number of currently connected slot"""
slot: int | None
"""Slot number of currently connected slot"""
auth: str | None
"""Name used in Connect packet"""
seed_name: str | None
"""Seed name that will be validated on opening a socket if present"""
team: typing.Optional[int]
slot: typing.Optional[int]
auth: typing.Optional[str]
seed_name: typing.Optional[str]
# locations
locations_checked: set[int]
"""
Local container of location ids checked to signal that LocationChecks should be resent after reconnecting
to be used to ensure that a LocationChecks packet does not get lost when disconnected
"""
locations_scouted: set[int]
"""
Local container of location ids scouted to signal that LocationScouts should be resent after reconnecting
to be used to ensure that a LocationScouts packet does not get lost when disconnected
"""
items_received: list[NetworkItem]
"""List of NetworkItems recieved from the server"""
missing_locations: set[int]
"""Container of Locations that are unchecked per server state"""
checked_locations: set[int]
"""Container of Locations that are checked per server state"""
server_locations: set[int]
"""Container of Locations that exist per server state; a combination between missing and checked locations"""
locations_info: dict[int, NetworkItem]
"""Dict of location id: NetworkItem info from LocationScouts request"""
locations_checked: typing.Set[int] # local state
locations_scouted: typing.Set[int]
items_received: typing.List[NetworkItem]
missing_locations: typing.Set[int] # server state
checked_locations: typing.Set[int] # server state
server_locations: typing.Set[int] # all locations the server knows of, missing_location | checked_locations
locations_info: typing.Dict[int, NetworkItem]
# data storage
stored_data: dict[str, typing.Any]
"""
Data Storage values by key that were retrieved from the server
any keys subscribed to with SetNotify will be kept up to date
"""
stored_data_notification_keys: set[str]
"""Current container of watched Data Storage keys, managed by ctx.set_notify"""
stored_data: typing.Dict[str, typing.Any]
stored_data_notification_keys: typing.Set[str]
# internals
# current message box through kvui
_messagebox: typing.Optional["kvui.MessageBox"] = None
"""Current message box through kvui"""
# message box reporting a loss of connection
_messagebox_connection_loss: typing.Optional["kvui.MessageBox"] = None
"""Message box reporting a loss of connection"""
def __init__(self, server_address: typing.Optional[str] = None, password: typing.Optional[str] = None) -> None:
# server state
@@ -422,12 +356,11 @@ class CommonContext:
self.item_names = self.NameLookupDict(self, "item")
self.location_names = self.NameLookupDict(self, "location")
self.versions = {}
self.checksums = {}
self.jsontotextparser = JSONtoTextParser(self)
self.rawjsontotextparser = RawJSONtoTextParser(self)
if self.game:
self.checksums[self.game] = network_data_package["games"][self.game]["checksum"]
self.update_data_package(network_data_package)
# execution
@@ -480,8 +413,7 @@ class CommonContext:
await self.server.socket.close()
if self.server_task is not None:
await self.server_task
if self.ui:
self.ui.update_hints()
self.ui.update_hints()
async def send_msgs(self, msgs: typing.List[typing.Any]) -> None:
""" `msgs` JSON serializable """
@@ -572,10 +504,6 @@ class CommonContext:
return print_json_packet.get("type", "") == "ItemSend" \
and not self.slot_concerns_self(print_json_packet["receiving"]) \
and not self.slot_concerns_self(print_json_packet["item"].player)
def is_connection_change(self, print_json_packet: dict) -> bool:
"""Helper function for filtering out connection changes."""
return print_json_packet.get("type", "") in ["Join","Part"]
def on_print(self, args: dict):
logger.info(args["text"])
@@ -642,6 +570,7 @@ class CommonContext:
# DataPackage
async def prepare_data_package(self, relevant_games: typing.Set[str],
remote_date_package_versions: typing.Dict[str, int],
remote_data_package_checksums: typing.Dict[str, str]):
"""Validate that all data is present for the current multiworld.
Download, assimilate and cache missing data from the server."""
@@ -650,26 +579,33 @@ class CommonContext:
needed_updates: typing.Set[str] = set()
for game in relevant_games:
if game not in remote_data_package_checksums:
if game not in remote_date_package_versions and game not in remote_data_package_checksums:
continue
remote_version: int = remote_date_package_versions.get(game, 0)
remote_checksum: typing.Optional[str] = remote_data_package_checksums.get(game)
if not remote_checksum: # custom data package and no checksum for this game
if remote_version == 0 and not remote_checksum: # custom data package and no checksum for this game
needed_updates.add(game)
continue
cached_version: int = self.versions.get(game, 0)
cached_checksum: typing.Optional[str] = self.checksums.get(game)
# no action required if cached version is new enough
if remote_checksum != cached_checksum:
if (not remote_checksum and (remote_version > cached_version or remote_version == 0)) \
or remote_checksum != cached_checksum:
local_version: int = network_data_package["games"].get(game, {}).get("version", 0)
local_checksum: typing.Optional[str] = network_data_package["games"].get(game, {}).get("checksum")
if remote_checksum == local_checksum:
if ((remote_checksum or remote_version <= local_version and remote_version != 0)
and remote_checksum == local_checksum):
self.update_game(network_data_package["games"][game], game)
else:
cached_game = Utils.load_data_package_for_checksum(game, remote_checksum)
cache_version: int = cached_game.get("version", 0)
cache_checksum: typing.Optional[str] = cached_game.get("checksum")
# download remote version if cache is not new enough
if remote_checksum != cache_checksum:
if (not remote_checksum and (remote_version > cache_version or remote_version == 0)) \
or remote_checksum != cache_checksum:
needed_updates.add(game)
else:
self.update_game(cached_game, game)
@@ -679,6 +615,7 @@ class CommonContext:
def update_game(self, game_package: dict, game: str):
self.item_names.update_game(game, game_package["item_name_to_id"])
self.location_names.update_game(game, game_package["location_name_to_id"])
self.versions[game] = game_package.get("version", 0)
self.checksums[game] = game_package.get("checksum")
def update_data_package(self, data_package: dict):
@@ -687,28 +624,13 @@ class CommonContext:
def consume_network_data_package(self, data_package: dict):
self.update_data_package(data_package)
current_cache = Utils.persistent_load().get("datapackage", {}).get("games", {})
current_cache.update(data_package["games"])
Utils.persistent_store("datapackage", "games", current_cache)
logger.info(f"Got new ID/Name DataPackage for {', '.join(data_package['games'])}")
for game, game_data in data_package["games"].items():
Utils.store_data_package_for_checksum(game, game_data)
def consume_network_item_groups(self):
data = {"item_name_groups": self.stored_data[f"_read_item_name_groups_{self.game}"]}
current_cache = Utils.persistent_load().get("groups_by_checksum", {}).get(self.checksums[self.game], {})
if self.game in current_cache:
current_cache[self.game].update(data)
else:
current_cache[self.game] = data
Utils.persistent_store("groups_by_checksum", self.checksums[self.game], current_cache)
def consume_network_location_groups(self):
data = {"location_name_groups": self.stored_data[f"_read_location_name_groups_{self.game}"]}
current_cache = Utils.persistent_load().get("groups_by_checksum", {}).get(self.checksums[self.game], {})
if self.game in current_cache:
current_cache[self.game].update(data)
else:
current_cache[self.game] = data
Utils.persistent_store("groups_by_checksum", self.checksums[self.game], current_cache)
# data storage
def set_notify(self, *keys: str) -> None:
@@ -860,9 +782,9 @@ async def server_loop(ctx: CommonContext, address: typing.Optional[str] = None)
server_url = urllib.parse.urlparse(address)
if server_url.username:
ctx.username = urllib.parse.unquote(server_url.username)
ctx.username = server_url.username
if server_url.password:
ctx.password = urllib.parse.unquote(server_url.password)
ctx.password = server_url.password
def reconnect_hint() -> str:
return ", type /connect to reconnect" if ctx.server_address else ""
@@ -967,8 +889,9 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
logger.info(' %s (Player %d)' % (network_player.alias, network_player.slot))
# update data package
data_package_versions = args.get("datapackage_versions", {})
data_package_checksums = args.get("datapackage_checksums", {})
await ctx.prepare_data_package(set(args["games"]), data_package_checksums)
await ctx.prepare_data_package(set(args["games"]), data_package_versions, data_package_checksums)
await ctx.server_auth(args['password'])
@@ -1009,12 +932,6 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
ctx.hint_points = args.get("hint_points", 0)
ctx.consume_players_package(args["players"])
ctx.stored_data_notification_keys.add(f"_read_hints_{ctx.team}_{ctx.slot}")
if ctx.game:
game = ctx.game
else:
game = ctx.slot_info[ctx.slot][1]
ctx.stored_data_notification_keys.add(f"_read_item_name_groups_{game}")
ctx.stored_data_notification_keys.add(f"_read_location_name_groups_{game}")
msgs = []
if ctx.locations_checked:
msgs.append({"cmd": "LocationChecks",
@@ -1095,19 +1012,11 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
ctx.stored_data.update(args["keys"])
if ctx.ui and f"_read_hints_{ctx.team}_{ctx.slot}" in args["keys"]:
ctx.ui.update_hints()
if f"_read_item_name_groups_{ctx.game}" in args["keys"]:
ctx.consume_network_item_groups()
if f"_read_location_name_groups_{ctx.game}" in args["keys"]:
ctx.consume_network_location_groups()
elif cmd == "SetReply":
ctx.stored_data[args["key"]] = args["value"]
if ctx.ui and f"_read_hints_{ctx.team}_{ctx.slot}" == args["key"]:
ctx.ui.update_hints()
elif f"_read_item_name_groups_{ctx.game}" == args["key"]:
ctx.consume_network_item_groups()
elif f"_read_location_name_groups_{ctx.game}" == args["key"]:
ctx.consume_network_location_groups()
elif args["key"].startswith("EnergyLink"):
ctx.current_energy_link_value = args["value"]
if ctx.ui:
@@ -1219,7 +1128,7 @@ def run_as_textclient(*args):
args = handle_url_arg(args, parser=parser)
# use colorama to display colored text highlighting on windows
colorama.just_fix_windows_console()
colorama.init()
asyncio.run(main(args))
colorama.deinit()

View File

@@ -1,100 +0,0 @@
# hadolint global ignore=SC1090,SC1091
# Source
FROM scratch AS release
WORKDIR /release
ADD https://github.com/Ijwu/Enemizer/releases/latest/download/ubuntu.16.04-x64.zip Enemizer.zip
# Enemizer
FROM alpine:3.21 AS enemizer
ARG TARGETARCH
WORKDIR /release
COPY --from=release /release/Enemizer.zip .
# No release for arm architecture. Skip.
RUN if [ "$TARGETARCH" = "amd64" ]; then \
apk add unzip=6.0-r15 --no-cache && \
unzip -u Enemizer.zip -d EnemizerCLI && \
chmod -R 777 EnemizerCLI; \
else touch EnemizerCLI; fi
# Cython builder stage
FROM python:3.12 AS cython-builder
WORKDIR /build
# Copy and install requirements first (better caching)
COPY requirements.txt WebHostLib/requirements.txt
RUN pip install --no-cache-dir -r \
WebHostLib/requirements.txt \
"setuptools>=75,<81"
COPY _speedups.pyx .
COPY intset.h .
RUN cythonize -b -i _speedups.pyx
# Archipelago
FROM python:3.12-slim-bookworm AS archipelago
ARG TARGETARCH
ENV VIRTUAL_ENV=/opt/venv
ENV PYTHONUNBUFFERED=1
WORKDIR /app
# Install requirements
# hadolint ignore=DL3008
RUN apt-get update && \
apt-get install -y --no-install-recommends \
git \
gcc=4:12.2.0-3 \
libc6-dev \
libtk8.6=8.6.13-2 \
g++=4:12.2.0-3 \
curl && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*
# Create and activate venv
RUN python -m venv $VIRTUAL_ENV; \
. $VIRTUAL_ENV/bin/activate
# Copy and install requirements first (better caching)
COPY WebHostLib/requirements.txt WebHostLib/requirements.txt
RUN pip install --no-cache-dir -r \
WebHostLib/requirements.txt \
gunicorn==23.0.0
COPY . .
COPY --from=cython-builder /build/*.so ./
# Run ModuleUpdate
RUN python ModuleUpdate.py -y
# Purge unneeded packages
RUN apt-get purge -y \
git \
gcc \
libc6-dev \
g++ && \
apt-get autoremove -y
# Copy necessary components
COPY --from=enemizer /release/EnemizerCLI /tmp/EnemizerCLI
# No release for arm architecture. Skip.
RUN if [ "$TARGETARCH" = "amd64" ]; then \
cp -r /tmp/EnemizerCLI EnemizerCLI; \
fi; \
rm -rf /tmp/EnemizerCLI
# Define health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
CMD curl -f http://localhost:${PORT:-80} || exit 1
# Ensure no runtime ModuleUpdate.
ENV SKIP_REQUIREMENTS_UPDATE=true
ENTRYPOINT [ "python", "WebHost.py" ]

267
FF1Client.py Normal file
View File

@@ -0,0 +1,267 @@
import asyncio
import copy
import json
import time
from asyncio import StreamReader, StreamWriter
from typing import List
import Utils
from Utils import async_start
from CommonClient import CommonContext, server_loop, gui_enabled, ClientCommandProcessor, logger, \
get_base_parser
SYSTEM_MESSAGE_ID = 0
CONNECTION_TIMING_OUT_STATUS = "Connection timing out. Please restart your emulator, then restart connector_ff1.lua"
CONNECTION_REFUSED_STATUS = "Connection Refused. Please start your emulator and make sure connector_ff1.lua is running"
CONNECTION_RESET_STATUS = "Connection was reset. Please restart your emulator, then restart connector_ff1.lua"
CONNECTION_TENTATIVE_STATUS = "Initial Connection Made"
CONNECTION_CONNECTED_STATUS = "Connected"
CONNECTION_INITIAL_STATUS = "Connection has not been initiated"
DISPLAY_MSGS = True
class FF1CommandProcessor(ClientCommandProcessor):
def __init__(self, ctx: CommonContext):
super().__init__(ctx)
def _cmd_nes(self):
"""Check NES Connection State"""
if isinstance(self.ctx, FF1Context):
logger.info(f"NES Status: {self.ctx.nes_status}")
def _cmd_toggle_msgs(self):
"""Toggle displaying messages in EmuHawk"""
global DISPLAY_MSGS
DISPLAY_MSGS = not DISPLAY_MSGS
logger.info(f"Messages are now {'enabled' if DISPLAY_MSGS else 'disabled'}")
class FF1Context(CommonContext):
command_processor = FF1CommandProcessor
game = 'Final Fantasy'
items_handling = 0b111 # full remote
def __init__(self, server_address, password):
super().__init__(server_address, password)
self.nes_streams: (StreamReader, StreamWriter) = None
self.nes_sync_task = None
self.messages = {}
self.locations_array = None
self.nes_status = CONNECTION_INITIAL_STATUS
self.awaiting_rom = False
self.display_msgs = True
async def server_auth(self, password_requested: bool = False):
if password_requested and not self.password:
await super(FF1Context, self).server_auth(password_requested)
if not self.auth:
self.awaiting_rom = True
logger.info('Awaiting connection to NES to get Player information')
return
await self.send_connect()
def _set_message(self, msg: str, msg_id: int):
if DISPLAY_MSGS:
self.messages[time.time(), msg_id] = msg
def on_package(self, cmd: str, args: dict):
if cmd == 'Connected':
async_start(parse_locations(self.locations_array, self, True))
elif cmd == 'Print':
msg = args['text']
if ': !' not in msg:
self._set_message(msg, SYSTEM_MESSAGE_ID)
def on_print_json(self, args: dict):
if self.ui:
self.ui.print_json(copy.deepcopy(args["data"]))
else:
text = self.jsontotextparser(copy.deepcopy(args["data"]))
logger.info(text)
relevant = args.get("type", None) in {"Hint", "ItemSend"}
if relevant:
item = args["item"]
# goes to this world
if self.slot_concerns_self(args["receiving"]):
relevant = True
# found in this world
elif self.slot_concerns_self(item.player):
relevant = True
# not related
else:
relevant = False
if relevant:
item = args["item"]
msg = self.raw_text_parser(copy.deepcopy(args["data"]))
self._set_message(msg, item.item)
def run_gui(self):
from kvui import GameManager
class FF1Manager(GameManager):
logging_pairs = [
("Client", "Archipelago")
]
base_title = "Archipelago Final Fantasy 1 Client"
self.ui = FF1Manager(self)
self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI")
def get_payload(ctx: FF1Context):
current_time = time.time()
return json.dumps(
{
"items": [item.item for item in ctx.items_received],
"messages": {f'{key[0]}:{key[1]}': value for key, value in ctx.messages.items()
if key[0] > current_time - 10}
}
)
async def parse_locations(locations_array: List[int], ctx: FF1Context, force: bool):
if locations_array == ctx.locations_array and not force:
return
else:
# print("New values")
ctx.locations_array = locations_array
locations_checked = []
if len(locations_array) > 0xFE and locations_array[0xFE] & 0x02 != 0 and not ctx.finished_game:
await ctx.send_msgs([
{"cmd": "StatusUpdate",
"status": 30}
])
ctx.finished_game = True
for location in ctx.missing_locations:
# index will be - 0x100 or 0x200
index = location
if location < 0x200:
# Location is a chest
index -= 0x100
flag = 0x04
else:
# Location is an NPC
index -= 0x200
flag = 0x02
# print(f"Location: {ctx.location_names[location]}")
# print(f"Index: {str(hex(index))}")
# print(f"value: {locations_array[index] & flag != 0}")
if locations_array[index] & flag != 0:
locations_checked.append(location)
if locations_checked:
# print([ctx.location_names[location] for location in locations_checked])
await ctx.send_msgs([
{"cmd": "LocationChecks",
"locations": locations_checked}
])
async def nes_sync_task(ctx: FF1Context):
logger.info("Starting nes connector. Use /nes for status information")
while not ctx.exit_event.is_set():
error_status = None
if ctx.nes_streams:
(reader, writer) = ctx.nes_streams
msg = get_payload(ctx).encode()
writer.write(msg)
writer.write(b'\n')
try:
await asyncio.wait_for(writer.drain(), timeout=1.5)
try:
# Data will return a dict with up to two fields:
# 1. A keepalive response of the Players Name (always)
# 2. An array representing the memory values of the locations area (if in game)
data = await asyncio.wait_for(reader.readline(), timeout=5)
data_decoded = json.loads(data.decode())
# print(data_decoded)
if ctx.game is not None and 'locations' in data_decoded:
# Not just a keep alive ping, parse
async_start(parse_locations(data_decoded['locations'], ctx, False))
if not ctx.auth:
ctx.auth = ''.join([chr(i) for i in data_decoded['playerName'] if i != 0])
if ctx.auth == '':
logger.info("Invalid ROM detected. No player name built into the ROM. Please regenerate"
"the ROM using the same link but adding your slot name")
if ctx.awaiting_rom:
await ctx.server_auth(False)
except asyncio.TimeoutError:
logger.debug("Read Timed Out, Reconnecting")
error_status = CONNECTION_TIMING_OUT_STATUS
writer.close()
ctx.nes_streams = None
except ConnectionResetError as e:
logger.debug("Read failed due to Connection Lost, Reconnecting")
error_status = CONNECTION_RESET_STATUS
writer.close()
ctx.nes_streams = None
except TimeoutError:
logger.debug("Connection Timed Out, Reconnecting")
error_status = CONNECTION_TIMING_OUT_STATUS
writer.close()
ctx.nes_streams = None
except ConnectionResetError:
logger.debug("Connection Lost, Reconnecting")
error_status = CONNECTION_RESET_STATUS
writer.close()
ctx.nes_streams = None
if ctx.nes_status == CONNECTION_TENTATIVE_STATUS:
if not error_status:
logger.info("Successfully Connected to NES")
ctx.nes_status = CONNECTION_CONNECTED_STATUS
else:
ctx.nes_status = f"Was tentatively connected but error occured: {error_status}"
elif error_status:
ctx.nes_status = error_status
logger.info("Lost connection to nes and attempting to reconnect. Use /nes for status updates")
else:
try:
logger.debug("Attempting to connect to NES")
ctx.nes_streams = await asyncio.wait_for(asyncio.open_connection("localhost", 52980), timeout=10)
ctx.nes_status = CONNECTION_TENTATIVE_STATUS
except TimeoutError:
logger.debug("Connection Timed Out, Trying Again")
ctx.nes_status = CONNECTION_TIMING_OUT_STATUS
continue
except ConnectionRefusedError:
logger.debug("Connection Refused, Trying Again")
ctx.nes_status = CONNECTION_REFUSED_STATUS
continue
if __name__ == '__main__':
# Text Mode to use !hint and such with games that have no text entry
Utils.init_logging("FF1Client")
options = Utils.get_options()
DISPLAY_MSGS = options["ffr_options"]["display_msgs"]
async def main(args):
ctx = FF1Context(args.connect, args.password)
ctx.server_task = asyncio.create_task(server_loop(ctx), name="ServerLoop")
if gui_enabled:
ctx.run_gui()
ctx.run_cli()
ctx.nes_sync_task = asyncio.create_task(nes_sync_task(ctx), name="NES Sync")
await ctx.exit_event.wait()
ctx.server_address = None
await ctx.shutdown()
if ctx.nes_sync_task:
await ctx.nes_sync_task
import colorama
parser = get_base_parser()
args = parser.parse_args()
colorama.init()
asyncio.run(main(args))
colorama.deinit()

12
FactorioClient.py Normal file
View File

@@ -0,0 +1,12 @@
from __future__ import annotations
import ModuleUpdate
ModuleUpdate.update()
from worlds.factorio.Client import check_stdin, launch
import Utils
if __name__ == "__main__":
Utils.init_logging("FactorioClient", exception_logger="Client")
check_stdin()
launch()

497
Fill.py
View File

@@ -4,7 +4,7 @@ import logging
import typing
from collections import Counter, deque
from BaseClasses import CollectionState, Item, Location, LocationProgressType, MultiWorld, PlandoItemBlock
from BaseClasses import CollectionState, Item, Location, LocationProgressType, MultiWorld
from Options import Accessibility
from worlds.AutoWorld import call_all
@@ -75,11 +75,9 @@ def fill_restrictive(multiworld: MultiWorld, base_state: CollectionState, locati
items_to_place.append(reachable_items[next_player].pop())
for item in items_to_place:
# The items added into `reachable_items` are placed starting from the end of each deque in
# `reachable_items`, so the items being placed are more likely to be found towards the end of `item_pool`.
for p, pool_item in enumerate(reversed(item_pool), start=1):
for p, pool_item in enumerate(item_pool):
if pool_item is item:
del item_pool[-p]
item_pool.pop(p)
break
maximum_exploration_state = sweep_from_pool(
@@ -100,7 +98,7 @@ def fill_restrictive(multiworld: MultiWorld, base_state: CollectionState, locati
# if minimal accessibility, only check whether location is reachable if game not beatable
if multiworld.worlds[item_to_place.player].options.accessibility == Accessibility.option_minimal:
perform_access_check = not multiworld.has_beaten_game(maximum_exploration_state,
item_to_place.player) \
item_to_place.player) \
if single_player_placement else not has_beaten_game
else:
perform_access_check = True
@@ -116,23 +114,12 @@ def fill_restrictive(multiworld: MultiWorld, base_state: CollectionState, locati
else:
# we filled all reachable spots.
if swap:
# Keep a cache of previous safe swap states that might be usable to sweep from to produce the next
# swap state, instead of sweeping from `base_state` each time.
previous_safe_swap_state_cache: typing.Deque[CollectionState] = deque()
# Almost never are more than 2 states needed. The rare cases that do are usually highly restrictive
# single_player_placement=True pre-fills which can go through more than 10 states in some seeds.
max_swap_base_state_cache_length = 3
# try swapping this item with previously placed items in a safe way then in an unsafe way
swap_attempts = ((i, location, unsafe)
for unsafe in (False, True)
for i, location in enumerate(placements))
for (i, location, unsafe) in swap_attempts:
placed_item = location.item
if item_to_place == placed_item:
# The number of allowed swaps is limited, so do not allow a swap of an item with a copy of
# itself.
continue
# Unplaceable items can sometimes be swapped infinitely. Limit the
# number of times we will swap an individual item to prevent this
swap_count = swapped_items[placed_item.player, placed_item.name, unsafe]
@@ -141,50 +128,40 @@ def fill_restrictive(multiworld: MultiWorld, base_state: CollectionState, locati
location.item = None
placed_item.location = None
for previous_safe_swap_state in previous_safe_swap_state_cache:
# If a state has already checked the location of the swap, then it cannot be used.
if location not in previous_safe_swap_state.advancements:
# Previous swap states will have collected all items in `item_pool`, so the new
# `swap_state` can skip having to collect them again.
# Previous swap states will also have already checked many locations, making the sweep
# faster.
swap_state = sweep_from_pool(previous_safe_swap_state, (placed_item,) if unsafe else (),
multiworld.get_filled_locations(item.player)
if single_player_placement else None)
break
else:
# No previous swap_state was usable as a base state to sweep from, so create a new one.
swap_state = sweep_from_pool(base_state, [placed_item, *item_pool] if unsafe else item_pool,
multiworld.get_filled_locations(item.player)
if single_player_placement else None)
# Unsafe states should not be added to the cache because they have collected `placed_item`.
if not unsafe:
if len(previous_safe_swap_state_cache) >= max_swap_base_state_cache_length:
# Remove the oldest cached state.
previous_safe_swap_state_cache.pop()
# Add the new state to the start of the cache.
previous_safe_swap_state_cache.appendleft(swap_state)
swap_state = sweep_from_pool(base_state, [placed_item, *item_pool] if unsafe else item_pool,
multiworld.get_filled_locations(item.player)
if single_player_placement else None)
# unsafe means swap_state assumes we can somehow collect placed_item before item_to_place
# by continuing to swap, which is not guaranteed. This is unsafe because there is no mechanic
# to clean that up later, so there is a chance generation fails.
if (not single_player_placement or location.player == item_to_place.player) \
and location.can_fill(swap_state, item_to_place, perform_access_check):
# Add this item to the existing placement, and
# add the old item to the back of the queue
spot_to_fill = placements.pop(i)
swap_count += 1
swapped_items[placed_item.player, placed_item.name, unsafe] = swap_count
# Verify placing this item won't reduce available locations, which would be a useless swap.
prev_state = swap_state.copy()
prev_loc_count = len(
multiworld.get_reachable_locations(prev_state))
reachable_items[placed_item.player].appendleft(
placed_item)
item_pool.append(placed_item)
swap_state.collect(item_to_place, True)
new_loc_count = len(
multiworld.get_reachable_locations(swap_state))
# cleanup at the end to hopefully get better errors
cleanup_required = True
if new_loc_count >= prev_loc_count:
# Add this item to the existing placement, and
# add the old item to the back of the queue
spot_to_fill = placements.pop(i)
break
swap_count += 1
swapped_items[placed_item.player, placed_item.name, unsafe] = swap_count
reachable_items[placed_item.player].appendleft(
placed_item)
item_pool.append(placed_item)
# cleanup at the end to hopefully get better errors
cleanup_required = True
break
# Item can't be placed here, restore original item
location.item = placed_item
@@ -263,7 +240,7 @@ def remaining_fill(multiworld: MultiWorld,
unplaced_items: typing.List[Item] = []
placements: typing.List[Location] = []
swapped_items: typing.Counter[typing.Tuple[int, str]] = Counter()
total = min(len(itempool), len(locations))
total = min(len(itempool), len(locations))
placed = 0
# Optimisation: Decide whether to do full location.can_fill check (respect excluded), or only check the item rule
@@ -362,26 +339,19 @@ def fast_fill(multiworld: MultiWorld,
return item_pool[placing:], fill_locations[placing:]
def accessibility_corrections(multiworld: MultiWorld,
state: CollectionState,
locations: list[Location],
pool: list[Item] | None = None) -> None:
if pool is None:
pool = []
def accessibility_corrections(multiworld: MultiWorld, state: CollectionState, locations, pool=[]):
maximum_exploration_state = sweep_from_pool(state, pool)
minimal_players = {player for player in multiworld.player_ids if
multiworld.worlds[player].options.accessibility == "minimal"}
unreachable_locations = [location for location in multiworld.get_locations() if
location.player in minimal_players and
minimal_players = {player for player in multiworld.player_ids if multiworld.worlds[player].options.accessibility == "minimal"}
unreachable_locations = [location for location in multiworld.get_locations() if location.player in minimal_players and
not location.can_reach(maximum_exploration_state)]
for location in unreachable_locations:
if (location.item is not None and location.item.advancement and location.address is not None and not
location.locked and location.item.player not in minimal_players):
pool.append(location.item)
state.remove(location.item)
location.item = None
if location in state.advancements:
state.advancements.remove(location)
state.remove(location.item)
locations.append(location)
if pool and locations:
locations.sort(key=lambda loc: loc.progress_type != LocationProgressType.PRIORITY)
@@ -393,7 +363,7 @@ def inaccessible_location_rules(multiworld: MultiWorld, state: CollectionState,
unreachable_locations = [location for location in locations if not location.can_reach(maximum_exploration_state)]
if unreachable_locations:
def forbid_important_item_rule(item: Item):
return not ((item.classification & 0b0011) and multiworld.worlds[item.player].options.accessibility != "minimal")
return not ((item.classification & 0b0011) and multiworld.worlds[item.player].options.accessibility != 'minimal')
for location in unreachable_locations:
add_item_rule(location, forbid_important_item_rule)
@@ -487,12 +457,6 @@ def distribute_early_items(multiworld: MultiWorld,
def distribute_items_restrictive(multiworld: MultiWorld,
panic_method: typing.Literal["swap", "raise", "start_inventory"] = "swap") -> None:
assert all(item.location is None for item in multiworld.itempool), (
"At the start of distribute_items_restrictive, "
"there are items in the multiworld itempool that are already placed on locations:\n"
f"{[(item.location, item) for item in multiworld.itempool if item.location is not None]}"
)
fill_locations = sorted(multiworld.get_unfilled_locations())
multiworld.random.shuffle(fill_locations)
# get items to distribute
@@ -535,64 +499,29 @@ def distribute_items_restrictive(multiworld: MultiWorld,
single_player = multiworld.players == 1 and not multiworld.groups
if prioritylocations:
regular_progression = []
deprioritized_progression = []
for item in progitempool:
if item.deprioritized:
deprioritized_progression.append(item)
else:
regular_progression.append(item)
# "priority fill"
# try without deprioritized items in the mix at all. This means they need to be collected into state first.
priority_fill_state = sweep_from_pool(multiworld.state, deprioritized_progression)
fill_restrictive(multiworld, priority_fill_state, prioritylocations, regular_progression,
fill_restrictive(multiworld, multiworld.state, prioritylocations, progitempool,
single_player_placement=single_player, swap=False, on_place=mark_for_locking,
name="Priority", one_item_per_player=True, allow_partial=True)
if prioritylocations and regular_progression:
if prioritylocations:
# retry with one_item_per_player off because some priority fills can fail to fill with that optimization
# deprioritized items are still not in the mix, so they need to be collected into state first.
# allow_partial should only be set if there is deprioritized progression to fall back on.
priority_retry_state = sweep_from_pool(multiworld.state, deprioritized_progression)
fill_restrictive(multiworld, priority_retry_state, prioritylocations, regular_progression,
single_player_placement=single_player, swap=False, on_place=mark_for_locking,
name="Priority Retry", one_item_per_player=False,
allow_partial=bool(deprioritized_progression))
if prioritylocations and deprioritized_progression:
# There are no more regular progression items that can be placed on any priority locations.
# We'd still prefer to place deprioritized progression items on priority locations over filler items.
# Since we're leaving out the remaining regular progression now, we need to collect it into state first.
priority_retry_2_state = sweep_from_pool(multiworld.state, regular_progression)
fill_restrictive(multiworld, priority_retry_2_state, prioritylocations, deprioritized_progression,
single_player_placement=single_player, swap=False, on_place=mark_for_locking,
name="Priority Retry 2", one_item_per_player=True, allow_partial=True)
if prioritylocations and deprioritized_progression:
# retry with deprioritized items AND without one_item_per_player optimisation
# Since we're leaving out the remaining regular progression now, we need to collect it into state first.
priority_retry_3_state = sweep_from_pool(multiworld.state, regular_progression)
fill_restrictive(multiworld, priority_retry_3_state, prioritylocations, deprioritized_progression,
single_player_placement=single_player, swap=False, on_place=mark_for_locking,
name="Priority Retry 3", one_item_per_player=False)
# restore original order of progitempool
progitempool[:] = [item for item in progitempool if not item.location]
fill_restrictive(multiworld, multiworld.state, prioritylocations, progitempool,
single_player_placement=single_player, swap=False, on_place=mark_for_locking,
name="Priority Retry", one_item_per_player=False)
accessibility_corrections(multiworld, multiworld.state, prioritylocations, progitempool)
defaultlocations = prioritylocations + defaultlocations
if progitempool:
# "advancement/progression fill"
maximum_exploration_state = sweep_from_pool(multiworld.state)
if panic_method == "swap":
fill_restrictive(multiworld, maximum_exploration_state, defaultlocations, progitempool, swap=True,
fill_restrictive(multiworld, multiworld.state, defaultlocations, progitempool, swap=True,
name="Progression", single_player_placement=single_player)
elif panic_method == "raise":
fill_restrictive(multiworld, maximum_exploration_state, defaultlocations, progitempool, swap=False,
fill_restrictive(multiworld, multiworld.state, defaultlocations, progitempool, swap=False,
name="Progression", single_player_placement=single_player)
elif panic_method == "start_inventory":
fill_restrictive(multiworld, maximum_exploration_state, defaultlocations, progitempool, swap=False,
fill_restrictive(multiworld, multiworld.state, defaultlocations, progitempool, swap=False,
allow_partial=True, name="Progression", single_player_placement=single_player)
if progitempool:
for item in progitempool:
@@ -743,9 +672,9 @@ def balance_multiworld_progression(multiworld: MultiWorld) -> None:
if multiworld.worlds[player].options.progression_balancing > 0
}
if not balanceable_players:
logging.info("Skipping multiworld progression balancing.")
logging.info('Skipping multiworld progression balancing.')
else:
logging.info(f"Balancing multiworld progression for {len(balanceable_players)} Players.")
logging.info(f'Balancing multiworld progression for {len(balanceable_players)} Players.')
logging.debug(balanceable_players)
state: CollectionState = CollectionState(multiworld)
checked_locations: typing.Set[Location] = set()
@@ -843,7 +772,7 @@ def balance_multiworld_progression(multiworld: MultiWorld) -> None:
if player in threshold_percentages):
break
elif not balancing_sphere:
raise RuntimeError("Not all required items reachable. Something went terribly wrong here.")
raise RuntimeError('Not all required items reachable. Something went terribly wrong here.')
# Gather a set of locations which we can swap items into
unlocked_locations: typing.Dict[int, typing.Set[Location]] = collections.defaultdict(set)
for l in unchecked_locations:
@@ -859,8 +788,8 @@ def balance_multiworld_progression(multiworld: MultiWorld) -> None:
testing = items_to_test.pop()
reducing_state = state.copy()
for location in itertools.chain((
l for l in items_to_replace
if l.item.player == player
l for l in items_to_replace
if l.item.player == player
), items_to_test):
reducing_state.collect(location.item, True, location)
@@ -933,30 +862,52 @@ def swap_location_item(location_1: Location, location_2: Location, check_locked:
location_2.item.location = location_2
def parse_planned_blocks(multiworld: MultiWorld) -> dict[int, list[PlandoItemBlock]]:
def warn(warning: str, force: bool | str) -> None:
if isinstance(force, bool):
logging.warning(f"{warning}")
def distribute_planned(multiworld: MultiWorld) -> None:
def warn(warning: str, force: typing.Union[bool, str]) -> None:
if force in [True, 'fail', 'failure', 'none', False, 'warn', 'warning']:
logging.warning(f'{warning}')
else:
logging.debug(f"{warning}")
logging.debug(f'{warning}')
def failed(warning: str, force: bool | str) -> None:
if force is True:
def failed(warning: str, force: typing.Union[bool, str]) -> None:
if force in [True, 'fail', 'failure']:
raise Exception(warning)
else:
warn(warning, force)
swept_state = multiworld.state.copy()
swept_state.sweep_for_advancements()
reachable = frozenset(multiworld.get_reachable_locations(swept_state))
early_locations: typing.Dict[int, typing.List[str]] = collections.defaultdict(list)
non_early_locations: typing.Dict[int, typing.List[str]] = collections.defaultdict(list)
for loc in multiworld.get_unfilled_locations():
if loc in reachable:
early_locations[loc.player].append(loc.name)
else: # not reachable with swept state
non_early_locations[loc.player].append(loc.name)
world_name_lookup = multiworld.world_name_lookup
plando_blocks: dict[int, list[PlandoItemBlock]] = dict()
player_ids: set[int] = set(multiworld.player_ids)
block_value = typing.Union[typing.List[str], typing.Dict[str, typing.Any], str]
plando_blocks: typing.List[typing.Dict[str, typing.Any]] = []
player_ids = set(multiworld.player_ids)
for player in player_ids:
plando_blocks[player] = []
for block in multiworld.worlds[player].options.plando_items:
new_block: PlandoItemBlock = PlandoItemBlock(player, block.from_pool, block.force)
target_world = block.world
for block in multiworld.plando_items[player]:
block['player'] = player
if 'force' not in block:
block['force'] = 'silent'
if 'from_pool' not in block:
block['from_pool'] = True
elif not isinstance(block['from_pool'], bool):
from_pool_type = type(block['from_pool'])
raise Exception(f'Plando "from_pool" has to be boolean, not {from_pool_type} for player {player}.')
if 'world' not in block:
target_world = False
else:
target_world = block['world']
if target_world is False or multiworld.players == 1: # target own world
worlds: set[int] = {player}
worlds: typing.Set[int] = {player}
elif target_world is True: # target any worlds besides own
worlds = set(multiworld.player_ids) - {player}
elif target_world is None: # target all worlds
@@ -965,201 +916,173 @@ def parse_planned_blocks(multiworld: MultiWorld) -> dict[int, list[PlandoItemBlo
worlds = set()
for listed_world in target_world:
if listed_world not in world_name_lookup:
failed(f"Cannot place item to {listed_world}'s world as that world does not exist.",
block.force)
failed(f"Cannot place item to {target_world}'s world as that world does not exist.",
block['force'])
continue
worlds.add(world_name_lookup[listed_world])
elif type(target_world) == int: # target world by slot number
if target_world not in range(1, multiworld.players + 1):
failed(
f"Cannot place item in world {target_world} as it is not in range of (1, {multiworld.players})",
block.force)
block['force'])
continue
worlds = {target_world}
else: # target world by slot name
if target_world not in world_name_lookup:
failed(f"Cannot place item to {target_world}'s world as that world does not exist.",
block.force)
block['force'])
continue
worlds = {world_name_lookup[target_world]}
new_block.worlds = worlds
block['world'] = worlds
items: list[str] | dict[str, typing.Any] = block.items
items: block_value = []
if "items" in block:
items = block["items"]
if 'count' not in block:
block['count'] = False
elif "item" in block:
items = block["item"]
if 'count' not in block:
block['count'] = 1
else:
failed("You must specify at least one item to place items with plando.", block['force'])
continue
if isinstance(items, dict):
item_list: list[str] = []
item_list: typing.List[str] = []
for key, value in items.items():
if value is True:
value = multiworld.itempool.count(multiworld.worlds[player].create_item(key))
item_list += [key] * value
items = item_list
new_block.items = items
if isinstance(items, str):
items = [items]
block['items'] = items
locations: list[str] = block.locations
locations: block_value = []
if 'location' in block:
locations = block['location'] # just allow 'location' to keep old yamls compatible
elif 'locations' in block:
locations = block['locations']
if isinstance(locations, str):
locations = [locations]
resolved_locations: list[Location] = []
for target_player in worlds:
locations_from_groups: list[str] = []
world_locations = multiworld.get_unfilled_locations(target_player)
for group in multiworld.worlds[target_player].location_name_groups:
if group in locations:
locations_from_groups.extend(multiworld.worlds[target_player].location_name_groups[group])
resolved_locations.extend(location for location in world_locations
if location.name in [*locations, *locations_from_groups])
new_block.locations = sorted(dict.fromkeys(locations))
new_block.resolved_locations = sorted(set(resolved_locations))
if isinstance(locations, dict):
location_list = []
for key, value in locations.items():
location_list += [key] * value
locations = location_list
count = block.count
if not count:
count = (min(len(new_block.items), len(new_block.resolved_locations))
if new_block.resolved_locations else len(new_block.items))
if isinstance(count, int):
count = {"min": count, "max": count}
if "min" not in count:
count["min"] = 0
if "max" not in count:
count["max"] = (min(len(new_block.items), len(new_block.resolved_locations))
if new_block.resolved_locations else len(new_block.items))
new_block.count = count
plando_blocks[player].append(new_block)
return plando_blocks
def resolve_early_locations_for_planned(multiworld: MultiWorld):
def warn(warning: str, force: bool | str) -> None:
if isinstance(force, bool):
logging.warning(f"{warning}")
else:
logging.debug(f"{warning}")
def failed(warning: str, force: bool | str) -> None:
if force is True:
raise Exception(warning)
else:
warn(warning, force)
swept_state = multiworld.state.copy()
swept_state.sweep_for_advancements()
reachable = frozenset(multiworld.get_reachable_locations(swept_state))
early_locations: dict[int, list[Location]] = collections.defaultdict(list)
non_early_locations: dict[int, list[Location]] = collections.defaultdict(list)
for loc in multiworld.get_unfilled_locations():
if loc in reachable:
early_locations[loc.player].append(loc)
else: # not reachable with swept state
non_early_locations[loc.player].append(loc)
for player in multiworld.plando_item_blocks:
removed = []
for block in multiworld.plando_item_blocks[player]:
locations = block.locations
resolved_locations = block.resolved_locations
worlds = block.worlds
if "early_locations" in locations:
locations.remove("early_locations")
for target_player in worlds:
resolved_locations += early_locations[target_player]
locations += early_locations[target_player]
if "non_early_locations" in locations:
locations.remove("non_early_locations")
for target_player in worlds:
resolved_locations += non_early_locations[target_player]
locations += non_early_locations[target_player]
if block.count["max"] > len(block.items):
count = block.count["max"]
failed(f"Plando count {count} greater than items specified", block.force)
block.count["max"] = len(block.items)
if block.count["min"] > len(block.items):
block.count["min"] = len(block.items)
if block.count["max"] > len(block.resolved_locations) > 0:
count = block.count["max"]
failed(f"Plando count {count} greater than locations specified", block.force)
block.count["max"] = len(block.resolved_locations)
if block.count["min"] > len(block.resolved_locations):
block.count["min"] = len(block.resolved_locations)
block.count["target"] = multiworld.random.randint(block.count["min"],
block.count["max"])
block['locations'] = list(dict.fromkeys(locations))
if not block.count["target"]:
removed.append(block)
if not block['count']:
block['count'] = (min(len(block['items']), len(block['locations'])) if
len(block['locations']) > 0 else len(block['items']))
if isinstance(block['count'], int):
block['count'] = {'min': block['count'], 'max': block['count']}
if 'min' not in block['count']:
block['count']['min'] = 0
if 'max' not in block['count']:
block['count']['max'] = (min(len(block['items']), len(block['locations'])) if
len(block['locations']) > 0 else len(block['items']))
if block['count']['max'] > len(block['items']):
count = block['count']
failed(f"Plando count {count} greater than items specified", block['force'])
block['count'] = len(block['items'])
if block['count']['max'] > len(block['locations']) > 0:
count = block['count']
failed(f"Plando count {count} greater than locations specified", block['force'])
block['count'] = len(block['locations'])
block['count']['target'] = multiworld.random.randint(block['count']['min'], block['count']['max'])
for block in removed:
multiworld.plando_item_blocks[player].remove(block)
def distribute_planned_blocks(multiworld: MultiWorld, plando_blocks: list[PlandoItemBlock]):
def warn(warning: str, force: bool | str) -> None:
if isinstance(force, bool):
logging.warning(f"{warning}")
else:
logging.debug(f"{warning}")
def failed(warning: str, force: bool | str) -> None:
if force is True:
raise Exception(warning)
else:
warn(warning, force)
if block['count']['target'] > 0:
plando_blocks.append(block)
# shuffle, but then sort blocks by number of locations minus number of items,
# so less-flexible blocks get priority
multiworld.random.shuffle(plando_blocks)
plando_blocks.sort(key=lambda block: (len(block.resolved_locations) - block.count["target"]
if len(block.resolved_locations) > 0
else len(multiworld.get_unfilled_locations(block.player)) -
block.count["target"]))
plando_blocks.sort(key=lambda block: (len(block['locations']) - block['count']['target']
if len(block['locations']) > 0
else len(multiworld.get_unfilled_locations(player)) - block['count']['target']))
for placement in plando_blocks:
player = placement.player
player = placement['player']
try:
worlds = placement.worlds
locations = placement.resolved_locations
items = placement.items
maxcount = placement.count["target"]
from_pool = placement.from_pool
worlds = placement['world']
locations = placement['locations']
items = placement['items']
maxcount = placement['count']['target']
from_pool = placement['from_pool']
item_candidates = []
if from_pool:
instances = [item for item in multiworld.itempool if item.player == player and item.name in items]
for item in multiworld.random.sample(items, maxcount):
candidate = next((i for i in instances if i.name == item), None)
if candidate is None:
warn(f"Could not remove {item} from pool for {multiworld.player_name[player]} as "
f"it's already missing from it", placement.force)
candidate = multiworld.worlds[player].create_item(item)
else:
multiworld.itempool.remove(candidate)
instances.remove(candidate)
item_candidates.append(candidate)
else:
item_candidates = [multiworld.worlds[player].create_item(item)
for item in multiworld.random.sample(items, maxcount)]
if any(item.code is None for item in item_candidates) \
and not all(item.code is None for item in item_candidates):
failed(f"Plando block for player {player} ({multiworld.player_name[player]}) contains both "
f"event items and non-event items. "
f"Event items: {[item for item in item_candidates if item.code is None]}, "
f"Non-event items: {[item for item in item_candidates if item.code is not None]}",
placement.force)
continue
else:
is_real = item_candidates[0].code is not None
candidates = [candidate for candidate in locations if candidate.item is None
and bool(candidate.address) == is_real]
candidates = list(multiworld.get_unfilled_locations_for_players(locations, sorted(worlds)))
multiworld.random.shuffle(candidates)
allstate = multiworld.get_all_state(False)
mincount = placement.count["min"]
allowed_margin = len(item_candidates) - mincount
fill_restrictive(multiworld, allstate, candidates, item_candidates, lock=True,
allow_partial=True, name="Plando Main Fill")
multiworld.random.shuffle(items)
count = 0
err: typing.List[str] = []
successful_pairs: typing.List[typing.Tuple[int, Item, Location]] = []
claimed_indices: typing.Set[typing.Optional[int]] = set()
for item_name in items:
index_to_delete: typing.Optional[int] = None
if from_pool:
try:
# If from_pool, try to find an existing item with this name & player in the itempool and use it
index_to_delete, item = next(
(i, item) for i, item in enumerate(multiworld.itempool)
if item.player == player and item.name == item_name and i not in claimed_indices
)
except StopIteration:
warn(
f"Could not remove {item_name} from pool for {multiworld.player_name[player]} as it's already missing from it.",
placement['force'])
item = multiworld.worlds[player].create_item(item_name)
else:
item = multiworld.worlds[player].create_item(item_name)
for location in reversed(candidates):
if (location.address is None) == (item.code is None): # either both None or both not None
if not location.item:
if location.item_rule(item):
if location.can_fill(multiworld.state, item, False):
successful_pairs.append((index_to_delete, item, location))
claimed_indices.add(index_to_delete)
candidates.remove(location)
count = count + 1
break
else:
err.append(f"Can't place item at {location} due to fill condition not met.")
else:
err.append(f"{item_name} not allowed at {location}.")
else:
err.append(f"Cannot place {item_name} into already filled location {location}.")
else:
err.append(f"Mismatch between {item_name} and {location}, only one is an event.")
if count == maxcount:
break
if count < placement['count']['min']:
m = placement['count']['min']
failed(
f"Plando block failed to place {m - count} of {m} item(s) for {multiworld.player_name[player]}, error(s): {' '.join(err)}",
placement['force'])
# Sort indices in reverse so we can remove them one by one
successful_pairs = sorted(successful_pairs, key=lambda successful_pair: successful_pair[0] or 0, reverse=True)
for (index, item, location) in successful_pairs:
multiworld.push_item(location, item, collect=False)
location.locked = True
logging.debug(f"Plando placed {item} at {location}")
if index is not None: # If this item is from_pool and was found in the pool, remove it.
multiworld.itempool.pop(index)
if len(item_candidates) > allowed_margin:
failed(f"Could not place {len(item_candidates)} "
f"of {mincount + allowed_margin} item(s) "
f"for {multiworld.player_name[player]}, "
f"remaining items: {item_candidates}",
placement.force)
if from_pool:
multiworld.itempool.extend([item for item in item_candidates if item.code is not None])
except Exception as e:
raise Exception(
f"Error running plando for player {player} ({multiworld.player_name[player]})") from e

View File

@@ -10,8 +10,8 @@ import sys
import urllib.parse
import urllib.request
from collections import Counter
from typing import Any, Dict, Tuple, Union
from itertools import chain
from typing import Any
import ModuleUpdate
@@ -23,7 +23,7 @@ from BaseClasses import seeddigits, get_seed, PlandoOptions
from Utils import parse_yamls, version_tuple, __version__, tuplize_version
def mystery_argparse(argv: list[str] | None = None):
def mystery_argparse():
from settings import get_settings
settings = get_settings()
defaults = settings.generator
@@ -54,22 +54,12 @@ def mystery_argparse(argv: list[str] | None = None):
parser.add_argument("--skip_output", action="store_true",
help="Skips generation assertion and output stages and skips multidata and spoiler output. "
"Intended for debugging and testing purposes.")
parser.add_argument("--spoiler_only", action="store_true",
help="Skips generation assertion and multidata, outputting only a spoiler log. "
"Intended for debugging and testing purposes.")
args = parser.parse_args(argv)
if args.skip_output and args.spoiler_only:
parser.error("Cannot mix --skip_output and --spoiler_only")
elif args.spoiler == 0 and args.spoiler_only:
parser.error("Cannot use --spoiler_only when --spoiler=0. Use --skip_output or set --spoiler to a different value")
args = parser.parse_args()
if not os.path.isabs(args.weights_file_path):
args.weights_file_path = os.path.join(args.player_files_path, args.weights_file_path)
if not os.path.isabs(args.meta_file_path):
args.meta_file_path = os.path.join(args.player_files_path, args.meta_file_path)
args.plando: PlandoOptions = PlandoOptions.from_option_string(args.plando)
return args
@@ -77,7 +67,7 @@ def get_seed_name(random_source) -> str:
return f"{random_source.randint(0, pow(10, seeddigits) - 1)}".zfill(seeddigits)
def main(args=None) -> tuple[argparse.Namespace, int]:
def main(args=None) -> Tuple[argparse.Namespace, int]:
# __name__ == "__main__" check so unittests that already imported worlds don't trip this.
if __name__ == "__main__" and "worlds" in sys.modules:
raise Exception("Worlds system should not be loaded before logging init.")
@@ -95,7 +85,7 @@ def main(args=None) -> tuple[argparse.Namespace, int]:
logging.info("Race mode enabled. Using non-deterministic random source.")
random.seed() # reset to time-based random source
weights_cache: dict[str, tuple[Any, ...]] = {}
weights_cache: Dict[str, Tuple[Any, ...]] = {}
if args.weights_file_path and os.path.exists(args.weights_file_path):
try:
weights_cache[args.weights_file_path] = read_weights_yamls(args.weights_file_path)
@@ -118,8 +108,6 @@ def main(args=None) -> tuple[argparse.Namespace, int]:
raise Exception("Cannot mix --sameoptions with --meta")
else:
meta_weights = None
player_id = 1
player_files = {}
for file in os.scandir(args.player_files_path):
@@ -166,12 +154,20 @@ def main(args=None) -> tuple[argparse.Namespace, int]:
f"A mix is also permitted.")
from worlds.AutoWorld import AutoWorldRegister
args.outputname = seed_name
args.sprite = dict.fromkeys(range(1, args.multi+1), None)
args.sprite_pool = dict.fromkeys(range(1, args.multi+1), None)
args.name = {}
from worlds.alttp.EntranceRandomizer import parse_arguments
erargs = parse_arguments(['--multi', str(args.multi)])
erargs.seed = seed
erargs.plando_options = args.plando
erargs.spoiler = args.spoiler
erargs.race = args.race
erargs.outputname = seed_name
erargs.outputpath = args.outputpath
erargs.skip_prog_balancing = args.skip_prog_balancing
erargs.skip_output = args.skip_output
erargs.name = {}
erargs.csv_output = args.csv_output
settings_cache: dict[str, tuple[argparse.Namespace, ...]] = \
settings_cache: Dict[str, Tuple[argparse.Namespace, ...]] = \
{fname: (tuple(roll_settings(yaml, args.plando) for yaml in yamls) if args.sameoptions else None)
for fname, yamls in weights_cache.items()}
@@ -189,11 +185,6 @@ def main(args=None) -> tuple[argparse.Namespace, int]:
yaml[category][key] = option
elif category_name not in yaml:
logging.warning(f"Meta: Category {category_name} is not present in {path}.")
elif key == "triggers":
if "triggers" not in yaml[category_name]:
yaml[category_name][key] = []
for trigger in option:
yaml[category_name][key].append(trigger)
else:
yaml[category_name][key] = option
@@ -201,34 +192,30 @@ def main(args=None) -> tuple[argparse.Namespace, int]:
for player in range(1, args.multi + 1):
player_path_cache[player] = player_files.get(player, args.weights_file_path)
name_counter = Counter()
args.player_options = {}
erargs.player_options = {}
player = 1
while player <= args.multi:
path = player_path_cache[player]
if path:
try:
settings: tuple[argparse.Namespace, ...] = settings_cache[path] if settings_cache[path] else \
settings: Tuple[argparse.Namespace, ...] = settings_cache[path] if settings_cache[path] else \
tuple(roll_settings(yaml, args.plando) for yaml in weights_cache[path])
for settingsObject in settings:
for k, v in vars(settingsObject).items():
if v is not None:
try:
getattr(args, k)[player] = v
getattr(erargs, k)[player] = v
except AttributeError:
setattr(args, k, {player: v})
setattr(erargs, k, {player: v})
except Exception as e:
raise Exception(f"Error setting {k} to {v} for player {player}") from e
# name was not specified
if player not in args.name:
if path == args.weights_file_path:
# weights file, so we need to make the name unique
args.name[player] = f"Player{player}"
else:
# use the filename
args.name[player] = os.path.splitext(os.path.split(path)[-1])[0]
args.name[player] = handle_name(args.name[player], player, name_counter)
if path == args.weights_file_path: # if name came from the weights file, just use base player name
erargs.name[player] = f"Player{player}"
elif player not in erargs.name: # if name was not specified, generate it from filename
erargs.name[player] = os.path.splitext(os.path.split(path)[-1])[0]
erargs.name[player] = handle_name(erargs.name[player], player, name_counter)
player += 1
except Exception as e:
@@ -236,13 +223,13 @@ def main(args=None) -> tuple[argparse.Namespace, int]:
else:
raise RuntimeError(f'No weights specified for player {player}')
if len(set(name.lower() for name in args.name.values())) != len(args.name):
raise Exception(f"Names have to be unique. Names: {Counter(name.lower() for name in args.name.values())}")
if len(set(name.lower() for name in erargs.name.values())) != len(erargs.name):
raise Exception(f"Names have to be unique. Names: {Counter(name.lower() for name in erargs.name.values())}")
return args, seed
return erargs, seed
def read_weights_yamls(path) -> tuple[Any, ...]:
def read_weights_yamls(path) -> Tuple[Any, ...]:
try:
if urllib.parse.urlparse(path).scheme in ('https', 'file'):
yaml = str(urllib.request.urlopen(path).read(), "utf-8-sig")
@@ -252,20 +239,7 @@ def read_weights_yamls(path) -> tuple[Any, ...]:
except Exception as e:
raise Exception(f"Failed to read weights ({path})") from e
from yaml.error import MarkedYAMLError
try:
return tuple(parse_yamls(yaml))
except MarkedYAMLError as ex:
if ex.problem_mark:
lines = yaml.splitlines()
if ex.context_mark:
relevant_lines = "\n".join(lines[ex.context_mark.line:ex.problem_mark.line+1])
else:
relevant_lines = lines[ex.problem_mark.line]
error_line = " " * ex.problem_mark.column + "^"
raise Exception(f"{ex.context} {ex.problem} on line {ex.problem_mark.line}:"
f"\n{relevant_lines}\n{error_line}")
raise ex
return tuple(parse_yamls(yaml))
def interpret_on_off(value) -> bool:
@@ -305,35 +279,33 @@ def get_choice(option, root, value=None) -> Any:
raise RuntimeError(f"All options specified in \"{option}\" are weighted as zero.")
class SafeFormatter(string.Formatter):
def get_value(self, key, args, kwargs):
if isinstance(key, int):
if key < len(args):
return args[key]
else:
return "{" + str(key) + "}"
else:
return kwargs.get(key, "{" + key + "}")
class SafeDict(dict):
def __missing__(self, key):
return '{' + key + '}'
def handle_name(name: str, player: int, name_counter: Counter):
name_counter[name.lower()] += 1
number = name_counter[name.lower()]
new_name = "%".join([x.replace("%number%", "{number}").replace("%player%", "{player}") for x in name.split("%%")])
new_name = SafeFormatter().vformat(new_name, (), {"number": number,
"NUMBER": (number if number > 1 else ''),
"player": player,
"PLAYER": (player if player > 1 else '')})
new_name = string.Formatter().vformat(new_name, (), SafeDict(number=number,
NUMBER=(number if number > 1 else ''),
player=player,
PLAYER=(player if player > 1 else '')))
# Run .strip twice for edge case where after the initial .slice new_name has a leading whitespace.
# Could cause issues for some clients that cannot handle the additional whitespace.
new_name = new_name.strip()[:16].strip()
if new_name == "Archipelago":
raise Exception(f"You cannot name yourself \"{new_name}\"")
return new_name
def roll_percentage(percentage: Union[int, float]) -> bool:
"""Roll a percentage chance.
percentage is expected to be in range [0, 100]"""
return random.random() < (float(percentage) / 100)
def update_weights(weights: dict, new_weights: dict, update_type: str, name: str) -> dict:
logging.debug(f'Applying {new_weights}')
cleaned_weights = {}
@@ -347,9 +319,7 @@ def update_weights(weights: dict, new_weights: dict, update_type: str, name: str
elif isinstance(new_value, list):
cleaned_value.extend(new_value)
elif isinstance(new_value, dict):
counter_value = Counter(cleaned_value)
counter_value.update(new_value)
cleaned_value = dict(counter_value)
cleaned_value = dict(Counter(cleaned_value) + Counter(new_value))
else:
raise Exception(f"Cannot apply merge to non-dict, set, or list type {option_name},"
f" received {type(new_value).__name__}.")
@@ -363,18 +333,13 @@ def update_weights(weights: dict, new_weights: dict, update_type: str, name: str
for element in new_value:
cleaned_value.remove(element)
elif isinstance(new_value, dict):
counter_value = Counter(cleaned_value)
counter_value.subtract(new_value)
cleaned_value = dict(counter_value)
cleaned_value = dict(Counter(cleaned_value) - Counter(new_value))
else:
raise Exception(f"Cannot apply remove to non-dict, set, or list type {option_name},"
f" received {type(new_value).__name__}.")
cleaned_weights[option_name] = cleaned_value
else:
# Options starting with + and - may modify values in-place, and new_weights may be shared by multiple slots
# using the same .yaml, so ensure that the new value is a copy.
cleaned_value = copy.deepcopy(new_weights[option])
cleaned_weights[option_name] = cleaned_value
cleaned_weights[option_name] = new_weights[option]
new_options = set(cleaned_weights) - set(weights)
weights.update(cleaned_weights)
if new_options:
@@ -385,7 +350,7 @@ def update_weights(weights: dict, new_weights: dict, update_type: str, name: str
return weights
def roll_meta_option(option_key, game: str, category_dict: dict) -> Any:
def roll_meta_option(option_key, game: str, category_dict: Dict) -> Any:
from worlds import AutoWorldRegister
if not game:
@@ -397,8 +362,6 @@ def roll_meta_option(option_key, game: str, category_dict: dict) -> Any:
if options[option_key].supports_weighting:
return get_choice(option_key, category_dict)
return category_dict[option_key]
if option_key == "triggers":
return category_dict[option_key]
raise Options.OptionError(f"Error generating meta option {option_key} for {game}.")
@@ -408,7 +371,7 @@ def roll_linked_options(weights: dict) -> dict:
if "name" not in option_set:
raise ValueError("One of your linked options does not have a name.")
try:
if Options.roll_percentage(option_set["percentage"]):
if roll_percentage(option_set["percentage"]):
logging.debug(f"Linked option {option_set['name']} triggered.")
new_options = option_set["options"]
for category_name, category_options in new_options.items():
@@ -441,7 +404,7 @@ def roll_triggers(weights: dict, triggers: list, valid_keys: set) -> dict:
trigger_result = get_choice("option_result", option_set)
result = get_choice(key, currently_targeted_weights)
currently_targeted_weights[key] = result
if result == trigger_result and Options.roll_percentage(get_choice("percentage", option_set, 100)):
if result == trigger_result and roll_percentage(get_choice("percentage", option_set, 100)):
for category_name, category_options in option_set["options"].items():
currently_targeted_weights = weights
if category_name:
@@ -472,14 +435,6 @@ def handle_option(ret: argparse.Namespace, game_weights: dict, option_key: str,
def roll_settings(weights: dict, plando_options: PlandoOptions = PlandoOptions.bosses):
"""
Roll options from specified weights, usually originating from a .yaml options file.
Important note:
The same weights dict is shared between all slots using the same yaml (e.g. generic weights file for filler slots).
This means it should never be modified without making a deepcopy first.
"""
from worlds import AutoWorldRegister
if "linked_options" in weights:
@@ -500,22 +455,7 @@ def roll_settings(weights: dict, plando_options: PlandoOptions = PlandoOptions.b
if required_plando_options:
raise Exception(f"Settings reports required plando module {str(required_plando_options)}, "
f"which is not enabled.")
games = requirements.get("game", {})
for game, version in games.items():
if game not in AutoWorldRegister.world_types:
continue
if not version:
raise Exception(f"Invalid version for game {game}: {version}.")
if isinstance(version, str):
version = {"min": version}
if "min" in version and tuplize_version(version["min"]) > AutoWorldRegister.world_types[game].world_version:
raise Exception(f"Settings reports required version of world \"{game}\" is at least {version['min']}, "
f"however world is of version "
f"{AutoWorldRegister.world_types[game].world_version.as_simple_string()}.")
if "max" in version and tuplize_version(version["max"]) < AutoWorldRegister.world_types[game].world_version:
raise Exception(f"Settings reports required version of world \"{game}\" is no later than {version['max']}, "
f"however world is of version "
f"{AutoWorldRegister.world_types[game].world_version.as_simple_string()}.")
ret = argparse.Namespace()
for option_key in Options.PerGameCommonOptions.type_hints:
if option_key in weights and option_key not in Options.CommonOptions.type_hints:
@@ -560,6 +500,10 @@ def roll_settings(weights: dict, plando_options: PlandoOptions = PlandoOptions.b
handle_option(ret, game_weights, option_key, option, plando_options)
valid_keys.add(option_key)
# TODO remove plando_items after moving it to the options system
valid_keys.add("plando_items")
if PlandoOptions.items in plando_options:
ret.plando_items = copy.deepcopy(game_weights.get("plando_items", []))
if ret.game == "A Link to the Past":
# TODO there are still more LTTP options not on the options system
valid_keys |= {"sprite_pool", "sprite", "random_sprite_on_event"}

9
KH1Client.py Normal file
View File

@@ -0,0 +1,9 @@
if __name__ == '__main__':
import ModuleUpdate
ModuleUpdate.update()
import Utils
Utils.init_logging("KH1Client", exception_logger="Client")
from worlds.kh1.Client import launch
launch()

8
KH2Client.py Normal file
View File

@@ -0,0 +1,8 @@
import ModuleUpdate
import Utils
from worlds.kh2.Client import launch
ModuleUpdate.update()
if __name__ == '__main__':
Utils.init_logging("KH2Client", exception_logger="Client")
launch()

View File

@@ -1,30 +1,29 @@
"""
Archipelago Launcher
Archipelago launcher for bundled app.
* If run with a patch file as argument, launch corresponding client with the patch file as an argument.
* If run with component name as argument, run it passing argv[2:] as arguments.
* If run without arguments or unknown arguments, open launcher GUI.
* if run with APBP as argument, launch corresponding client.
* if run with executable as argument, run it passing argv[2:] as arguments
* if run without arguments, open launcher GUI
Additional components can be added to worlds.LauncherComponents.components.
Scroll down to components= to add components to the launcher as well as setup.py
"""
import argparse
import itertools
import logging
import multiprocessing
import os
import shlex
import subprocess
import sys
import urllib.parse
import webbrowser
from collections.abc import Callable, Sequence
from os.path import isfile
from shutil import which
from typing import Any
from typing import Callable, Optional, Sequence, Tuple, Union
if __name__ == "__main__":
import ModuleUpdate
ModuleUpdate.update()
import settings
@@ -42,17 +41,13 @@ def open_host_yaml():
if is_linux:
exe = which('sensible-editor') or which('gedit') or \
which('xdg-open') or which('gnome-open') or which('kde-open')
subprocess.Popen([exe, file])
elif is_macos:
exe = which("open")
subprocess.Popen([exe, file])
else:
webbrowser.open(file)
return
env = os.environ
if "LD_LIBRARY_PATH" in env:
env = env.copy()
del env["LD_LIBRARY_PATH"] # exe is a system binary, so reset LD_LIBRARY_PATH
subprocess.Popen([exe, file], env=env)
def open_patch():
suffixes = []
@@ -75,17 +70,12 @@ def open_patch():
launch([*exe, file], component.cli)
def generate_yamls(*args):
def generate_yamls():
from Options import generate_yaml_templates
parser = argparse.ArgumentParser(description="Generate Template Options", usage="[-h] [--skip_open_folder]")
parser.add_argument("--skip_open_folder", action="store_true")
args = parser.parse_args(args)
target = Utils.user_path("Players", "Templates")
generate_yaml_templates(target, False)
if not args.skip_open_folder:
open_folder(target)
open_folder(target)
def browse_files():
@@ -95,20 +85,12 @@ def browse_files():
def open_folder(folder_path):
if is_linux:
exe = which('xdg-open') or which('gnome-open') or which('kde-open')
subprocess.Popen([exe, folder_path])
elif is_macos:
exe = which("open")
subprocess.Popen([exe, folder_path])
else:
webbrowser.open(folder_path)
return
if exe:
env = os.environ
if "LD_LIBRARY_PATH" in env:
env = env.copy()
del env["LD_LIBRARY_PATH"] # exe is a system binary, so reset LD_LIBRARY_PATH
subprocess.Popen([exe, folder_path], env=env)
else:
logging.warning(f"No file browser available to open {folder_path}")
def update_settings():
@@ -118,51 +100,74 @@ def update_settings():
components.extend([
# Functions
Component("Open host.yaml", func=open_host_yaml,
description="Open the host.yaml file to change settings for generation, games, and more."),
Component("Open Patch", func=open_patch,
description="Open a patch file, downloaded from the room page or provided by the host."),
Component("Generate Template Options", func=generate_yamls,
description="Generate template YAMLs for currently installed games."),
Component("Archipelago Website", func=lambda: webbrowser.open("https://archipelago.gg/"),
description="Open archipelago.gg in your browser."),
Component("Discord Server", icon="discord", func=lambda: webbrowser.open("https://discord.gg/8Z65BR2"),
description="Join the Discord server to play public multiworlds, report issues, or just chat!"),
Component("Unrated/18+ Discord Server", icon="discord",
func=lambda: webbrowser.open("https://discord.gg/fqvNCCRsu4"),
description="Find unrated and 18+ games in the After Dark Discord server."),
Component("Browse Files", func=browse_files,
description="Open the Archipelago installation folder in your file browser."),
Component("Open host.yaml", func=open_host_yaml),
Component("Open Patch", func=open_patch),
Component("Generate Template Options", func=generate_yamls),
Component("Archipelago Website", func=lambda: webbrowser.open("https://archipelago.gg/")),
Component("Discord Server", icon="discord", func=lambda: webbrowser.open("https://discord.gg/8Z65BR2")),
Component("Unrated/18+ Discord Server", icon="discord", func=lambda: webbrowser.open("https://discord.gg/fqvNCCRsu4")),
Component("Browse Files", func=browse_files),
])
def handle_uri(path: str) -> tuple[list[Component], Component]:
def handle_uri(path: str, launch_args: Tuple[str, ...]) -> None:
url = urllib.parse.urlparse(path)
queries = urllib.parse.parse_qs(url.query)
client_components = []
launch_args = (path, *launch_args)
client_component = None
text_client_component = None
game = queries["game"][0]
if "game" in queries:
game = queries["game"][0]
else: # TODO around 0.6.0 - this is for pre this change webhost uri's
game = "Archipelago"
for component in components:
if component.supports_uri and component.game_name == game:
client_components.append(component)
client_component = component
elif component.display_name == "Text Client":
text_client_component = component
return client_components, text_client_component
if client_component is None:
run_component(text_client_component, *launch_args)
return
from kvui import App, Button, BoxLayout, Label, Window
class Popup(App):
def __init__(self):
self.title = "Connect to Multiworld"
self.icon = r"data/icon.png"
super().__init__()
def build(self):
layout = BoxLayout(orientation="vertical")
layout.add_widget(Label(text="Select client to open and connect with."))
button_row = BoxLayout(orientation="horizontal", size_hint=(1, 0.4))
text_client_button = Button(
text=text_client_component.display_name,
on_release=lambda *args: run_component(text_client_component, *launch_args)
)
button_row.add_widget(text_client_button)
game_client_button = Button(
text=client_component.display_name,
on_release=lambda *args: run_component(client_component, *launch_args)
)
button_row.add_widget(game_client_button)
layout.add_widget(button_row)
return layout
def _stop(self, *largs):
# see run_gui Launcher _stop comment for details
self.root_window.close()
super()._stop(*largs)
Popup().run()
def build_uri_popup(component_list: list[Component], launch_args: tuple[str, ...]) -> None:
from kvui import ButtonsPrompt
component_options = {
component.display_name: component for component in component_list
}
popup = ButtonsPrompt("Connect to Multiworld",
"Select client to open and connect with.",
lambda component_name: run_component(component_options[component_name], *launch_args),
*component_options.keys())
popup.open()
def identify(path: None | str) -> tuple[None | str, None | Component]:
def identify(path: Union[None, str]) -> Tuple[Union[None, str], Union[None, Component]]:
if path is None:
return None, None
for component in components:
@@ -173,7 +178,7 @@ def identify(path: None | str) -> tuple[None | str, None | Component]:
return None, None
def get_exe(component: str | Component) -> Sequence[str] | None:
def get_exe(component: Union[str, Component]) -> Optional[Sequence[str]]:
if isinstance(component, str):
name = component
component = None
@@ -201,8 +206,7 @@ def get_exe(component: str | Component) -> Sequence[str] | None:
def launch(exe, in_terminal=False):
if in_terminal:
if is_windows:
# intentionally using a window title with a space so it gets quoted and treated as a title
subprocess.Popen(["start", "Running Archipelago", *exe], shell=True)
subprocess.Popen(['start', *exe], shell=True)
return
elif is_linux:
terminal = which('x-terminal-emulator') or which('gnome-terminal') or which('xterm')
@@ -216,194 +220,100 @@ def launch(exe, in_terminal=False):
subprocess.Popen(exe)
def create_shortcut(button: Any, component: Component) -> None:
from pyshortcuts import make_shortcut
env = os.environ
if "APPIMAGE" in env:
script = env["ARGV0"]
wkdir = None # defaults to ~ on Linux
else:
script = sys.argv[0]
wkdir = Utils.local_path()
script = f"{script} \"{component.display_name}\""
make_shortcut(script, name=f"Archipelago {component.display_name}", icon=local_path("data", "icon.ico"),
startmenu=False, terminal=False, working_dir=wkdir, noexe=Utils.is_frozen())
button.menu.dismiss()
refresh_components: Optional[Callable[[], None]] = None
refresh_components: Callable[[], None] | None = None
def run_gui(launch_components: list[Component], args: Any) -> None:
from kvui import (ThemedApp, MDFloatLayout, MDGridLayout, ScrollBox)
from kivy.properties import ObjectProperty
def run_gui():
from kvui import App, ContainerLayout, GridLayout, Button, Label, ScrollBox, Widget, ApAsyncImage
from kivy.core.window import Window
from kivy.metrics import dp
from kivymd.uix.button import MDIconButton, MDButton
from kivymd.uix.card import MDCard
from kivymd.uix.menu import MDDropdownMenu
from kivymd.uix.snackbar import MDSnackbar, MDSnackbarText
from kivymd.uix.textfield import MDTextField
from kivy.uix.relativelayout import RelativeLayout
from kivy.lang.builder import Builder
class LauncherCard(MDCard):
component: Component | None
image: str
context_button: MDIconButton = ObjectProperty(None)
def __init__(self, *args, component: Component | None = None, image_path: str = "", **kwargs):
self.component = component
self.image = image_path
super().__init__(args, kwargs)
class Launcher(ThemedApp):
class Launcher(App):
base_title: str = "Archipelago Launcher"
top_screen: MDFloatLayout = ObjectProperty(None)
navigation: MDGridLayout = ObjectProperty(None)
grid: MDGridLayout = ObjectProperty(None)
button_layout: ScrollBox = ObjectProperty(None)
search_box: MDTextField = ObjectProperty(None)
cards: list[LauncherCard]
current_filter: Sequence[str | Type] | None
container: ContainerLayout
grid: GridLayout
_tool_layout: Optional[ScrollBox] = None
_client_layout: Optional[ScrollBox] = None
def __init__(self, ctx=None, components=None, args=None):
def __init__(self, ctx=None):
self.title = self.base_title + " " + Utils.__version__
self.ctx = ctx
self.icon = r"data/icon.png"
self.favorites = []
self.launch_components = components
self.launch_args = args
self.cards = []
self.current_filter = (Type.CLIENT, Type.TOOL, Type.ADJUSTER, Type.MISC)
persistent = Utils.persistent_load()
if "launcher" in persistent:
if "favorites" in persistent["launcher"]:
self.favorites.extend(persistent["launcher"]["favorites"])
if "filter" in persistent["launcher"]:
if persistent["launcher"]["filter"]:
filters = []
for filter in persistent["launcher"]["filter"].split(", "):
if filter == "favorites":
filters.append(filter)
else:
filters.append(Type[filter])
self.current_filter = filters
super().__init__()
def set_favorite(self, caller):
if caller.component.display_name in self.favorites:
self.favorites.remove(caller.component.display_name)
caller.icon = "star-outline"
else:
self.favorites.append(caller.component.display_name)
caller.icon = "star"
def _refresh_components(self) -> None:
def build_card(self, component: Component) -> LauncherCard:
"""
Builds a card widget for a given component.
:param component: The component associated with the button.
:return: The created Card Widget.
def build_button(component: Component) -> Widget:
"""
button_card = LauncherCard(component=component,
image_path=icon_paths[component.icon])
Builds a button widget for a given component.
def open_menu(caller):
caller.menu.open()
Args:
component (Component): The component associated with the button.
menu_items = [
{
"text": "Add shortcut on desktop",
"leading_icon": "laptop",
"on_release": lambda: create_shortcut(button_card.context_button, component)
}
]
button_card.context_button.menu = MDDropdownMenu(caller=button_card.context_button, items=menu_items)
button_card.context_button.bind(on_release=open_menu)
Returns:
None. The button is added to the parent grid layout.
return button_card
def _refresh_components(self, type_filter: Sequence[str | Type] | None = None) -> None:
if not type_filter:
type_filter = [Type.CLIENT, Type.ADJUSTER, Type.TOOL, Type.MISC]
favorites = "favorites" in type_filter
"""
button = Button(text=component.display_name, size_hint_y=None, height=40)
button.component = component
button.bind(on_release=self.component_action)
if component.icon != "icon":
image = ApAsyncImage(source=icon_paths[component.icon],
size=(38, 38), size_hint=(None, 1), pos=(5, 0))
box_layout = RelativeLayout(size_hint_y=None, height=40)
box_layout.add_widget(button)
box_layout.add_widget(image)
return box_layout
return button
# clear before repopulating
assert self.button_layout, "must call `build` first"
tool_children = reversed(self.button_layout.layout.children)
assert self._tool_layout and self._client_layout, "must call `build` first"
tool_children = reversed(self._tool_layout.layout.children)
for child in tool_children:
self.button_layout.layout.remove_widget(child)
self._tool_layout.layout.remove_widget(child)
client_children = reversed(self._client_layout.layout.children)
for child in client_children:
self._client_layout.layout.remove_widget(child)
cards = [card for card in self.cards if card.component.type in type_filter
or favorites and card.component.display_name in self.favorites]
_tools = {c.display_name: c for c in components if c.type == Type.TOOL}
_clients = {c.display_name: c for c in components if c.type == Type.CLIENT}
_adjusters = {c.display_name: c for c in components if c.type == Type.ADJUSTER}
_miscs = {c.display_name: c for c in components if c.type == Type.MISC}
self.current_filter = type_filter
for card in cards:
self.button_layout.layout.add_widget(card)
top = self.button_layout.children[0].y + self.button_layout.children[0].height \
- self.button_layout.height
scroll_percent = self.button_layout.convert_distance_to_scroll(0, top)
self.button_layout.scroll_y = max(0, min(1, scroll_percent[1]))
def filter_clients_by_type(self, caller: MDButton):
self._refresh_components(caller.type)
self.search_box.text = ""
def filter_clients_by_name(self, caller: MDTextField, name: str) -> None:
if len(name) == 0:
self._refresh_components(self.current_filter)
return
sub_matches = [
card for card in self.cards
if name.lower() in card.component.display_name.lower() and card.component.type != Type.HIDDEN
]
self.button_layout.layout.clear_widgets()
for card in sub_matches:
self.button_layout.layout.add_widget(card)
for (tool, client) in itertools.zip_longest(itertools.chain(
_tools.items(), _miscs.items(), _adjusters.items()
), _clients.items()):
# column 1
if tool:
self._tool_layout.layout.add_widget(build_button(tool[1]))
# column 2
if client:
self._client_layout.layout.add_widget(build_button(client[1]))
def build(self):
self.top_screen = Builder.load_file(Utils.local_path("data/launcher.kv"))
self.grid = self.top_screen.ids.grid
self.navigation = self.top_screen.ids.navigation
self.button_layout = self.top_screen.ids.button_layout
self.search_box = self.top_screen.ids.search_box
self.set_colors()
self.top_screen.md_bg_color = self.theme_cls.backgroundColor
self.container = ContainerLayout()
self.grid = GridLayout(cols=2)
self.container.add_widget(self.grid)
self.grid.add_widget(Label(text="General", size_hint_y=None, height=40))
self.grid.add_widget(Label(text="Clients", size_hint_y=None, height=40))
self._tool_layout = ScrollBox()
self._tool_layout.layout.orientation = "vertical"
self.grid.add_widget(self._tool_layout)
self._client_layout = ScrollBox()
self._client_layout.layout.orientation = "vertical"
self.grid.add_widget(self._client_layout)
self._refresh_components()
global refresh_components
refresh_components = self._refresh_components
Window.bind(on_drop_file=self._on_drop_file)
Window.bind(on_keyboard=self._on_keyboard)
for component in components:
self.cards.append(self.build_card(component))
self._refresh_components(self.current_filter)
# Uncomment to re-enable the Kivy console/live editor
# Ctrl-E to enable it, make sure numlock/capslock is disabled
# from kivy.modules.console import create_console
# create_console(Window, self.top_screen)
return self.top_screen
def on_start(self):
if self.launch_components:
build_uri_popup(self.launch_components, self.launch_args)
self.launch_components = None
self.launch_args = None
return self.container
@staticmethod
def component_action(button):
MDSnackbar(MDSnackbarText(text="Opening in a new window..."), y=dp(24), pos_hint={"center_x": 0.5},
size_hint_x=0.5).open()
if button.component.func:
button.component.func()
else:
@@ -415,16 +325,7 @@ def run_gui(launch_components: list[Component], args: Any) -> None:
if file and component:
run_component(component, file)
else:
logging.warning(f"unable to identify component for {filename}")
def _on_keyboard(self, window: Window, key: int, scancode: int, codepoint: str, modifier: list[str]):
# Activate search as soon as we start typing, no matter if we are focused on the search box or not.
# Focus first, then capture the first character we type, otherwise it gets swallowed and lost.
# Limit text input to ASCII non-control characters (space bar to tilde).
if not self.search_box.focus:
self.search_box.focus = True
if key in range(32, 126):
self.search_box.text += codepoint
logging.warning(f"unable to identify component for {file}")
def _stop(self, *largs):
# ran into what appears to be https://groups.google.com/g/kivy-users/c/saWDLoYCSZ4 with PyCharm.
@@ -432,13 +333,7 @@ def run_gui(launch_components: list[Component], args: Any) -> None:
self.root_window.close()
super()._stop(*largs)
def on_stop(self):
Utils.persistent_store("launcher", "favorites", self.favorites)
Utils.persistent_store("launcher", "filter", ", ".join(filter.name if isinstance(filter, Type) else filter
for filter in self.current_filter))
super().on_stop()
Launcher(components=launch_components, args=args).run()
Launcher().run()
# avoiding Launcher reference leak
# and don't try to do something with widgets after window closed
@@ -457,7 +352,7 @@ def run_component(component: Component, *args):
logging.warning(f"Component {component} does not appear to be executable.")
def main(args: argparse.Namespace | dict | None = None):
def main(args: Optional[Union[argparse.Namespace, dict]] = None):
if isinstance(args, argparse.Namespace):
args = {k: v for k, v in args._get_kwargs()}
elif not args:
@@ -466,21 +361,15 @@ def main(args: argparse.Namespace | dict | None = None):
path = args.get("Patch|Game|Component|url", None)
if path is not None:
if path.startswith("archipelago://"):
args["args"] = (path, *args.get("args", ()))
# add the url arg to the passthrough args
components, text_client_component = handle_uri(path)
if not components:
args["component"] = text_client_component
else:
args['launch_components'] = [text_client_component, *components]
else:
file, component = identify(path)
if file:
args['file'] = file
if component:
args['component'] = component
if not component:
logging.warning(f"Could not identify Component responsible for {path}")
handle_uri(path, args.get("args", ()))
return
file, component = identify(path)
if file:
args['file'] = file
if component:
args['component'] = component
if not component:
logging.warning(f"Could not identify Component responsible for {path}")
if args["update_settings"]:
update_settings()
@@ -489,12 +378,12 @@ def main(args: argparse.Namespace | dict | None = None):
elif "component" in args:
run_component(args["component"], *args["args"])
elif not args["update_settings"]:
run_gui(args.get("launch_components", None), args.get("args", ()))
run_gui()
if __name__ == '__main__':
init_logging('Launcher')
multiprocessing.freeze_support()
Utils.freeze_support()
multiprocessing.set_start_method("spawn") # if launched process uses kivy, fork won't work
parser = argparse.ArgumentParser(
description='Archipelago Launcher',
@@ -511,7 +400,6 @@ if __name__ == '__main__':
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

View File

@@ -3,6 +3,9 @@ ModuleUpdate.update()
import Utils
if __name__ == "__main__":
Utils.init_logging("LinksAwakeningContext", exception_logger="Client")
import asyncio
import base64
import binascii
@@ -23,14 +26,15 @@ import typing
from CommonClient import (CommonContext, get_base_parser, gui_enabled, logger,
server_loop)
from NetUtils import ClientStatus
from . import LinksAwakeningWorld
from .Common import BASE_ID as LABaseID
from .GpsTracker import GpsTracker
from .TrackerConsts import storage_key
from .ItemTracker import ItemTracker
from .LADXR.checkMetadata import checkMetadataTable
from .Locations import get_locations_to_id, meta_to_name
from .Tracker import LocationTracker, MagpieBridge, Check
from worlds.ladx.Common import BASE_ID as LABaseID
from worlds.ladx.GpsTracker import GpsTracker
from worlds.ladx.TrackerConsts import storage_key
from worlds.ladx.ItemTracker import ItemTracker
from worlds.ladx.LADXR.checkMetadata import checkMetadataTable
from worlds.ladx.Locations import get_locations_to_id, meta_to_name
from worlds.ladx.Tracker import LocationTracker, MagpieBridge
class GameboyException(Exception):
pass
@@ -47,8 +51,20 @@ class BadRetroArchResponse(GameboyException):
pass
class VersionError(Exception):
pass
def magpie_logo():
from kivy.uix.image import CoreImage
binary_data = """
iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAIAAACQkWg2AAAAAXN
SR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA
7DAcdvqGQAAADGSURBVDhPhVLBEcIwDHOYhjHCBuXHj2OTbAL8+
MEGZIxOQ1CinOOk0Op0bmo7tlXXeR9FJMYDLOD9mwcLjQK7+hSZ
wgcWMZJOAGeGKtChNHFL0j+FZD3jSCuo0w7l03wDrWdg00C4/aW
eDEYNenuzPOfPspBnxf0kssE80vN0L8361j10P03DK4x6FHabuV
ear8fHme+b17rwSjbAXeUMLb+EVTV2QHm46MWQanmnydA98KsVS
XkV+qFpGQXrLhT/fqraQeQLuplpNH5g+WkAAAAASUVORK5CYII="""
binary_data = base64.b64decode(binary_data)
data = io.BytesIO(binary_data)
return CoreImage(data, ext="png").texture
class LAClientConstants:
@@ -123,7 +139,7 @@ class RAGameboy():
def set_checks_range(self, checks_start, checks_size):
self.checks_start = checks_start
self.checks_size = checks_size
def set_location_range(self, location_start, location_size, critical_addresses):
self.location_start = location_start
self.location_size = location_size
@@ -140,7 +156,7 @@ class RAGameboy():
return response
async def async_recv(self, timeout=1.0):
response = await asyncio.wait_for(asyncio.get_running_loop().sock_recv(self.socket, 4096), timeout)
response = await asyncio.wait_for(asyncio.get_event_loop().sock_recv(self.socket, 4096), timeout)
return response
async def check_safe_gameplay(self, throw=True):
@@ -221,7 +237,7 @@ class RAGameboy():
self.cache[start:start + len(hram_block)] = hram_block
self.last_cache_read = time.time()
async def read_memory_block(self, address: int, size: int):
block = bytearray()
remaining_size = size
@@ -229,7 +245,7 @@ class RAGameboy():
chunk = await self.async_read_memory(address + len(block), remaining_size)
remaining_size -= len(chunk)
block += chunk
return block
async def read_memory_cache(self, addresses):
@@ -411,10 +427,10 @@ class LinksAwakeningClient():
status = (await self.gameboy.async_read_memory_safe(LAClientConstants.wLinkStatusBits))[0]
item_id -= LABaseID
# The player name table only goes up to 101, so don't go past that
# The player name table only goes up to 100, so don't go past that
# Even if it didn't, the remote player _index_ byte is just a byte, so 255 max
if from_player > 101:
from_player = 101
if from_player > 100:
from_player = 100
next_index += 1
self.gameboy.write_memory(LAClientConstants.wLinkGiveItem, [
@@ -490,7 +506,7 @@ class LinksAwakeningContext(CommonContext):
la_task = None
client = None
# TODO: does this need to re-read on reset?
found_checks = set()
found_checks = []
last_resend = time.time()
magpie_enabled = False
@@ -498,8 +514,8 @@ class LinksAwakeningContext(CommonContext):
magpie_task = None
won = False
@property
def slot_storage_key(self):
@property
def slot_storage_key(self):
return f"{self.slot_info[self.slot].name}_{storage_key}"
def __init__(self, server_address: typing.Optional[str], password: typing.Optional[str], magpie: typing.Optional[bool]) -> None:
@@ -513,32 +529,40 @@ class LinksAwakeningContext(CommonContext):
def run_gui(self) -> None:
import webbrowser
from kvui import GameManager
from kivy.metrics import dp
from kivymd.uix.button import MDButton, MDButtonText
import kvui
from kvui import Button, GameManager
from kivy.uix.image import Image
class LADXManager(GameManager):
logging_pairs = [
("Client", "Archipelago"),
("Tracker", "Tracker"),
]
base_title = f"Links Awakening DX Client {LinksAwakeningWorld.world_version.as_simple_string()} | Archipelago"
base_title = "Archipelago Links Awakening DX Client"
def build(self):
b = super().build()
if self.ctx.magpie_enabled:
button = MDButton(MDButtonText(text="Open Tracker"), style="filled", size=(dp(100), dp(70)), radius=5,
size_hint_x=None, size_hint_y=None, pos_hint={"center_y": 0.55},
on_press=lambda _: webbrowser.open('https://magpietracker.us/?enable_autotracker=1'))
button.height = self.server_connect_bar.height
self.connect_layout.add_widget(button)
button = Button(text="", size=(30, 30), size_hint_x=None,
on_press=lambda _: webbrowser.open('https://magpietracker.us/?enable_autotracker=1'))
image = Image(size=(16, 16), texture=magpie_logo())
button.add_widget(image)
def set_center(_, center):
image.center = center
button.bind(center=set_center)
self.connect_layout.add_widget(button)
return b
self.ui = LADXManager(self)
self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI")
async def send_checks(self):
message = [{"cmd": "LocationChecks", "locations": self.found_checks}]
await self.send_msgs(message)
async def send_new_entrances(self, entrances: typing.Dict[str, str]):
# Store the entrances we find on the server for future sessions
message = [{
@@ -577,20 +601,20 @@ class LinksAwakeningContext(CommonContext):
logger.info("victory!")
await self.send_msgs(message)
self.won = True
async def request_found_entrances(self):
await self.send_msgs([{"cmd": "Get", "keys": [self.slot_storage_key]}])
# Ask for updates so that players can co-op entrances in a seed
await self.send_msgs([{"cmd": "SetNotify", "keys": [self.slot_storage_key]}])
# Ask for updates so that players can co-op entrances in a seed
await self.send_msgs([{"cmd": "SetNotify", "keys": [self.slot_storage_key]}])
async def on_deathlink(self, data: typing.Dict[str, typing.Any]) -> None:
if self.ENABLE_DEATHLINK:
self.client.pending_deathlink = True
def new_checks(self, item_ids, ladxr_ids):
self.found_checks.update(item_ids)
create_task_log_exception(self.check_locations(self.found_checks))
self.found_checks += item_ids
create_task_log_exception(self.send_checks())
if self.magpie_enabled:
create_task_log_exception(self.magpie.send_new_checks(ladxr_ids))
@@ -618,32 +642,12 @@ class LinksAwakeningContext(CommonContext):
if cmd == "Connected":
self.game = self.slot_info[self.slot].game
self.slot_data = args.get("slot_data", {})
generated_version = Utils.tuplize_version(self.slot_data.get("world_version", "2.0.0"))
client_version = LinksAwakeningWorld.world_version
if generated_version.major != client_version.major:
self.disconnected_intentionally = True
raise VersionError(
f"The installed world ({client_version.as_simple_string()}) is incompatible with "
f"the world this game was generated on ({generated_version.as_simple_string()})"
)
# This is sent to magpie over local websocket to make its own connection
self.slot_data.update({
"server_address": self.server_address,
"slot_name": self.player_names[self.slot],
"password": self.password,
"client_version": client_version.as_simple_string(),
})
# We can process linked items on already-checked checks now that we have slot_data
if self.client.tracker:
checked_checks = set(self.client.tracker.all_checks) - set(self.client.tracker.remaining_checks)
self.add_linked_items(checked_checks)
# TODO - use watcher_event
if cmd == "ReceivedItems":
for index, item in enumerate(args["items"], start=args["index"]):
self.client.recvd_checks[index] = item
if cmd == "Retrieved" and self.magpie_enabled and self.slot_storage_key in args["keys"]:
self.client.gps_tracker.receive_found_entrances(args["keys"][self.slot_storage_key])
@@ -654,13 +658,6 @@ class LinksAwakeningContext(CommonContext):
sync_msg = [{'cmd': 'Sync'}]
await self.send_msgs(sync_msg)
def add_linked_items(self, checks: typing.List[Check]):
for check in checks:
if check.value and check.linkedItem:
linkedItem = check.linkedItem
if 'condition' not in linkedItem or (self.slot_data and linkedItem['condition'](self.slot_data)):
self.client.item_tracker.setExtraItem(check.linkedItem['item'], check.linkedItem['qty'])
item_id_lookup = get_locations_to_id()
async def run_game_loop(self):
@@ -669,7 +666,11 @@ class LinksAwakeningContext(CommonContext):
checkMetadataTable[check.id])] for check in ladxr_checks]
self.new_checks(checks, [check.id for check in ladxr_checks])
self.add_linked_items(ladxr_checks)
for check in ladxr_checks:
if check.value and check.linkedItem:
linkedItem = check.linkedItem
if 'condition' not in linkedItem or linkedItem['condition'](self.slot_data):
self.client.item_tracker.setExtraItem(check.linkedItem['item'], check.linkedItem['qty'])
async def victory():
await self.send_victory()
@@ -720,15 +721,13 @@ class LinksAwakeningContext(CommonContext):
if self.last_resend + 5.0 < now:
self.last_resend = now
await self.check_locations(self.found_checks)
await self.send_checks()
if self.magpie_enabled:
try:
self.magpie.set_checks(self.client.tracker.all_checks)
await self.magpie.set_item_tracker(self.client.item_tracker)
if self.slot_data and "slot_data" in self.magpie.features and not self.magpie.has_sent_slot_data:
self.magpie.slot_data = self.slot_data
await self.magpie.send_slot_data()
self.magpie.slot_data = self.slot_data
if self.client.gps_tracker.needs_found_entrances:
await self.request_found_entrances()
self.client.gps_tracker.needs_found_entrances = False
@@ -746,8 +745,8 @@ class LinksAwakeningContext(CommonContext):
await asyncio.sleep(1.0)
def run_game(romfile: str) -> None:
auto_start = LinksAwakeningWorld.settings.rom_start
auto_start = typing.cast(typing.Union[bool, str],
Utils.get_options()["ladx_options"].get("rom_start", True))
if auto_start is True:
import webbrowser
webbrowser.open(romfile)
@@ -768,44 +767,42 @@ def run_game(romfile: str) -> None:
except FileNotFoundError:
logger.error(f"Couldn't launch ROM, {args[0]} is missing")
def launch(*launch_args):
async def main():
parser = get_base_parser(description="Link's Awakening Client.")
parser.add_argument("--url", help="Archipelago connection url")
parser.add_argument("--no-magpie", dest='magpie', default=True, action='store_false', help="Disable magpie bridge")
parser.add_argument('diff_file', default="", type=str, nargs="?",
help='Path to a .apladx Archipelago Binary Patch file')
async def main():
parser = get_base_parser(description="Link's Awakening Client.")
parser.add_argument("--url", help="Archipelago connection url")
parser.add_argument("--no-magpie", dest='magpie', default=True, action='store_false', help="Disable magpie bridge")
parser.add_argument('diff_file', default="", type=str, nargs="?",
help='Path to a .apladx Archipelago Binary Patch file')
args = parser.parse_args(launch_args)
args = parser.parse_args()
if args.diff_file:
import Patch
logger.info("patch file was supplied - creating rom...")
meta, rom_file = Patch.create_rom_file(args.diff_file)
if "server" in meta and not args.connect:
args.connect = meta["server"]
logger.info(f"wrote rom file to {rom_file}")
if args.diff_file:
import Patch
logger.info("patch file was supplied - creating rom...")
meta, rom_file = Patch.create_rom_file(args.diff_file)
if "server" in meta and not args.connect:
args.connect = meta["server"]
logger.info(f"wrote rom file to {rom_file}")
ctx = LinksAwakeningContext(args.connect, args.password, args.magpie)
ctx = LinksAwakeningContext(args.connect, args.password, args.magpie)
ctx.server_task = asyncio.create_task(server_loop(ctx), name="server loop")
ctx.server_task = asyncio.create_task(server_loop(ctx), name="server loop")
# TODO: nothing about the lambda about has to be in a lambda
ctx.la_task = create_task_log_exception(ctx.run_game_loop())
if gui_enabled:
ctx.run_gui()
ctx.run_cli()
# TODO: nothing about the lambda about has to be in a lambda
ctx.la_task = create_task_log_exception(ctx.run_game_loop())
if gui_enabled:
ctx.run_gui()
ctx.run_cli()
# Down below run_gui so that we get errors out of the process
if args.diff_file:
run_game(rom_file)
# Down below run_gui so that we get errors out of the process
if args.diff_file:
run_game(rom_file)
await ctx.exit_event.wait()
await ctx.shutdown()
await ctx.exit_event.wait()
await ctx.shutdown()
Utils.init_logging("LinksAwakeningContext", exception_logger="Client")
colorama.just_fix_windows_console()
if __name__ == '__main__':
colorama.init()
asyncio.run(main())
colorama.deinit()

View File

@@ -32,7 +32,6 @@ GAME_ALTTP = "A Link to the Past"
WINDOW_MIN_HEIGHT = 525
WINDOW_MIN_WIDTH = 425
class AdjusterWorld(object):
class AdjusterSubWorld(object):
def __init__(self, random):
@@ -41,6 +40,7 @@ class AdjusterWorld(object):
def __init__(self, sprite_pool):
import random
self.sprite_pool = {1: sprite_pool}
self.per_slot_randoms = {1: random}
self.worlds = {1: self.AdjusterSubWorld(random)}
@@ -49,7 +49,6 @@ class ArgumentDefaultsHelpFormatter(argparse.RawTextHelpFormatter):
def _get_help_string(self, action):
return textwrap.dedent(action.help)
# See argparse.BooleanOptionalAction
class BooleanOptionalActionWithDisable(argparse.Action):
def __init__(self,
@@ -365,10 +364,10 @@ def run_sprite_update():
logging.info("Done updating sprites")
def update_sprites(task, on_finish=None, repository_url: str = "https://alttpr.com/sprites"):
def update_sprites(task, on_finish=None):
resultmessage = ""
successful = True
sprite_dir = user_path("data", "sprites", "alttp", "remote")
sprite_dir = user_path("data", "sprites", "alttpr")
os.makedirs(sprite_dir, exist_ok=True)
ctx = get_cert_none_ssl_context()
@@ -378,11 +377,11 @@ def update_sprites(task, on_finish=None, repository_url: str = "https://alttpr.c
on_finish(successful, resultmessage)
try:
task.update_status("Downloading remote sprites list")
with urlopen(repository_url, context=ctx) as response:
task.update_status("Downloading alttpr sprites list")
with urlopen('https://alttpr.com/sprites', context=ctx) as response:
sprites_arr = json.loads(response.read().decode("utf-8"))
except Exception as e:
resultmessage = "Error getting list of remote sprites. Sprites not updated.\n\n%s: %s" % (type(e).__name__, e)
resultmessage = "Error getting list of alttpr sprites. Sprites not updated.\n\n%s: %s" % (type(e).__name__, e)
successful = False
task.queue_event(finished)
return
@@ -390,13 +389,13 @@ def update_sprites(task, on_finish=None, repository_url: str = "https://alttpr.c
try:
task.update_status("Determining needed sprites")
current_sprites = [os.path.basename(file) for file in glob(sprite_dir + '/*')]
remote_sprites = [(sprite['file'], os.path.basename(urlparse(sprite['file']).path))
alttpr_sprites = [(sprite['file'], os.path.basename(urlparse(sprite['file']).path))
for sprite in sprites_arr if sprite["author"] != "Nintendo"]
needed_sprites = [(sprite_url, filename) for (sprite_url, filename) in remote_sprites if
needed_sprites = [(sprite_url, filename) for (sprite_url, filename) in alttpr_sprites if
filename not in current_sprites]
remote_filenames = [filename for (_, filename) in remote_sprites]
obsolete_sprites = [sprite for sprite in current_sprites if sprite not in remote_filenames]
alttpr_filenames = [filename for (_, filename) in alttpr_sprites]
obsolete_sprites = [sprite for sprite in current_sprites if sprite not in alttpr_filenames]
except Exception as e:
resultmessage = "Error Determining which sprites to update. Sprites not updated.\n\n%s: %s" % (
type(e).__name__, e)
@@ -448,7 +447,7 @@ def update_sprites(task, on_finish=None, repository_url: str = "https://alttpr.c
successful = False
if successful:
resultmessage = "Remote sprites updated successfully"
resultmessage = "alttpr sprites updated successfully"
task.queue_event(finished)
@@ -869,7 +868,7 @@ class SpriteSelector():
def open_custom_sprite_dir(_evt):
open_file(self.custom_sprite_dir)
remote_frametitle = Label(self.window, text='Remote Sprites')
alttpr_frametitle = Label(self.window, text='ALTTPR Sprites')
custom_frametitle = Frame(self.window)
title_text = Label(custom_frametitle, text="Custom Sprites")
@@ -878,8 +877,8 @@ class SpriteSelector():
title_link.pack(side=LEFT)
title_link.bind("<Button-1>", open_custom_sprite_dir)
self.icon_section(remote_frametitle, self.remote_sprite_dir,
'Remote sprites not found. Click "Update remote sprites" to download them.')
self.icon_section(alttpr_frametitle, self.alttpr_sprite_dir,
'ALTTPR sprites not found. Click "Update alttpr sprites" to download them.')
self.icon_section(custom_frametitle, self.custom_sprite_dir,
'Put sprites in the custom sprites folder (see open link above) to have them appear here.')
if not randomOnEvent:
@@ -892,18 +891,11 @@ class SpriteSelector():
button = Button(frame, text="Browse for file...", command=self.browse_for_sprite)
button.pack(side=RIGHT, padx=(5, 0))
button = Button(frame, text="Update remote sprites", command=self.update_remote_sprites)
button = Button(frame, text="Update alttpr sprites", command=self.update_alttpr_sprites)
button.pack(side=RIGHT, padx=(5, 0))
repository_label = Label(frame, text='Sprite Repository:')
self.repository_url = StringVar(frame, "https://alttpr.com/sprites")
repository_entry = Entry(frame, textvariable=self.repository_url)
repository_entry.pack(side=RIGHT, expand=True, fill=BOTH, pady=1)
repository_label.pack(side=RIGHT, expand=False, padx=(0, 5))
button = Button(frame, text="Do not adjust sprite",command=self.use_default_sprite)
button.pack(side=LEFT, padx=(0, 5))
button.pack(side=LEFT,padx=(0,5))
button = Button(frame, text="Default Link sprite", command=self.use_default_link_sprite)
button.pack(side=LEFT, padx=(0, 5))
@@ -1063,7 +1055,7 @@ class SpriteSelector():
for i, button in enumerate(frame.buttons):
button.grid(row=i // self.spritesPerRow, column=i % self.spritesPerRow)
def update_remote_sprites(self):
def update_alttpr_sprites(self):
# need to wrap in try catch. We don't want errors getting the json or downloading the files to break us.
self.window.destroy()
self.parent.update()
@@ -1076,8 +1068,7 @@ class SpriteSelector():
messagebox.showerror("Sprite Updater", resultmessage)
SpriteSelector(self.parent, self.callback, self.adjuster)
BackgroundTaskProgress(self.parent, update_sprites, "Updating Sprites",
on_finish, self.repository_url.get())
BackgroundTaskProgress(self.parent, update_sprites, "Updating Sprites", on_finish)
def browse_for_sprite(self):
sprite = filedialog.askopenfilename(
@@ -1167,13 +1158,12 @@ class SpriteSelector():
os.makedirs(self.custom_sprite_dir)
@property
def remote_sprite_dir(self):
return user_path("data", "sprites", "alttp", "remote")
def alttpr_sprite_dir(self):
return user_path("data", "sprites", "alttpr")
@property
def custom_sprite_dir(self):
return user_path("data", "sprites", "alttp", "custom")
return user_path("data", "sprites", "custom")
def get_image_for_sprite(sprite, gif_only: bool = False):
if not sprite.valid:

View File

@@ -286,14 +286,16 @@ async def gba_sync_task(ctx: MMBN3Context):
except ConnectionRefusedError:
logger.debug("Connection Refused, Trying Again")
ctx.gba_status = CONNECTION_REFUSED_STATUS
await asyncio.sleep(1)
continue
async def run_game(romfile):
from worlds.mmbn3 import MMBN3World
auto_start = MMBN3World.settings.rom_start
if auto_start is True:
options = Utils.get_options().get("mmbn3_options", None)
if options is None:
auto_start = True
else:
auto_start = options.get("rom_start", True)
if auto_start:
import webbrowser
webbrowser.open(romfile)
elif os.path.isfile(auto_start):
@@ -368,7 +370,7 @@ if __name__ == "__main__":
import colorama
colorama.just_fix_windows_console()
colorama.init()
asyncio.run(main())
colorama.deinit()

129
Main.py
View File

@@ -1,21 +1,20 @@
import collections
from collections.abc import Mapping
import concurrent.futures
import logging
import os
import pickle
import tempfile
import time
from typing import Any
import zipfile
import zlib
from typing import Dict, List, Optional, Set, Tuple, Union
import worlds
from BaseClasses import CollectionState, Item, Location, LocationProgressType, MultiWorld
from Fill import FillError, balance_multiworld_progression, distribute_items_restrictive, flood_items, \
parse_planned_blocks, distribute_planned_blocks, resolve_early_locations_for_planned
from NetUtils import convert_to_base_types
from BaseClasses import CollectionState, Item, Location, LocationProgressType, MultiWorld, Region
from Fill import FillError, balance_multiworld_progression, distribute_items_restrictive, distribute_planned, \
flood_items
from Options import StartInventoryPool
from Utils import __version__, output_path, restricted_dumps, version_tuple
from Utils import __version__, output_path, version_tuple, get_settings
from settings import get_settings
from worlds import AutoWorld
from worlds.generic.Rules import exclusion_rules, locality_rules
@@ -23,7 +22,7 @@ from worlds.generic.Rules import exclusion_rules, locality_rules
__all__ = ["main"]
def main(args, seed=None, baked_server_options: dict[str, object] | None = None):
def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = None):
if not baked_server_options:
baked_server_options = get_settings().server_options.as_dict()
assert isinstance(baked_server_options, dict)
@@ -37,7 +36,10 @@ def main(args, seed=None, baked_server_options: dict[str, object] | None = None)
logger = logging.getLogger()
multiworld.set_seed(seed, args.race, str(args.outputname) if args.outputname else None)
multiworld.plando_options = args.plando
multiworld.plando_options = args.plando_options
multiworld.plando_items = args.plando_items.copy()
multiworld.plando_texts = args.plando_texts.copy()
multiworld.plando_connections = args.plando_connections.copy()
multiworld.game = args.game.copy()
multiworld.player_name = args.name.copy()
multiworld.sprite = args.sprite.copy()
@@ -54,23 +56,32 @@ def main(args, seed=None, baked_server_options: dict[str, object] | None = None)
logger.info(f"Found {len(AutoWorld.AutoWorldRegister.world_types)} World Types:")
longest_name = max(len(text) for text in AutoWorld.AutoWorldRegister.world_types)
world_classes = AutoWorld.AutoWorldRegister.world_types.values()
max_item = 0
max_location = 0
for cls in AutoWorld.AutoWorldRegister.world_types.values():
if cls.item_id_to_name:
max_item = max(max_item, max(cls.item_id_to_name))
max_location = max(max_location, max(cls.location_id_to_name))
version_count = max(len(cls.world_version.as_simple_string()) for cls in world_classes)
item_count = len(str(max(len(cls.item_names) for cls in world_classes)))
location_count = len(str(max(len(cls.location_names) for cls in world_classes)))
item_digits = len(str(max_item))
location_digits = len(str(max_location))
item_count = len(str(max(len(cls.item_names) for cls in AutoWorld.AutoWorldRegister.world_types.values())))
location_count = len(str(max(len(cls.location_names) for cls in AutoWorld.AutoWorldRegister.world_types.values())))
del max_item, max_location
for name, cls in AutoWorld.AutoWorldRegister.world_types.items():
if not cls.hidden and len(cls.item_names) > 0:
logger.info(f" {name:{longest_name}}: "
f"v{cls.world_version.as_simple_string():{version_count}} | "
f"Items: {len(cls.item_names):{item_count}} | "
f"Locations: {len(cls.location_names):{location_count}}")
logger.info(f" {name:{longest_name}}: {len(cls.item_names):{item_count}} "
f"Items (IDs: {min(cls.item_id_to_name):{item_digits}} - "
f"{max(cls.item_id_to_name):{item_digits}}) | "
f"{len(cls.location_names):{location_count}} "
f"Locations (IDs: {min(cls.location_id_to_name):{location_digits}} - "
f"{max(cls.location_id_to_name):{location_digits}})")
del item_count, location_count
del item_digits, location_digits, item_count, location_count
# This assertion method should not be necessary to run if we are not outputting any multidata.
if not args.skip_output and not args.spoiler_only:
if not args.skip_output:
AutoWorld.call_stage(multiworld, "assert_generate")
AutoWorld.call_all(multiworld, "generate_early")
@@ -99,15 +110,6 @@ def main(args, seed=None, baked_server_options: dict[str, object] | None = None)
del local_early
del early
# items can't be both local and non-local, prefer local
multiworld.worlds[player].options.non_local_items.value -= multiworld.worlds[player].options.local_items.value
multiworld.worlds[player].options.non_local_items.value -= set(multiworld.local_early_items[player])
# Clear non-applicable local and non-local items.
if multiworld.players == 1:
multiworld.worlds[1].options.non_local_items.value = set()
multiworld.worlds[1].options.local_items.value = set()
logger.info('Creating MultiWorld.')
AutoWorld.call_all(multiworld, "create_regions")
@@ -115,6 +117,12 @@ def main(args, seed=None, baked_server_options: dict[str, object] | None = None)
AutoWorld.call_all(multiworld, "create_items")
logger.info('Calculating Access Rules.')
for player in multiworld.player_ids:
# items can't be both local and non-local, prefer local
multiworld.worlds[player].options.non_local_items.value -= multiworld.worlds[player].options.local_items.value
multiworld.worlds[player].options.non_local_items.value -= set(multiworld.local_early_items[player])
AutoWorld.call_all(multiworld, "set_rules")
for player in multiworld.player_ids:
@@ -135,11 +143,11 @@ def main(args, seed=None, baked_server_options: dict[str, object] | None = None)
multiworld.worlds[player].options.priority_locations.value -= world_excluded_locations
# Set local and non-local item rules.
# This function is called so late because worlds might otherwise overwrite item_rules which are how locality works
if multiworld.players > 1:
locality_rules(multiworld)
multiworld.plando_item_blocks = parse_planned_blocks(multiworld)
else:
multiworld.worlds[1].options.non_local_items.value = set()
multiworld.worlds[1].options.local_items.value = set()
AutoWorld.call_all(multiworld, "connect_entrances")
AutoWorld.call_all(multiworld, "generate_basic")
@@ -147,7 +155,7 @@ def main(args, seed=None, baked_server_options: dict[str, object] | None = None)
# remove starting inventory from pool items.
# Because some worlds don't actually create items during create_items this has to be as late as possible.
fallback_inventory = StartInventoryPool({})
depletion_pool: dict[int, dict[str, int]] = {
depletion_pool: Dict[int, Dict[str, int]] = {
player: getattr(multiworld.worlds[player].options, "start_inventory_from_pool", fallback_inventory).value.copy()
for player in multiworld.player_ids
}
@@ -156,7 +164,7 @@ def main(args, seed=None, baked_server_options: dict[str, object] | None = None)
}
if target_per_player:
new_itempool: list[Item] = []
new_itempool: List[Item] = []
# Make new itempool with start_inventory_from_pool items removed
for item in multiworld.itempool:
@@ -181,13 +189,12 @@ def main(args, seed=None, baked_server_options: dict[str, object] | None = None)
multiworld.link_items()
if any(world.options.item_links for world in multiworld.worlds.values()):
if any(multiworld.item_links.values()):
multiworld._all_state = None
logger.info("Running Item Plando.")
resolve_early_locations_for_planned(multiworld)
distribute_planned_blocks(multiworld, [x for player in multiworld.plando_item_blocks
for x in multiworld.plando_item_blocks[player]])
distribute_planned(multiworld)
logger.info('Running Pre Main Fill.')
@@ -217,15 +224,6 @@ def main(args, seed=None, baked_server_options: dict[str, object] | None = None)
logger.info(f'Beginning output...')
outfilebase = 'AP_' + multiworld.seed_name
if args.spoiler_only:
if args.spoiler > 1:
logger.info('Calculating playthrough.')
multiworld.spoiler.create_playthrough(create_paths=args.spoiler > 2)
multiworld.spoiler.to_file(output_path('%s_Spoiler.txt' % outfilebase))
logger.info('Done. Skipped multidata modification. Total time: %s', time.perf_counter() - start)
return multiworld
output = tempfile.TemporaryDirectory()
with output as temp_dir:
output_players = [player for player in multiworld.player_ids if AutoWorld.World.generate_output.__code__
@@ -240,19 +238,17 @@ def main(args, seed=None, baked_server_options: dict[str, object] | None = None)
pool.submit(AutoWorld.call_single, multiworld, "generate_output", player, temp_dir))
# collect ER hint info
er_hint_data: dict[int, dict[int, str]] = {}
er_hint_data: Dict[int, Dict[int, str]] = {}
AutoWorld.call_all(multiworld, 'extend_hint_information', er_hint_data)
def write_multidata():
import NetUtils
from NetUtils import HintStatus
slot_data: dict[int, Mapping[str, Any]] = {}
client_versions: dict[int, tuple[int, int, int]] = {}
games: dict[int, str] = {}
minimum_versions: NetUtils.MinimumVersions = {
"server": AutoWorld.World.required_server_version, "clients": client_versions
}
slot_info: dict[int, NetUtils.NetworkSlot] = {}
slot_data = {}
client_versions = {}
games = {}
minimum_versions = {"server": AutoWorld.World.required_server_version, "clients": client_versions}
slot_info = {}
names = [[name for player, name in sorted(multiworld.player_name.items())]]
for slot in multiworld.player_ids:
player_world: AutoWorld.World = multiworld.worlds[slot]
@@ -267,9 +263,7 @@ def main(args, seed=None, baked_server_options: dict[str, object] | None = None)
group_members=sorted(group["players"]))
precollected_items = {player: [item.code for item in world_precollected if type(item.code) == int]
for player, world_precollected in multiworld.precollected_items.items()}
precollected_hints: dict[int, set[NetUtils.Hint]] = {
player: set() for player in range(1, multiworld.players + 1 + len(multiworld.groups))
}
precollected_hints = {player: set() for player in range(1, multiworld.players + 1 + len(multiworld.groups))}
for slot in multiworld.player_ids:
slot_data[slot] = multiworld.worlds[slot].fill_slot_data()
@@ -285,7 +279,7 @@ def main(args, seed=None, baked_server_options: dict[str, object] | None = None)
for player in multiworld.groups[location.item.player]["players"]:
precollected_hints[player].add(hint)
locations_data: dict[int, dict[int, tuple[int, int, int]]] = {player: {} for player in multiworld.player_ids}
locations_data: Dict[int, Dict[int, Tuple[int, int, int]]] = {player: {} for player in multiworld.player_ids}
for location in multiworld.get_filled_locations():
if type(location.address) == int:
assert location.item.code is not None, "item code None should be event, " \
@@ -312,21 +306,20 @@ def main(args, seed=None, baked_server_options: dict[str, object] | None = None)
game_world.game: worlds.network_data_package["games"][game_world.game]
for game_world in multiworld.worlds.values()
}
data_package["Archipelago"] = worlds.network_data_package["games"]["Archipelago"]
checks_in_area: dict[int, dict[str, int | list[int]]] = {}
checks_in_area: Dict[int, Dict[str, Union[int, List[int]]]] = {}
# get spheres -> filter address==None -> skip empty
spheres: list[dict[int, set[int]]] = []
spheres: List[Dict[int, Set[int]]] = []
for sphere in multiworld.get_sendable_spheres():
current_sphere: dict[int, set[int]] = collections.defaultdict(set)
current_sphere: Dict[int, Set[int]] = collections.defaultdict(set)
for sphere_location in sphere:
current_sphere[sphere_location.player].add(sphere_location.address)
if current_sphere:
spheres.append(dict(current_sphere))
multidata: NetUtils.MultiData = {
multidata = {
"slot_data": slot_data,
"slot_info": slot_info,
"connect_names": {name: (0, player) for player, name in multiworld.player_name.items()},
@@ -336,7 +329,7 @@ def main(args, seed=None, baked_server_options: dict[str, object] | None = None)
"er_hint_data": er_hint_data,
"precollected_items": precollected_items,
"precollected_hints": precollected_hints,
"version": (version_tuple.major, version_tuple.minor, version_tuple.build),
"version": tuple(version_tuple),
"tags": ["AP"],
"minimum_versions": minimum_versions,
"seed_name": multiworld.seed_name,
@@ -344,17 +337,13 @@ def main(args, seed=None, baked_server_options: dict[str, object] | None = None)
"datapackage": data_package,
"race_mode": int(multiworld.is_race),
}
# TODO: change to `"version": version_tuple` after getting better serialization
AutoWorld.call_all(multiworld, "modify_multidata", multidata)
for key in ("slot_data", "er_hint_data"):
multidata[key] = convert_to_base_types(multidata[key])
serialized_multidata = zlib.compress(restricted_dumps(multidata), 9)
multidata = zlib.compress(pickle.dumps(multidata), 9)
with open(os.path.join(temp_dir, f'{outfilebase}.archipelago'), 'wb') as f:
f.write(bytes([3])) # version of format
f.write(serialized_multidata)
f.write(multidata)
output_file_futures.append(pool.submit(write_multidata))
if not check_accessibility_task.result():

344
MinecraftClient.py Normal file
View File

@@ -0,0 +1,344 @@
import argparse
import json
import os
import sys
import re
import atexit
import shutil
from subprocess import Popen
from shutil import copyfile
from time import strftime
import logging
import requests
import Utils
from Utils import is_windows
atexit.register(input, "Press enter to exit.")
# 1 or more digits followed by m or g, then optional b
max_heap_re = re.compile(r"^\d+[mMgG][bB]?$")
def prompt_yes_no(prompt):
yes_inputs = {'yes', 'ye', 'y'}
no_inputs = {'no', 'n'}
while True:
choice = input(prompt + " [y/n] ").lower()
if choice in yes_inputs:
return True
elif choice in no_inputs:
return False
else:
print('Please respond with "y" or "n".')
def find_ap_randomizer_jar(forge_dir):
"""Create mods folder if needed; find AP randomizer jar; return None if not found."""
mods_dir = os.path.join(forge_dir, 'mods')
if os.path.isdir(mods_dir):
for entry in os.scandir(mods_dir):
if entry.name.startswith("aprandomizer") and entry.name.endswith(".jar"):
logging.info(f"Found AP randomizer mod: {entry.name}")
return entry.name
return None
else:
os.mkdir(mods_dir)
logging.info(f"Created mods folder in {forge_dir}")
return None
def replace_apmc_files(forge_dir, apmc_file):
"""Create APData folder if needed; clean .apmc files from APData; copy given .apmc into directory."""
if apmc_file is None:
return
apdata_dir = os.path.join(forge_dir, 'APData')
copy_apmc = True
if not os.path.isdir(apdata_dir):
os.mkdir(apdata_dir)
logging.info(f"Created APData folder in {forge_dir}")
for entry in os.scandir(apdata_dir):
if entry.name.endswith(".apmc") and entry.is_file():
if not os.path.samefile(apmc_file, entry.path):
os.remove(entry.path)
logging.info(f"Removed {entry.name} in {apdata_dir}")
else: # apmc already in apdata
copy_apmc = False
if copy_apmc:
copyfile(apmc_file, os.path.join(apdata_dir, os.path.basename(apmc_file)))
logging.info(f"Copied {os.path.basename(apmc_file)} to {apdata_dir}")
def read_apmc_file(apmc_file):
from base64 import b64decode
with open(apmc_file, 'r') as f:
return json.loads(b64decode(f.read()))
def update_mod(forge_dir, url: str):
"""Check mod version, download new mod from GitHub releases page if needed. """
ap_randomizer = find_ap_randomizer_jar(forge_dir)
os.path.basename(url)
if ap_randomizer is not None:
logging.info(f"Your current mod is {ap_randomizer}.")
else:
logging.info(f"You do not have the AP randomizer mod installed.")
if ap_randomizer != os.path.basename(url):
logging.info(f"A new release of the Minecraft AP randomizer mod was found: "
f"{os.path.basename(url)}")
if prompt_yes_no("Would you like to update?"):
old_ap_mod = os.path.join(forge_dir, 'mods', ap_randomizer) if ap_randomizer is not None else None
new_ap_mod = os.path.join(forge_dir, 'mods', os.path.basename(url))
logging.info("Downloading AP randomizer mod. This may take a moment...")
apmod_resp = requests.get(url)
if apmod_resp.status_code == 200:
with open(new_ap_mod, 'wb') as f:
f.write(apmod_resp.content)
logging.info(f"Wrote new mod file to {new_ap_mod}")
if old_ap_mod is not None:
os.remove(old_ap_mod)
logging.info(f"Removed old mod file from {old_ap_mod}")
else:
logging.error(f"Error retrieving the randomizer mod (status code {apmod_resp.status_code}).")
logging.error(f"Please report this issue on the Archipelago Discord server.")
sys.exit(1)
def check_eula(forge_dir):
"""Check if the EULA is agreed to, and prompt the user to read and agree if necessary."""
eula_path = os.path.join(forge_dir, "eula.txt")
if not os.path.isfile(eula_path):
# Create eula.txt
with open(eula_path, 'w') as f:
f.write("#By changing the setting below to TRUE you are indicating your agreement to our EULA (https://account.mojang.com/documents/minecraft_eula).\n")
f.write(f"#{strftime('%a %b %d %X %Z %Y')}\n")
f.write("eula=false\n")
with open(eula_path, 'r+') as f:
text = f.read()
if 'false' in text:
# Prompt user to agree to the EULA
logging.info("You need to agree to the Minecraft EULA in order to run the server.")
logging.info("The EULA can be found at https://account.mojang.com/documents/minecraft_eula")
if prompt_yes_no("Do you agree to the EULA?"):
f.seek(0)
f.write(text.replace('false', 'true'))
f.truncate()
logging.info(f"Set {eula_path} to true")
else:
sys.exit(0)
def find_jdk_dir(version: str) -> str:
"""get the specified versions jdk directory"""
for entry in os.listdir():
if os.path.isdir(entry) and entry.startswith(f"jdk{version}"):
return os.path.abspath(entry)
def find_jdk(version: str) -> str:
"""get the java exe location"""
if is_windows:
jdk = find_jdk_dir(version)
jdk_exe = os.path.join(jdk, "bin", "java.exe")
if os.path.isfile(jdk_exe):
return jdk_exe
else:
jdk_exe = shutil.which(options["minecraft_options"].get("java", "java"))
if not jdk_exe:
raise Exception("Could not find Java. Is Java installed on the system?")
return jdk_exe
def download_java(java: str):
"""Download Corretto (Amazon JDK)"""
jdk = find_jdk_dir(java)
if jdk is not None:
print(f"Removing old JDK...")
from shutil import rmtree
rmtree(jdk)
print(f"Downloading Java...")
jdk_url = f"https://corretto.aws/downloads/latest/amazon-corretto-{java}-x64-windows-jdk.zip"
resp = requests.get(jdk_url)
if resp.status_code == 200: # OK
print(f"Extracting...")
import zipfile
from io import BytesIO
with zipfile.ZipFile(BytesIO(resp.content)) as zf:
zf.extractall()
else:
print(f"Error downloading Java (status code {resp.status_code}).")
print(f"If this was not expected, please report this issue on the Archipelago Discord server.")
if not prompt_yes_no("Continue anyways?"):
sys.exit(0)
def install_forge(directory: str, forge_version: str, java_version: str):
"""download and install forge"""
java_exe = find_jdk(java_version)
if java_exe is not None:
print(f"Downloading Forge {forge_version}...")
forge_url = f"https://maven.minecraftforge.net/net/minecraftforge/forge/{forge_version}/forge-{forge_version}-installer.jar"
resp = requests.get(forge_url)
if resp.status_code == 200: # OK
forge_install_jar = os.path.join(directory, "forge_install.jar")
if not os.path.exists(directory):
os.mkdir(directory)
with open(forge_install_jar, 'wb') as f:
f.write(resp.content)
print(f"Installing Forge...")
install_process = Popen([java_exe, "-jar", forge_install_jar, "--installServer", directory])
install_process.wait()
os.remove(forge_install_jar)
def run_forge_server(forge_dir: str, java_version: str, heap_arg: str) -> Popen:
"""Run the Forge server."""
java_exe = find_jdk(java_version)
if not os.path.isfile(java_exe):
java_exe = "java" # try to fall back on java in the PATH
heap_arg = max_heap_re.match(heap_arg).group()
if heap_arg[-1] in ['b', 'B']:
heap_arg = heap_arg[:-1]
heap_arg = "-Xmx" + heap_arg
os_args = "win_args.txt" if is_windows else "unix_args.txt"
args_file = os.path.join(forge_dir, "libraries", "net", "minecraftforge", "forge", forge_version, os_args)
forge_args = []
with open(args_file) as argfile:
for line in argfile:
forge_args.extend(line.strip().split(" "))
args = [java_exe, heap_arg, *forge_args, "-nogui"]
logging.info(f"Running Forge server: {args}")
os.chdir(forge_dir)
return Popen(args)
def get_minecraft_versions(version, release_channel="release"):
version_file_endpoint = "https://raw.githubusercontent.com/KonoTyran/Minecraft_AP_Randomizer/master/versions/minecraft_versions.json"
resp = requests.get(version_file_endpoint)
local = False
if resp.status_code == 200: # OK
try:
data = resp.json()
except requests.exceptions.JSONDecodeError:
logging.warning(f"Unable to fetch version update file, using local version. (status code {resp.status_code}).")
local = True
else:
logging.warning(f"Unable to fetch version update file, using local version. (status code {resp.status_code}).")
local = True
if local:
with open(Utils.user_path("minecraft_versions.json"), 'r') as f:
data = json.load(f)
else:
with open(Utils.user_path("minecraft_versions.json"), 'w') as f:
json.dump(data, f)
try:
if version:
return next(filter(lambda entry: entry["version"] == version, data[release_channel]))
else:
return resp.json()[release_channel][0]
except (StopIteration, KeyError):
logging.error(f"No compatible mod version found for client version {version} on \"{release_channel}\" channel.")
if release_channel != "release":
logging.error("Consider switching \"release_channel\" to \"release\" in your Host.yaml file")
else:
logging.error("No suitable mod found on the \"release\" channel. Please Contact us on discord to report this error.")
sys.exit(0)
def is_correct_forge(forge_dir) -> bool:
if os.path.isdir(os.path.join(forge_dir, "libraries", "net", "minecraftforge", "forge", forge_version)):
return True
return False
if __name__ == '__main__':
Utils.init_logging("MinecraftClient")
parser = argparse.ArgumentParser()
parser.add_argument("apmc_file", default=None, nargs='?', help="Path to an Archipelago Minecraft data file (.apmc)")
parser.add_argument('--install', '-i', dest='install', default=False, action='store_true',
help="Download and install Java and the Forge server. Does not launch the client afterwards.")
parser.add_argument('--release_channel', '-r', dest="channel", type=str, action='store',
help="Specify release channel to use.")
parser.add_argument('--java', '-j', metavar='17', dest='java', type=str, default=False, action='store',
help="specify java version.")
parser.add_argument('--forge', '-f', metavar='1.18.2-40.1.0', dest='forge', type=str, default=False, action='store',
help="specify forge version. (Minecraft Version-Forge Version)")
parser.add_argument('--version', '-v', metavar='9', dest='data_version', type=int, action='store',
help="specify Mod data version to download.")
args = parser.parse_args()
apmc_file = os.path.abspath(args.apmc_file) if args.apmc_file else None
# Change to executable's working directory
os.chdir(os.path.abspath(os.path.dirname(sys.argv[0])))
options = Utils.get_options()
channel = args.channel or options["minecraft_options"]["release_channel"]
apmc_data = None
data_version = args.data_version or None
if apmc_file is None and not args.install:
apmc_file = Utils.open_filename('Select APMC file', (('APMC File', ('.apmc',)),))
if apmc_file is not None and data_version is None:
apmc_data = read_apmc_file(apmc_file)
data_version = apmc_data.get('client_version', '')
versions = get_minecraft_versions(data_version, channel)
forge_dir = options["minecraft_options"]["forge_directory"]
max_heap = options["minecraft_options"]["max_heap_size"]
forge_version = args.forge or versions["forge"]
java_version = args.java or versions["java"]
mod_url = versions["url"]
java_dir = find_jdk_dir(java_version)
if args.install:
if is_windows:
print("Installing Java")
download_java(java_version)
if not is_correct_forge(forge_dir):
print("Installing Minecraft Forge")
install_forge(forge_dir, forge_version, java_version)
else:
print("Correct Forge version already found, skipping install.")
sys.exit(0)
if apmc_data is None:
raise FileNotFoundError(f"APMC file does not exist or is inaccessible at the given location ({apmc_file})")
if is_windows:
if java_dir is None or not os.path.isdir(java_dir):
if prompt_yes_no("Did not find java directory. Download and install java now?"):
download_java(java_version)
java_dir = find_jdk_dir(java_version)
if java_dir is None or not os.path.isdir(java_dir):
raise NotADirectoryError(f"Path {java_dir} does not exist or could not be accessed.")
if not is_correct_forge(forge_dir):
if prompt_yes_no(f"Did not find forge version {forge_version} download and install it now?"):
install_forge(forge_dir, forge_version, java_version)
if not os.path.isdir(forge_dir):
raise NotADirectoryError(f"Path {forge_dir} does not exist or could not be accessed.")
if not max_heap_re.match(max_heap):
raise Exception(f"Max heap size {max_heap} in incorrect format. Use a number followed by M or G, e.g. 512M or 2G.")
update_mod(forge_dir, mod_url)
replace_apmc_files(forge_dir, apmc_file)
check_eula(forge_dir)
server_process = run_forge_server(forge_dir, java_version, max_heap)
server_process.wait()

View File

@@ -5,22 +5,18 @@ import multiprocessing
import warnings
if sys.platform in ("win32", "darwin") and sys.version_info < (3, 11, 9):
if sys.platform in ("win32", "darwin") and sys.version_info < (3, 10, 11):
# Official micro version updates. This should match the number in docs/running from source.md.
raise RuntimeError(f"Incompatible Python Version found: {sys.version_info}. Official 3.11.9+ is supported.")
elif sys.platform in ("win32", "darwin") and sys.version_info < (3, 11, 13):
raise RuntimeError(f"Incompatible Python Version found: {sys.version_info}. Official 3.10.15+ is supported.")
elif sys.platform in ("win32", "darwin") and sys.version_info < (3, 10, 15):
# There are known security issues, but no easy way to install fixed versions on Windows for testing.
warnings.warn(f"Python Version {sys.version_info} has security issues. Don't use in production.")
elif sys.version_info < (3, 11, 0):
elif sys.version_info < (3, 10, 1):
# Other platforms may get security backports instead of micro updates, so the number is unreliable.
raise RuntimeError(f"Incompatible Python Version found: {sys.version_info}. 3.11.0+ is supported.")
raise RuntimeError(f"Incompatible Python Version found: {sys.version_info}. 3.10.1+ is supported.")
# don't run update if environment is frozen/compiled or if not the parent process (skip in subprocess)
_skip_update = bool(
getattr(sys, "frozen", False) or
multiprocessing.parent_process() or
os.environ.get("SKIP_REQUIREMENTS_UPDATE", "").lower() in ("1", "true", "yes")
)
_skip_update = bool(getattr(sys, "frozen", False) or multiprocessing.parent_process())
update_ran = _skip_update
@@ -74,11 +70,11 @@ def update_command():
def install_pkg_resources(yes=False):
try:
import pkg_resources # noqa: F401
except (AttributeError, ImportError):
except ImportError:
check_pip()
if not yes:
confirm("pkg_resources not found, press enter to install it")
subprocess.call([sys.executable, "-m", "pip", "install", "--upgrade", "setuptools>=75,<81"])
subprocess.call([sys.executable, "-m", "pip", "install", "--upgrade", "setuptools"])
def update(yes: bool = False, force: bool = False) -> None:

View File

@@ -32,7 +32,7 @@ if typing.TYPE_CHECKING:
import colorama
import websockets
from websockets.extensions.permessage_deflate import PerMessageDeflate, ServerPerMessageDeflateFactory
from websockets.extensions.permessage_deflate import PerMessageDeflate
try:
# ponyorm is a requirement for webhost, not default server, so may not be importable
from pony.orm.dbapiprovider import OperationalError
@@ -43,21 +43,11 @@ import NetUtils
import Utils
from Utils import version_tuple, restricted_loads, Version, async_start, get_intended_text
from NetUtils import Endpoint, ClientStatus, NetworkItem, decode, encode, NetworkPlayer, Permission, NetworkSlot, \
SlotType, LocationStore, MultiData, Hint, HintStatus
SlotType, LocationStore, Hint, HintStatus
from BaseClasses import ItemClassification
min_client_version = Version(0, 5, 0)
colorama.just_fix_windows_console()
no_version = Version(0, 0, 0)
assert isinstance(no_version, tuple) # assert immutable
server_per_message_deflate_factory = ServerPerMessageDeflateFactory(
server_max_window_bits=11,
client_max_window_bits=11,
compress_settings={"memLevel": 4},
)
min_client_version = Version(0, 1, 6)
colorama.init()
def remove_from_list(container, value):
@@ -76,13 +66,9 @@ def pop_from_container(container, value):
return container
def update_container_unique(container, entries):
if isinstance(container, list):
existing_container_as_set = set(container)
container.extend([entry for entry in entries if entry not in existing_container_as_set])
else:
container.update(entries)
return container
def update_dict(dictionary, entries):
dictionary.update(entries)
return dictionary
def queue_gc():
@@ -123,7 +109,7 @@ modify_functions = {
# lists/dicts:
"remove": remove_from_list,
"pop": pop_from_container,
"update": update_container_unique,
"update": update_dict,
}
@@ -134,31 +120,8 @@ def get_saving_second(seed_name: str, interval: int = 60) -> int:
class Client(Endpoint):
__slots__ = (
"__weakref__",
"version",
"auth",
"team",
"slot",
"send_index",
"tags",
"messageprocessor",
"ctx",
"remote_items",
"remote_start_inventory",
"no_items",
"no_locations",
"no_text",
)
version: Version
auth: bool
team: int | None
slot: int | None
send_index: int
tags: list[str]
messageprocessor: ClientMessageProcessor
ctx: weakref.ref[Context]
version = Version(0, 0, 0)
tags: typing.List[str]
remote_items: bool
remote_start_inventory: bool
no_items: bool
@@ -167,7 +130,6 @@ class Client(Endpoint):
def __init__(self, socket: "ServerConnection", ctx: Context) -> None:
super().__init__(socket)
self.version = no_version
self.auth = False
self.team = None
self.slot = None
@@ -175,11 +137,6 @@ class Client(Endpoint):
self.tags = []
self.messageprocessor = client_message_processor(ctx, self)
self.ctx = weakref.ref(ctx)
self.remote_items = False
self.remote_start_inventory = False
self.no_items = False
self.no_locations = False
self.no_text = False
@property
def items_handling(self):
@@ -217,7 +174,6 @@ class Context:
"release_mode": str,
"remaining_mode": str,
"collect_mode": str,
"countdown_mode": str,
"item_cheat": bool,
"compatibility": int}
# team -> slot id -> list of clients authenticated to slot.
@@ -247,8 +203,8 @@ class Context:
def __init__(self, host: str, port: int, server_password: str, password: str, location_check_points: int,
hint_cost: int, item_cheat: bool, release_mode: str = "disabled", collect_mode="disabled",
countdown_mode: str = "auto", remaining_mode: str = "disabled", auto_shutdown: typing.SupportsFloat = 0,
compatibility: int = 2, log_network: bool = False, logger: logging.Logger = logging.getLogger()):
remaining_mode: str = "disabled", auto_shutdown: typing.SupportsFloat = 0, compatibility: int = 2,
log_network: bool = False, logger: logging.Logger = logging.getLogger()):
self.logger = logger
super(Context, self).__init__()
self.slot_info = {}
@@ -281,7 +237,6 @@ class Context:
self.release_mode: str = release_mode
self.remaining_mode: str = remaining_mode
self.collect_mode: str = collect_mode
self.countdown_mode: str = countdown_mode
self.item_cheat = item_cheat
self.exit_event = asyncio.Event()
self.client_activity_timers: typing.Dict[
@@ -485,7 +440,7 @@ class Context:
raise Utils.VersionException("Incompatible multidata.")
return restricted_loads(zlib.decompress(data[1:]))
def _load(self, decoded_obj: MultiData, game_data_packages: typing.Dict[str, typing.Any],
def _load(self, decoded_obj: dict, game_data_packages: typing.Dict[str, typing.Any],
use_embedded_server_options: bool):
self.read_data = {}
@@ -493,17 +448,13 @@ class Context:
self.read_data["race_mode"] = lambda: decoded_obj.get("race_mode", 0)
mdata_ver = decoded_obj["minimum_versions"]["server"]
if mdata_ver > version_tuple:
raise RuntimeError(f"Supplied Multidata (.archipelago) requires a server of at least version {mdata_ver}, "
raise RuntimeError(f"Supplied Multidata (.archipelago) requires a server of at least version {mdata_ver},"
f"however this server is of version {version_tuple}")
self.generator_version = Version(*decoded_obj["version"])
clients_ver = decoded_obj["minimum_versions"].get("clients", {})
self.minimum_client_versions = {}
if self.generator_version < Version(0, 6, 2):
min_version = Version(0, 1, 6)
else:
min_version = min_client_version
for player, version in clients_ver.items():
self.minimum_client_versions[player] = max(Version(*version), min_version)
self.minimum_client_versions[player] = max(Version(*version), min_client_version)
self.slot_info = decoded_obj["slot_info"]
self.games = {slot: slot_info.game for slot, slot_info in self.slot_info.items()}
@@ -586,7 +537,6 @@ class Context:
def _save(self, exit_save: bool = False) -> bool:
try:
# Does not use Utils.restricted_dumps because we'd rather make a save than not make one
encoded_save = pickle.dumps(self.get_save())
with open(self.save_filename, "wb") as f:
f.write(zlib.compress(encoded_save))
@@ -667,7 +617,6 @@ class Context:
"server_password": self.server_password, "password": self.password,
"release_mode": self.release_mode,
"remaining_mode": self.remaining_mode, "collect_mode": self.collect_mode,
"countdown_mode": self.countdown_mode,
"item_cheat": self.item_cheat, "compatibility": self.compatibility}
}
@@ -702,7 +651,6 @@ class Context:
self.release_mode = savedata["game_options"]["release_mode"]
self.remaining_mode = savedata["game_options"]["remaining_mode"]
self.collect_mode = savedata["game_options"]["collect_mode"]
self.countdown_mode = savedata["game_options"].get("countdown_mode", self.countdown_mode)
self.item_cheat = savedata["game_options"]["item_cheat"]
self.compatibility = savedata["game_options"]["compatibility"]
@@ -795,7 +743,7 @@ class Context:
return self.player_names[team, slot]
def notify_hints(self, team: int, hints: typing.List[Hint], only_new: bool = False,
persist_even_if_found: bool = False, recipients: typing.Sequence[int] = None):
recipients: typing.Sequence[int] = None):
"""Send and remember hints."""
if only_new:
hints = [hint for hint in hints if hint not in self.hints[team, hint.finding_player]]
@@ -810,9 +758,8 @@ class Context:
if not hint.local and data not in concerns[hint.finding_player]:
concerns[hint.finding_player].append(data)
# For !hint use cases, only hints that were not already found at the time of creation should be remembered
# For LocationScouts use-cases, all hints should be remembered
if not hint.found or persist_even_if_found:
# only remember hints that were not already found at the time of creation
if not hint.found:
# since hints are bidirectional, finding player and receiving player,
# we can check once if hint already exists
if hint not in self.hints[team, hint.finding_player]:
@@ -1177,13 +1124,8 @@ def register_location_checks(ctx: Context, team: int, slot: int, locations: typi
ctx.save()
def collect_hints(ctx: Context, team: int, slot: int, item: typing.Union[int, str],
status: HintStatus | None = None) -> typing.List[Hint]:
"""
Collect a new hint for a given item id or name, with a given status.
If status is None (which is the default value), an automatic status will be determined from the item's quality.
"""
def collect_hints(ctx: Context, team: int, slot: int, item: typing.Union[int, str], auto_status: HintStatus) \
-> typing.List[Hint]:
hints = []
slots: typing.Set[int] = {slot}
for group_id, group in ctx.groups.items():
@@ -1199,39 +1141,25 @@ def collect_hints(ctx: Context, team: int, slot: int, item: typing.Union[int, st
else:
found = location_id in ctx.location_checks[team, finding_player]
entrance = ctx.er_hint_data.get(finding_player, {}).get(location_id, "")
hint_status = status # Assign again because we're in a for loop
new_status = auto_status
if found:
hint_status = HintStatus.HINT_FOUND
elif hint_status is None:
if item_flags & ItemClassification.trap:
hint_status = HintStatus.HINT_AVOID
else:
hint_status = HintStatus.HINT_PRIORITY
hints.append(
Hint(receiving_player, finding_player, location_id, item_id, found, entrance, item_flags, hint_status)
)
new_status = HintStatus.HINT_FOUND
elif item_flags & ItemClassification.trap:
new_status = HintStatus.HINT_AVOID
hints.append(Hint(receiving_player, finding_player, location_id, item_id, found, entrance,
item_flags, new_status))
return hints
def collect_hint_location_name(ctx: Context, team: int, slot: int, location: str,
status: HintStatus | None = HintStatus.HINT_UNSPECIFIED) -> typing.List[Hint]:
"""
Collect a new hint for a given location name, with a given status (defaults to "unspecified").
If None is passed for the status, then an automatic status will be determined from the item's quality.
"""
def collect_hint_location_name(ctx: Context, team: int, slot: int, location: str, auto_status: HintStatus) \
-> typing.List[Hint]:
seeked_location: int = ctx.location_names_for_game(ctx.games[slot])[location]
return collect_hint_location_id(ctx, team, slot, seeked_location, status)
return collect_hint_location_id(ctx, team, slot, seeked_location, auto_status)
def collect_hint_location_id(ctx: Context, team: int, slot: int, seeked_location: int,
status: HintStatus | None = HintStatus.HINT_UNSPECIFIED) -> typing.List[Hint]:
"""
Collect a new hint for a given location id, with a given status (defaults to "unspecified").
If None is passed for the status, then an automatic status will be determined from the item's quality.
"""
def collect_hint_location_id(ctx: Context, team: int, slot: int, seeked_location: int, auto_status: HintStatus) \
-> typing.List[Hint]:
prev_hint = ctx.get_hint(team, slot, seeked_location)
if prev_hint:
return [prev_hint]
@@ -1241,16 +1169,13 @@ def collect_hint_location_id(ctx: Context, team: int, slot: int, seeked_location
found = seeked_location in ctx.location_checks[team, slot]
entrance = ctx.er_hint_data.get(slot, {}).get(seeked_location, "")
new_status = auto_status
if found:
status = HintStatus.HINT_FOUND
elif status is None:
if item_flags & ItemClassification.trap:
status = HintStatus.HINT_AVOID
else:
status = HintStatus.HINT_PRIORITY
return [Hint(receiving_player, slot, seeked_location, item_id, found, entrance, item_flags, status)]
new_status = HintStatus.HINT_FOUND
elif item_flags & ItemClassification.trap:
new_status = HintStatus.HINT_AVOID
return [Hint(receiving_player, slot, seeked_location, item_id, found, entrance, item_flags,
new_status)]
return []
@@ -1364,8 +1289,7 @@ class CommandProcessor(metaclass=CommandMeta):
argname += "=" + parameter.default
argtext += argname
argtext += " "
doctext = '\n '.join(inspect.getdoc(method).split('\n'))
s += f"{self.marker}{command} {argtext}\n {doctext}\n"
s += f"{self.marker}{command} {argtext}\n {method.__doc__}\n"
return s
def _cmd_help(self):
@@ -1394,6 +1318,19 @@ class CommandProcessor(metaclass=CommandMeta):
class CommonCommandProcessor(CommandProcessor):
ctx: Context
def _cmd_countdown(self, seconds: str = "10") -> bool:
"""Start a countdown in seconds"""
try:
timer = int(seconds, 10)
except ValueError:
timer = 10
else:
if timer > 60 * 60:
raise ValueError(f"{timer} is invalid. Maximum is 1 hour.")
async_start(countdown(self.ctx, timer))
return True
def _cmd_options(self):
"""List all current options. Warning: lists password."""
self.output("Current options:")
@@ -1535,23 +1472,6 @@ class ClientMessageProcessor(CommonCommandProcessor):
" You can ask the server admin for a /collect")
return False
def _cmd_countdown(self, seconds: str = "10") -> bool:
"""Start a countdown in seconds"""
if self.ctx.countdown_mode == "disabled" or \
self.ctx.countdown_mode == "auto" and len(self.ctx.player_names) >= 30:
self.output("Sorry, client countdowns have been disabled on this server. You can ask the server admin for a /countdown")
return False
try:
timer = int(seconds, 10)
except ValueError:
timer = 10
else:
if timer > 60 * 60:
raise ValueError(f"{timer} is invalid. Maximum is 1 hour.")
async_start(countdown(self.ctx, timer))
return True
def _cmd_remaining(self) -> bool:
"""List remaining items in your game, but not their location or recipient"""
if self.ctx.remaining_mode == "enabled":
@@ -1679,6 +1599,7 @@ class ClientMessageProcessor(CommonCommandProcessor):
def get_hints(self, input_text: str, for_location: bool = False) -> bool:
points_available = get_client_points(self.ctx, self.client)
cost = self.ctx.get_hint_cost(self.client.slot)
auto_status = HintStatus.HINT_UNSPECIFIED if for_location else HintStatus.HINT_PRIORITY
if not input_text:
hints = {hint.re_check(self.ctx, self.client.team) for hint in
self.ctx.hints[self.client.team, self.client.slot]}
@@ -1704,9 +1625,9 @@ class ClientMessageProcessor(CommonCommandProcessor):
self.output(f"Sorry, \"{hint_name}\" is marked as non-hintable.")
hints = []
elif not for_location:
hints = collect_hints(self.ctx, self.client.team, self.client.slot, hint_id)
hints = collect_hints(self.ctx, self.client.team, self.client.slot, hint_id, auto_status)
else:
hints = collect_hint_location_id(self.ctx, self.client.team, self.client.slot, hint_id)
hints = collect_hint_location_id(self.ctx, self.client.team, self.client.slot, hint_id, auto_status)
else:
game = self.ctx.games[self.client.slot]
@@ -1726,18 +1647,16 @@ class ClientMessageProcessor(CommonCommandProcessor):
hints = []
for item_name in self.ctx.item_name_groups[game][hint_name]:
if item_name in self.ctx.item_names_for_game(game): # ensure item has an ID
hints.extend(collect_hints(self.ctx, self.client.team, self.client.slot, item_name))
hints.extend(collect_hints(self.ctx, self.client.team, self.client.slot, item_name, auto_status))
elif not for_location and hint_name in self.ctx.item_names_for_game(game): # item name
hints = collect_hints(self.ctx, self.client.team, self.client.slot, hint_name)
hints = collect_hints(self.ctx, self.client.team, self.client.slot, hint_name, auto_status)
elif hint_name in self.ctx.location_name_groups[game]: # location group name
hints = []
for loc_name in self.ctx.location_name_groups[game][hint_name]:
if loc_name in self.ctx.location_names_for_game(game):
hints.extend(
collect_hint_location_name(self.ctx, self.client.team, self.client.slot, loc_name)
)
hints.extend(collect_hint_location_name(self.ctx, self.client.team, self.client.slot, loc_name, auto_status))
else: # location name
hints = collect_hint_location_name(self.ctx, self.client.team, self.client.slot, hint_name)
hints = collect_hint_location_name(self.ctx, self.client.team, self.client.slot, hint_name, auto_status)
else:
self.output(response)
@@ -1902,7 +1821,7 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
ctx.clients[team][slot].append(client)
client.version = args['version']
client.tags = args['tags']
client.no_locations = bool(client.tags & _non_game_messages.keys())
client.no_locations = "TextOnly" in client.tags or "Tracker" in client.tags
# set NoText for old PopTracker clients that predate the tag to save traffic
client.no_text = "NoText" in client.tags or ("PopTracker" in client.tags and client.version < (0, 5, 1))
connected_packet = {
@@ -1976,7 +1895,7 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
old_tags = client.tags
client.tags = args["tags"]
if set(old_tags) != set(client.tags):
client.no_locations = bool(client.tags & _non_game_messages.keys())
client.no_locations = 'TextOnly' in client.tags or 'Tracker' in client.tags
client.no_text = "NoText" in client.tags or (
"PopTracker" in client.tags and client.version < (0, 5, 1)
)
@@ -2015,64 +1934,13 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
target_item, target_player, flags = ctx.locations[client.slot][location]
if create_as_hint:
hints.extend(collect_hint_location_id(ctx, client.team, client.slot, location))
hints.extend(collect_hint_location_id(ctx, client.team, client.slot, location,
HintStatus.HINT_UNSPECIFIED))
locs.append(NetworkItem(target_item, location, target_player, flags))
ctx.notify_hints(client.team, hints, only_new=create_as_hint == 2, persist_even_if_found=True)
ctx.notify_hints(client.team, hints, only_new=create_as_hint == 2)
if locs and create_as_hint:
ctx.save()
await ctx.send_msgs(client, [{'cmd': 'LocationInfo', 'locations': locs}])
elif cmd == 'CreateHints':
location_player = args.get("player", client.slot)
locations = args["locations"]
status = args.get("status", HintStatus.HINT_UNSPECIFIED)
if not locations:
await ctx.send_msgs(client, [{"cmd": "InvalidPacket", "type": "arguments",
"text": "CreateHints: No locations specified.", "original_cmd": cmd}])
return
try:
status = HintStatus(status)
except ValueError as err:
await ctx.send_msgs(client,
[{"cmd": "InvalidPacket", "type": "arguments",
"text": f"Unknown Status: {err}",
"original_cmd": cmd}])
return
hints = []
for location in locations:
if location_player != client.slot and location not in ctx.locations[location_player]:
error_text = (
"CreateHints: One or more of the locations do not exist for the specified off-world player. "
"Please refrain from hinting other slot's locations that you don't know contain your items."
)
await ctx.send_msgs(client, [{"cmd": "InvalidPacket", "type": "arguments",
"text": error_text, "original_cmd": cmd}])
return
target_item, item_player, flags = ctx.locations[location_player][location]
if client.slot not in ctx.slot_set(item_player):
if status != HintStatus.HINT_UNSPECIFIED:
error_text = 'CreateHints: Must use "unspecified"/None status for items from other players.'
await ctx.send_msgs(client, [{"cmd": "InvalidPacket", "type": "arguments",
"text": error_text, "original_cmd": cmd}])
return
if client.slot != location_player:
error_text = "CreateHints: Can only create hints for own items or own locations."
await ctx.send_msgs(client, [{"cmd": "InvalidPacket", "type": "arguments",
"text": error_text, "original_cmd": cmd}])
return
hints += collect_hint_location_id(ctx, client.team, location_player, location, status)
# As of writing this code, only_new=True does not update status for existing hints
ctx.notify_hints(client.team, hints, only_new=True, persist_even_if_found=True)
ctx.save()
elif cmd == 'UpdateHint':
location = args["location"]
@@ -2110,21 +1978,14 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
new_hint = new_hint.re_prioritize(ctx, status)
if hint == new_hint:
return
concerning_slots = ctx.slot_set(hint.receiving_player) | {hint.finding_player}
for slot in concerning_slots:
ctx.replace_hint(client.team, slot, hint, new_hint)
ctx.replace_hint(client.team, hint.finding_player, hint, new_hint)
ctx.replace_hint(client.team, hint.receiving_player, hint, new_hint)
ctx.save()
for slot in concerning_slots:
ctx.on_changed_hints(client.team, slot)
ctx.on_changed_hints(client.team, hint.finding_player)
ctx.on_changed_hints(client.team, hint.receiving_player)
elif cmd == 'StatusUpdate':
if client.no_locations and args["status"] == ClientStatus.CLIENT_GOAL:
await ctx.send_msgs(client, [{'cmd': 'InvalidPacket', "type": "cmd",
"text": "Trackers can't register Goal Complete",
"original_cmd": cmd}])
else:
update_client_status(ctx, client, args["status"])
update_client_status(ctx, client, args["status"])
elif cmd == 'Say':
if "text" not in args or type(args["text"]) is not str or not args["text"].isprintable():
@@ -2176,7 +2037,7 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
value = func(value, operation["value"])
ctx.stored_data[args["key"]] = args["value"] = value
targets = set(ctx.stored_data_notification_clients[args["key"]])
if args.get("want_reply", False):
if args.get("want_reply", True):
targets.add(client)
if targets:
ctx.broadcast(targets, [args])
@@ -2307,19 +2168,6 @@ class ServerCommandProcessor(CommonCommandProcessor):
self.output(f"Could not find player {player_name} to collect")
return False
def _cmd_countdown(self, seconds: str = "10") -> bool:
"""Start a countdown in seconds"""
try:
timer = int(seconds, 10)
except ValueError:
timer = 10
else:
if timer > 60 * 60:
raise ValueError(f"{timer} is invalid. Maximum is 1 hour.")
async_start(countdown(self.ctx, timer))
return True
@mark_raw
def _cmd_release(self, player_name: str) -> bool:
"""Send out the remaining items from a player to their intended recipients."""
@@ -2441,9 +2289,9 @@ class ServerCommandProcessor(CommonCommandProcessor):
hints = []
for item_name_from_group in self.ctx.item_name_groups[game][item]:
if item_name_from_group in self.ctx.item_names_for_game(game): # ensure item has an ID
hints.extend(collect_hints(self.ctx, team, slot, item_name_from_group))
hints.extend(collect_hints(self.ctx, team, slot, item_name_from_group, HintStatus.HINT_PRIORITY))
else: # item name or id
hints = collect_hints(self.ctx, team, slot, item)
hints = collect_hints(self.ctx, team, slot, item, HintStatus.HINT_PRIORITY)
if hints:
self.ctx.notify_hints(team, hints)
@@ -2477,14 +2325,17 @@ class ServerCommandProcessor(CommonCommandProcessor):
if usable:
if isinstance(location, int):
hints = collect_hint_location_id(self.ctx, team, slot, location)
hints = collect_hint_location_id(self.ctx, team, slot, location,
HintStatus.HINT_UNSPECIFIED)
elif game in self.ctx.location_name_groups and location in self.ctx.location_name_groups[game]:
hints = []
for loc_name_from_group in self.ctx.location_name_groups[game][location]:
if loc_name_from_group in self.ctx.location_names_for_game(game):
hints.extend(collect_hint_location_name(self.ctx, team, slot, loc_name_from_group))
hints.extend(collect_hint_location_name(self.ctx, team, slot, loc_name_from_group,
HintStatus.HINT_UNSPECIFIED))
else:
hints = collect_hint_location_name(self.ctx, team, slot, location)
hints = collect_hint_location_name(self.ctx, team, slot, location,
HintStatus.HINT_UNSPECIFIED)
if hints:
self.ctx.notify_hints(team, hints)
else:
@@ -2512,11 +2363,6 @@ class ServerCommandProcessor(CommonCommandProcessor):
elif value_type == str and option_name.endswith("password"):
def value_type(input_text: str):
return None if input_text.lower() in {"null", "none", '""', "''"} else input_text
elif option_name == "countdown_mode":
valid_values = {"enabled", "disabled", "auto"}
if option_value.lower() not in valid_values:
self.output(f"Unrecognized {option_name} value '{option_value}', known: {', '.join(valid_values)}")
return False
elif value_type == str and option_name.endswith("mode"):
valid_values = {"goal", "enabled", "disabled"}
valid_values.update(("auto", "auto_enabled") if option_name != "remaining_mode" else [])
@@ -2566,10 +2412,8 @@ async def console(ctx: Context):
def parse_args() -> argparse.Namespace:
from settings import get_settings
parser = argparse.ArgumentParser()
defaults = get_settings().server_options.as_dict()
defaults = Utils.get_settings()["server_options"].as_dict()
parser.add_argument('multidata', nargs="?", default=defaults["multidata"])
parser.add_argument('--host', default=defaults["host"])
parser.add_argument('--port', default=defaults["port"], type=int)
@@ -2604,13 +2448,6 @@ def parse_args() -> argparse.Namespace:
goal: !collect can be used after goal completion
auto-enabled: !collect is available and automatically triggered on goal completion
''')
parser.add_argument('--countdown_mode', default=defaults["countdown_mode"], nargs='?',
choices=['enabled', 'disabled', "auto"], help='''\
Select !countdown Accessibility. (default: %(default)s)
enabled: !countdown is always available
disabled: !countdown is never available
auto: !countdown is available for rooms with less than 30 players
''')
parser.add_argument('--remaining_mode', default=defaults["remaining_mode"], nargs='?',
choices=['enabled', 'disabled', "goal"], help='''\
Select !remaining Accessibility. (default: %(default)s)
@@ -2676,7 +2513,7 @@ async def main(args: argparse.Namespace):
ctx = Context(args.host, args.port, args.server_password, args.password, args.location_check_points,
args.hint_cost, not args.disable_item_cheat, args.release_mode, args.collect_mode,
args.countdown_mode, args.remaining_mode,
args.remaining_mode,
args.auto_shutdown, args.compatibility, args.log_network)
data_filename = args.multidata
@@ -2711,13 +2548,7 @@ async def main(args: argparse.Namespace):
ssl_context = load_server_cert(args.cert, args.cert_key) if args.cert else None
ctx.server = websockets.serve(
functools.partial(server, ctx=ctx),
host=ctx.host,
port=ctx.port,
ssl=ssl_context,
extensions=[server_per_message_deflate_factory],
)
ctx.server = websockets.serve(functools.partial(server, ctx=ctx), host=ctx.host, port=ctx.port, ssl=ssl_context)
ip = args.host if args.host else Utils.get_public_ipv4()
logging.info('Hosting game at %s:%d (%s)' % (ip, ctx.port,
'No password' if not ctx.password else 'Password: %s' % ctx.password))

View File

@@ -1,6 +1,5 @@
from __future__ import annotations
from collections.abc import Mapping, Sequence
import typing
import enum
import warnings
@@ -84,7 +83,7 @@ class NetworkSlot(typing.NamedTuple):
name: str
game: str
type: SlotType
group_members: Sequence[int] = () # only populated if type == group
group_members: typing.Union[typing.List[int], typing.Tuple] = () # only populated if type == group
class NetworkItem(typing.NamedTuple):
@@ -107,27 +106,6 @@ def _scan_for_TypedTuples(obj: typing.Any) -> typing.Any:
return obj
_base_types = str | int | bool | float | None | tuple["_base_types", ...] | dict["_base_types", "base_types"]
def convert_to_base_types(obj: typing.Any) -> _base_types:
if isinstance(obj, (tuple, list, set, frozenset)):
return tuple(convert_to_base_types(o) for o in obj)
elif isinstance(obj, dict):
return {convert_to_base_types(key): convert_to_base_types(value) for key, value in obj.items()}
elif obj is None or type(obj) in (str, int, float, bool):
return obj
# unwrap simple types to their base, such as StrEnum
elif isinstance(obj, str):
return str(obj)
elif isinstance(obj, int):
return int(obj)
elif isinstance(obj, float):
return float(obj)
else:
raise Exception(f"Cannot handle {type(obj)}")
_encode = JSONEncoder(
ensure_ascii=False,
check_circular=False,
@@ -174,8 +152,6 @@ decode = JSONDecoder(object_hook=_object_hook).decode
class Endpoint:
__slots__ = ("socket",)
socket: "ServerConnection"
def __init__(self, socket):
@@ -474,42 +450,6 @@ class _LocationStore(dict, typing.MutableMapping[int, typing.Dict[int, typing.Tu
location_id not in checked])
class MinimumVersions(typing.TypedDict):
server: tuple[int, int, int]
clients: dict[int, tuple[int, int, int]]
class GamesPackage(typing.TypedDict, total=False):
item_name_groups: dict[str, list[str]]
item_name_to_id: dict[str, int]
location_name_groups: dict[str, list[str]]
location_name_to_id: dict[str, int]
checksum: str
class DataPackage(typing.TypedDict):
games: dict[str, GamesPackage]
class MultiData(typing.TypedDict):
slot_data: dict[int, Mapping[str, typing.Any]]
slot_info: dict[int, NetworkSlot]
connect_names: dict[str, tuple[int, int]]
locations: dict[int, dict[int, tuple[int, int, int]]]
checks_in_area: dict[int, dict[str, int | list[int]]]
server_options: dict[str, object]
er_hint_data: dict[int, dict[int, str]]
precollected_items: dict[int, list[int]]
precollected_hints: dict[int, set[Hint]]
version: tuple[int, int, int]
tags: list[str]
minimum_versions: MinimumVersions
seed_name: str
spheres: list[dict[int, set[int]]]
datapackage: dict[str, GamesPackage]
race_mode: int
if typing.TYPE_CHECKING: # type-check with pure python implementation until we have a typing stub
LocationStore = _LocationStore
else:

View File

@@ -12,7 +12,6 @@ from CommonClient import CommonContext, server_loop, gui_enabled, \
import Utils
from Utils import async_start
from worlds import network_data_package
from worlds.oot import OOTWorld
from worlds.oot.Rom import Rom, compress_rom_file
from worlds.oot.N64Patch import apply_patch_file
from worlds.oot.Utils import data_path
@@ -277,12 +276,11 @@ async def n64_sync_task(ctx: OoTContext):
except ConnectionRefusedError:
logger.debug("Connection Refused, Trying Again")
ctx.n64_status = CONNECTION_REFUSED_STATUS
await asyncio.sleep(1)
continue
async def run_game(romfile):
auto_start = OOTWorld.settings.rom_start
auto_start = Utils.get_options()["oot_options"].get("rom_start", True)
if auto_start is True:
import webbrowser
webbrowser.open(romfile)
@@ -297,7 +295,7 @@ async def patch_and_run_game(apz5_file):
decomp_path = base_name + '-decomp.z64'
comp_path = base_name + '.z64'
# Load vanilla ROM, patch file, compress ROM
rom_file_name = OOTWorld.settings.rom_file
rom_file_name = Utils.get_options()["oot_options"]["rom_file"]
rom = Rom(rom_file_name)
sub_file = None
@@ -348,7 +346,7 @@ if __name__ == '__main__':
import colorama
colorama.just_fix_windows_console()
colorama.init()
asyncio.run(main())
colorama.deinit()

View File

@@ -1,7 +1,6 @@
from __future__ import annotations
import abc
import collections
import functools
import logging
import math
@@ -24,12 +23,6 @@ if typing.TYPE_CHECKING:
import pathlib
def roll_percentage(percentage: int | float) -> bool:
"""Roll a percentage chance.
percentage is expected to be in range [0, 100]"""
return random.random() < (float(percentage) / 100)
class OptionError(ValueError):
pass
@@ -494,30 +487,6 @@ class Choice(NumericOption):
else:
raise TypeError(f"Can't compare {self.__class__.__name__} with {other.__class__.__name__}")
def __lt__(self, other: typing.Union[Choice, int, str]):
if isinstance(other, str):
assert other in self.options, f"compared against an unknown string. {self} < {other}"
other = self.options[other]
return super(Choice, self).__lt__(other)
def __gt__(self, other: typing.Union[Choice, int, str]):
if isinstance(other, str):
assert other in self.options, f"compared against an unknown string. {self} > {other}"
other = self.options[other]
return super(Choice, self).__gt__(other)
def __le__(self, other: typing.Union[Choice, int, str]):
if isinstance(other, str):
assert other in self.options, f"compared against an unknown string. {self} <= {other}"
other = self.options[other]
return super(Choice, self).__le__(other)
def __ge__(self, other: typing.Union[Choice, int, str]):
if isinstance(other, str):
assert other in self.options, f"compared against an unknown string. {self} >= {other}"
other = self.options[other]
return super(Choice, self).__ge__(other)
__hash__ = Option.__hash__ # see https://docs.python.org/3/reference/datamodel.html#object.__hash__
@@ -688,12 +657,6 @@ class Range(NumericOption):
range_start = 0
range_end = 1
_RANDOM_OPTS = [
"random", "random-low", "random-middle", "random-high",
"random-range-low-<min>-<max>", "random-range-middle-<min>-<max>",
"random-range-high-<min>-<max>", "random-range-<min>-<max>",
]
def __init__(self, value: int):
if value < self.range_start:
raise Exception(f"{value} is lower than minimum {self.range_start} for option {self.__class__.__name__}")
@@ -719,26 +682,9 @@ class Range(NumericOption):
# these are the conditions where "true" and "false" make sense
if text == "true":
return cls.from_any(cls.default)
# "false"
return cls(0)
try:
num = int(text)
except ValueError:
# text is not a number
# Handle conditionally acceptable values here rather than in the f-string
default = ""
truefalse = ""
if hasattr(cls, "default"):
default = ", default"
if cls.range_start == 0 and cls.default != 0:
truefalse = ", \"true\", \"false\""
raise Exception(f"Invalid range value {text!r}. Acceptable values are: "
f"<int>{default}, high, low{truefalse}, "
f"{', '.join(cls._RANDOM_OPTS)}.")
return cls(num)
else: # "false"
return cls(0)
return cls(int(text))
@classmethod
def weighted_range(cls, text) -> Range:
@@ -754,7 +700,9 @@ class Range(NumericOption):
return cls(random.randint(cls.range_start, cls.range_end))
else:
raise Exception(f"random text \"{text}\" did not resolve to a recognized pattern. "
f"Acceptable values are: {', '.join(cls._RANDOM_OPTS)}.")
f"Acceptable values are: random, random-high, random-middle, random-low, "
f"random-range-low-<min>-<max>, random-range-middle-<min>-<max>, "
f"random-range-high-<min>-<max>, or random-range-<min>-<max>.")
@classmethod
def custom_range(cls, text) -> Range:
@@ -910,57 +858,23 @@ class OptionDict(Option[typing.Dict[str, typing.Any]], VerifyKeys, typing.Mappin
return ", ".join(f"{key}: {v}" for key, v in value.items())
def __getitem__(self, item: str) -> typing.Any:
return self.value[item]
return self.value.__getitem__(item)
def __iter__(self) -> typing.Iterator[str]:
return iter(self.value)
return self.value.__iter__()
def __len__(self) -> int:
return len(self.value)
# __getitem__ fallback fails for Counters, so we define this explicitly
def __contains__(self, item) -> bool:
return item in self.value
return self.value.__len__()
class OptionCounter(OptionDict):
min: int | None = None
max: int | None = None
def __init__(self, value: dict[str, int]) -> None:
super(OptionCounter, self).__init__(collections.Counter(value))
def verify(self, world: type[World], player_name: str, plando_options: PlandoOptions) -> None:
super(OptionCounter, self).verify(world, player_name, plando_options)
range_errors = []
if self.max is not None:
range_errors += [
f"\"{key}: {value}\" is higher than maximum allowed value {self.max}."
for key, value in self.value.items() if value > self.max
]
if self.min is not None:
range_errors += [
f"\"{key}: {value}\" is lower than minimum allowed value {self.min}."
for key, value in self.value.items() if value < self.min
]
if range_errors:
range_errors = [f"For option {getattr(self, 'display_name', self)}:"] + range_errors
raise OptionError("\n".join(range_errors))
class ItemDict(OptionCounter):
class ItemDict(OptionDict):
verify_item_name = True
min = 0
def __init__(self, value: dict[str, int]) -> None:
# Backwards compatibility: Cull 0s to make "in" checks behave the same as when this wasn't a OptionCounter
value = {item_name: amount for item_name, amount in value.items() if amount != 0}
def __init__(self, value: typing.Dict[str, int]):
if any(item_count is None for item_count in value.values()):
raise Exception("Items must have counts associated with them. Please provide positive integer values in the format \"item\": count .")
if any(item_count < 1 for item_count in value.values()):
raise Exception("Cannot have non-positive item counts.")
super(ItemDict, self).__init__(value)
@@ -1039,8 +953,6 @@ class PlandoTexts(Option[typing.List[PlandoText]], VerifyKeys):
supports_weighting = False
display_name = "Plando Texts"
visibility = Visibility.template | Visibility.complex_ui | Visibility.spoiler
def __init__(self, value: typing.Iterable[PlandoText]) -> None:
self.value = list(deepcopy(value))
super().__init__()
@@ -1072,7 +984,7 @@ class PlandoTexts(Option[typing.List[PlandoText]], VerifyKeys):
if isinstance(data, typing.Iterable):
for text in data:
if isinstance(text, typing.Mapping):
if roll_percentage(text.get("percentage", 100)):
if random.random() < float(text.get("percentage", 100)/100):
at = text.get("at", None)
if at is not None:
if isinstance(at, dict):
@@ -1098,7 +1010,7 @@ class PlandoTexts(Option[typing.List[PlandoText]], VerifyKeys):
else:
raise OptionError("\"at\" must be a valid string or weighted list of strings!")
elif isinstance(text, PlandoText):
if roll_percentage(text.percentage):
if random.random() < float(text.percentage/100):
texts.append(text)
else:
raise Exception(f"Cannot create plando text from non-dictionary type, got {type(text)}")
@@ -1114,10 +1026,10 @@ class PlandoTexts(Option[typing.List[PlandoText]], VerifyKeys):
yield from self.value
def __getitem__(self, index: typing.SupportsIndex) -> PlandoText:
return self.value[index]
return self.value.__getitem__(index)
def __len__(self) -> int:
return len(self.value)
return self.value.__len__()
class ConnectionsMeta(AssembleOptions):
@@ -1141,7 +1053,7 @@ class PlandoConnection(typing.NamedTuple):
entrance: str
exit: str
direction: typing.Literal["entrance", "exit", "both"] # TODO: convert Direction to StrEnum once 3.10 is dropped
direction: typing.Literal["entrance", "exit", "both"] # TODO: convert Direction to StrEnum once 3.8 is dropped
percentage: int = 100
@@ -1167,8 +1079,6 @@ class PlandoConnections(Option[typing.List[PlandoConnection]], metaclass=Connect
entrances: typing.ClassVar[typing.AbstractSet[str]]
exits: typing.ClassVar[typing.AbstractSet[str]]
visibility = Visibility.template | Visibility.complex_ui | Visibility.spoiler
duplicate_exits: bool = False
"""Whether or not exits should be allowed to be duplicate."""
@@ -1224,7 +1134,7 @@ class PlandoConnections(Option[typing.List[PlandoConnection]], metaclass=Connect
for connection in data:
if isinstance(connection, typing.Mapping):
percentage = connection.get("percentage", 100)
if roll_percentage(percentage):
if random.random() < float(percentage / 100):
entrance = connection.get("entrance", None)
if is_iterable_except_str(entrance):
entrance = random.choice(sorted(entrance))
@@ -1242,7 +1152,7 @@ class PlandoConnections(Option[typing.List[PlandoConnection]], metaclass=Connect
percentage
))
elif isinstance(connection, PlandoConnection):
if roll_percentage(connection.percentage):
if random.random() < float(connection.percentage / 100):
value.append(connection)
else:
raise Exception(f"Cannot create connection from non-Dict type, got {type(connection)}.")
@@ -1266,7 +1176,7 @@ class PlandoConnections(Option[typing.List[PlandoConnection]], metaclass=Connect
connection.exit) for connection in value])
def __getitem__(self, index: typing.SupportsIndex) -> PlandoConnection:
return self.value[index]
return self.value.__getitem__(index)
def __iter__(self) -> typing.Iterator[PlandoConnection]:
yield from self.value
@@ -1347,48 +1257,42 @@ class CommonOptions(metaclass=OptionsMetaProperty):
progression_balancing: ProgressionBalancing
accessibility: Accessibility
def as_dict(
self,
*option_names: str,
casing: typing.Literal["snake", "camel", "pascal", "kebab"] = "snake",
toggles_as_bools: bool = False,
) -> dict[str, typing.Any]:
def as_dict(self,
*option_names: str,
casing: typing.Literal["snake", "camel", "pascal", "kebab"] = "snake",
toggles_as_bools: bool = False) -> typing.Dict[str, typing.Any]:
"""
Returns a dictionary of [str, Option.value]
:param option_names: Names of the options to get the values of.
:param casing: Casing of the keys to return. Supports `snake`, `camel`, `pascal`, `kebab`.
:param toggles_as_bools: Whether toggle options should be returned as bools instead of ints.
:return: A dictionary of each option name to the value of its Option. If the option is an OptionSet, the value
will be returned as a sorted list.
:param option_names: names of the options to return
:param casing: case of the keys to return. Supports `snake`, `camel`, `pascal`, `kebab`
:param toggles_as_bools: whether toggle options should be output as bools instead of strings
"""
assert option_names, "options.as_dict() was used without any option names."
assert len(option_names) < len(self.__class__.type_hints), "Specify only options you need."
option_results = {}
for option_name in option_names:
if option_name not in type(self).type_hints:
raise ValueError(f"{option_name} not found in {tuple(type(self).type_hints)}")
if casing == "snake":
display_name = option_name
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("_", "-")
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)
elif toggles_as_bools and issubclass(type(self).type_hints[option_name], Toggle):
value = bool(value)
option_results[display_name] = value
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)
elif toggles_as_bools and issubclass(type(self).type_hints[option_name], Toggle):
value = bool(value)
option_results[display_name] = value
raise ValueError(f"{option_name} not found in {tuple(type(self).type_hints)}")
return option_results
@@ -1405,15 +1309,14 @@ class NonLocalItems(ItemSet):
class StartInventory(ItemDict):
"""Start with the specified amount of these items. Example: "Bomb: 1" """
"""Start with these items."""
verify_item_name = True
display_name = "Start Inventory"
rich_text_doc = True
max = 10000
class StartInventoryPool(StartInventory):
"""Start with the specified amount of these items and don't place them in the world. Example: "Bomb: 1"
"""Start with these items and don't place them in the world.
The game decides what the replacement items will be.
"""
@@ -1460,7 +1363,6 @@ class DeathLink(Toggle):
class ItemLinks(OptionList):
"""Share part of your item pool with other players."""
display_name = "Item Links"
visibility = Visibility.template | Visibility.complex_ui | Visibility.spoiler
rich_text_doc = True
default = []
schema = Schema([
@@ -1472,7 +1374,6 @@ class ItemLinks(OptionList):
Optional("local_items"): [And(str, len)],
Optional("non_local_items"): [And(str, len)],
Optional("link_replacement"): Or(None, bool),
Optional("skip_if_solo"): Or(None, bool),
}
])
@@ -1500,10 +1401,8 @@ class ItemLinks(OptionList):
super(ItemLinks, self).verify(world, player_name, plando_options)
existing_links = set()
for link in self.value:
link["name"] = link["name"].strip()[:16].strip()
if link["name"] in existing_links:
raise Exception(f"Item link names are limited to their first 16 characters and must be unique. "
f"You have more than one link named '{link['name']}'.")
raise Exception(f"You cannot have more than one link named {link['name']}.")
existing_links.add(link["name"])
pool = self.verify_items(link["item_pool"], link["name"], "item_pool", world)
@@ -1529,134 +1428,6 @@ class ItemLinks(OptionList):
link["item_pool"] = list(pool)
@dataclass(frozen=True)
class PlandoItem:
items: list[str] | dict[str, typing.Any]
locations: list[str]
world: int | str | bool | None | typing.Iterable[str] | set[int] = False
from_pool: bool = True
force: bool | typing.Literal["silent"] = "silent"
count: int | bool | dict[str, int] = False
percentage: int = 100
class PlandoItems(Option[typing.List[PlandoItem]]):
"""Generic items plando."""
default = ()
supports_weighting = False
display_name = "Plando Items"
visibility = Visibility.template | Visibility.spoiler
def __init__(self, value: typing.Iterable[PlandoItem]) -> None:
self.value = list(deepcopy(value))
super().__init__()
@classmethod
def from_any(cls, data: typing.Any) -> Option[typing.List[PlandoItem]]:
if not isinstance(data, typing.Iterable):
raise OptionError(f"Cannot create plando items from non-Iterable type, got {type(data)}")
value: typing.List[PlandoItem] = []
for item in data:
if isinstance(item, typing.Mapping):
percentage = item.get("percentage", 100)
if not isinstance(percentage, int):
raise OptionError(f"Plando `percentage` has to be int, not {type(percentage)}.")
if not (0 <= percentage <= 100):
raise OptionError(f"Plando `percentage` has to be between 0 and 100 (inclusive) not {percentage}.")
if roll_percentage(percentage):
count = item.get("count", False)
items = item.get("items", [])
if not items:
items = item.get("item", None) # explicitly throw an error here if not present
if not items:
raise OptionError("You must specify at least one item to place items with plando.")
count = 1
if isinstance(items, str):
items = [items]
elif not isinstance(items, (dict, list)):
raise OptionError(f"Plando 'items' has to be string, list, or "
f"dictionary, not {type(items)}")
locations = item.get("locations", [])
if not locations:
locations = item.get("location", [])
if locations:
count = 1
else:
locations = ["Everywhere"]
if isinstance(locations, str):
locations = [locations]
if not isinstance(locations, list):
raise OptionError(f"Plando `location` has to be string or list, not {type(locations)}")
world = item.get("world", False)
from_pool = item.get("from_pool", True)
force = item.get("force", "silent")
if not isinstance(from_pool, bool):
raise OptionError(f"Plando 'from_pool' has to be true or false, not {from_pool!r}.")
if not (isinstance(force, bool) or force == "silent"):
raise OptionError(f"Plando `force` has to be true or false or `silent`, not {force!r}.")
value.append(PlandoItem(items, locations, world, from_pool, force, count, percentage))
elif isinstance(item, PlandoItem):
if roll_percentage(item.percentage):
value.append(item)
else:
raise OptionError(f"Cannot create plando item from non-Dict type, got {type(item)}.")
return cls(value)
def verify(self, world: typing.Type[World], player_name: str, plando_options: "PlandoOptions") -> None:
if not self.value:
return
from BaseClasses import PlandoOptions
if not (PlandoOptions.items & plando_options):
# plando is disabled but plando options were given so overwrite the options
self.value = []
logging.warning(f"The plando items module is turned off, "
f"so items for {player_name} will be ignored.")
else:
# filter down item groups
for plando in self.value:
# confirm a valid count
if isinstance(plando.count, dict):
if "min" in plando.count and "max" in plando.count:
if plando.count["min"] > plando.count["max"]:
raise OptionError("Plando cannot have count `min` greater than `max`.")
items_copy = plando.items.copy()
if isinstance(plando.items, dict):
for item in items_copy:
if item in world.item_name_groups:
value = plando.items.pop(item)
group = world.item_name_groups[item]
filtered_items = sorted(group.difference(list(plando.items.keys())))
if not filtered_items:
raise OptionError(f"Plando `items` contains the group \"{item}\" "
f"and every item in it. This is not allowed.")
if value is True:
for key in filtered_items:
plando.items[key] = True
else:
for key in random.choices(filtered_items, k=value):
plando.items[key] = plando.items.get(key, 0) + 1
else:
assert isinstance(plando.items, list) # pycharm can't figure out the hinting without the hint
for item in items_copy:
if item in world.item_name_groups:
plando.items.remove(item)
plando.items.extend(sorted(world.item_name_groups[item]))
@classmethod
def get_option_name(cls, value: list[PlandoItem]) -> str:
return ", ".join(["(%s: %s)" % (item.items, item.locations) for item in value]) #TODO: see what a better way to display would be
def __getitem__(self, index: typing.SupportsIndex) -> PlandoItem:
return self.value.__getitem__(index)
def __iter__(self) -> typing.Iterator[PlandoItem]:
yield from self.value
def __len__(self) -> int:
return len(self.value)
class Removed(FreeText):
"""This Option has been Removed."""
rich_text_doc = True
@@ -1679,7 +1450,6 @@ class PerGameCommonOptions(CommonOptions):
exclude_locations: ExcludeLocations
priority_locations: PriorityLocations
item_links: ItemLinks
plando_items: PlandoItems
@dataclass
@@ -1698,7 +1468,7 @@ class OptionGroup(typing.NamedTuple):
item_and_loc_options = [LocalItems, NonLocalItems, StartInventory, StartInventoryPool, StartHints,
StartLocationHints, ExcludeLocations, PriorityLocations, ItemLinks, PlandoItems]
StartLocationHints, ExcludeLocations, PriorityLocations, ItemLinks]
"""
Options that are always populated in "Item & Location Options" Option Group. Cannot be moved to another group.
If desired, a custom "Item & Location Options" Option Group can be defined, but only for adding additional options to
@@ -1733,7 +1503,6 @@ def get_option_groups(world: typing.Type[World], visibility_level: Visibility =
def generate_yaml_templates(target_folder: typing.Union[str, "pathlib.Path"], generate_hidden: bool = True) -> None:
import os
from inspect import cleandoc
import yaml
from jinja2 import Template
@@ -1753,16 +1522,11 @@ def generate_yaml_templates(target_folder: typing.Union[str, "pathlib.Path"], ge
def dictify_range(option: Range):
data = {option.default: 50}
for sub_option in ["random", "random-low", "random-high",
f"random-range-{option.range_start}-{option.range_end}"]:
for sub_option in ["random", "random-low", "random-high"]:
if sub_option != option.default:
data[sub_option] = 0
notes = {
"random-low": "random value weighted towards lower values",
"random-high": "random value weighted towards higher values",
f"random-range-{option.range_start}-{option.range_end}": f"random value between "
f"{option.range_start} and {option.range_end}"
}
notes = {}
for name, number in getattr(option, "special_range_names", {}).items():
notes[name] = f"equivalent to {number}"
if number in data:
@@ -1777,24 +1541,19 @@ def generate_yaml_templates(target_folder: typing.Union[str, "pathlib.Path"], ge
# yaml dump may add end of document marker and newlines.
return yaml.dump(scalar).replace("...\n", "").strip()
with open(local_path("data", "options.yaml")) as f:
file_data = f.read()
template = Template(file_data)
for game_name, world in AutoWorldRegister.world_types.items():
if not world.hidden or generate_hidden:
option_groups = get_option_groups(world)
res = template.render(
with open(local_path("data", "options.yaml")) as f:
file_data = f.read()
res = Template(file_data).render(
option_groups=option_groups,
__version__=__version__,
game=game_name,
world_version=world.world_version.as_simple_string(),
yaml_dump=yaml_dump_scalar,
__version__=__version__, game=game_name, yaml_dump=yaml_dump_scalar,
dictify_range=dictify_range,
cleandoc=cleandoc,
)
del file_data
with open(os.path.join(target_folder, get_file_safe_name(game_name) + ".yaml"), "w", encoding="utf-8-sig") as f:
f.write(res)
@@ -1820,7 +1579,6 @@ def dump_player_options(multiworld: MultiWorld) -> None:
player_output = {
"Game": multiworld.game[player],
"Name": multiworld.get_player_name(player),
"ID": player,
}
output.append(player_output)
for option_key, option in world.options_dataclass.type_hints.items():
@@ -1833,7 +1591,7 @@ def dump_player_options(multiworld: MultiWorld) -> None:
game_option_names.append(display_name)
with open(output_path(f"generate_{multiworld.seed_name}.csv"), mode="w", newline="") as file:
fields = ["ID", "Game", "Name", *all_option_names]
fields = ["Game", "Name", *all_option_names]
writer = DictWriter(file, fields)
writer.writeheader()
writer.writerows(output)

View File

@@ -1,674 +0,0 @@
if __name__ == "__main__":
import ModuleUpdate
ModuleUpdate.update()
from kvui import (ThemedApp, ScrollBox, MainLayout, ContainerLayout, dp, Widget, MDBoxLayout, TooltipLabel, MDLabel,
ToggleButton, MarkupDropdown, ResizableTextField)
from kivy.uix.behaviors.button import ButtonBehavior
from kivymd.uix.behaviors import RotateBehavior
from kivymd.uix.anchorlayout import MDAnchorLayout
from kivymd.uix.expansionpanel import MDExpansionPanel, MDExpansionPanelContent, MDExpansionPanelHeader
from kivymd.uix.list import MDListItem, MDListItemTrailingIcon, MDListItemSupportingText
from kivymd.uix.slider import MDSlider
from kivymd.uix.snackbar import MDSnackbar, MDSnackbarText
from kivymd.uix.menu import MDDropdownMenu
from kivymd.uix.button import MDButton, MDButtonText, MDIconButton
from kivymd.uix.dialog import MDDialog
from kivy.core.text.markup import MarkupLabel
from kivy.utils import escape_markup
from kivy.lang.builder import Builder
from kivy.properties import ObjectProperty
from textwrap import dedent
from copy import deepcopy
import Utils
import typing
import webbrowser
import re
from urllib.parse import urlparse
from worlds.AutoWorld import AutoWorldRegister, World
from Options import (Option, Toggle, TextChoice, Choice, FreeText, NamedRange, Range, OptionSet, OptionList, Removed,
OptionCounter, Visibility)
def validate_url(x):
try:
result = urlparse(x)
return all([result.scheme, result.netloc])
except AttributeError:
return False
def filter_tooltip(tooltip):
if tooltip is None:
tooltip = "No tooltip available."
tooltip = dedent(tooltip).strip().replace("\n", "<br>").replace("&", "&amp;") \
.replace("[", "&bl;").replace("]", "&br;")
tooltip = re.sub(r"\*\*(.+?)\*\*", r"[b]\g<1>[/b]", tooltip)
tooltip = re.sub(r"\*(.+?)\*", r"[i]\g<1>[/i]", tooltip)
return escape_markup(tooltip)
def option_can_be_randomized(option: typing.Type[Option]):
# most options can be randomized, so we should just check for those that cannot
if not option.supports_weighting:
return False
elif issubclass(option, FreeText) and not issubclass(option, TextChoice):
return False
return True
def check_random(value: typing.Any):
if not isinstance(value, str):
return value # cannot be random if evaluated
if value.startswith("random-"):
return "random"
return value
class TrailingPressedIconButton(ButtonBehavior, RotateBehavior, MDListItemTrailingIcon):
pass
class WorldButton(ToggleButton):
world_cls: typing.Type[World]
class VisualRange(MDBoxLayout):
option: typing.Type[Range]
name: str
tag: MDLabel = ObjectProperty(None)
slider: MDSlider = ObjectProperty(None)
def __init__(self, *args, option: typing.Type[Range], name: str, **kwargs):
self.option = option
self.name = name
super().__init__(*args, **kwargs)
def update_points(*update_args):
pass
self.slider._update_points = update_points
class VisualChoice(MDButton):
option: typing.Type[Choice]
name: str
text: MDButtonText = ObjectProperty(None)
def __init__(self, *args, option: typing.Type[Choice], name: str, **kwargs):
self.option = option
self.name = name
super().__init__(*args, **kwargs)
class VisualNamedRange(MDBoxLayout):
option: typing.Type[NamedRange]
name: str
range: VisualRange = ObjectProperty(None)
choice: MDButton = ObjectProperty(None)
def __init__(self, *args, option: typing.Type[NamedRange], name: str, range_widget: VisualRange, **kwargs):
self.option = option
self.name = name
super().__init__(*args, **kwargs)
self.range = range_widget
self.add_widget(self.range)
class VisualFreeText(ResizableTextField):
option: typing.Type[FreeText] | typing.Type[TextChoice]
name: str
def __init__(self, *args, option: typing.Type[FreeText] | typing.Type[TextChoice], name: str, **kwargs):
self.option = option
self.name = name
super().__init__(*args, **kwargs)
class VisualTextChoice(MDBoxLayout):
option: typing.Type[TextChoice]
name: str
choice: VisualChoice = ObjectProperty(None)
text: VisualFreeText = ObjectProperty(None)
def __init__(self, *args, option: typing.Type[TextChoice], name: str, choice: VisualChoice,
text: VisualFreeText, **kwargs):
self.option = option
self.name = name
super(MDBoxLayout, self).__init__(*args, **kwargs)
self.choice = choice
self.text = text
self.add_widget(self.choice)
self.add_widget(self.text)
class VisualToggle(MDBoxLayout):
button: MDIconButton = ObjectProperty(None)
option: typing.Type[Toggle]
name: str
def __init__(self, *args, option: typing.Type[Toggle], name: str, **kwargs):
self.option = option
self.name = name
super().__init__(*args, **kwargs)
class CounterItemValue(ResizableTextField):
pat = re.compile('[^0-9]')
def insert_text(self, substring, from_undo=False):
return super().insert_text(re.sub(self.pat, "", substring), from_undo=from_undo)
class VisualListSetCounter(MDDialog):
button: MDIconButton = ObjectProperty(None)
option: typing.Type[OptionSet] | typing.Type[OptionList] | typing.Type[OptionCounter]
scrollbox: ScrollBox = ObjectProperty(None)
add: MDIconButton = ObjectProperty(None)
save: MDButton = ObjectProperty(None)
input: ResizableTextField = ObjectProperty(None)
dropdown: MDDropdownMenu
valid_keys: typing.Iterable[str]
def __init__(self, *args, option: typing.Type[OptionSet] | typing.Type[OptionList],
name: str, valid_keys: typing.Iterable[str], **kwargs):
self.option = option
self.name = name
self.valid_keys = valid_keys
super().__init__(*args, **kwargs)
self.dropdown = MarkupDropdown(caller=self.input, border_margin=dp(2),
width=self.input.width, position="bottom")
self.input.bind(text=self.on_text)
self.input.bind(on_text_validate=self.validate_add)
def validate_add(self, instance):
if self.valid_keys:
if self.input.text not in self.valid_keys:
MDSnackbar(MDSnackbarText(text="Item must be a valid key for this option."), y=dp(24),
pos_hint={"center_x": 0.5}, size_hint_x=0.5).open()
return
if not issubclass(self.option, OptionList):
if any(self.input.text == child.text.text for child in self.scrollbox.layout.children):
MDSnackbar(MDSnackbarText(text="This value is already in the set."), y=dp(24),
pos_hint={"center_x": 0.5}, size_hint_x=0.5).open()
return
self.add_set_item(self.input.text)
self.input.set_text(self.input, "")
def remove_item(self, button: MDIconButton):
list_item = button.parent
self.scrollbox.layout.remove_widget(list_item)
def add_set_item(self, key: str, value: int | None = None):
text = MDListItemSupportingText(text=key, id="value")
if issubclass(self.option, OptionCounter):
value_txt = CounterItemValue(text=str(value) if value else "1")
item = MDListItem(text,
value_txt,
MDIconButton(icon="minus", on_release=self.remove_item), focus_behavior=False)
item.value = value_txt
else:
item = MDListItem(text, MDIconButton(icon="minus", on_release=self.remove_item), focus_behavior=False)
item.text = text
self.scrollbox.layout.add_widget(item)
def on_text(self, instance, value):
if not self.valid_keys:
return
if len(value) >= 3:
self.dropdown.items.clear()
def on_press(txt):
split_text = MarkupLabel(text=txt, markup=True).markup
self.input.set_text(self.input, "".join(text_frag for text_frag in split_text
if not text_frag.startswith("[")))
self.input.focus = True
self.dropdown.dismiss()
lowered = value.lower()
for item_name in self.valid_keys:
try:
index = item_name.lower().index(lowered)
except ValueError:
pass # substring not found
else:
text = escape_markup(item_name)
text = text[:index] + "[b]" + text[index:index + len(value)] + "[/b]" + text[index + len(value):]
self.dropdown.items.append({
"text": text,
"on_release": lambda txt=text: on_press(txt),
"markup": True
})
if not self.dropdown.parent:
self.dropdown.open()
else:
self.dropdown.dismiss()
class OptionsCreator(ThemedApp):
base_title: str = "Archipelago Options Creator"
container: ContainerLayout
main_layout: MainLayout
scrollbox: ScrollBox
main_panel: MainLayout
player_options: MainLayout
option_layout: MainLayout
name_input: ResizableTextField
game_label: MDLabel
current_game: str
options: typing.Dict[str, typing.Any]
def __init__(self):
self.title = self.base_title + " " + Utils.__version__
self.icon = r"data/icon.png"
self.current_game = ""
self.options = {}
super().__init__()
def export_options(self, button: Widget):
if 0 < len(self.name_input.text) < 17 and self.current_game:
file_name = Utils.save_filename("Export Options File As...", [("YAML", ["*.yaml"])],
Utils.get_file_safe_name(f"{self.name_input.text}.yaml"))
options = {
"name": self.name_input.text,
"description": f"YAML generated by Archipelago {Utils.__version__}.",
"game": self.current_game,
self.current_game: {k: check_random(v) for k, v in self.options.items()}
}
try:
with open(file_name, 'w') as f:
f.write(Utils.dump(options, sort_keys=False))
f.close()
MDSnackbar(MDSnackbarText(text="File saved successfully."), y=dp(24), pos_hint={"center_x": 0.5},
size_hint_x=0.5).open()
except FileNotFoundError:
MDSnackbar(MDSnackbarText(text="Saving cancelled."), y=dp(24), pos_hint={"center_x": 0.5},
size_hint_x=0.5).open()
elif not self.name_input.text:
MDSnackbar(MDSnackbarText(text="Name must not be empty."), y=dp(24), pos_hint={"center_x": 0.5},
size_hint_x=0.5).open()
elif not self.current_game:
MDSnackbar(MDSnackbarText(text="You must select a game to play."), y=dp(24), pos_hint={"center_x": 0.5},
size_hint_x=0.5).open()
else:
MDSnackbar(MDSnackbarText(text="Name cannot be longer than 16 characters."), y=dp(24),
pos_hint={"center_x": 0.5}, size_hint_x=0.5).open()
def create_range(self, option: typing.Type[Range], name: str):
def update_text(range_box: VisualRange):
self.options[name] = int(range_box.slider.value)
range_box.tag.text = str(int(range_box.slider.value))
return
box = VisualRange(option=option, name=name)
box.slider.bind(on_touch_move=lambda _, _1: update_text(box))
self.options[name] = option.default
return box
def create_named_range(self, option: typing.Type[NamedRange], name: str):
def set_to_custom(range_box: VisualNamedRange):
if (not self.options[name] == range_box.range.slider.value) \
and (not self.options[name] in option.special_range_names or
range_box.range.slider.value != option.special_range_names[self.options[name]]):
# we should validate the touch here,
# but this is much cheaper
self.options[name] = int(range_box.range.slider.value)
range_box.range.tag.text = str(int(range_box.range.slider.value))
set_button_text(range_box.choice, "Custom")
def set_button_text(button: MDButton, text: str):
button.text.text = text
def set_value(text: str, range_box: VisualNamedRange):
range_box.range.slider.value = min(max(option.special_range_names[text.lower()], option.range_start),
option.range_end)
range_box.range.tag.text = str(int(range_box.range.slider.value))
set_button_text(range_box.choice, text)
self.options[name] = text.lower()
range_box.range.slider.dropdown.dismiss()
def open_dropdown(button):
# for some reason this fixes an issue causing some to not open
box.range.slider.dropdown.open()
box = VisualNamedRange(option=option, name=name, range_widget=self.create_range(option, name))
if option.default in option.special_range_names:
# value can get mismatched in this case
box.range.slider.value = min(max(option.special_range_names[option.default], option.range_start),
option.range_end)
box.range.tag.text = str(int(box.range.slider.value))
box.range.slider.bind(on_touch_move=lambda _, _2: set_to_custom(box))
items = [
{
"text": choice.title(),
"on_release": lambda text=choice.title(): set_value(text, box)
}
for choice in option.special_range_names
]
box.range.slider.dropdown = MDDropdownMenu(caller=box.choice, items=items)
box.choice.bind(on_release=open_dropdown)
self.options[name] = option.default
return box
def create_free_text(self, option: typing.Type[FreeText] | typing.Type[TextChoice], name: str):
text = VisualFreeText(option=option, name=name)
def set_value(instance):
self.options[name] = instance.text
text.bind(on_text_validate=set_value)
return text
def create_choice(self, option: typing.Type[Choice], name: str):
def set_button_text(button: VisualChoice, text: str):
button.text.text = text
def set_value(text, value):
set_button_text(main_button, text)
self.options[name] = value
dropdown.dismiss()
def open_dropdown(button):
# for some reason this fixes an issue causing some to not open
dropdown.open()
default_string = isinstance(option.default, str)
main_button = VisualChoice(option=option, name=name)
main_button.bind(on_release=open_dropdown)
items = [
{
"text": option.get_option_name(choice),
"on_release": lambda val=choice: set_value(option.get_option_name(val), option.name_lookup[val])
}
for choice in option.name_lookup
]
dropdown = MDDropdownMenu(caller=main_button, items=items)
self.options[name] = option.name_lookup[option.default] if not default_string else option.default
return main_button
def create_text_choice(self, option: typing.Type[TextChoice], name: str):
def set_button_text(button: MDButton, text: str):
for child in button.children:
if isinstance(child, MDButtonText):
child.text = text
box = VisualTextChoice(option=option, name=name, choice=self.create_choice(option, name),
text=self.create_free_text(option, name))
def set_value(instance):
set_button_text(box.choice, "Custom")
self.options[name] = instance.text
box.text.bind(on_text_validate=set_value)
return box
def create_toggle(self, option: typing.Type[Toggle], name: str) -> Widget:
def set_value(instance: MDIconButton):
if instance.icon == "checkbox-outline":
instance.icon = "checkbox-blank-outline"
else:
instance.icon = "checkbox-outline"
self.options[name] = bool(not self.options[name])
self.options[name] = bool(option.default)
checkbox = VisualToggle(option=option, name=name)
checkbox.button.bind(on_release=set_value)
return checkbox
def create_popup(self, option: typing.Type[OptionList] | typing.Type[OptionSet] | typing.Type[OptionCounter],
name: str, world: typing.Type[World]):
valid_keys = sorted(option.valid_keys)
if option.verify_item_name:
valid_keys += list(world.item_name_to_id.keys())
if option.verify_location_name:
valid_keys += list(world.location_name_to_id.keys())
if not issubclass(option, OptionCounter):
def apply_changes(button):
self.options[name].clear()
for list_item in dialog.scrollbox.layout.children:
self.options[name].append(getattr(list_item.text, "text"))
dialog.dismiss()
else:
def apply_changes(button):
self.options[name].clear()
for list_item in dialog.scrollbox.layout.children:
self.options[name][getattr(list_item.text, "text")] = int(getattr(list_item.value, "text"))
dialog.dismiss()
dialog = VisualListSetCounter(option=option, name=name, valid_keys=valid_keys)
dialog.ids.container.spacing = dp(30)
dialog.scrollbox.layout.theme_bg_color = "Custom"
dialog.scrollbox.layout.md_bg_color = self.theme_cls.surfaceContainerLowColor
dialog.scrollbox.layout.spacing = dp(5)
dialog.scrollbox.layout.padding = [0, dp(5), 0, 0]
if name not in self.options:
# convert from non-mutable to mutable
# We use list syntax even for sets, set behavior is enforced through GUI
if issubclass(option, OptionCounter):
self.options[name] = deepcopy(option.default)
else:
self.options[name] = sorted(option.default)
if issubclass(option, OptionCounter):
for value in sorted(self.options[name]):
dialog.add_set_item(value, self.options[name].get(value, None))
else:
for value in sorted(self.options[name]):
dialog.add_set_item(value)
dialog.save.bind(on_release=apply_changes)
dialog.open()
def create_option_set_list_counter(self, option: typing.Type[OptionList] | typing.Type[OptionSet] |
typing.Type[OptionCounter], name: str, world: typing.Type[World]):
main_button = MDButton(MDButtonText(text="Edit"), on_release=lambda x: self.create_popup(option, name, world))
return main_button
def create_option(self, option: typing.Type[Option], name: str, world: typing.Type[World]) -> Widget:
option_base = MDBoxLayout(orientation="vertical", size_hint_y=None, padding=[0, 0, dp(5), dp(5)])
tooltip = filter_tooltip(option.__doc__)
option_label = TooltipLabel(text=f"[ref=0|{tooltip}]{getattr(option, 'display_name', name)}")
label_box = MDBoxLayout(orientation="horizontal")
label_anchor = MDAnchorLayout(anchor_x="right", anchor_y="center")
label_anchor.add_widget(option_label)
label_box.add_widget(label_anchor)
option_base.add_widget(label_box)
if issubclass(option, NamedRange):
option_base.add_widget(self.create_named_range(option, name))
elif issubclass(option, Range):
option_base.add_widget(self.create_range(option, name))
elif issubclass(option, Toggle):
option_base.add_widget(self.create_toggle(option, name))
elif issubclass(option, TextChoice):
option_base.add_widget(self.create_text_choice(option, name))
elif issubclass(option, Choice):
option_base.add_widget(self.create_choice(option, name))
elif issubclass(option, FreeText):
option_base.add_widget(self.create_free_text(option, name))
elif any(issubclass(option, cls) for cls in (OptionSet, OptionList, OptionCounter)):
option_base.add_widget(self.create_option_set_list_counter(option, name, world))
else:
option_base.add_widget(MDLabel(text="This option isn't supported by the option creator.\n"
"Please edit your yaml manually to set this option."))
if option_can_be_randomized(option):
def randomize_option(instance: Widget, value: str):
value = value == "down"
if value:
self.options[name] = "random-" + str(self.options[name])
else:
self.options[name] = self.options[name].replace("random-", "")
if self.options[name].isnumeric() or self.options[name] in ("True", "False"):
self.options[name] = eval(self.options[name])
base_object = instance.parent.parent
label_object = instance.parent
for child in base_object.children:
if child is not label_object:
child.disabled = value
default_random = option.default == "random"
random_toggle = ToggleButton(MDButtonText(text="Random?"), size_hint_x=None, width=dp(100),
state="down" if default_random else "normal")
random_toggle.bind(state=randomize_option)
label_box.add_widget(random_toggle)
if default_random:
randomize_option(random_toggle, "down")
return option_base
def create_options_panel(self, world_button: WorldButton):
self.option_layout.clear_widgets()
self.options.clear()
cls: typing.Type[World] = world_button.world_cls
self.current_game = cls.game
if not cls.web.options_page:
self.current_game = "None"
return
elif isinstance(cls.web.options_page, str):
self.current_game = "None"
if validate_url(cls.web.options_page):
webbrowser.open(cls.web.options_page)
MDSnackbar(MDSnackbarText(text="Launching in default browser..."), y=dp(24), pos_hint={"center_x": 0.5},
size_hint_x=0.5).open()
world_button.state = "normal"
else:
# attach onto archipelago.gg and see if we pass
new_url = "https://archipelago.gg/" + cls.web.options_page
if validate_url(new_url):
webbrowser.open(new_url)
MDSnackbar(MDSnackbarText(text="Launching in default browser..."), y=dp(24),
pos_hint={"center_x": 0.5},
size_hint_x=0.5).open()
else:
MDSnackbar(MDSnackbarText(text="Invalid options page, please report to world developer."), y=dp(24),
pos_hint={"center_x": 0.5},
size_hint_x=0.5).open()
world_button.state = "normal"
# else just fall through
else:
expansion_box = ScrollBox()
expansion_box.layout.orientation = "vertical"
expansion_box.layout.spacing = dp(3)
expansion_box.scroll_type = ["bars"]
expansion_box.do_scroll_x = False
group_names = ["Game Options", *(group.name for group in cls.web.option_groups)]
groups = {name: [] for name in group_names}
for name, option in cls.options_dataclass.type_hints.items():
group = next((group.name for group in cls.web.option_groups if option in group.options), "Game Options")
groups[group].append((name, option))
for group, options in groups.items():
options = [(name, option) for name, option in options
if name and option.visibility & Visibility.simple_ui]
if not options:
continue # Game Options can be empty if every other option is in another group
# Can also have an option group of options that should not render on simple ui
group_item = MDExpansionPanel(size_hint_y=None)
group_header = MDExpansionPanelHeader(MDListItem(MDListItemSupportingText(text=group),
TrailingPressedIconButton(icon="chevron-right",
on_release=lambda x,
item=group_item:
self.tap_expansion_chevron(
item, x)),
md_bg_color=self.theme_cls.surfaceContainerLowestColor,
theme_bg_color="Custom",
on_release=lambda x, item=group_item:
self.tap_expansion_chevron(item, x)))
group_content = MDExpansionPanelContent(orientation="vertical", theme_bg_color="Custom",
md_bg_color=self.theme_cls.surfaceContainerLowestColor,
padding=[dp(12), dp(100), dp(12), 0],
spacing=dp(3))
group_item.add_widget(group_header)
group_item.add_widget(group_content)
group_box = ScrollBox()
group_box.layout.orientation = "vertical"
group_box.layout.spacing = dp(3)
for name, option in options:
group_content.add_widget(self.create_option(option, name, cls))
expansion_box.layout.add_widget(group_item)
self.option_layout.add_widget(expansion_box)
self.game_label.text = f"Game: {self.current_game}"
@staticmethod
def tap_expansion_chevron(panel: MDExpansionPanel, chevron: TrailingPressedIconButton | MDListItem):
if isinstance(chevron, MDListItem):
chevron = next((child for child in chevron.ids.trailing_container.children
if isinstance(child, TrailingPressedIconButton)), None)
panel.open() if not panel.is_open else panel.close()
if chevron:
panel.set_chevron_down(
chevron
) if not panel.is_open else panel.set_chevron_up(chevron)
def build(self):
self.set_colors()
self.options = {}
self.container = Builder.load_file(Utils.local_path("data/optionscreator.kv"))
self.root = self.container
self.main_layout = self.container.ids.main
self.scrollbox = self.container.ids.scrollbox
def world_button_action(world_btn: WorldButton):
if self.current_game != world_btn.world_cls.game:
old_button = next((button for button in self.scrollbox.layout.children
if button.world_cls.game == self.current_game), None)
if old_button:
old_button.state = "normal"
else:
world_btn.state = "down"
self.create_options_panel(world_btn)
for world, cls in sorted(AutoWorldRegister.world_types.items(), key=lambda x: x[0]):
if world == "Archipelago":
continue
world_text = MDButtonText(text=world, size_hint_y=None, width=dp(150),
pos_hint={"x": 0.03, "center_y": 0.5})
world_text.text_size = (world_text.width, None)
world_text.bind(width=lambda *x, text=world_text: text.setter('text_size')(text, (text.width, None)),
texture_size=lambda *x, text=world_text: text.setter("height")(text,
world_text.texture_size[1]))
world_button = WorldButton(world_text, size_hint_x=None, width=dp(150), theme_width="Custom",
radius=(dp(5), dp(5), dp(5), dp(5)))
world_button.bind(on_release=world_button_action)
world_button.world_cls = cls
self.scrollbox.layout.add_widget(world_button)
self.main_panel = self.container.ids.player_layout
self.player_options = self.container.ids.player_options
self.game_label = self.container.ids.game
self.name_input = self.container.ids.player_name
self.option_layout = self.container.ids.options
def set_height(instance, value):
instance.height = value[1]
self.game_label.bind(texture_size=set_height)
# Uncomment to re-enable the Kivy console/live editor
# Ctrl-E to enable it, make sure numlock/capslock is disabled
# from kivy.modules.console import create_console
# from kivy.core.window import Window
# create_console(Window, self.container)
return self.container
def launch():
OptionsCreator().run()
if __name__ == "__main__":
Utils.init_logging("OptionsCreator")
launch()

View File

@@ -7,19 +7,23 @@ Currently, the following games are supported:
* The Legend of Zelda: A Link to the Past
* Factorio
* Minecraft
* Subnautica
* Slay the Spire
* Risk of Rain 2
* The Legend of Zelda: Ocarina of Time
* Timespinner
* Super Metroid
* Secret of Evermore
* Final Fantasy
* Rogue Legacy
* VVVVVV
* Raft
* Super Mario 64
* Meritous
* Super Metroid/Link to the Past combo randomizer (SMZ3)
* ChecksFinder
* ArchipIDLE
* Hollow Knight
* The Witness
* Sonic Adventure 2: Battle
@@ -39,6 +43,7 @@ Currently, the following games are supported:
* The Messenger
* Kingdom Hearts 2
* The Legend of Zelda: Link's Awakening DX
* Clique
* Adventure
* DLC Quest
* Noita
@@ -58,6 +63,7 @@ Currently, the following games are supported:
* TUNIC
* Kirby's Dream Land 3
* Celeste 64
* Zork Grand Inquisitor
* Castlevania 64
* A Short Hike
* Yoshi's Island
@@ -75,14 +81,6 @@ Currently, the following games are supported:
* Castlevania: Circle of the Moon
* Inscryption
* Civilization VI
* The Legend of Zelda: The Wind Waker
* Jak and Daxter: The Precursor Legacy
* Super Mario Land 2: 6 Golden Coins
* shapez
* Paint
* Celeste (Open World)
* Choo-Choo Charles
* APQuest
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

View File

@@ -18,7 +18,6 @@ from json import loads, dumps
from CommonClient import CommonContext, server_loop, ClientCommandProcessor, gui_enabled, get_base_parser
import Utils
import settings
from Utils import async_start
from MultiServer import mark_raw
if typing.TYPE_CHECKING:
@@ -286,7 +285,7 @@ class SNESState(enum.IntEnum):
def launch_sni() -> None:
sni_path = settings.get_settings().sni_options.sni_path
sni_path = Utils.get_settings()["sni_options"]["sni_path"]
if not os.path.isdir(sni_path):
sni_path = Utils.local_path(sni_path)
@@ -669,7 +668,8 @@ async def game_watcher(ctx: SNIContext) -> None:
async def run_game(romfile: str) -> None:
auto_start = settings.get_settings().sni_options.snes_rom_start
auto_start = typing.cast(typing.Union[bool, str],
Utils.get_settings()["sni_options"].get("snes_rom_start", True))
if auto_start is True:
import webbrowser
webbrowser.open(romfile)
@@ -735,6 +735,6 @@ async def main() -> None:
if __name__ == '__main__':
colorama.just_fix_windows_console()
colorama.init()
asyncio.run(main())
colorama.deinit()

11
Starcraft2Client.py Normal file
View File

@@ -0,0 +1,11 @@
from __future__ import annotations
import ModuleUpdate
ModuleUpdate.update()
from worlds.sc2.Client import launch
import Utils
if __name__ == "__main__":
Utils.init_logging("Starcraft2Client", exception_logger="Client")
launch()

View File

@@ -500,7 +500,7 @@ def main():
import colorama
colorama.just_fix_windows_console()
colorama.init()
asyncio.run(_main())
colorama.deinit()

242
Utils.py
View File

@@ -1,7 +1,6 @@
from __future__ import annotations
import asyncio
import concurrent.futures
import json
import typing
import builtins
@@ -36,7 +35,7 @@ if typing.TYPE_CHECKING:
def tuplize_version(version: str) -> Version:
return Version(*(int(piece) for piece in version.split(".")))
return Version(*(int(piece, 10) for piece in version.split(".")))
class Version(typing.NamedTuple):
@@ -48,7 +47,7 @@ class Version(typing.NamedTuple):
return ".".join(str(item) for item in self)
__version__ = "0.6.6"
__version__ = "0.6.0"
version_tuple = tuplize_version(__version__)
is_linux = sys.platform.startswith("linux")
@@ -115,8 +114,6 @@ def cache_self1(function: typing.Callable[[S, T], RetType]) -> typing.Callable[[
cache[arg] = res
return res
wrap.__defaults__ = function.__defaults__
return wrap
@@ -140,11 +137,8 @@ def local_path(*path: str) -> str:
local_path.cached_path = os.path.dirname(os.path.abspath(sys.argv[0]))
else:
import __main__
if globals().get("__file__") and os.path.isfile(__file__):
if hasattr(__main__, "__file__") and os.path.isfile(__main__.__file__):
# we are running in a normal Python environment
local_path.cached_path = os.path.dirname(os.path.abspath(__file__))
elif hasattr(__main__, "__file__") and os.path.isfile(__main__.__file__):
# we are running in a normal Python environment, but AP was imported weirdly
local_path.cached_path = os.path.dirname(os.path.abspath(__main__.__file__))
else:
# pray
@@ -167,10 +161,6 @@ def home_path(*path: str) -> str:
os.symlink(home_path.cached_path, legacy_home_path)
else:
os.makedirs(home_path.cached_path, 0o700, exist_ok=True)
elif sys.platform == 'darwin':
import platformdirs
home_path.cached_path = platformdirs.user_data_dir("Archipelago", False)
os.makedirs(home_path.cached_path, 0o700, exist_ok=True)
else:
# not implemented
home_path.cached_path = local_path() # this will generate the same exceptions we got previously
@@ -182,7 +172,7 @@ def user_path(*path: str) -> str:
"""Returns either local_path or home_path based on write permissions."""
if hasattr(user_path, "cached_path"):
pass
elif os.access(local_path(), os.W_OK) and not (is_macos and is_frozen()):
elif os.access(local_path(), os.W_OK):
user_path.cached_path = local_path()
else:
user_path.cached_path = home_path()
@@ -231,12 +221,7 @@ def open_file(filename: typing.Union[str, "pathlib.Path"]) -> None:
from shutil import which
open_command = which("open") if is_macos else (which("xdg-open") or which("gnome-open") or which("kde-open"))
assert open_command, "Didn't find program for open_file! Please report this together with system details."
env = os.environ
if "LD_LIBRARY_PATH" in env:
env = env.copy()
del env["LD_LIBRARY_PATH"] # exe is a system binary, so reset LD_LIBRARY_PATH
subprocess.call([open_command, filename], env=env)
subprocess.call([open_command, filename])
# from https://gist.github.com/pypt/94d747fe5180851196eb#gistcomment-4015118 with some changes
@@ -314,18 +299,20 @@ def get_public_ipv6() -> str:
return ip
OptionsType = Settings # TODO: remove when removing get_options
def get_options() -> Settings:
deprecate("Utils.get_options() is deprecated. Use the settings API instead.")
# TODO: switch to Utils.deprecate after 0.4.4
warnings.warn("Utils.get_options() is deprecated. Use the settings API instead.", DeprecationWarning)
return get_settings()
def persistent_store(category: str, key: str, value: typing.Any, force_store: bool = False):
def persistent_store(category: str, key: str, value: typing.Any):
path = user_path("_persistent_storage.yaml")
storage = persistent_load()
if not force_store and category in storage and key in storage[category] and storage[category][key] == value:
return # no changes necessary
category_dict = storage.setdefault(category, {})
category_dict[key] = value
path = user_path("_persistent_storage.yaml")
with open(path, "wt") as f:
f.write(dump(storage, Dumper=Dumper))
@@ -412,26 +399,13 @@ def get_adjuster_settings(game_name: str) -> Namespace:
@cache_argsless
def get_unique_identifier():
common_path = cache_path("common.json")
try:
with open(common_path) as f:
common_file = json.load(f)
uuid = common_file.get("uuid", None)
except FileNotFoundError:
common_file = {}
uuid = None
uuid = persistent_load().get("client", {}).get("uuid", None)
if uuid:
return uuid
from uuid import uuid4
uuid = str(uuid4())
common_file["uuid"] = uuid
cache_folder = os.path.dirname(common_path)
os.makedirs(cache_folder, exist_ok=True)
with open(common_path, "w") as f:
json.dump(common_file, f, separators=(",", ":"))
import uuid
uuid = uuid.getnode()
persistent_store("client", "uuid", uuid)
return uuid
@@ -453,10 +427,6 @@ class RestrictedUnpickler(pickle.Unpickler):
def find_class(self, module: str, name: str) -> type:
if module == "builtins" and name in safe_builtins:
return getattr(builtins, name)
# used by OptionCounter
# necessary because the actual Options class instances are pickled when transfered to WebHost generation pool
if module == "collections" and name == "Counter":
return collections.Counter
# used by MultiServer -> savegame/multidata
if module == "NetUtils" and name in {"NetworkItem", "ClientStatus", "Hint",
"SlotType", "NetworkSlot", "HintStatus"}:
@@ -474,7 +444,7 @@ class RestrictedUnpickler(pickle.Unpickler):
mod = importlib.import_module(module)
obj = getattr(mod, name)
if issubclass(obj, (self.options_module.Option, self.options_module.PlandoConnection,
self.options_module.PlandoItem, self.options_module.PlandoText)):
self.options_module.PlandoText)):
return obj
# Forbid everything else.
raise pickle.UnpicklingError(f"global '{module}.{name}' is forbidden")
@@ -485,18 +455,6 @@ def restricted_loads(s: bytes) -> Any:
return RestrictedUnpickler(io.BytesIO(s)).load()
def restricted_dumps(obj: Any) -> bytes:
"""Helper function analogous to pickle.dumps()."""
s = pickle.dumps(obj)
# Assert that the string can be successfully loaded by restricted_loads
try:
restricted_loads(s)
except pickle.UnpicklingError as e:
raise pickle.PicklingError(e) from e
return s
class ByValue:
"""
Mixin for enums to pickle value instead of name (restores pre-3.11 behavior). Use as left-most parent.
@@ -574,8 +532,6 @@ def init_logging(name: str, loglevel: typing.Union[str, int] = logging.INFO,
if add_timestamp:
stream_handler.setFormatter(formatter)
root_logger.addHandler(stream_handler)
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
# Relay unhandled exceptions to logger.
if not getattr(sys.excepthook, "_wrapped", False): # skip if already modified
@@ -674,8 +630,6 @@ def get_fuzzy_results(input_word: str, word_list: typing.Collection[str], limit:
import jellyfish
def get_fuzzy_ratio(word1: str, word2: str) -> float:
if word1 == word2:
return 1.01
return (1 - jellyfish.damerau_levenshtein_distance(word1.lower(), word2.lower())
/ max(len(word1), len(word2)))
@@ -696,10 +650,8 @@ def get_intended_text(input_text: str, possible_answers) -> typing.Tuple[str, bo
picks = get_fuzzy_results(input_text, possible_answers, limit=2)
if len(picks) > 1:
dif = picks[0][1] - picks[1][1]
if picks[0][1] == 101:
if picks[0][1] == 100:
return picks[0][0], True, "Perfect Match"
elif picks[0][1] == 100:
return picks[0][0], True, "Case Insensitive Perfect Match"
elif picks[0][1] < 75:
return picks[0][0], False, f"Didn't find something that closely matches '{input_text}', " \
f"did you mean '{picks[0][0]}'? ({picks[0][1]}% sure)"
@@ -717,22 +669,13 @@ def get_intended_text(input_text: str, possible_answers) -> typing.Tuple[str, bo
def get_input_text_from_response(text: str, command: str) -> typing.Optional[str]:
"""
Parses the response text from `get_intended_text` to find the suggested input and autocomplete the command in
arguments with it.
:param text: The response text from `get_intended_text`.
:param command: The command to which the input text should be added. Must contain the prefix used by the command
(`!` or `/`).
:return: The command with the suggested input text appended, or None if no suggestion was found.
"""
if "did you mean " in text:
for question in ("Didn't find something that closely matches",
"Too many close matches"):
if text.startswith(question):
name = get_text_between(text, "did you mean '",
"'? (")
return f"{command} {name}"
return f"!{command} {name}"
elif text.startswith("Missing: "):
return text.replace("Missing: ", "!hint_location ")
return None
@@ -751,35 +694,25 @@ def _mp_open_filename(res: "multiprocessing.Queue[typing.Optional[str]]", *args:
res.put(open_filename(*args))
def _mp_save_filename(res: "multiprocessing.Queue[typing.Optional[str]]", *args: Any) -> None:
if is_kivy_running():
raise RuntimeError("kivy should not be running in multiprocess")
res.put(save_filename(*args))
def _run_for_stdout(*args: str):
env = os.environ
if "LD_LIBRARY_PATH" in env:
env = env.copy()
del env["LD_LIBRARY_PATH"] # exe is a system binary, so reset LD_LIBRARY_PATH
return subprocess.run(args, capture_output=True, text=True, env=env).stdout.split("\n", 1)[0] or None
def open_filename(title: str, filetypes: typing.Iterable[typing.Tuple[str, typing.Iterable[str]]], suggest: str = "") \
-> typing.Optional[str]:
logging.info(f"Opening file input dialog for {title}.")
def run(*args: str):
return subprocess.run(args, capture_output=True, text=True).stdout.split("\n", 1)[0] or None
if is_linux:
# prefer native dialog
from shutil import which
kdialog = which("kdialog")
if kdialog:
k_filters = '|'.join((f'{text} (*{" *".join(ext)})' for (text, ext) in filetypes))
return _run_for_stdout(kdialog, f"--title={title}", "--getopenfilename", suggest or ".", k_filters)
return run(kdialog, f"--title={title}", "--getopenfilename", suggest or ".", k_filters)
zenity = which("zenity")
if zenity:
z_filters = (f'--file-filter={text} ({", ".join(ext)}) | *{" *".join(ext)}' for (text, ext) in filetypes)
selection = (f"--filename={suggest}",) if suggest else ()
return _run_for_stdout(zenity, f"--title={title}", "--file-selection", *z_filters, *selection)
return run(zenity, f"--title={title}", "--file-selection", *z_filters, *selection)
# fall back to tk
try:
@@ -806,10 +739,13 @@ def open_filename(title: str, filetypes: typing.Iterable[typing.Tuple[str, typin
initialfile=suggest or None)
def save_filename(title: str, filetypes: typing.Iterable[typing.Tuple[str, typing.Iterable[str]]], suggest: str = "") \
-> typing.Optional[str]:
logging.info(f"Opening file save dialog for {title}.")
def _mp_open_directory(res: "multiprocessing.Queue[typing.Optional[str]]", *args: Any) -> None:
if is_kivy_running():
raise RuntimeError("kivy should not be running in multiprocess")
res.put(open_directory(*args))
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
@@ -818,58 +754,13 @@ def save_filename(title: str, filetypes: typing.Iterable[typing.Tuple[str, typin
from shutil import which
kdialog = which("kdialog")
if kdialog:
k_filters = '|'.join((f'{text} (*{" *".join(ext)})' for (text, ext) in filetypes))
return run(kdialog, f"--title={title}", "--getsavefilename", suggest or ".", k_filters)
zenity = which("zenity")
if zenity:
z_filters = (f'--file-filter={text} ({", ".join(ext)}) | *{" *".join(ext)}' for (text, ext) in filetypes)
selection = (f"--filename={suggest}",) if suggest else ()
return run(zenity, f"--title={title}", "--file-selection", "--save", *z_filters, *selection)
# fall back to tk
try:
import tkinter
import tkinter.filedialog
except Exception as e:
logging.error('Could not load tkinter, which is likely not installed. '
f'This attempt was made because save_filename was used for "{title}".')
raise e
else:
if is_macos and is_kivy_running():
# on macOS, mixing kivy and tk does not work, so spawn a new process
# FIXME: performance of this is pretty bad, and we should (also) look into alternatives
from multiprocessing import Process, Queue
res: "Queue[typing.Optional[str]]" = Queue()
Process(target=_mp_save_filename, args=(res, title, filetypes, suggest)).start()
return res.get()
try:
root = tkinter.Tk()
except tkinter.TclError:
return None # GUI not available. None is the same as a user clicking "cancel"
root.withdraw()
return tkinter.filedialog.asksaveasfilename(title=title, filetypes=((t[0], ' '.join(t[1])) for t in filetypes),
initialfile=suggest or None)
def _mp_open_directory(res: "multiprocessing.Queue[typing.Optional[str]]", *args: Any) -> None:
if is_kivy_running():
raise RuntimeError("kivy should not be running in multiprocess")
res.put(open_directory(*args))
def open_directory(title: str, suggest: str = "") -> typing.Optional[str]:
if is_linux:
# prefer native dialog
from shutil import which
kdialog = which("kdialog")
if kdialog:
return _run_for_stdout(kdialog, f"--title={title}", "--getexistingdirectory",
return run(kdialog, f"--title={title}", "--getexistingdirectory",
os.path.abspath(suggest) if suggest else ".")
zenity = which("zenity")
if zenity:
z_filters = ("--directory",)
selection = (f"--filename={os.path.abspath(suggest)}/",) if suggest else ()
return _run_for_stdout(zenity, f"--title={title}", "--file-selection", *z_filters, *selection)
return run(zenity, f"--title={title}", "--file-selection", *z_filters, *selection)
# fall back to tk
try:
@@ -896,6 +787,9 @@ def open_directory(title: str, suggest: str = "") -> typing.Optional[str]:
def messagebox(title: str, text: str, error: bool = False) -> None:
def run(*args: str):
return subprocess.run(args, capture_output=True, text=True).stdout.split("\n", 1)[0] or None
if is_kivy_running():
from kvui import MessageBox
MessageBox(title, text, error).open()
@@ -906,10 +800,10 @@ def messagebox(title: str, text: str, error: bool = False) -> None:
from shutil import which
kdialog = which("kdialog")
if kdialog:
return _run_for_stdout(kdialog, f"--title={title}", "--error" if error else "--msgbox", text)
return run(kdialog, f"--title={title}", "--error" if error else "--msgbox", text)
zenity = which("zenity")
if zenity:
return _run_for_stdout(zenity, f"--title={title}", f"--text={text}", "--error" if error else "--info")
return run(zenity, f"--title={title}", f"--text={text}", "--error" if error else "--info")
elif is_windows:
import ctypes
@@ -961,7 +855,7 @@ def async_start(co: Coroutine[None, None, typing.Any], name: Optional[str] = Non
Use this to start a task when you don't keep a reference to it or immediately await it,
to prevent early garbage collection. "fire-and-forget"
"""
# https://docs.python.org/3.11/library/asyncio-task.html#asyncio.create_task
# https://docs.python.org/3.10/library/asyncio-task.html#asyncio.create_task
# Python docs:
# ```
# Important: Save a reference to the result of [asyncio.create_task],
@@ -998,15 +892,15 @@ class DeprecateDict(dict):
def _extend_freeze_support() -> None:
"""Extend multiprocessing.freeze_support() to also work on Non-Windows and without setting spawn method first."""
# original upstream issue: https://github.com/python/cpython/issues/76327
"""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 # noqa
from subprocess import _args_from_interpreter_flags
# Prevent `spawn` from trying to read `__main__` in from the main script
multiprocessing.process.ORIGINAL_DIR = None
@@ -1014,7 +908,8 @@ def _extend_freeze_support() -> 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.resource_tracker import main',
'from multiprocessing.semaphore_tracker import main', # Py<3.8
'from multiprocessing.resource_tracker import main', # Py>=3.8
'from multiprocessing.forkserver import main'
)) and set(sys.argv[1:-2]) == set(_args_from_interpreter_flags())
):
@@ -1033,23 +928,17 @@ def _extend_freeze_support() -> None:
multiprocessing.spawn.spawn_main(**kwargs)
sys.exit()
def _noop() -> None:
pass
multiprocessing.freeze_support = multiprocessing.spawn.freeze_support = _freeze_support if is_frozen() else _noop
if not is_windows and is_frozen():
multiprocessing.freeze_support = multiprocessing.spawn.freeze_support = _freeze_support
def freeze_support() -> None:
"""This now only calls multiprocessing.freeze_support since we are patching freeze_support on module load."""
"""This behaves like multiprocessing.freeze_support but also works on Non-Windows."""
import multiprocessing
deprecate("Use multiprocessing.freeze_support() instead")
_extend_freeze_support()
multiprocessing.freeze_support()
_extend_freeze_support()
def visualize_regions(root_region: Region, file_name: str, *,
show_entrance_names: bool = False, show_locations: bool = True, show_other_regions: bool = True,
linetype_ortho: bool = True, regions_to_highlight: set[Region] | None = None) -> None:
@@ -1185,40 +1074,3 @@ def is_iterable_except_str(obj: object) -> TypeGuard[typing.Iterable[typing.Any]
if isinstance(obj, str):
return False
return isinstance(obj, typing.Iterable)
class DaemonThreadPoolExecutor(concurrent.futures.ThreadPoolExecutor):
"""
ThreadPoolExecutor that uses daemonic threads that do not keep the program alive.
NOTE: use this with caution because killed threads will not properly clean up.
"""
def _adjust_thread_count(self):
# see upstream ThreadPoolExecutor for details
import threading
import weakref
from concurrent.futures.thread import _worker
if self._idle_semaphore.acquire(timeout=0):
return
def weakref_cb(_, q=self._work_queue):
q.put(None)
num_threads = len(self._threads)
if num_threads < self._max_workers:
thread_name = f"{self._thread_name_prefix or self}_{num_threads}"
t = threading.Thread(
name=thread_name,
target=_worker,
args=(
weakref.ref(self, weakref_cb),
self._work_queue,
self._initializer,
self._initargs,
),
daemon=True,
)
t.start()
self._threads.add(t)
# NOTE: don't add to _threads_queues so we don't block on shutdown

View File

@@ -2,15 +2,14 @@ from __future__ import annotations
import atexit
import os
import pkgutil
import sys
import asyncio
import random
import typing
import shutil
from typing import Tuple, List, Iterable, Dict
from . import WargrooveWorld
from .Items import item_table, faction_table, CommanderData, ItemData
from worlds.wargroove import WargrooveWorld
from worlds.wargroove.Items import item_table, faction_table, CommanderData, ItemData
import ModuleUpdate
ModuleUpdate.update()
@@ -22,7 +21,7 @@ import logging
if __name__ == "__main__":
Utils.init_logging("WargrooveClient", exception_logger="Client")
from NetUtils import ClientStatus
from NetUtils import NetworkItem, ClientStatus
from CommonClient import gui_enabled, logger, get_base_parser, ClientCommandProcessor, \
CommonContext, server_loop
@@ -30,34 +29,6 @@ wg_logger = logging.getLogger("WG")
class WargrooveClientCommandProcessor(ClientCommandProcessor):
def _cmd_sacrifice_summon(self):
"""Toggles sacrifices and summons On/Off"""
if isinstance(self.ctx, WargrooveContext):
self.ctx.has_sacrifice_summon = not self.ctx.has_sacrifice_summon
if self.ctx.has_sacrifice_summon:
self.output(f"Sacrifices and summons are enabled.")
else:
unit_summon_response_file = os.path.join(self.ctx.game_communication_path, "unitSummonResponse")
if os.path.exists(unit_summon_response_file):
os.remove(unit_summon_response_file)
self.output(f"Sacrifices and summons are disabled.")
def _cmd_deathlink(self):
"""Toggles deathlink On/Off"""
if isinstance(self.ctx, WargrooveContext):
self.ctx.has_death_link = not self.ctx.has_death_link
Utils.async_start(self.ctx.update_death_link(self.ctx.has_death_link), name="Update Deathlink")
if self.ctx.has_death_link:
death_link_send_file = os.path.join(self.ctx.game_communication_path, "deathLinkSend")
if os.path.exists(death_link_send_file):
os.remove(death_link_send_file)
self.output(f"Deathlink enabled.")
else:
death_link_receive_file = os.path.join(self.ctx.game_communication_path, "deathLinkReceive")
if os.path.exists(death_link_receive_file):
os.remove(death_link_receive_file)
self.output(f"Deathlink disabled.")
def _cmd_resync(self):
"""Manually trigger a resync."""
self.output(f"Syncing items.")
@@ -87,11 +58,6 @@ class WargrooveContext(CommonContext):
commander_defense_boost_multiplier: int = 0
income_boost_multiplier: int = 0
starting_groove_multiplier: float
has_death_link: bool = False
has_sacrifice_summon: bool = True
player_stored_units_key: str = ""
ai_stored_units_key: str = ""
max_stored_units: int = 1000
faction_item_ids = {
'Starter': 0,
'Cherrystone': 52025,
@@ -105,31 +71,6 @@ class WargrooveContext(CommonContext):
'Income Boost': 52023,
'Commander Defense Boost': 52024,
}
unit_classes = {
"archer",
"ballista",
"balloon",
"dog",
"dragon",
"giant",
"harpoonship",
"harpy",
"knight",
"mage",
"merman",
"rifleman",
"soldier",
"spearman",
"thief",
"thief_with_gold",
"travelboat",
"trebuchet",
"turtle",
"villager",
"wagon",
"warship",
"witch",
}
def __init__(self, server_address, password):
super(WargrooveContext, self).__init__(server_address, password)
@@ -137,80 +78,31 @@ class WargrooveContext(CommonContext):
self.syncing = False
self.awaiting_bridge = False
# self.game_communication_path: files go in this path to pass data between us and the actual game
game_options = WargrooveWorld.settings
# Validate the AppData directory with Wargroove save data.
# By default, Windows sets an environment variable we can leverage.
# However, other OSes don't usually have this value set, so we need to rely on a settings value instead.
appdata_wargroove = None
if "appdata" in os.environ:
appdata_wargroove = os.environ['appdata']
else:
try:
appdata_wargroove = game_options.save_directory
except FileNotFoundError:
print_error_and_close("WargrooveClient couldn't detect a path to the AppData folder.\n"
"Unable to infer required game_communication_path.\n"
"Try setting the \"save_directory\" value in your local options file "
"to the AppData folder containing your Wargroove saves.")
appdata_wargroove = os.path.expandvars(os.path.join(appdata_wargroove, "Chucklefish", "Wargroove"))
if not os.path.isdir(appdata_wargroove):
print_error_and_close(f"WargrooveClient couldn't find Wargroove data in your AppData folder.\n"
f"Looked in \"{appdata_wargroove}\".\n"
f"If you haven't yet booted the game at least once, boot Wargroove "
f"and then close it to attempt to fix this error.\n"
f"If the AppData folder above seems wrong, try setting the "
f"\"save_directory\" value in your local options file "
f"to the AppData folder containing your Wargroove saves.")
# Check for the Wargroove game executable path.
# This should always be set regardless of the OS.
root_directory = game_options["root_directory"]
if not os.path.isfile(os.path.join(root_directory, "win64_bin", "wargroove64.exe")):
print_error_and_close(f"WargrooveClient couldn't find wargroove64.exe in "
f"\"{root_directory}/win64_bin/\".\n"
f"Unable to infer required game_communication_path.\n"
f"Please verify the \"root_directory\" value in your local "
f"options file is set correctly.")
self.game_communication_path = os.path.join(root_directory, "AP")
if not os.path.exists(self.game_communication_path):
os.makedirs(self.game_communication_path)
self.remove_communication_files()
atexit.register(self.remove_communication_files)
if not os.path.isdir(appdata_wargroove):
print_error_and_close("WargrooveClient couldn't find Wargoove in appdata! "
"Boot Wargroove and then close it to attempt to fix this error")
mods_directory = os.path.join(appdata_wargroove, "mods", "ArchipelagoMod")
save_directory = os.path.join(appdata_wargroove, "save")
# Wargroove doesn't always create the mods directory, so we have to do it
if not os.path.isdir(mods_directory):
os.makedirs(mods_directory)
resources = ["data/mods/ArchipelagoMod/maps.dat",
"data/mods/ArchipelagoMod/mod.dat",
"data/mods/ArchipelagoMod/modAssets.dat",
"data/save/campaign-c40a6e5b0cdf86ddac03b276691c483d.cmp",
"data/save/campaign-c40a6e5b0cdf86ddac03b276691c483d.cmp.bak"]
file_paths = [os.path.join(mods_directory, "maps.dat"),
os.path.join(mods_directory, "mod.dat"),
os.path.join(mods_directory, "modAssets.dat"),
os.path.join(save_directory, "campaign-c40a6e5b0cdf86ddac03b276691c483d.cmp"),
os.path.join(save_directory, "campaign-c40a6e5b0cdf86ddac03b276691c483d.cmp.bak")]
for resource, destination in zip(resources, file_paths):
file_data = pkgutil.get_data("worlds.wargroove", resource)
if file_data is None:
options = Utils.get_options()
root_directory = os.path.join(options["wargroove_options"]["root_directory"])
data_directory = os.path.join("lib", "worlds", "wargroove", "data")
dev_data_directory = os.path.join("worlds", "wargroove", "data")
appdata_wargroove = os.path.expandvars(os.path.join("%APPDATA%", "Chucklefish", "Wargroove"))
if not os.path.isfile(os.path.join(root_directory, "win64_bin", "wargroove64.exe")):
print_error_and_close("WargrooveClient couldn't find wargroove64.exe. "
"Unable to infer required game_communication_path")
self.game_communication_path = os.path.join(root_directory, "AP")
if not os.path.exists(self.game_communication_path):
os.makedirs(self.game_communication_path)
self.remove_communication_files()
atexit.register(self.remove_communication_files)
if not os.path.isdir(appdata_wargroove):
print_error_and_close("WargrooveClient couldn't find Wargoove in appdata!"
"Boot Wargroove and then close it to attempt to fix this error")
if not os.path.isdir(data_directory):
data_directory = dev_data_directory
if not os.path.isdir(data_directory):
print_error_and_close("WargrooveClient couldn't find Wargoove mod and save files in install!")
with open(destination, 'wb') as f:
f.write(file_data)
def on_deathlink(self, data: typing.Dict[str, typing.Any]) -> None:
with open(os.path.join(self.game_communication_path, "deathLinkReceive"), 'w+') as f:
text = data.get("cause", "")
if text:
f.write(f"DeathLink: {text}")
else:
f.write(f"DeathLink: Received from {data['source']}")
super(WargrooveContext, self).on_deathlink(data)
shutil.copytree(data_directory, appdata_wargroove, dirs_exist_ok=True)
else:
print_error_and_close("WargrooveClient couldn't detect system type. "
"Unable to infer required game_communication_path")
async def server_auth(self, password_requested: bool = False):
if password_requested and not self.password:
@@ -246,25 +138,20 @@ class WargrooveContext(CommonContext):
def on_package(self, cmd: str, args: dict):
if cmd in {"Connected"}:
slot_data = args["slot_data"]
self.has_death_link = slot_data.get("death_link", False)
filename = f"AP_settings.json"
with open(os.path.join(self.game_communication_path, filename), 'w') as f:
json.dump(slot_data, f)
slot_data = args["slot_data"]
json.dump(args["slot_data"], f)
self.can_choose_commander = slot_data["can_choose_commander"]
print('can choose commander:', self.can_choose_commander)
self.starting_groove_multiplier = slot_data["starting_groove_multiplier"]
self.income_boost_multiplier = slot_data["income_boost"]
self.commander_defense_boost_multiplier = slot_data["commander_defense_boost"]
f.close()
for ss in self.checked_locations:
filename = f"send{ss}"
with open(os.path.join(self.game_communication_path, filename), 'w') as f:
pass
self.player_stored_units_key = f"wargroove_player_units_{self.team}"
self.ai_stored_units_key = f"wargroove_ai_units_{self.team}"
self.set_notify(self.player_stored_units_key, self.ai_stored_units_key)
f.close()
self.update_commander_data()
self.ui.update_tracker()
@@ -274,6 +161,7 @@ class WargrooveContext(CommonContext):
filename = f"seed{i}"
with open(os.path.join(self.game_communication_path, filename), 'w') as f:
f.write(str(random.randint(0, 4294967295)))
f.close()
if cmd in {"RoomInfo"}:
self.seed_name = args["seed_name"]
@@ -301,6 +189,7 @@ class WargrooveContext(CommonContext):
f.write(f"{item_count * self.commander_defense_boost_multiplier}")
else:
f.write(f"{item_count}")
f.close()
print_filename = f"AP_{str(network_item.item)}.item.print"
print_path = os.path.join(self.game_communication_path, print_filename)
@@ -311,6 +200,7 @@ class WargrooveContext(CommonContext):
self.item_names.lookup_in_game(network_item.item) +
" from " +
self.player_names[network_item.player])
f.close()
self.update_commander_data()
self.ui.update_tracker()
@@ -319,16 +209,22 @@ class WargrooveContext(CommonContext):
for ss in self.checked_locations:
filename = f"send{ss}"
with open(os.path.join(self.game_communication_path, filename), 'w') as f:
pass
f.close()
def run_gui(self):
"""Import kivy UI system and start running it as self.ui_task."""
from kvui import GameManager, HoverBehavior, ServerToolTip
from kivymd.uix.tab import MDTabsItem, MDTabsItemText
from kivy.uix.tabbedpanel import TabbedPanelItem
from kivy.lang import Builder
from kivy.uix.button import Button
from kivy.uix.togglebutton import ToggleButton
from kivy.uix.boxlayout import BoxLayout
from kivy.uix.gridlayout import GridLayout
from kivy.uix.image import AsyncImage, Image
from kivy.uix.stacklayout import StackLayout
from kivy.uix.label import Label
from kivy.properties import ColorProperty
from kivy.uix.image import Image
import pkgutil
class TrackerLayout(BoxLayout):
@@ -495,75 +391,32 @@ class WargrooveContext(CommonContext):
async def game_watcher(ctx: WargrooveContext):
from worlds.wargroove.Locations import location_table
while not ctx.exit_event.is_set():
try:
if ctx.syncing == True:
sync_msg = [{'cmd': 'Sync'}]
if ctx.locations_checked:
sync_msg.append({"cmd": "LocationChecks", "locations": list(ctx.locations_checked)})
await ctx.send_msgs(sync_msg)
ctx.syncing = False
sending = []
victory = False
for root, dirs, files in os.walk(ctx.game_communication_path):
for file in files:
if file == "deathLinkSend" and ctx.has_death_link:
with open(os.path.join(ctx.game_communication_path, file), 'r') as f:
failed_mission = f.read()
if ctx.slot is not None:
await ctx.send_death(f"{ctx.player_names[ctx.slot]} failed {failed_mission}")
os.remove(os.path.join(ctx.game_communication_path, file))
if file.find("send") > -1:
st = file.split("send", -1)[1]
sending = sending+[(int(st))]
os.remove(os.path.join(ctx.game_communication_path, file))
if file.find("victory") > -1:
victory = True
os.remove(os.path.join(ctx.game_communication_path, file))
if file == "unitSacrifice" or file == "unitSacrificeAI":
if ctx.has_sacrifice_summon:
stored_units_key = ctx.player_stored_units_key
if file == "unitSacrificeAI":
stored_units_key = ctx.ai_stored_units_key
with open(os.path.join(ctx.game_communication_path, file), 'r') as f:
unit_class = f.read()
message = [{"cmd": 'Set', "key": stored_units_key,
"default": [],
"want_reply": True,
"operations": [{"operation": "add", "value": [unit_class[:64]]}]}]
await ctx.send_msgs(message)
os.remove(os.path.join(ctx.game_communication_path, file))
if file == "unitSummonRequestAI" or file == "unitSummonRequest":
if ctx.has_sacrifice_summon:
stored_units_key = ctx.player_stored_units_key
if file == "unitSummonRequestAI":
stored_units_key = ctx.ai_stored_units_key
with open(os.path.join(ctx.game_communication_path, "unitSummonResponse"), 'w') as f:
if stored_units_key in ctx.stored_data:
stored_units = ctx.stored_data[stored_units_key]
if stored_units is None:
stored_units = []
wg1_stored_units = [unit for unit in stored_units if unit in ctx.unit_classes]
if len(wg1_stored_units) != 0:
summoned_unit = random.choice(wg1_stored_units)
message = [{"cmd": 'Set', "key": stored_units_key,
"default": [],
"want_reply": True,
"operations": [{"operation": "remove", "value": summoned_unit[:64]}]}]
await ctx.send_msgs(message)
f.write(summoned_unit)
os.remove(os.path.join(ctx.game_communication_path, file))
ctx.locations_checked = sending
message = [{"cmd": 'LocationChecks', "locations": sending}]
await ctx.send_msgs(message)
if not ctx.finished_game and victory:
await ctx.send_msgs([{"cmd": "StatusUpdate", "status": ClientStatus.CLIENT_GOAL}])
ctx.finished_game = True
await asyncio.sleep(0.1)
except Exception as err:
logger.warn("Exception in communication thread, a check may not have been sent: " + str(err))
if ctx.syncing == True:
sync_msg = [{'cmd': 'Sync'}]
if ctx.locations_checked:
sync_msg.append({"cmd": "LocationChecks", "locations": list(ctx.locations_checked)})
await ctx.send_msgs(sync_msg)
ctx.syncing = False
sending = []
victory = False
for root, dirs, files in os.walk(ctx.game_communication_path):
for file in files:
if file.find("send") > -1:
st = file.split("send", -1)[1]
sending = sending+[(int(st))]
os.remove(os.path.join(ctx.game_communication_path, file))
if file.find("victory") > -1:
victory = True
os.remove(os.path.join(ctx.game_communication_path, file))
ctx.locations_checked = sending
message = [{"cmd": 'LocationChecks', "locations": sending}]
await ctx.send_msgs(message)
if not ctx.finished_game and victory:
await ctx.send_msgs([{"cmd": "StatusUpdate", "status": ClientStatus.CLIENT_GOAL}])
ctx.finished_game = True
await asyncio.sleep(0.1)
def print_error_and_close(msg):
@@ -571,9 +424,8 @@ def print_error_and_close(msg):
Utils.messagebox("Error", msg, error=True)
sys.exit(1)
def launch(*launch_args: str):
async def main():
args = parser.parse_args(launch_args)
if __name__ == '__main__':
async def main(args):
ctx = WargrooveContext(args.connect, args.password)
ctx.server_task = asyncio.create_task(server_loop(ctx), name="server loop")
if gui_enabled:
@@ -593,6 +445,7 @@ def launch(*launch_args: str):
parser = get_base_parser(description="Wargroove Client, for text interfacing.")
colorama.just_fix_windows_console()
asyncio.run(main())
args, rest = parser.parse_known_args()
colorama.init()
asyncio.run(main(args))
colorama.deinit()

View File

@@ -54,15 +54,16 @@ def get_app() -> "Flask":
return app
def copy_tutorials_files_to_static() -> None:
def create_ordered_tutorials_file() -> typing.List[typing.Dict[str, typing.Any]]:
import json
import shutil
import zipfile
from werkzeug.utils import secure_filename
zfile: zipfile.ZipInfo
from worlds.AutoWorld import AutoWorldRegister
worlds = {}
data = []
for game, world in AutoWorldRegister.world_types.items():
if hasattr(world.web, 'tutorials') and (not world.hidden or game == 'Archipelago'):
worlds[game] = world
@@ -71,7 +72,7 @@ def copy_tutorials_files_to_static() -> None:
shutil.rmtree(base_target_path, ignore_errors=True)
for game, world in worlds.items():
# copy files from world's docs folder to the generated folder
target_path = os.path.join(base_target_path, secure_filename(game))
target_path = os.path.join(base_target_path, get_file_safe_name(game))
os.makedirs(target_path, exist_ok=True)
if world.zip_path:
@@ -84,14 +85,45 @@ def copy_tutorials_files_to_static() -> None:
for zfile in zf.infolist():
if not zfile.is_dir() and "/docs/" in zfile.filename:
zfile.filename = os.path.basename(zfile.filename)
with open(os.path.join(target_path, secure_filename(zfile.filename)), "wb") as f:
f.write(zf.read(zfile))
zf.extract(zfile, target_path)
else:
source_path = Utils.local_path(os.path.dirname(world.__file__), "docs")
files = os.listdir(source_path)
for file in files:
shutil.copyfile(Utils.local_path(source_path, file),
Utils.local_path(target_path, secure_filename(file)))
shutil.copyfile(Utils.local_path(source_path, file), Utils.local_path(target_path, file))
# build a json tutorial dict per game
game_data = {'gameTitle': game, 'tutorials': []}
for tutorial in world.web.tutorials:
# build dict for the json file
current_tutorial = {
'name': tutorial.tutorial_name,
'description': tutorial.description,
'files': [{
'language': tutorial.language,
'filename': game + '/' + tutorial.file_name,
'link': f'{game}/{tutorial.link}',
'authors': tutorial.authors
}]
}
# check if the name of the current guide exists already
for guide in game_data['tutorials']:
if guide and tutorial.tutorial_name == guide['name']:
guide['files'].append(current_tutorial['files'][0])
break
else:
game_data['tutorials'].append(current_tutorial)
data.append(game_data)
with open(Utils.local_path("WebHostLib", "static", "generated", "tutorials.json"), 'w', encoding='utf-8-sig') as json_target:
generic_data = {}
for games in data:
if 'Archipelago' in games['gameTitle']:
generic_data = data.pop(data.index(games))
sorted_data = [generic_data] + Utils.title_sorted(data, key=lambda entry: entry["gameTitle"])
json.dump(sorted_data, json_target, indent=2, ensure_ascii=False)
return sorted_data
if __name__ == "__main__":
@@ -99,25 +131,18 @@ if __name__ == "__main__":
multiprocessing.set_start_method('spawn')
logging.basicConfig(format='[%(asctime)s] %(message)s', level=logging.INFO)
from WebHostLib.lttpsprites import update_sprites_lttp
from WebHostLib.autolauncher import autohost, autogen, stop
from WebHostLib.options import create as create_options_files
try:
from WebHostLib.lttpsprites import update_sprites_lttp
update_sprites_lttp()
except Exception as e:
logging.exception(e)
logging.warning("Could not update LttP sprites.")
app = get_app()
from worlds import AutoWorldRegister
# Update to only valid WebHost worlds
invalid_worlds = {name for name, world in AutoWorldRegister.world_types.items()
if not hasattr(world.web, "tutorials")}
if invalid_worlds:
logging.error(f"Following worlds not loaded as they are invalid for WebHost: {invalid_worlds}")
AutoWorldRegister.world_types = {k: v for k, v in AutoWorldRegister.world_types.items() if k not in invalid_worlds}
create_options_files()
copy_tutorials_files_to_static()
create_ordered_tutorials_file()
if app.config["SELFLAUNCH"]:
autohost(app.config)
if app.config["SELFGEN"]:

View File

@@ -1,7 +1,6 @@
import base64
import os
import socket
import typing
import uuid
from flask import Flask
@@ -23,17 +22,6 @@ app.jinja_env.filters['any'] = any
app.jinja_env.filters['all'] = all
app.jinja_env.filters['get_file_safe_name'] = get_file_safe_name
# overwrites of flask default config
app.config["DEBUG"] = False
app.config["PORT"] = 80
app.config["UPLOAD_FOLDER"] = UPLOAD_FOLDER
app.config["MAX_CONTENT_LENGTH"] = 64 * 1024 * 1024 # 64 megabyte limit
# if you want to deploy, make sure you have a non-guessable secret key
app.config["SECRET_KEY"] = bytes(socket.gethostname(), encoding="utf-8")
app.config["SESSION_PERMANENT"] = True
app.config["MAX_FORM_MEMORY_SIZE"] = 2 * 1024 * 1024 # 2 MB, needed for large option pages such as SC2
# custom config
app.config["SELFHOST"] = True # application process is in charge of running the websites
app.config["GENERATORS"] = 8 # maximum concurrent world gens
app.config["HOSTERS"] = 8 # maximum concurrent room hosters
@@ -41,12 +29,19 @@ app.config["SELFLAUNCH"] = True # application process is in charge of launching
app.config["SELFLAUNCHCERT"] = None # can point to a SSL Certificate to encrypt Room websocket connections
app.config["SELFLAUNCHKEY"] = None # can point to a SSL Certificate Key to encrypt Room websocket connections
app.config["SELFGEN"] = True # application process is in charge of scheduling Generations.
app.config["DEBUG"] = False
app.config["PORT"] = 80
app.config['UPLOAD_FOLDER'] = UPLOAD_FOLDER
app.config['MAX_CONTENT_LENGTH'] = 64 * 1024 * 1024 # 64 megabyte limit
# if you want to deploy, make sure you have a non-guessable secret key
app.config["SECRET_KEY"] = bytes(socket.gethostname(), encoding="utf-8")
# at what amount of worlds should scheduling be used, instead of rolling in the web-thread
app.config["JOB_THRESHOLD"] = 1
# after what time in seconds should generation be aborted, freeing the queue slot. Can be set to None to disable.
app.config["JOB_TIME"] = 600
# memory limit for generator processes in bytes
app.config["GENERATOR_MEMORY_LIMIT"] = 4294967296
app.config['SESSION_PERMANENT'] = True
# waitress uses one thread for I/O, these are for processing of views that then get sent
# archipelago.gg uses gunicorn + nginx; ignoring this option
@@ -66,44 +61,32 @@ cache = Cache()
Compress(app)
def to_python(value: str) -> uuid.UUID:
return uuid.UUID(bytes=base64.urlsafe_b64decode(value + '=='))
def to_url(value: uuid.UUID) -> str:
return base64.urlsafe_b64encode(value.bytes).rstrip(b'=').decode('ascii')
class B64UUIDConverter(BaseConverter):
def to_python(self, value: str) -> uuid.UUID:
return to_python(value)
def to_python(self, value):
return uuid.UUID(bytes=base64.urlsafe_b64decode(value + '=='))
def to_url(self, value: typing.Any) -> str:
assert isinstance(value, uuid.UUID)
return to_url(value)
def to_url(self, value):
return base64.urlsafe_b64encode(value.bytes).rstrip(b'=').decode('ascii')
# short UUID
app.url_map.converters["suuid"] = B64UUIDConverter
app.jinja_env.filters["suuid"] = to_url
app.jinja_env.filters['suuid'] = lambda value: base64.urlsafe_b64encode(value.bytes).rstrip(b'=').decode('ascii')
app.jinja_env.filters["title_sorted"] = title_sorted
def register() -> None:
def register():
"""Import submodules, triggering their registering on flask routing.
Note: initializes worlds subsystem."""
import importlib
from werkzeug.utils import find_modules
# has automatic patch integration
import worlds.AutoWorld
import worlds.Files
app.jinja_env.filters['is_applayercontainer'] = worlds.Files.is_ap_player_container
app.jinja_env.filters['supports_apdeltapatch'] = lambda game_name: \
game_name in worlds.Files.AutoPatchRegister.patch_types
from WebHostLib.customserver import run_server_process
# to trigger app routing picking up on it
from . import tracker, upload, landing, check, generate, downloads, api, stats, misc, robots, options, session
for module in find_modules("WebHostLib", include_packages=True):
importlib.import_module(module)
from . import api
app.register_blueprint(api.api_endpoints)

View File

@@ -11,5 +11,5 @@ api_endpoints = Blueprint('api', __name__, url_prefix="/api")
def get_players(seed: Seed) -> List[Tuple[str, str]]:
return [(slot.player_name, slot.game) for slot in seed.slots.order_by(Slot.player_id)]
# trigger endpoint registration
from . import datapackage, generate, room, tracker, user
from . import datapackage, generate, room, user # trigger registration

View File

@@ -1,11 +1,11 @@
import json
import pickle
from uuid import UUID
from flask import request, session, url_for
from markupsafe import Markup
from pony.orm import commit
from Utils import restricted_dumps
from WebHostLib import app
from WebHostLib.check import get_yaml_data, roll_options
from WebHostLib.generate import get_meta
@@ -56,7 +56,7 @@ def generate_api():
"detail": results}, 400
else:
gen = Generation(
options=restricted_dumps({name: vars(options) for name, options in gen_options.items()}),
options=pickle.dumps({name: vars(options) for name, options in gen_options.items()}),
# convert to json compatible
meta=json.dumps(meta), state=STATE_QUEUED,
owner=session["_id"])

View File

@@ -3,7 +3,6 @@ from uuid import UUID
from flask import abort, url_for
from WebHostLib import to_url
import worlds.Files
from . import api_endpoints, get_players
from ..models import Room
@@ -34,7 +33,7 @@ def room_info(room_id: UUID) -> Dict[str, Any]:
downloads.append(slot_download)
return {
"tracker": to_url(room.tracker),
"tracker": room.tracker,
"players": get_players(room.seed),
"last_port": room.last_port,
"last_activity": room.last_activity,

View File

@@ -1,258 +0,0 @@
from datetime import datetime, timezone
from typing import Any, TypedDict
from uuid import UUID
from flask import abort
from NetUtils import ClientStatus, Hint, NetworkItem, SlotType
from WebHostLib import cache
from WebHostLib.api import api_endpoints
from WebHostLib.models import Room
from WebHostLib.tracker import TrackerData
class PlayerAlias(TypedDict):
team: int
player: int
alias: str | None
class PlayerItemsReceived(TypedDict):
team: int
player: int
items: list[NetworkItem]
class PlayerChecksDone(TypedDict):
team: int
player: int
locations: list[int]
class TeamTotalChecks(TypedDict):
team: int
checks_done: int
class PlayerHints(TypedDict):
team: int
player: int
hints: list[Hint]
class PlayerTimer(TypedDict):
team: int
player: int
time: datetime | None
class PlayerStatus(TypedDict):
team: int
player: int
status: ClientStatus
class PlayerLocationsTotal(TypedDict):
team: int
player: int
total_locations: int
class PlayerGame(TypedDict):
team: int
player: int
game: str
@api_endpoints.route("/tracker/<suuid:tracker>")
@cache.memoize(timeout=60)
def tracker_data(tracker: UUID) -> dict[str, Any]:
"""
Outputs json data to <root_path>/api/tracker/<id of current session tracker>.
:param tracker: UUID of current session tracker.
:return: Tracking data for all players in the room. Typing and docstrings describe the format of each value.
"""
room: Room | None = Room.get(tracker=tracker)
if not room:
abort(404)
tracker_data = TrackerData(room)
all_players: dict[int, list[int]] = tracker_data.get_all_players()
player_aliases: list[PlayerAlias] = []
"""Slot aliases of all players."""
for team, players in all_players.items():
for player in players:
player_aliases.append(
{"team": team, "player": player, "alias": tracker_data.get_player_alias(team, player)})
player_items_received: list[PlayerItemsReceived] = []
"""Items received by each player."""
for team, players in all_players.items():
for player in players:
player_items_received.append(
{"team": team, "player": player, "items": tracker_data.get_player_received_items(team, player)})
player_checks_done: list[PlayerChecksDone] = []
"""ID of all locations checked by each player."""
for team, players in all_players.items():
for player in players:
player_checks_done.append(
{"team": team, "player": player,
"locations": sorted(tracker_data.get_player_checked_locations(team, player))})
total_checks_done: list[TeamTotalChecks] = [
{"team": team, "checks_done": checks_done}
for team, checks_done in tracker_data.get_team_locations_checked_count().items()
]
"""Total number of locations checked for the entire multiworld per team."""
hints: list[PlayerHints] = []
"""Hints that all players have used or received."""
for team, players in tracker_data.get_all_slots().items():
for player in players:
player_hints = sorted(tracker_data.get_player_hints(team, player))
hints.append({"team": team, "player": player, "hints": player_hints})
slot_info = tracker_data.get_slot_info(player)
# this assumes groups are always after players
if slot_info.type != SlotType.group:
continue
for member in slot_info.group_members:
hints[member - 1]["hints"] += player_hints
activity_timers: list[PlayerTimer] = []
"""Time of last activity per player. Returned as RFC 1123 format and null if no connection has been made."""
for team, players in all_players.items():
for player in players:
activity_timers.append({"team": team, "player": player, "time": None})
for (team, player), timestamp in tracker_data._multisave.get("client_activity_timers", []):
for entry in activity_timers:
if entry["team"] == team and entry["player"] == player:
entry["time"] = datetime.fromtimestamp(timestamp, timezone.utc)
break
connection_timers: list[PlayerTimer] = []
"""Time of last connection per player. Returned as RFC 1123 format and null if no connection has been made."""
for team, players in all_players.items():
for player in players:
connection_timers.append({"team": team, "player": player, "time": None})
for (team, player), timestamp in tracker_data._multisave.get("client_connection_timers", []):
# find the matching entry
for entry in connection_timers:
if entry["team"] == team and entry["player"] == player:
entry["time"] = datetime.fromtimestamp(timestamp, timezone.utc)
break
player_status: list[PlayerStatus] = []
"""The current client status for each player."""
for team, players in all_players.items():
for player in players:
player_status.append(
{"team": team, "player": player, "status": tracker_data.get_player_client_status(team, player)})
return {
"aliases": player_aliases,
"player_items_received": player_items_received,
"player_checks_done": player_checks_done,
"total_checks_done": total_checks_done,
"hints": hints,
"activity_timers": activity_timers,
"connection_timers": connection_timers,
"player_status": player_status,
}
class PlayerGroups(TypedDict):
slot: int
name: str
members: list[int]
class PlayerSlotData(TypedDict):
player: int
slot_data: dict[str, Any]
@api_endpoints.route("/static_tracker/<suuid:tracker>")
@cache.memoize(timeout=300)
def static_tracker_data(tracker: UUID) -> dict[str, Any]:
"""
Outputs json data to <root_path>/api/static_tracker/<id of current session tracker>.
:param tracker: UUID of current session tracker.
:return: Static tracking data for all players in the room. Typing and docstrings describe the format of each value.
"""
room: Room | None = Room.get(tracker=tracker)
if not room:
abort(404)
tracker_data = TrackerData(room)
all_players: dict[int, list[int]] = tracker_data.get_all_players()
groups: list[PlayerGroups] = []
"""The Slot ID of groups and the IDs of the group's members."""
for team, players in tracker_data.get_all_slots().items():
for player in players:
slot_info = tracker_data.get_slot_info(player)
if slot_info.type != SlotType.group or not slot_info.group_members:
continue
groups.append(
{
"slot": player,
"name": slot_info.name,
"members": list(slot_info.group_members),
})
break
player_locations_total: list[PlayerLocationsTotal] = []
for team, players in all_players.items():
for player in players:
player_locations_total.append(
{"team": team, "player": player, "total_locations": len(tracker_data.get_player_locations(player))})
player_game: list[PlayerGame] = []
"""The played game per player slot."""
for team, players in all_players.items():
for player in players:
player_game.append({"team": team, "player": player, "game": tracker_data.get_player_game(player)})
return {
"groups": groups,
"datapackage": tracker_data._multidata["datapackage"],
"player_locations_total": player_locations_total,
"player_game": player_game,
}
# It should be exceedingly rare that slot data is needed, so it's separated out.
@api_endpoints.route("/slot_data_tracker/<suuid:tracker>")
@cache.memoize(timeout=300)
def tracker_slot_data(tracker: UUID) -> list[PlayerSlotData]:
"""
Outputs json data to <root_path>/api/slot_data_tracker/<id of current session tracker>.
:param tracker: UUID of current session tracker.
:return: Slot data for all players in the room. Typing completely arbitrary per game.
"""
room: Room | None = Room.get(tracker=tracker)
if not room:
abort(404)
tracker_data = TrackerData(room)
all_players: dict[int, list[int]] = tracker_data.get_all_players()
slot_data: list[PlayerSlotData] = []
"""Slot data for each player."""
for team, players in all_players.items():
for player in players:
slot_data.append({"player": player, "slot_data": tracker_data.get_slot_data(player)})
break
return slot_data

View File

@@ -1,7 +1,6 @@
from flask import session, jsonify
from pony.orm import select
from WebHostLib import to_url
from WebHostLib.models import Room, Seed
from . import api_endpoints, get_players
@@ -11,13 +10,13 @@ def get_rooms():
response = []
for room in select(room for room in Room if room.owner == session["_id"]):
response.append({
"room_id": to_url(room.id),
"seed_id": to_url(room.seed.id),
"room_id": room.id,
"seed_id": room.seed.id,
"creation_time": room.creation_time,
"last_activity": room.last_activity,
"last_port": room.last_port,
"timeout": room.timeout,
"tracker": to_url(room.tracker),
"tracker": room.tracker,
})
return jsonify(response)
@@ -27,8 +26,8 @@ def get_seeds():
response = []
for seed in select(seed for seed in Seed if seed.owner == session["_id"]):
response.append({
"seed_id": to_url(seed.id),
"seed_id": seed.id,
"creation_time": seed.creation_time,
"players": get_players(seed),
"players": get_players(seed.slots),
})
return jsonify(response)

View File

@@ -9,7 +9,7 @@ from threading import Event, Thread
from typing import Any
from uuid import UUID
from pony.orm import db_session, select, commit, PrimaryKey
from pony.orm import db_session, select, commit
from Utils import restricted_loads
from .locker import Locker, AlreadyRunningException
@@ -17,7 +17,7 @@ from .locker import Locker, AlreadyRunningException
_stop_event = Event()
def stop() -> None:
def stop():
"""Stops previously launched threads"""
global _stop_event
stop_event = _stop_event
@@ -36,39 +36,16 @@ def handle_generation_failure(result: BaseException):
logging.exception(e)
def _mp_gen_game(
gen_options: dict,
meta: dict[str, Any] | None = None,
owner=None,
sid=None,
timeout: int|None = None,
) -> PrimaryKey | None:
from setproctitle import setproctitle
setproctitle(f"Generator ({sid})")
try:
return gen_game(gen_options, meta=meta, owner=owner, sid=sid, timeout=timeout)
finally:
setproctitle(f"Generator (idle)")
def launch_generator(pool: multiprocessing.pool.Pool, generation: Generation, timeout: int|None) -> None:
def launch_generator(pool: multiprocessing.pool.Pool, generation: Generation):
try:
meta = json.loads(generation.meta)
options = restricted_loads(generation.options)
logging.info(f"Generating {generation.id} for {len(options)} players")
pool.apply_async(
_mp_gen_game,
(options,),
{
"meta": meta,
"sid": generation.id,
"owner": generation.owner,
"timeout": timeout,
},
handle_generation_success,
handle_generation_failure,
)
pool.apply_async(gen_game, (options,),
{"meta": meta,
"sid": generation.id,
"owner": generation.owner},
handle_generation_success, handle_generation_failure)
except Exception as e:
generation.state = STATE_ERROR
commit()
@@ -78,10 +55,6 @@ def launch_generator(pool: multiprocessing.pool.Pool, generation: Generation, ti
def init_generator(config: dict[str, Any]) -> None:
from setproctitle import setproctitle
setproctitle("Generator (idle)")
try:
import resource
except ModuleNotFoundError:
@@ -149,7 +122,6 @@ def autogen(config: dict):
with multiprocessing.Pool(config["GENERATORS"], initializer=init_generator,
initargs=(config,), maxtasksperchild=10) as generator_pool:
job_time = config["JOB_TIME"]
with db_session:
to_start = select(generation for generation in Generation if generation.state == STATE_STARTED)
@@ -160,7 +132,7 @@ def autogen(config: dict):
if sid:
generation.delete()
else:
launch_generator(generator_pool, generation, timeout=job_time)
launch_generator(generator_pool, generation)
commit()
select(generation for generation in Generation if generation.state == STATE_ERROR).delete()
@@ -172,13 +144,16 @@ def autogen(config: dict):
generation for generation in Generation
if generation.state == STATE_QUEUED).for_update()
for generation in to_start:
launch_generator(generator_pool, generation, timeout=job_time)
launch_generator(generator_pool, generation)
except AlreadyRunningException:
logging.info("Autogen reports as already running, not starting another.")
Thread(target=keep_running, name="AP_Autogen").start()
multiworlds: typing.Dict[type(Room.id), MultiworldInstance] = {}
class MultiworldInstance():
def __init__(self, config: dict, id: int):
self.room_ids = set()

View File

@@ -1,7 +1,7 @@
import os
import zipfile
import base64
from collections.abc import Set
from typing import Union, Dict, Set, Tuple
from flask import request, flash, redirect, url_for, render_template
from markupsafe import Markup
@@ -43,7 +43,7 @@ def mysterycheck():
return redirect(url_for("check"), 301)
def get_yaml_data(files) -> dict[str, str] | str | Markup:
def get_yaml_data(files) -> Union[Dict[str, str], str, Markup]:
options = {}
for uploaded_file in files:
if banned_file(uploaded_file.filename):
@@ -84,12 +84,12 @@ def get_yaml_data(files) -> dict[str, str] | str | Markup:
return options
def roll_options(options: dict[str, dict | str],
def roll_options(options: Dict[str, Union[dict, str]],
plando_options: Set[str] = frozenset({"bosses", "items", "connections", "texts"})) -> \
tuple[dict[str, str | bool], dict[str, dict]]:
Tuple[Dict[str, Union[str, bool]], Dict[str, dict]]:
plando_options = PlandoOptions.from_set(set(plando_options))
results: dict[str, str | bool] = {}
rolled_results: dict[str, dict] = {}
results = {}
rolled_results = {}
for filename, text in options.items():
try:
if type(text) is dict:

View File

@@ -19,10 +19,7 @@ from pony.orm import commit, db_session, select
import Utils
from MultiServer import (
Context, server, auto_shutdown, ServerCommandProcessor, ClientMessageProcessor, load_server_cert,
server_per_message_deflate_factory,
)
from MultiServer import Context, server, auto_shutdown, ServerCommandProcessor, ClientMessageProcessor, load_server_cert
from Utils import restricted_loads, cache_argsless
from .locker import Locker
from .models import Command, GameDataPackage, Room, db
@@ -100,7 +97,6 @@ class WebHostContext(Context):
self.main_loop.call_soon_threadsafe(cmdprocessor, command.commandtext)
command.delete()
commit()
del commands
time.sleep(5)
@db_session
@@ -133,7 +129,7 @@ class WebHostContext(Context):
else:
row = GameDataPackage.get(checksum=game_data["checksum"])
if row: # None if rolled on >= 0.3.9 but uploaded to <= 0.3.8. multidata should be complete
game_data_packages[game] = restricted_loads(row.data)
game_data_packages[game] = Utils.restricted_loads(row.data)
continue
else:
self.logger.warning(f"Did not find game_data_package for {game}: {game_data['checksum']}")
@@ -150,20 +146,19 @@ class WebHostContext(Context):
self.location_name_groups = static_location_name_groups
return self._load(multidata, game_data_packages, True)
@db_session
def init_save(self, enabled: bool = True):
self.saving = enabled
if self.saving:
with db_session:
savegame_data = Room.get(id=self.room_id).multisave
if savegame_data:
self.set_save(restricted_loads(Room.get(id=self.room_id).multisave))
savegame_data = Room.get(id=self.room_id).multisave
if savegame_data:
self.set_save(restricted_loads(Room.get(id=self.room_id).multisave))
self._start_async_saving(atexit_save=False)
threading.Thread(target=self.listen_to_db_commands, daemon=True).start()
@db_session
def _save(self, exit_save: bool = False) -> bool:
room = Room.get(id=self.room_id)
# Does not use Utils.restricted_dumps because we'd rather make a save than not make one
room.multisave = pickle.dumps(self.get_save())
# saving only occurs on activity, so we can "abuse" this information to mark this as last_activity
if not exit_save: # we don't want to count a shutdown as activity, which would restart the server again
@@ -232,9 +227,6 @@ def set_up_logging(room_id) -> logging.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):
from setproctitle import setproctitle
setproctitle(name)
Utils.init_logging(name)
try:
import resource
@@ -255,23 +247,8 @@ def run_server_process(name: str, ponyconfig: dict, static_server_data: dict,
raise Exception("Worlds system should not be loaded in the custom server.")
import gc
if not cert_file:
def get_ssl_context():
return None
else:
load_date = None
ssl_context = load_server_cert(cert_file, cert_key_file)
def get_ssl_context():
nonlocal load_date, ssl_context
today = datetime.date.today()
if load_date != today:
ssl_context = load_server_cert(cert_file, cert_key_file)
load_date = today
return ssl_context
del ponyconfig
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()
@@ -286,16 +263,12 @@ def run_server_process(name: str, ponyconfig: dict, static_server_data: dict,
assert ctx.server is None
try:
ctx.server = websockets.serve(
functools.partial(server, ctx=ctx),
ctx.host,
ctx.port,
ssl=get_ssl_context(),
extensions=[server_per_message_deflate_factory],
)
functools.partial(server, ctx=ctx), ctx.host, ctx.port, ssl=ssl_context)
await ctx.server
except OSError: # likely port in use
ctx.server = websockets.serve(
functools.partial(server, ctx=ctx), ctx.host, 0, ssl=get_ssl_context())
functools.partial(server, ctx=ctx), ctx.host, 0, ssl=ssl_context)
await ctx.server
port = 0
@@ -312,7 +285,6 @@ def run_server_process(name: str, ponyconfig: dict, static_server_data: dict,
with db_session:
room = Room.get(id=ctx.room_id)
room.last_port = port
del room
else:
ctx.logger.exception("Could not determine port. Likely hosting failure.")
with db_session:
@@ -331,7 +303,6 @@ def run_server_process(name: str, ponyconfig: dict, static_server_data: dict,
with db_session:
room = Room.get(id=room_id)
room.last_port = -1
del room
logger.exception(e)
raise
else:
@@ -343,12 +314,11 @@ def run_server_process(name: str, ponyconfig: dict, static_server_data: dict,
ctx.save_dirty = False # make sure the saving thread does not write to DB after final wakeup
ctx.exit_event.set() # make sure the saving thread stops at some point
# NOTE: async saving should probably be an async task and could be merged with shutdown_task
with db_session:
with (db_session):
# ensure the Room does not spin up again on its own, minute of safety buffer
room = Room.get(id=room_id)
room.last_activity = datetime.datetime.utcnow() - \
datetime.timedelta(minutes=1, seconds=room.timeout)
del room
logging.info(f"Shutting down room {room_id} on {name}.")
finally:
await asyncio.sleep(5)

View File

@@ -61,7 +61,12 @@ def download_slot_file(room_id, player_id: int):
else:
import io
if slot_data.game == "Factorio":
if slot_data.game == "Minecraft":
from worlds.minecraft import mc_update_output
fname = f"AP_{app.jinja_env.filters['suuid'](room_id)}_P{slot_data.player_id}_{slot_data.player_name}.apmc"
data = mc_update_output(slot_data.data, server=app.config['HOST_ADDRESS'], port=room.last_port)
return send_file(io.BytesIO(data), as_attachment=True, download_name=fname)
elif slot_data.game == "Factorio":
with zipfile.ZipFile(io.BytesIO(slot_data.data)) as zf:
for name in zf.namelist():
if name.endswith("info.json"):

View File

@@ -1,29 +1,30 @@
import concurrent.futures
import json
import os
import pickle
import random
import tempfile
import zipfile
from collections import Counter
from pickle import PicklingError
from typing import Any
from typing import Any, Dict, List, Optional, Union, Set
from flask import flash, redirect, render_template, request, session, url_for
from pony.orm import commit, db_session
from BaseClasses import get_seed, seeddigits
from Generate import PlandoOptions, handle_name, mystery_argparse
from Generate import PlandoOptions, handle_name
from Main import main as ERmain
from Utils import __version__, restricted_dumps, DaemonThreadPoolExecutor
from Utils import __version__
from WebHostLib import app
from settings import ServerOptions, GeneratorOptions
from worlds.alttp.EntranceRandomizer import parse_arguments
from .check import get_yaml_data, roll_options
from .models import Generation, STATE_ERROR, STATE_QUEUED, Seed, UUID
from .upload import upload_zip_to_db
def get_meta(options_source: dict, race: bool = False) -> dict[str, list[str] | dict[str, Any]]:
plando_options: set[str] = set()
def get_meta(options_source: dict, race: bool = False) -> Dict[str, Union[List[str], Dict[str, Any]]]:
plando_options: Set[str] = set()
for substr in ("bosses", "items", "connections", "texts"):
if options_source.get(f"plando_{substr}", substr in GeneratorOptions.plando_options):
plando_options.add(substr)
@@ -33,7 +34,6 @@ def get_meta(options_source: dict, race: bool = False) -> dict[str, list[str] |
"release_mode": str(options_source.get("release_mode", ServerOptions.release_mode)),
"remaining_mode": str(options_source.get("remaining_mode", ServerOptions.remaining_mode)),
"collect_mode": str(options_source.get("collect_mode", ServerOptions.collect_mode)),
"countdown_mode": str(options_source.get("countdown_mode", ServerOptions.countdown_mode)),
"item_cheat": bool(int(options_source.get("item_cheat", not ServerOptions.disable_item_cheat))),
"server_password": str(options_source.get("server_password", None)),
}
@@ -73,11 +73,7 @@ def generate(race=False):
return render_template("generate.html", race=race, version=__version__)
def format_exception(e: BaseException) -> str:
return f"{e.__class__.__name__}: {e}"
def start_generation(options: dict[str, dict | str], meta: dict[str, Any]):
def start_generation(options: Dict[str, Union[dict, str]], meta: Dict[str, Any]):
results, gen_options = roll_options(options, set(meta["plando_options"]))
if any(type(result) == str for result in results.values()):
@@ -87,40 +83,30 @@ def start_generation(options: dict[str, dict | str], meta: dict[str, Any]):
f"If you have a larger group, please generate it yourself and upload it.")
return redirect(url_for(request.endpoint, **(request.view_args or {})))
elif len(gen_options) >= app.config["JOB_THRESHOLD"]:
try:
gen = Generation(
options=restricted_dumps({name: vars(options) for name, options in gen_options.items()}),
# convert to json compatible
meta=json.dumps(meta),
state=STATE_QUEUED,
owner=session["_id"])
except PicklingError as e:
from .autolauncher import handle_generation_failure
handle_generation_failure(e)
meta["error"] = format_exception(e)
details = json.dumps(meta, indent=4).strip()
return render_template("seedError.html", seed_error=meta["error"], details=details)
gen = Generation(
options=pickle.dumps({name: vars(options) for name, options in gen_options.items()}),
# convert to json compatible
meta=json.dumps(meta),
state=STATE_QUEUED,
owner=session["_id"])
commit()
return redirect(url_for("wait_seed", seed=gen.id))
else:
try:
seed_id = gen_game({name: vars(options) for name, options in gen_options.items()},
meta=meta, owner=session["_id"].int, timeout=app.config["JOB_TIME"])
meta=meta, owner=session["_id"].int)
except BaseException as e:
from .autolauncher import handle_generation_failure
handle_generation_failure(e)
meta["error"] = format_exception(e)
details = json.dumps(meta, indent=4).strip()
return render_template("seedError.html", seed_error=meta["error"], details=details)
return render_template("seedError.html", seed_error=(e.__class__.__name__ + ": " + str(e)))
return redirect(url_for("view_seed", seed=seed_id))
def gen_game(gen_options: dict, meta: dict[str, Any] | None = None, owner=None, sid=None, timeout: int|None = None):
if meta is None:
meta = {}
def gen_game(gen_options: dict, meta: Optional[Dict[str, Any]] = None, owner=None, sid=None):
if not meta:
meta: Dict[str, Any] = {}
meta.setdefault("server_options", {}).setdefault("hint_cost", 10)
race = meta.setdefault("generator_options", {}).setdefault("race", False)
@@ -137,47 +123,42 @@ def gen_game(gen_options: dict, meta: dict[str, Any] | None = None, owner=None,
seedname = "W" + (f"{random.randint(0, pow(10, seeddigits) - 1)}".zfill(seeddigits))
args = mystery_argparse([]) # Just to set up the Namespace with defaults
args.multi = playercount
args.seed = seed
args.name = {x: "" for x in range(1, playercount + 1)} # only so it can be overwritten in mystery
args.spoiler = meta["generator_options"].get("spoiler", 0)
args.race = race
args.outputname = seedname
args.outputpath = target.name
args.teams = 1
args.plando_options = PlandoOptions.from_set(meta.setdefault("plando_options",
{"bosses", "items", "connections", "texts"}))
args.skip_prog_balancing = False
args.skip_output = False
args.spoiler_only = False
args.csv_output = False
args.sprite = dict.fromkeys(range(1, args.multi+1), None)
args.sprite_pool = dict.fromkeys(range(1, args.multi+1), None)
erargs = parse_arguments(['--multi', str(playercount)])
erargs.seed = seed
erargs.name = {x: "" for x in range(1, playercount + 1)} # only so it can be overwritten in mystery
erargs.spoiler = meta["generator_options"].get("spoiler", 0)
erargs.race = race
erargs.outputname = seedname
erargs.outputpath = target.name
erargs.teams = 1
erargs.plando_options = PlandoOptions.from_set(meta.setdefault("plando_options",
{"bosses", "items", "connections", "texts"}))
erargs.skip_prog_balancing = False
erargs.skip_output = False
erargs.csv_output = False
name_counter = Counter()
for player, (playerfile, settings) in enumerate(gen_options.items(), 1):
for k, v in settings.items():
if v is not None:
if hasattr(args, k):
getattr(args, k)[player] = v
if hasattr(erargs, k):
getattr(erargs, k)[player] = v
else:
setattr(args, k, {player: v})
setattr(erargs, k, {player: v})
if not args.name[player]:
args.name[player] = os.path.splitext(os.path.split(playerfile)[-1])[0]
args.name[player] = handle_name(args.name[player], player, name_counter)
if len(set(args.name.values())) != len(args.name):
raise Exception(f"Names have to be unique. Names: {Counter(args.name.values())}")
ERmain(args, seed, baked_server_options=meta["server_options"])
if not erargs.name[player]:
erargs.name[player] = os.path.splitext(os.path.split(playerfile)[-1])[0]
erargs.name[player] = handle_name(erargs.name[player], player, name_counter)
if len(set(erargs.name.values())) != len(erargs.name):
raise Exception(f"Names have to be unique. Names: {Counter(erargs.name.values())}")
ERmain(erargs, seed, baked_server_options=meta["server_options"])
return upload_to_db(target.name, sid, owner, race)
thread_pool = DaemonThreadPoolExecutor(max_workers=1)
thread_pool = concurrent.futures.ThreadPoolExecutor(max_workers=1)
thread = thread_pool.submit(task)
try:
return thread.result(timeout)
return thread.result(app.config["JOB_TIME"])
except concurrent.futures.TimeoutError as e:
if sid:
with db_session:
@@ -185,14 +166,11 @@ def gen_game(gen_options: dict, meta: dict[str, Any] | None = None, owner=None,
if gen is not None:
gen.state = STATE_ERROR
meta = json.loads(gen.meta)
meta["error"] = ("Allowed time for Generation exceeded, " +
"please consider generating locally instead. " +
format_exception(e))
meta["error"] = (
"Allowed time for Generation exceeded, please consider generating locally instead. " +
e.__class__.__name__ + ": " + str(e))
gen.meta = json.dumps(meta)
commit()
except (KeyboardInterrupt, SystemExit):
# don't update db, retry next time
raise
except BaseException as e:
if sid:
with db_session:
@@ -200,15 +178,10 @@ def gen_game(gen_options: dict, meta: dict[str, Any] | None = None, owner=None,
if gen is not None:
gen.state = STATE_ERROR
meta = json.loads(gen.meta)
meta["error"] = format_exception(e)
meta["error"] = (e.__class__.__name__ + ": " + str(e))
gen.meta = json.dumps(meta)
commit()
raise
finally:
# free resources claimed by thread pool, if possible
# NOTE: Timeout depends on the process being killed at some point
# since we can't actually cancel a running gen at the moment.
thread_pool.shutdown(wait=False, cancel_futures=True)
@app.route('/wait/<suuid:seed>')
@@ -222,9 +195,7 @@ def wait_seed(seed: UUID):
if not generation:
return "Generation not found."
elif generation.state == STATE_ERROR:
meta = json.loads(generation.meta)
details = json.dumps(meta, indent=4).strip()
return render_template("seedError.html", seed_error=meta["error"], details=details)
return render_template("seedError.html", seed_error=generation.meta)
return render_template("waitSeed.html", seed_id=seed_id)

View File

@@ -3,10 +3,10 @@ import threading
import json
from Utils import local_path, user_path
from worlds.alttp.Rom import Sprite
def update_sprites_lttp():
from worlds.alttp.Rom import Sprite
from tkinter import Tk
from LttPAdjuster import get_image_for_sprite
from LttPAdjuster import BackgroundTaskProgress
@@ -14,7 +14,7 @@ def update_sprites_lttp():
from LttPAdjuster import update_sprites
# Target directories
input_dir = user_path("data", "sprites", "alttp", "remote")
input_dir = user_path("data", "sprites", "alttpr")
output_dir = local_path("WebHostLib", "static", "generated") # TODO: move to user_path
os.makedirs(os.path.join(output_dir, "sprites"), exist_ok=True)

View File

@@ -1,90 +0,0 @@
import re
from collections import Counter
import mistune
from werkzeug.utils import secure_filename
__all__ = [
"ImgUrlRewriteInlineParser",
'render_markdown',
]
class ImgUrlRewriteInlineParser(mistune.InlineParser):
relative_url_base: str
def __init__(self, relative_url_base: str, hard_wrap: bool = False) -> None:
super().__init__(hard_wrap)
self.relative_url_base = relative_url_base
@staticmethod
def _find_game_name_by_folder_name(name: str) -> str | None:
from worlds.AutoWorld import AutoWorldRegister
for world_name, world_type in AutoWorldRegister.world_types.items():
if world_type.__module__ == f"worlds.{name}":
return world_name
return None
def parse_link(self, m: re.Match[str], state: mistune.InlineState) -> int | None:
res = super().parse_link(m, state)
if res is not None and state.tokens and state.tokens[-1]["type"] == "image":
image_token = state.tokens[-1]
url: str = image_token["attrs"]["url"]
if not url.startswith("/") and not "://" in url:
# replace relative URL to another world's doc folder with the webhost folder layout
if url.startswith("../../") and "/docs/" in self.relative_url_base:
parts = url.split("/", 4)
if parts[2] != ".." and parts[3] == "docs":
game_name = self._find_game_name_by_folder_name(parts[2])
if game_name is not None:
url = "/".join(parts[1:2] + [secure_filename(game_name)] + parts[4:])
# change relative URL to point to deployment folder
url = f"{self.relative_url_base}/{url}"
image_token['attrs']['url'] = url
return res
def render_markdown(path: str, img_url_base: str | None = None) -> str:
markdown = mistune.create_markdown(
escape=False,
plugins=[
"strikethrough",
"footnotes",
"table",
"speedup",
],
)
heading_id_count: Counter[str] = Counter()
def heading_id(text: str) -> str:
nonlocal heading_id_count
# there is no good way to do this without regex
s = re.sub(r"[^\w\- ]", "", text.lower()).replace(" ", "-").strip("-")
n = heading_id_count[s]
heading_id_count[s] += 1
if n > 0:
s += f"-{n}"
return s
def id_hook(_: mistune.Markdown, state: mistune.BlockState) -> None:
for tok in state.tokens:
if tok["type"] == "heading" and tok["attrs"]["level"] < 4:
text = tok["text"]
assert isinstance(text, str)
unique_id = heading_id(text)
tok["attrs"]["id"] = unique_id
tok["text"] = f"<a href=\"#{unique_id}\">{text}</a>" # make header link to itself
markdown.before_render_hooks.append(id_hook)
if img_url_base:
markdown.inline = ImgUrlRewriteInlineParser(img_url_base)
with open(path, encoding="utf-8-sig") as f:
document = f.read()
html = markdown(document)
assert isinstance(html, str), "Unexpected mistune renderer in render_markdown"
return html

View File

@@ -1,7 +1,5 @@
import datetime
import os
import warnings
from enum import StrEnum
from typing import Any, IO, Dict, Iterator, List, Tuple, Union
import jinja2.exceptions
@@ -9,39 +7,15 @@ from flask import request, redirect, url_for, render_template, Response, session
from pony.orm import count, commit, db_session
from werkzeug.utils import secure_filename
from worlds.AutoWorld import AutoWorldRegister, World
from worlds.AutoWorld import AutoWorldRegister
from . import app, cache
from .markdown import render_markdown
from .models import Seed, Room, Command, UUID, uuid4
from Utils import title_sorted
class WebWorldTheme(StrEnum):
DIRT = "dirt"
GRASS = "grass"
GRASS_FLOWERS = "grassFlowers"
ICE = "ice"
JUNGLE = "jungle"
OCEAN = "ocean"
PARTY_TIME = "partyTime"
STONE = "stone"
def get_world_theme(game_name: str) -> str:
if game_name not in AutoWorldRegister.world_types:
return "grass"
chosen_theme = AutoWorldRegister.world_types[game_name].web.theme
available_themes = [theme.value for theme in WebWorldTheme]
if chosen_theme not in available_themes:
warnings.warn(f"Theme '{chosen_theme}' for {game_name} not valid, switching to default 'grass' theme.")
return "grass"
return chosen_theme
def get_visible_worlds() -> dict[str, type(World)]:
worlds = {}
for game, world in AutoWorldRegister.world_types.items():
if not world.hidden:
worlds[game] = world
return worlds
def get_world_theme(game_name: str):
if game_name in AutoWorldRegister.world_types:
return AutoWorldRegister.world_types[game_name].web.theme
return 'grass'
@app.errorhandler(404)
@@ -57,101 +31,71 @@ def start_playing():
return render_template(f"startPlaying.html")
# Game Info Pages
@app.route('/games/<string:game>/info/<string:lang>')
@cache.cached()
def game_info(game, lang):
"""Game Info Pages"""
try:
theme = get_world_theme(game)
secure_game_name = secure_filename(game)
lang = secure_filename(lang)
file_dir = os.path.join(app.static_folder, "generated", "docs", secure_game_name)
file_dir_url = url_for("static", filename=f"generated/docs/{secure_game_name}")
document = render_markdown(os.path.join(file_dir, f"{lang}_{secure_game_name}.md"), file_dir_url)
return render_template(
"markdown_document.html",
title=f"{game} Guide",
html_from_markdown=document,
theme=theme,
)
except FileNotFoundError:
return abort(404)
return render_template('gameInfo.html', game=game, lang=lang, theme=get_world_theme(game))
# List of supported games
@app.route('/games')
@cache.cached()
def games():
"""List of supported games"""
return render_template("supportedGames.html", worlds=get_visible_worlds())
@app.route('/tutorial/<string:game>/<string:file>')
@cache.cached()
def tutorial(game: str, file: str):
try:
theme = get_world_theme(game)
secure_game_name = secure_filename(game)
file = secure_filename(file)
file_dir = os.path.join(app.static_folder, "generated", "docs", secure_game_name)
file_dir_url = url_for("static", filename=f"generated/docs/{secure_game_name}")
document = render_markdown(os.path.join(file_dir, f"{file}.md"), file_dir_url)
return render_template(
"markdown_document.html",
title=f"{game} Guide",
html_from_markdown=document,
theme=theme,
)
except FileNotFoundError:
return abort(404)
worlds = {}
for game, world in AutoWorldRegister.world_types.items():
if not world.hidden:
worlds[game] = world
return render_template("supportedGames.html", worlds=worlds)
@app.route('/tutorial/<string:game>/<string:file>/<string:lang>')
def tutorial_redirect(game: str, file: str, lang: str):
"""
Permanent redirect old tutorial URLs to new ones to keep search engines happy.
e.g. /tutorial/Archipelago/setup/en -> /tutorial/Archipelago/setup_en
"""
return redirect(url_for("tutorial", game=game, file=f"{file}_{lang}"), code=301)
@cache.cached()
def tutorial(game, file, lang):
return render_template("tutorial.html", game=game, file=file, lang=lang, theme=get_world_theme(game))
@app.route('/tutorial/')
@cache.cached()
def tutorial_landing():
tutorials = {}
worlds = AutoWorldRegister.world_types
for world_name, world_type in worlds.items():
current_world = tutorials[world_name] = {}
for tutorial in world_type.web.tutorials:
current_tutorial = current_world.setdefault(tutorial.tutorial_name, {
"description": tutorial.description, "files": {}})
current_tutorial["files"][secure_filename(tutorial.file_name).rsplit(".", 1)[0]] = {
"authors": tutorial.authors,
"language": tutorial.language
}
tutorials = {world_name: tutorials for world_name, tutorials in title_sorted(
tutorials.items(), key=lambda element: "\x00" if element[0] == "Archipelago" else worlds[element[0]].game)}
return render_template("tutorialLanding.html", worlds=worlds, tutorials=tutorials)
return render_template("tutorialLanding.html")
@app.route('/faq/<string:lang>/')
@cache.cached()
def faq(lang: str):
document = render_markdown(os.path.join(app.static_folder, "assets", "faq", secure_filename(lang)+".md"))
import markdown
with open(os.path.join(app.static_folder, "assets", "faq", secure_filename(lang)+".md")) as f:
document = f.read()
return render_template(
"markdown_document.html",
title="Frequently Asked Questions",
html_from_markdown=document,
html_from_markdown=markdown.markdown(
document,
extensions=["toc", "mdx_breakless_lists"],
extension_configs={
"toc": {"anchorlink": True}
}
),
)
@app.route('/glossary/<string:lang>/')
@cache.cached()
def glossary(lang: str):
document = render_markdown(os.path.join(app.static_folder, "assets", "glossary", secure_filename(lang)+".md"))
import markdown
with open(os.path.join(app.static_folder, "assets", "glossary", secure_filename(lang)+".md")) as f:
document = f.read()
return render_template(
"markdown_document.html",
title="Glossary",
html_from_markdown=document,
html_from_markdown=markdown.markdown(
document,
extensions=["toc", "mdx_breakless_lists"],
extension_configs={
"toc": {"anchorlink": True}
}
),
)
@@ -232,10 +176,7 @@ def host_room(room: UUID):
# indicate that the page should reload to get the assigned port
should_refresh = ((not room.last_port and now - room.creation_time < datetime.timedelta(seconds=3))
or room.last_activity < now - datetime.timedelta(seconds=room.timeout))
if now - room.last_activity > datetime.timedelta(minutes=1):
# we only set last_activity if needed, otherwise parallel access on /room will cause an internal server error
# due to "pony.orm.core.OptimisticCheckError: Object Room was updated outside of current transaction"
with db_session:
room.last_activity = now # will trigger a spinup, if it's not already running
browser_tokens = "Mozilla", "Chrome", "Safari"
@@ -243,9 +184,9 @@ def host_room(room: UUID):
or "Discordbot" in request.user_agent.string
or not any(browser_token in request.user_agent.string for browser_token in browser_tokens))
def get_log(max_size: int = 0 if automated else 1024000) -> Tuple[str, int]:
def get_log(max_size: int = 0 if automated else 1024000) -> str:
if max_size == 0:
return "", 0
return ""
try:
with open(os.path.join("logs", str(room.id) + ".txt"), "rb") as log:
raw_size = 0
@@ -256,9 +197,9 @@ def host_room(room: UUID):
break
raw_size += len(block)
fragments.append(block.decode("utf-8"))
return "".join(fragments), raw_size
return "".join(fragments)
except FileNotFoundError:
return "", 0
return ""
return render_template("hostRoom.html", room=room, should_refresh=should_refresh, get_log=get_log)

View File

@@ -6,14 +6,13 @@ from typing import Dict, Union
from docutils.core import publish_parts
import yaml
from flask import redirect, render_template, request, Response, abort
from flask import redirect, render_template, request, Response
import Options
from Utils import local_path
from worlds.AutoWorld import AutoWorldRegister
from . import app, cache
from .generate import get_meta
from .misc import get_world_theme
def create() -> None:
@@ -23,6 +22,12 @@ def create() -> None:
Options.generate_yaml_templates(yaml_folder)
def get_world_theme(game_name: str) -> str:
if game_name in AutoWorldRegister.world_types:
return AutoWorldRegister.world_types[game_name].web.theme
return 'grass'
def render_options_page(template: str, world_name: str, is_complex: bool = False) -> Union[Response, str]:
world = AutoWorldRegister.world_types[world_name]
if world.hidden or world.web.options_page is False:
@@ -71,7 +76,7 @@ def filter_rst_to_html(text: str) -> str:
lines = text.splitlines()
text = lines[0] + "\n" + dedent("\n".join(lines[1:]))
return publish_parts(text, writer='html', settings=None, settings_overrides={
return publish_parts(text, writer_name='html', settings=None, settings_overrides={
'raw_enable': False,
'file_insertion_enabled': False,
'output_encoding': 'unicode'
@@ -103,7 +108,7 @@ def option_presets(game: str) -> Response:
f"Expected {option.special_range_names.keys()} or {option.range_start}-{option.range_end}."
presets[preset_name][preset_option_name] = option.value
elif isinstance(option, (Options.Range, Options.OptionSet, Options.OptionList, Options.OptionCounter)):
elif isinstance(option, (Options.Range, Options.OptionSet, Options.OptionList, Options.ItemDict)):
presets[preset_name][preset_option_name] = option.value
elif isinstance(preset_option, str):
# Ensure the option value is valid for Choice and Toggle options
@@ -137,10 +142,7 @@ def weighted_options_old():
@app.route("/games/<string:game>/weighted-options")
@cache.cached()
def weighted_options(game: str):
try:
return render_options_page("weightedOptions/weightedOptions.html", game, is_complex=True)
except KeyError:
return abort(404)
return render_options_page("weightedOptions/weightedOptions.html", game, is_complex=True)
@app.route("/games/<string:game>/generate-weighted-yaml", methods=["POST"])
@@ -150,9 +152,7 @@ def generate_weighted_yaml(game: str):
options = {}
for key, val in request.form.items():
if val == "_ensure-empty-list":
options[key] = {}
elif "||" not in key:
if "||" not in key:
if len(str(val)) == 0:
continue
@@ -197,10 +197,7 @@ def generate_weighted_yaml(game: str):
@app.route("/games/<string:game>/player-options")
@cache.cached()
def player_options(game: str):
try:
return render_options_page("playerOptions/playerOptions.html", game, is_complex=False)
except KeyError:
return abort(404)
return render_options_page("playerOptions/playerOptions.html", game, is_complex=False)
# YAML generator for player-options
@@ -209,11 +206,8 @@ def generate_yaml(game: str):
if request.method == "POST":
options = {}
intent_generate = False
for key, val in request.form.items(multi=True):
if val == "_ensure-empty-list":
options[key] = []
elif options.get(key):
if key in options:
if not isinstance(options[key], list):
options[key] = [options[key]]
options[key].append(val)
@@ -222,11 +216,11 @@ def generate_yaml(game: str):
for key, val in options.copy().items():
key_parts = key.rsplit("||", 2)
# Detect and build OptionCounter options from their name pattern
# Detect and build ItemDict options from their name pattern
if key_parts[-1] == "qty":
if key_parts[0] not in options:
options[key_parts[0]] = {}
if val and val != "0":
if val != "0":
options[key_parts[0]][key_parts[1]] = int(val)
del options[key]

View File

@@ -1,13 +1,11 @@
flask>=3.1.1
werkzeug>=3.1.3
pony>=0.7.19; python_version <= '3.12'
pony @ git+https://github.com/black-sliver/pony@7feb1221953b7fa4a6735466bf21a8b4d35e33ba#0.7.19; python_version >= '3.13'
waitress>=3.0.2
flask>=3.0.3
werkzeug>=3.0.6
pony>=0.7.19
waitress>=3.0.0
Flask-Caching>=2.3.0
Flask-Compress==1.18 # pkg_resources can't resolve the "backports.zstd" dependency of >1.18, breaking ModuleUpdate.py
Flask-Limiter>=3.12
bokeh>=3.6.3
markupsafe>=3.0.2
setproctitle>=1.3.5
mistune>=3.1.3
docutils>=0.22.2
Flask-Compress>=1.15
Flask-Limiter>=3.8.0
bokeh>=3.5.2
markupsafe>=2.1.5
Markdown>=3.7
mdx-breakless-lists>=1.0.1

View File

@@ -23,7 +23,7 @@ players to rely upon each other to complete their game.
While a multiworld game traditionally requires all players to be playing the same game, a multi-game multiworld allows
players to randomize any of the supported games, and send items between them. This allows players of different
games to interact with one another in a single multiplayer environment. Archipelago supports multi-game multiworlds.
Here is a list of our [Supported Games](/games).
Here is a list of our [Supported Games](https://archipelago.gg/games).
## Can I generate a single-player game with Archipelago?
@@ -33,7 +33,7 @@ play, open the Settings Page, pick your settings, and click Generate Game.
## How do I get started?
We have a [Getting Started](/tutorial/Archipelago/setup/en) guide that will help you get the
We have a [Getting Started](https://archipelago.gg/tutorial/Archipelago/setup/en) guide that will help you get the
software set up. You can use that guide to learn how to generate multiworlds. There are also basic instructions for
including multiple games, and hosting multiworlds on the website for ease and convenience.
@@ -57,7 +57,7 @@ their multiworld.
If a player must leave early, they can use Archipelago's release system. When a player releases their game, all items
in that game belonging to other players are sent out automatically. This allows other players to continue to play
uninterrupted. Here is a list of all of our [Server Commands](/tutorial/Archipelago/commands/en).
uninterrupted. Here is a list of all of our [Server Commands](https://archipelago.gg/tutorial/Archipelago/commands/en).
## What happens if an item is placed somewhere it is impossible to get?
@@ -66,7 +66,7 @@ is to ensure items necessary to complete the game will be accessible to the play
rules allowing certain items to be placed in normally unreachable locations, provided the player has indicated they are
comfortable exploiting certain glitches in the game.
## I want to develop a game implementation for Archipelago. How do I do that?
## I want to add a game to the Archipelago randomizer. How do I do that?
The best way to get started is to take a look at our code on GitHub:
[Archipelago GitHub Page](https://github.com/ArchipelagoMW/Archipelago).
@@ -77,5 +77,4 @@ There, you will find examples of games in the `worlds` folder:
You may also find developer documentation in the `docs` folder:
[/docs Folder in Archipelago Code](https://github.com/ArchipelagoMW/Archipelago/tree/main/docs).
If you have more questions regarding development of a game implementation, feel free to ask in the **#ap-world-dev**
channel on our Discord.
If you have more questions, feel free to ask in the **#ap-world-dev** channel on our Discord.

View File

@@ -0,0 +1,51 @@
window.addEventListener('load', () => {
const gameInfo = document.getElementById('game-info');
new Promise((resolve, reject) => {
const ajax = new XMLHttpRequest();
ajax.onreadystatechange = () => {
if (ajax.readyState !== 4) { return; }
if (ajax.status === 404) {
reject("Sorry, this game's info page is not available in that language yet.");
return;
}
if (ajax.status !== 200) {
reject("Something went wrong while loading the info page.");
return;
}
resolve(ajax.responseText);
};
ajax.open('GET', `${window.location.origin}/static/generated/docs/${gameInfo.getAttribute('data-game')}/` +
`${gameInfo.getAttribute('data-lang')}_${gameInfo.getAttribute('data-game')}.md`, true);
ajax.send();
}).then((results) => {
// Populate page with HTML generated from markdown
showdown.setOption('tables', true);
showdown.setOption('strikethrough', true);
showdown.setOption('literalMidWordUnderscores', true);
gameInfo.innerHTML += (new showdown.Converter()).makeHtml(results);
adjustHeaderWidth();
// Reset the id of all header divs to something nicer
for (const header of document.querySelectorAll('h1, h2, h3, h4, h5, h6')) {
const headerId = header.innerText.replace(/\s+/g, '-').toLowerCase();
header.setAttribute('id', headerId);
header.addEventListener('click', () => {
window.location.hash = `#${headerId}`;
header.scrollIntoView();
});
}
// Manually scroll the user to the appropriate header if anchor navigation is used
document.fonts.ready.finally(() => {
if (window.location.hash) {
const scrollTarget = document.getElementById(window.location.hash.substring(1));
scrollTarget?.scrollIntoView();
}
});
}).catch((error) => {
console.error(error);
gameInfo.innerHTML =
`<h2>This page is out of logic!</h2>
<h3>Click <a href="${window.location.origin}">here</a> to return to safety.</h3>`;
});
});

View File

@@ -6,4 +6,6 @@ window.addEventListener('load', () => {
document.getElementById('file-input').addEventListener('change', () => {
document.getElementById('host-game-form').submit();
});
adjustFooterHeight();
});

View File

@@ -0,0 +1,49 @@
window.addEventListener('load', () => {
// Reload tracker every 15 seconds
const url = window.location;
setInterval(() => {
const ajax = new XMLHttpRequest();
ajax.onreadystatechange = () => {
if (ajax.readyState !== 4) { return; }
// Create a fake DOM using the returned HTML
const domParser = new DOMParser();
const fakeDOM = domParser.parseFromString(ajax.responseText, 'text/html');
// Update item tracker
document.getElementById('inventory-table').innerHTML = fakeDOM.getElementById('inventory-table').innerHTML;
// Update only counters in the location-table
let counters = document.getElementsByClassName('counter');
const fakeCounters = fakeDOM.getElementsByClassName('counter');
for (let i = 0; i < counters.length; i++) {
counters[i].innerHTML = fakeCounters[i].innerHTML;
}
};
ajax.open('GET', url);
ajax.send();
}, 15000)
// Collapsible advancement sections
const categories = document.getElementsByClassName("location-category");
for (let i = 0; i < categories.length; i++) {
let hide_id = categories[i].id.split('-')[0];
if (hide_id == 'Total') {
continue;
}
categories[i].addEventListener('click', function() {
// Toggle the advancement list
document.getElementById(hide_id).classList.toggle("hide");
// Change text of the header
const tab_header = document.getElementById(hide_id+'-header').children[0];
const orig_text = tab_header.innerHTML;
let new_text;
if (orig_text.includes("▼")) {
new_text = orig_text.replace("▼", "▲");
}
else {
new_text = orig_text.replace("▲", "▼");
}
tab_header.innerHTML = new_text;
});
}
});

View File

@@ -1,43 +1,49 @@
let updateSection = (sectionName, fakeDOM) => {
document.getElementById(sectionName).innerHTML = fakeDOM.getElementById(sectionName).innerHTML;
}
window.addEventListener('load', () => {
// Reload tracker every 60 seconds (sync'd)
const url = window.location;
// Note: This synchronization code is adapted from code in trackerCommon.js
const targetSecond = parseInt(document.getElementById('player-tracker').getAttribute('data-second')) + 3;
console.log("Target second of refresh: " + targetSecond);
// Reload tracker every 15 seconds
const url = window.location;
setInterval(() => {
const ajax = new XMLHttpRequest();
ajax.onreadystatechange = () => {
if (ajax.readyState !== 4) { return; }
let getSleepTimeSeconds = () => {
// -40 % 60 is -40, which is absolutely wrong and should burn
var sleepSeconds = (((targetSecond - new Date().getSeconds()) % 60) + 60) % 60;
return sleepSeconds || 60;
};
// Create a fake DOM using the returned HTML
const domParser = new DOMParser();
const fakeDOM = domParser.parseFromString(ajax.responseText, 'text/html');
let updateTracker = () => {
const ajax = new XMLHttpRequest();
ajax.onreadystatechange = () => {
if (ajax.readyState !== 4) { return; }
// Create a fake DOM using the returned HTML
const domParser = new DOMParser();
const fakeDOM = domParser.parseFromString(ajax.responseText, 'text/html');
// Update dynamic sections
updateSection('player-info', fakeDOM);
updateSection('section-filler', fakeDOM);
updateSection('section-terran', fakeDOM);
updateSection('section-zerg', fakeDOM);
updateSection('section-protoss', fakeDOM);
updateSection('section-nova', fakeDOM);
updateSection('section-kerrigan', fakeDOM);
updateSection('section-keys', fakeDOM);
updateSection('section-locations', fakeDOM);
};
ajax.open('GET', url);
ajax.send();
updater = setTimeout(updateTracker, getSleepTimeSeconds() * 1000);
// 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;
}
};
window.updater = setTimeout(updateTracker, getSleepTimeSeconds() * 1000);
ajax.open('GET', url);
ajax.send();
}, 15000)
// Collapsible advancement sections
const categories = document.getElementsByClassName("location-category");
for (let category of categories) {
let hide_id = category.id.split('_')[0];
if (hide_id === 'Total') {
continue;
}
category.addEventListener('click', function() {
// Toggle the advancement list
document.getElementById(hide_id).classList.toggle("hide");
// Change text of the header
const tab_header = document.getElementById(hide_id+'_header').children[0];
const orig_text = tab_header.innerHTML;
let new_text;
if (orig_text.includes("▼")) {
new_text = orig_text.replace("▼", "▲");
}
else {
new_text = orig_text.replace("▲", "▼");
}
tab_header.innerHTML = new_text;
});
}
});

View File

@@ -0,0 +1,47 @@
const adjustFooterHeight = () => {
// If there is no footer on this page, do nothing
const footer = document.getElementById('island-footer');
if (!footer) { return; }
// If the body is taller than the window, also do nothing
if (document.body.offsetHeight > window.innerHeight) {
footer.style.marginTop = '0';
return;
}
// Add a margin-top to the footer to position it at the bottom of the screen
const sibling = footer.previousElementSibling;
const margin = (window.innerHeight - sibling.offsetTop - sibling.offsetHeight - footer.offsetHeight);
if (margin < 1) {
footer.style.marginTop = '0';
return;
}
footer.style.marginTop = `${margin}px`;
};
const adjustHeaderWidth = () => {
// If there is no header, do nothing
const header = document.getElementById('base-header');
if (!header) { return; }
const tempDiv = document.createElement('div');
tempDiv.style.width = '100px';
tempDiv.style.height = '100px';
tempDiv.style.overflow = 'scroll';
tempDiv.style.position = 'absolute';
tempDiv.style.top = '-500px';
document.body.appendChild(tempDiv);
const scrollbarWidth = tempDiv.offsetWidth - tempDiv.clientWidth;
document.body.removeChild(tempDiv);
const documentRoot = document.compatMode === 'BackCompat' ? document.body : document.documentElement;
const margin = (documentRoot.scrollHeight > documentRoot.clientHeight) ? 0-scrollbarWidth : 0;
document.getElementById('base-header-right').style.marginRight = `${margin}px`;
};
window.addEventListener('load', () => {
window.addEventListener('resize', adjustFooterHeight);
window.addEventListener('resize', adjustHeaderWidth);
adjustFooterHeight();
adjustHeaderWidth();
});

View File

@@ -0,0 +1,58 @@
window.addEventListener('load', () => {
const tutorialWrapper = document.getElementById('tutorial-wrapper');
new Promise((resolve, reject) => {
const ajax = new XMLHttpRequest();
ajax.onreadystatechange = () => {
if (ajax.readyState !== 4) { return; }
if (ajax.status === 404) {
reject("Sorry, the tutorial is not available in that language yet.");
return;
}
if (ajax.status !== 200) {
reject("Something went wrong while loading the tutorial.");
return;
}
resolve(ajax.responseText);
};
ajax.open('GET', `${window.location.origin}/static/generated/docs/` +
`${tutorialWrapper.getAttribute('data-game')}/${tutorialWrapper.getAttribute('data-file')}_` +
`${tutorialWrapper.getAttribute('data-lang')}.md`, true);
ajax.send();
}).then((results) => {
// Populate page with HTML generated from markdown
showdown.setOption('tables', true);
showdown.setOption('strikethrough', true);
showdown.setOption('literalMidWordUnderscores', true);
showdown.setOption('disableForced4SpacesIndentedSublists', true);
tutorialWrapper.innerHTML += (new showdown.Converter()).makeHtml(results);
adjustHeaderWidth();
const title = document.querySelector('h1')
if (title) {
document.title = title.textContent;
}
// Reset the id of all header divs to something nicer
for (const header of document.querySelectorAll('h1, h2, h3, h4, h5, h6')) {
const headerId = header.innerText.replace(/\s+/g, '-').toLowerCase();
header.setAttribute('id', headerId);
header.addEventListener('click', () => {
window.location.hash = `#${headerId}`;
header.scrollIntoView();
});
}
// Manually scroll the user to the appropriate header if anchor navigation is used
document.fonts.ready.finally(() => {
if (window.location.hash) {
const scrollTarget = document.getElementById(window.location.hash.substring(1));
scrollTarget?.scrollIntoView();
}
});
}).catch((error) => {
console.error(error);
tutorialWrapper.innerHTML =
`<h2>This page is out of logic!</h2>
<h3>Click <a href="${window.location.origin}/tutorial">here</a> to return to safety.</h3>`;
});
});

View File

@@ -0,0 +1,81 @@
const showError = () => {
const tutorial = document.getElementById('tutorial-landing');
document.getElementById('page-title').innerText = 'This page is out of logic!';
tutorial.removeChild(document.getElementById('loading'));
const userMessage = document.createElement('h3');
const homepageLink = document.createElement('a');
homepageLink.innerText = 'Click here';
homepageLink.setAttribute('href', '/');
userMessage.append(homepageLink);
userMessage.append(' to go back to safety!');
tutorial.append(userMessage);
};
window.addEventListener('load', () => {
const ajax = new XMLHttpRequest();
ajax.onreadystatechange = () => {
if (ajax.readyState !== 4) { return; }
const tutorialDiv = document.getElementById('tutorial-landing');
if (ajax.status !== 200) { return showError(); }
try {
const games = JSON.parse(ajax.responseText);
games.forEach((game) => {
const gameTitle = document.createElement('h2');
gameTitle.innerText = game.gameTitle;
gameTitle.id = `${encodeURIComponent(game.gameTitle)}`;
tutorialDiv.appendChild(gameTitle);
game.tutorials.forEach((tutorial) => {
const tutorialName = document.createElement('h3');
tutorialName.innerText = tutorial.name;
tutorialDiv.appendChild(tutorialName);
const tutorialDescription = document.createElement('p');
tutorialDescription.innerText = tutorial.description;
tutorialDiv.appendChild(tutorialDescription);
const intro = document.createElement('p');
intro.innerText = 'This guide is available in the following languages:';
tutorialDiv.appendChild(intro);
const fileList = document.createElement('ul');
tutorial.files.forEach((file) => {
const listItem = document.createElement('li');
const anchor = document.createElement('a');
anchor.innerText = file.language;
anchor.setAttribute('href', `${window.location.origin}/tutorial/${file.link}`);
listItem.appendChild(anchor);
listItem.append(' by ');
for (let author of file.authors) {
listItem.append(author);
if (file.authors.indexOf(author) !== (file.authors.length -1)) {
listItem.append(', ');
}
}
fileList.appendChild(listItem);
});
tutorialDiv.appendChild(fileList);
});
});
tutorialDiv.removeChild(document.getElementById('loading'));
} catch (error) {
showError();
console.error(error);
}
// Check if we are on an anchor when coming in, and scroll to it.
const hash = window.location.hash;
if (hash) {
const offset = 128; // To account for navbar banner at top of page.
window.scrollTo(0, 0);
const rect = document.getElementById(hash.slice(1)).getBoundingClientRect();
window.scrollTo(rect.left, rect.top - offset);
}
};
ajax.open('GET', `${window.location.origin}/static/generated/tutorials.json`, true);
ajax.send();
});

View File

@@ -36,13 +36,6 @@ html{
body{
margin: 0;
display: flex;
flex-direction: column;
min-height: calc(100vh - 110px);
}
main {
flex-grow: 1;
}
a{

View File

@@ -28,6 +28,7 @@
font-weight: normal;
font-family: LondrinaSolid-Regular, sans-serif;
text-transform: uppercase;
cursor: pointer; /* TODO: remove once we drop showdown.js */
width: 100%;
text-shadow: 1px 1px 4px #000000;
}
@@ -36,6 +37,7 @@
font-size: 38px;
font-weight: normal;
font-family: LondrinaSolid-Light, sans-serif;
cursor: pointer; /* TODO: remove once we drop showdown.js */
width: 100%;
margin-top: 20px;
margin-bottom: 0.5rem;
@@ -48,6 +50,7 @@
font-family: LexendDeca-Regular, sans-serif;
text-transform: none;
text-align: left;
cursor: pointer; /* TODO: remove once we drop showdown.js */
width: 100%;
margin-bottom: 0.5rem;
}
@@ -56,6 +59,7 @@
font-family: LexendDeca-Regular, sans-serif;
text-transform: none;
font-size: 24px;
cursor: pointer; /* TODO: remove once we drop showdown.js */
margin-bottom: 24px;
}
@@ -63,12 +67,14 @@
font-family: LexendDeca-Regular, sans-serif;
text-transform: none;
font-size: 22px;
cursor: pointer; /* TODO: remove once we drop showdown.js */
}
.markdown h6, .markdown details summary.h6{
font-family: LexendDeca-Regular, sans-serif;
text-transform: none;
font-size: 20px;
cursor: pointer; /* TODO: remove once we drop showdown.js */
}
.markdown h4, .markdown h5, .markdown h6{

View File

@@ -0,0 +1,102 @@
#player-tracker-wrapper{
margin: 0;
}
#inventory-table{
border-top: 2px solid #000000;
border-left: 2px solid #000000;
border-right: 2px solid #000000;
border-top-left-radius: 4px;
border-top-right-radius: 4px;
padding: 3px 3px 10px;
width: 384px;
background-color: #42b149;
}
#inventory-table td{
width: 40px;
height: 40px;
text-align: center;
vertical-align: middle;
}
#inventory-table img{
height: 100%;
max-width: 40px;
max-height: 40px;
filter: grayscale(100%) contrast(75%) brightness(30%);
}
#inventory-table img.acquired{
filter: none;
}
#inventory-table div.counted-item {
position: relative;
}
#inventory-table div.item-count {
position: absolute;
color: white;
font-family: "Minecraftia", monospace;
font-weight: bold;
bottom: 0;
right: 0;
}
#location-table{
width: 384px;
border-left: 2px solid #000000;
border-right: 2px solid #000000;
border-bottom: 2px solid #000000;
border-bottom-left-radius: 4px;
border-bottom-right-radius: 4px;
background-color: #42b149;
padding: 0 3px 3px;
font-family: "Minecraftia", monospace;
font-size: 14px;
cursor: default;
}
#location-table th{
vertical-align: middle;
text-align: left;
padding-right: 10px;
}
#location-table td{
padding-top: 2px;
padding-bottom: 2px;
line-height: 20px;
}
#location-table td.counter {
text-align: right;
font-size: 14px;
}
#location-table td.toggle-arrow {
text-align: right;
}
#location-table tr#Total-header {
font-weight: bold;
}
#location-table img{
height: 100%;
max-width: 30px;
max-height: 30px;
}
#location-table tbody.locations {
font-size: 12px;
}
#location-table td.location-name {
padding-left: 16px;
}
.hide {
display: none;
}

View File

@@ -1,276 +1,160 @@
*{
margin: 0;
font-family: "JuraBook", monospace;
}
body{
--icon-size: 36px;
--item-class-padding: 4px;
}
a{
color: #1ae;
#player-tracker-wrapper{
margin: 0;
}
/* Section colours */
#player-info{
background-color: #37a;
}
.player-tracker{
max-width: 100%;
}
.tracker-section{
background-color: grey;
}
#terran-items{
background-color: #3a7;
}
#zerg-items{
background-color: #d94;
}
#protoss-items{
background-color: #37a;
}
#nova-items{
background-color: #777;
}
#kerrigan-items{
background-color: #a37;
}
#keys{
background-color: #aa2;
#tracker-table td {
vertical-align: top;
}
/* Sections */
.section-body{
display: flex;
flex-flow: row wrap;
justify-content: flex-start;
align-items: flex-start;
padding-bottom: 3px;
}
.section-body-2{
display: flex;
flex-direction: column;
}
.tracker-section:has(input.collapse-section[type=checkbox]:checked) .section-body,
.tracker-section:has(input.collapse-section[type=checkbox]:checked) .section-body-2{
display: none;
}
.section-title{
position: relative;
border-bottom: 3px solid black;
/* Prevent text selection */
user-select: none;
-webkit-user-select: none;
-ms-user-select: none;
}
input[type="checkbox"]{
position: absolute;
cursor: pointer;
opacity: 0;
z-index: 1;
width: 100%;
height: 100%;
}
.section-title:hover h2{
text-shadow: 0 0 4px #ddd;
}
.f {
display: flex;
overflow: hidden;
.inventory-table-area{
border: 2px solid #000000;
border-radius: 4px;
padding: 3px 10px 3px 10px;
}
/* Acquire item filters */
.tracker-section img{
height: 100%;
width: var(--icon-size);
height: var(--icon-size);
background-color: black;
}
.unacquired, .lvl-0 .f{
filter: grayscale(100%) contrast(80%) brightness(42%) blur(0.5px);
}
.spacer{
width: var(--icon-size);
height: var(--icon-size);
.inventory-table-area:has(.inventory-table-terran) {
width: 690px;
background-color: #525494;
}
/* Item groups */
.item-class{
display: flex;
flex-flow: column;
justify-content: center;
padding: var(--item-class-padding);
}
.item-class-header{
display: flex;
flex-flow: row;
}
.item-class-upgrades{
/* Note: {display: flex; flex-flow: column wrap} */
/* just breaks on Firefox (width does not scale to content) */
display: grid;
grid-template-rows: repeat(4, auto);
grid-auto-flow: column;
.inventory-table-area:has(.inventory-table-zerg) {
width: 360px;
background-color: #9d60d2;
}
/* Subsections */
.section-toc{
display: flex;
flex-direction: row;
}
.toc-box{
position: relative;
padding-left: 15px;
padding-right: 15px;
}
.toc-box:hover{
text-shadow: 0 0 7px white;
}
.ss-header{
position: relative;
text-align: center;
writing-mode: sideways-lr;
user-select: none;
padding-top: 5px;
font-size: 115%;
}
.tracker-section:has(input.ss-1-toggle:checked) .ss-1{
display: none;
}
.tracker-section:has(input.ss-2-toggle:checked) .ss-2{
display: none;
}
.tracker-section:has(input.ss-3-toggle:checked) .ss-3{
display: none;
}
.tracker-section:has(input.ss-4-toggle:checked) .ss-4{
display: none;
}
.tracker-section:has(input.ss-5-toggle:checked) .ss-5{
display: none;
}
.tracker-section:has(input.ss-6-toggle:checked) .ss-6{
display: none;
}
.tracker-section:has(input.ss-7-toggle:checked) .ss-7{
display: none;
}
.tracker-section:has(input.ss-1-toggle:hover) .ss-1{
background-color: #fff5;
box-shadow: 0 0 1px 1px white;
}
.tracker-section:has(input.ss-2-toggle:hover) .ss-2{
background-color: #fff5;
box-shadow: 0 0 1px 1px white;
}
.tracker-section:has(input.ss-3-toggle:hover) .ss-3{
background-color: #fff5;
box-shadow: 0 0 1px 1px white;
}
.tracker-section:has(input.ss-4-toggle:hover) .ss-4{
background-color: #fff5;
box-shadow: 0 0 1px 1px white;
}
.tracker-section:has(input.ss-5-toggle:hover) .ss-5{
background-color: #fff5;
box-shadow: 0 0 1px 1px white;
}
.tracker-section:has(input.ss-6-toggle:hover) .ss-6{
background-color: #fff5;
box-shadow: 0 0 1px 1px white;
}
.tracker-section:has(input.ss-7-toggle:hover) .ss-7{
background-color: #fff5;
box-shadow: 0 0 1px 1px white;
.inventory-table-area:has(.inventory-table-protoss) {
width: 400px;
background-color: #d2b260;
}
/* Progressive items */
.progressive{
max-height: var(--icon-size);
display: contents;
#tracker-table .inventory-table td{
width: 40px;
height: 40px;
text-align: center;
vertical-align: middle;
}
.lvl-0 > :nth-child(2),
.lvl-0 > :nth-child(3),
.lvl-0 > :nth-child(4),
.lvl-0 > :nth-child(5){
display: none;
}
.lvl-1 > :nth-child(2),
.lvl-1 > :nth-child(3),
.lvl-1 > :nth-child(4),
.lvl-1 > :nth-child(5){
display: none;
}
.lvl-2 > :nth-child(1),
.lvl-2 > :nth-child(3),
.lvl-2 > :nth-child(4),
.lvl-2 > :nth-child(5){
display: none;
}
.lvl-3 > :nth-child(1),
.lvl-3 > :nth-child(2),
.lvl-3 > :nth-child(4),
.lvl-3 > :nth-child(5){
display: none;
}
.lvl-4 > :nth-child(1),
.lvl-4 > :nth-child(2),
.lvl-4 > :nth-child(3),
.lvl-4 > :nth-child(5){
display: none;
}
.lvl-5 > :nth-child(1),
.lvl-5 > :nth-child(2),
.lvl-5 > :nth-child(3),
.lvl-5 > :nth-child(4){
display: none;
.inventory-table td.title{
padding-top: 10px;
height: 20px;
font-family: "JuraBook", monospace;
font-size: 16px;
font-weight: bold;
}
/* Filler item counters */
.item-counter{
display: table;
text-align: center;
padding: var(--item-class-padding);
}
.item-count{
display: table-cell;
vertical-align: middle;
padding-left: 3px;
padding-right: 15px;
.inventory-table img{
height: 100%;
max-width: 40px;
max-height: 40px;
border: 1px solid #000000;
filter: grayscale(100%) contrast(75%) brightness(20%);
background-color: black;
}
/* Hidden items */
.hidden-class:not(:has(.f:not(.unacquired))), .hidden-item{
display: none;
.inventory-table img.acquired{
filter: none;
background-color: black;
}
/* Keys */
#keys ol, #keys ul{
columns: 3;
-webkit-columns: 3;
-moz-columns: 3;
}
#keys li{
padding-right: 15pt;
.inventory-table .tint-terran img.acquired {
filter: sepia(100%) saturate(300%) brightness(130%) hue-rotate(120deg)
}
/* Locations */
#section-locations{
padding-left: 5px;
}
@media only screen and (min-width: 120ch){
#section-locations ul{
columns: 2;
-webkit-columns: 2;
-moz-columns: 2;
}
}
#locations li.checked{
list-style-type: "✔ ";
.inventory-table .tint-protoss img.acquired {
filter: sepia(100%) saturate(1000%) brightness(110%) hue-rotate(180deg)
}
/* Allowing scrolling down a little further */
.bottom-padding{
min-height: 33vh;
}
.inventory-table .tint-level-1 img.acquired {
filter: sepia(100%) saturate(1000%) brightness(110%) hue-rotate(60deg)
}
.inventory-table .tint-level-2 img.acquired {
filter: sepia(100%) saturate(1000%) brightness(110%) hue-rotate(60deg) hue-rotate(120deg)
}
.inventory-table .tint-level-3 img.acquired {
filter: sepia(100%) saturate(1000%) brightness(110%) hue-rotate(60deg) hue-rotate(240deg)
}
.inventory-table div.counted-item {
position: relative;
}
.inventory-table div.item-count {
width: 160px;
text-align: left;
color: black;
font-family: "JuraBook", monospace;
font-weight: bold;
}
#location-table{
border: 2px solid #000000;
border-radius: 4px;
background-color: #87b678;
padding: 10px 3px 3px;
font-family: "JuraBook", monospace;
font-size: 16px;
font-weight: bold;
cursor: default;
}
#location-table table{
width: 100%;
}
#location-table th{
vertical-align: middle;
text-align: left;
padding-right: 10px;
}
#location-table td{
padding-top: 2px;
padding-bottom: 2px;
line-height: 20px;
}
#location-table td.counter {
text-align: right;
font-size: 14px;
}
#location-table td.toggle-arrow {
text-align: right;
}
#location-table tr#Total-header {
font-weight: bold;
}
#location-table img{
height: 100%;
max-width: 30px;
max-height: 30px;
}
#location-table tbody.locations {
font-size: 16px;
}
#location-table td.location-name {
padding-left: 16px;
}
#location-table td:has(.location-column) {
vertical-align: top;
}
#location-table .location-column {
width: 100%;
height: 100%;
}
#location-table .location-column .spacer {
min-height: 24px;
}
.hide {
display: none;
}

File diff suppressed because it is too large Load Diff

View File

@@ -72,13 +72,3 @@ code{
padding-right: 0.25rem;
color: #000000;
}
code.grassy {
background-color: #b5e9a4;
border: 1px solid #2a6c2f;
white-space: preserve;
text-align: left;
display: block;
font-size: 14px;
line-height: 20px;
}

View File

@@ -13,7 +13,3 @@
min-height: 360px;
text-align: center;
}
h2, h4 {
color: #ffffff;
}

View File

@@ -1,3 +1,4 @@
import typing
from collections import Counter, defaultdict
from colorsys import hsv_to_rgb
from datetime import datetime, timedelta, date
@@ -17,23 +18,21 @@ from .models import Room
PLOT_WIDTH = 600
def get_db_data(known_games: set[str]) -> tuple[Counter[str], defaultdict[date, dict[str, int]]]:
games_played: defaultdict[date, dict[str, int]] = defaultdict(Counter)
total_games: Counter[str] = Counter()
def get_db_data(known_games: typing.Set[str]) -> typing.Tuple[typing.Counter[str],
typing.DefaultDict[datetime.date, typing.Dict[str, int]]]:
games_played = defaultdict(Counter)
total_games = Counter()
cutoff = date.today() - timedelta(days=30)
room: Room
for room in select(room for room in Room if room.creation_time >= cutoff):
for slot in room.seed.slots:
if slot.game in known_games:
current_game = slot.game
else:
current_game = "Other"
total_games[current_game] += 1
games_played[room.creation_time.date()][current_game] += 1
total_games[slot.game] += 1
games_played[room.creation_time.date()][slot.game] += 1
return total_games, games_played
def get_color_palette(colors_needed: int) -> list[RGB]:
def get_color_palette(colors_needed: int) -> typing.List[RGB]:
colors = []
# colors_needed +1 to prevent first and last color being too close to each other
colors_needed += 1
@@ -48,7 +47,8 @@ def get_color_palette(colors_needed: int) -> list[RGB]:
return colors
def create_game_played_figure(all_games_data: dict[date, dict[str, int]], game: str, color: RGB) -> figure:
def create_game_played_figure(all_games_data: typing.Dict[datetime.date, typing.Dict[str, int]],
game: str, color: RGB) -> figure:
occurences = []
days = [day for day, game_data in all_games_data.items() if game_data[game]]
for day in days:
@@ -84,7 +84,7 @@ def stats():
days = sorted(games_played)
color_palette = get_color_palette(len(total_games))
game_to_color: dict[str, RGB] = {game: color for game, color in zip(total_games, color_palette)}
game_to_color: typing.Dict[str, RGB] = {game: color for game, color in zip(total_games, color_palette)}
for game in sorted(total_games):
occurences = []

View File

@@ -1,6 +1,5 @@
{% extends 'pageWrapper.html' %}
{% import "macros.html" as macros %}
{% set show_footer = True %}
{% block head %}
<title>Page Not Found (404)</title>
@@ -14,4 +13,5 @@
The page you're looking for doesn&apos;t exist.<br />
<a href="/">Click here to return to safety.</a>
</div>
{% include 'islandFooter.html' %}
{% endblock %}

View File

@@ -0,0 +1,17 @@
{% extends 'pageWrapper.html' %}
{% block head %}
<title>{{ game }} Info</title>
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/markdown.css") }}" />
<script src="https://cdnjs.cloudflare.com/ajax/libs/showdown/1.9.1/showdown.min.js"
integrity="sha512-L03kznCrNOfVxOUovR6ESfCz9Gfny7gihUX/huVbQB9zjODtYpxaVtIaAkpetoiyV2eqWbvxMH9fiSv5enX7bw=="
crossorigin="anonymous"></script>
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/gameInfo.js") }}"></script>
{% endblock %}
{% block body %}
{% include 'header/'+theme+'Header.html' %}
<div id="game-info" class="markdown" data-lang="{{ lang }}" data-game="{{ game | get_file_safe_name }}">
<!-- Populated my JS / MD -->
</div>
{% endblock %}

View File

@@ -98,7 +98,7 @@
<td>
{% if hint.finding_player == player %}
<b>{{ player_names_with_alias[(team, hint.finding_player)] }}</b>
{% elif get_slot_info(hint.finding_player).type == 2 %}
{% elif get_slot_info(team, hint.finding_player).type == 2 %}
<i>{{ player_names_with_alias[(team, hint.finding_player)] }}</i>
{% else %}
<a href="{{ url_for("get_player_tracker", tracker=room.tracker, tracked_team=team, tracked_player=hint.finding_player) }}">
@@ -109,7 +109,7 @@
<td>
{% if hint.receiving_player == player %}
<b>{{ player_names_with_alias[(team, hint.receiving_player)] }}</b>
{% elif get_slot_info(hint.receiving_player).type == 2 %}
{% elif get_slot_info(team, hint.receiving_player).type == 2 %}
<i>{{ player_names_with_alias[(team, hint.receiving_player)] }}</i>
{% else %}
<a href="{{ url_for("get_player_tracker", tracker=room.tracker, tracked_team=team, tracked_player=hint.receiving_player) }}">

View File

@@ -1,5 +1,4 @@
{% extends 'pageWrapper.html' %}
{% set show_footer = True %}
{% block head %}
<title>Upload Multidata</title>
@@ -28,4 +27,6 @@
</div>
</div>
</div>
{% include 'islandFooter.html' %}
{% endblock %}

View File

@@ -58,7 +58,8 @@
Open Log File...
</a>
</div>
{% set log, log_len = get_log() -%}
{% set log = get_log() -%}
{%- set log_len = log | length - 1 if log.endswith("…") else log | length -%}
<div id="logger" style="white-space: pre">{{ log }}</div>
<script>
let url = '{{ url_for('display_log', room = room.id) }}';

View File

@@ -1,5 +1,4 @@
{% extends 'pageWrapper.html' %}
{% set show_footer = True %}
{% block head %}
<title>Archipelago</title>
@@ -58,4 +57,5 @@
</div>
</div>
</div>
{% include 'islandFooter.html' %}
{% endblock %}

View File

@@ -26,18 +26,30 @@
<td>{{ patch.game }}</td>
<td>
{% if patch.data %}
{% if patch.game == "VVVVVV" and room.seed.slots|length == 1 %}
{% if patch.game == "Minecraft" %}
<a href="{{ url_for("download_slot_file", room_id=room.id, player_id=patch.player_id) }}" download>
Download APMC File...</a>
{% elif patch.game == "Factorio" %}
<a href="{{ url_for("download_slot_file", room_id=room.id, player_id=patch.player_id) }}" download>
Download Factorio Mod...</a>
{% elif patch.game == "Kingdom Hearts 2" %}
<a href="{{ url_for("download_slot_file", room_id=room.id, player_id=patch.player_id) }}" download>
Download Kingdom Hearts 2 Mod...</a>
{% elif patch.game == "Ocarina of Time" %}
<a href="{{ url_for("download_slot_file", room_id=room.id, player_id=patch.player_id) }}" download>
Download APZ5 File...</a>
{% elif patch.game == "VVVVVV" and room.seed.slots|length == 1 %}
<a href="{{ url_for("download_slot_file", room_id=room.id, player_id=patch.player_id) }}" download>
Download APV6 File...</a>
{% elif patch.game == "Super Mario 64" and room.seed.slots|length == 1 %}
<a href="{{ url_for("download_slot_file", room_id=room.id, player_id=patch.player_id) }}" download>
Download APSM64EX File...</a>
{% elif patch.game == "Factorio" %}
<a href="{{ url_for("download_slot_file", room_id=room.id, player_id=patch.player_id) }}" download>
Download Factorio Mod...</a>
{% elif patch.game | is_applayercontainer(patch.data, patch.player_id) %}
{% elif patch.game | supports_apdeltapatch %}
<a href="{{ url_for("download_patch", patch_id=patch.id, room_id=room.id) }}" download>
Download Patch File...</a>
{% elif patch.game == "Final Fantasy Mystic Quest" %}
<a href="{{ url_for("download_slot_file", room_id=room.id, player_id=patch.player_id) }}" download>
Download APMQ File...</a>
{% else %}
No file to download for this game.
{% endif %}

View File

@@ -1,8 +1,7 @@
{% extends 'pageWrapper.html' %}
{% block head %}
{% set theme_name = theme|default("grass", true) %}
{% include "header/"+theme_name+"Header.html" %}
{% include 'header/grassHeader.html' %}
<title>{{ title }}</title>
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/markdown.css") }}" />
{% endblock %}

View File

@@ -45,15 +45,15 @@
{%- set current_sphere = loop.index %}
{%- for player, sphere_location_ids in sphere.items() %}
{%- set checked_locations = tracker_data.get_player_checked_locations(team, player) %}
{%- set finder_game = tracker_data.get_player_game(player) %}
{%- set player_location_data = tracker_data.get_player_locations(player) %}
{%- set finder_game = tracker_data.get_player_game(team, player) %}
{%- set player_location_data = tracker_data.get_player_locations(team, player) %}
{%- for location_id in sphere_location_ids.intersection(checked_locations) %}
<tr>
{%- set item_id, receiver, item_flags = player_location_data[location_id] %}
{%- set receiver_game = tracker_data.get_player_game(receiver) %}
{%- set receiver_game = tracker_data.get_player_game(team, receiver) %}
<td>{{ current_sphere }}</td>
<td>{{ tracker_data.get_player_name(player) }}</td>
<td>{{ tracker_data.get_player_name(receiver) }}</td>
<td>{{ tracker_data.get_player_name(team, player) }}</td>
<td>{{ tracker_data.get_player_name(team, receiver) }}</td>
<td>{{ tracker_data.item_id_to_name[receiver_game][item_id] }}</td>
<td>{{ tracker_data.location_id_to_name[finder_game][location_id] }}</td>
<td>{{ finder_game }}</td>

View File

@@ -22,14 +22,14 @@
-%}
<tr>
<td>
{% if get_slot_info(hint.finding_player).type == 2 %}
{% if get_slot_info(team, hint.finding_player).type == 2 %}
<i>{{ player_names_with_alias[(team, hint.finding_player)] }}</i>
{% else %}
{{ player_names_with_alias[(team, hint.finding_player)] }}
{% endif %}
</td>
<td>
{% if get_slot_info(hint.receiving_player).type == 2 %}
{% if get_slot_info(team, hint.receiving_player).type == 2 %}
<i>{{ player_names_with_alias[(team, hint.receiving_player)] }}</i>
{% else %}
{{ player_names_with_alias[(team, hint.receiving_player)] }}

View File

@@ -5,29 +5,26 @@
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/tooltip.css") }}" />
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/cookieNotice.css") }}" />
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/globalStyles.css") }}" />
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/styleController.js") }}"></script>
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/cookieNotice.js") }}"></script>
{% block head %}
<title>Archipelago</title>
{% endblock %}
</head>
<body>
<main>
{% with messages = get_flashed_messages() %}
{% if messages %}
<div>
{% for message in messages | unique %}
<div class="user-message">{{ message }}</div>
{% endfor %}
</div>
{% endif %}
{% endwith %}
{% block body %}
{% endblock %}
</main>
{% if show_footer %}
{% include "islandFooter.html" %}
{% with messages = get_flashed_messages() %}
{% if messages %}
<div>
{% for message in messages | unique %}
<div class="user-message">{{ message }}</div>
{% endfor %}
</div>
{% endif %}
{% endwith %}
{% block body %}
{% endblock %}
</body>
</html>

View File

@@ -111,19 +111,10 @@
</div>
{% endmacro %}
{% macro OptionCounter(option_name, option) %}
{% set relevant_keys = option.valid_keys %}
{% if not relevant_keys %}
{% if option.verify_item_name %}
{% set relevant_keys = world.item_names %}
{% elif option.verify_location_name %}
{% set relevant_keys = world.location_names %}
{% endif %}
{% endif %}
{% macro ItemDict(option_name, option) %}
{{ OptionTitle(option_name, option) }}
<div class="option-container">
{% for item_name in (relevant_keys if relevant_keys is ordered else relevant_keys|sort) %}
{% for item_name in (option.valid_keys|sort if (option.valid_keys|length > 0) else world.item_names|sort) %}
<div class="option-entry">
<label for="{{ option_name }}-{{ item_name }}-qty">{{ item_name }}</label>
<input type="number" id="{{ option_name }}-{{ item_name }}-qty" name="{{ option_name }}||{{ item_name }}||qty" value="{{ option.default[item_name]|default("0") }}" data-option-name="{{ option_name }}" data-item-name="{{ item_name }}" />
@@ -134,7 +125,6 @@
{% macro OptionList(option_name, option) %}
{{ OptionTitle(option_name, option) }}
<input type="hidden" id="{{ option_name }}-{{ key }}-hidden" name="{{ option_name }}" value="_ensure-empty-list"/>
<div class="option-container">
{% for key in (option.valid_keys if option.valid_keys is ordered else option.valid_keys|sort) %}
<div class="option-entry">
@@ -147,7 +137,6 @@
{% macro LocationSet(option_name, option) %}
{{ OptionTitle(option_name, option) }}
<input type="hidden" id="{{ option_name }}-{{ key }}-hidden" name="{{ option_name }}" value="_ensure-empty-list"/>
<div class="option-container">
{% for group_name in world.location_name_groups.keys()|sort %}
{% if group_name != "Everywhere" %}
@@ -171,7 +160,6 @@
{% macro ItemSet(option_name, option) %}
{{ OptionTitle(option_name, option) }}
<input type="hidden" id="{{ option_name }}-{{ key }}-hidden" name="{{ option_name }}" value="_ensure-empty-list"/>
<div class="option-container">
{% for group_name in world.item_name_groups.keys()|sort %}
{% if group_name != "Everything" %}
@@ -195,7 +183,6 @@
{% macro OptionSet(option_name, option) %}
{{ OptionTitle(option_name, option) }}
<input type="hidden" id="{{ option_name }}-{{ key }}-hidden" name="{{ option_name }}" value="_ensure-empty-list"/>
<div class="option-container">
{% for key in (option.valid_keys if option.valid_keys is ordered else option.valid_keys|sort) %}
<div class="option-entry">
@@ -226,7 +213,7 @@
{% endmacro %}
{% macro RandomizeButton(option_name, option) %}
<div class="randomize-button" data-tooltip="Pick a random value for this option.">
<div class="randomize-button" data-tooltip="Toggle randomization for this option!">
<label for="random-{{ option_name }}">
<input type="checkbox" id="random-{{ option_name }}" name="random-{{ option_name }}" class="randomize-checkbox" data-option-name="{{ option_name }}" {{ "checked" if option.default == "random" }} />
🎲

View File

@@ -93,10 +93,8 @@
{% elif issubclass(option, Options.FreeText) %}
{{ inputs.FreeText(option_name, option) }}
{% elif issubclass(option, Options.OptionCounter) and (
option.valid_keys or option.verify_item_name or option.verify_location_name
) %}
{{ inputs.OptionCounter(option_name, option) }}
{% elif issubclass(option, Options.ItemDict) and option.verify_item_name %}
{{ inputs.ItemDict(option_name, option) }}
{% elif issubclass(option, Options.OptionList) and option.valid_keys %}
{{ inputs.OptionList(option_name, option) }}
@@ -135,10 +133,8 @@
{% elif issubclass(option, Options.FreeText) %}
{{ inputs.FreeText(option_name, option) }}
{% elif issubclass(option, Options.OptionCounter) and (
option.valid_keys or option.verify_item_name or option.verify_location_name
) %}
{{ inputs.OptionCounter(option_name, option) }}
{% elif issubclass(option, Options.ItemDict) and option.verify_item_name %}
{{ inputs.ItemDict(option_name, option) }}
{% elif issubclass(option, Options.OptionList) and option.valid_keys %}
{{ inputs.OptionList(option_name, option) }}

View File

@@ -1,23 +1,19 @@
{% extends 'pageWrapper.html' %}
{% import "macros.html" as macros %}
{% set show_footer = True %}
{% block head %}
<title>Generation failed, please retry.</title>
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename='styles/waitSeed.css') }}"/>
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/waitSeed.css") }}"/>
{% endblock %}
{% block body %}
{% include 'header/oceanIslandHeader.html' %}
<div id="wait-seed-wrapper" class="grass-island">
<div id="wait-seed">
<h1>Generation Failed</h1>
<h2>Please try again!</h2>
<p>{{ seed_error }}</p>
<h4>More details:</h4>
<p>
<code class="grassy">{{ details }}</code>
</p>
<h1>Generation failed</h1>
<h2>please retry</h2>
{{ seed_error }}
</div>
</div>
{% include 'islandFooter.html' %}
{% endblock %}

View File

@@ -11,32 +11,32 @@
<h1>Site Map</h1>
<h2>Base Pages</h2>
<ul>
<li><a href="{{ url_for('discord') }}">Discord Link</a></li>
<li><a href="{{ url_for('faq', lang='en') }}">F.A.Q. Page</a></li>
<li><a href="{{ url_for('favicon') }}">Favicon</a></li>
<li><a href="{{ url_for('generate') }}">Generate Game Page</a></li>
<li><a href="{{ url_for('landing') }}">Homepage</a></li>
<li><a href="{{ url_for('uploads') }}">Host Game Page</a></li>
<li><a href="{{ url_for('get_datapackage') }}">Raw Data Package</a></li>
<li><a href="{{ url_for('check') }}">Settings Validator</a></li>
<li><a href="{{ url_for('get_sitemap') }}">Site Map</a></li>
<li><a href="{{ url_for('start_playing') }}">Start Playing</a></li>
<li><a href="{{ url_for('games') }}">Supported Games Page</a></li>
<li><a href="{{ url_for('tutorial_landing') }}">Tutorials Page</a></li>
<li><a href="{{ url_for('user_content') }}">User Content</a></li>
<li><a href="{{ url_for('stats') }}">Game Statistics</a></li>
<li><a href="{{ url_for('glossary', lang='en') }}">Glossary</a></li>
<li><a href="{{ url_for('show_session') }}">Session / Login</a></li>
<li><a href="/discord">Discord Link</a></li>
<li><a href="/faq/en">F.A.Q. Page</a></li>
<li><a href="/favicon.ico">Favicon</a></li>
<li><a href="/generate">Generate Game Page</a></li>
<li><a href="/">Homepage</a></li>
<li><a href="/uploads">Host Game Page</a></li>
<li><a href="/datapackage">Raw Data Package</a></li>
<li><a href="{{ url_for('check')}}">Settings Validator</a></li>
<li><a href="/sitemap">Site Map</a></li>
<li><a href="/start-playing">Start Playing</a></li>
<li><a href="/games">Supported Games Page</a></li>
<li><a href="/tutorial">Tutorials Page</a></li>
<li><a href="/user-content">User Content</a></li>
<li><a href="{{url_for('stats')}}">Game Statistics</a></li>
<li><a href="/glossary/en">Glossary</a></li>
<li><a href="{{url_for("show_session")}}">Session / Login</a></li>
</ul>
<h2>Tutorials</h2>
<ul>
<li><a href="{{ url_for('tutorial', game='Archipelago', file='setup_en') }}">Multiworld Setup Tutorial</a></li>
<li><a href="{{ url_for('tutorial', game='Archipelago', file='mac_en') }}">Setup Guide for Mac</a></li>
<li><a href="{{ url_for('tutorial', game='Archipelago', file='commands_en') }}">Server and Client Commands</a></li>
<li><a href="{{ url_for('tutorial', game='Archipelago', file='advanced_settings_en') }}">Advanced YAML Guide</a></li>
<li><a href="{{ url_for('tutorial', game='Archipelago', file='triggers_en') }}">Triggers Guide</a></li>
<li><a href="{{ url_for('tutorial', game='Archipelago', file='plando_en') }}">Plando Guide</a></li>
<li><a href="/tutorial/Archipelago/setup/en">Multiworld Setup Tutorial</a></li>
<li><a href="/tutorial/Archipelago/mac/en">Setup Guide for Mac</a></li>
<li><a href="/tutorial/Archipelago/commands/en">Server and Client Commands</a></li>
<li><a href="/tutorial/Archipelago/advanced_settings/en">Advanced YAML Guide</a></li>
<li><a href="/tutorial/Archipelago/triggers/en">Triggers Guide</a></li>
<li><a href="/tutorial/Archipelago/plando/en">Plando Guide</a></li>
</ul>
<h2>Game Info Pages</h2>

View File

@@ -1,5 +1,4 @@
{% extends 'pageWrapper.html' %}
{% set show_footer = True %}
{% block head %}
<title>Start Playing</title>
@@ -27,4 +26,6 @@
</p>
</div>
</div>
{% include 'islandFooter.html' %}
{% endblock %}

View File

@@ -31,9 +31,6 @@
{% include 'header/oceanHeader.html' %}
<div id="games" class="markdown">
<h1>Currently Supported Games</h1>
<p>Below are the games that are currently included with the Archipelago software. To play a game that is not on
this page, please refer to the <a href="/tutorial/Archipelago/setup/en#playing-with-custom-worlds">playing with
custom worlds</a> section of the setup guide.</p>
<div class="js-only">
<label for="game-search">Search for your game below!</label><br />
<div class="page-controls">

View File

@@ -0,0 +1,84 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>{{ player_name }}&apos;s Tracker</title>
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename='styles/minecraftTracker.css') }}"/>
<script type="application/ecmascript" src="{{ url_for('static', filename='assets/minecraftTracker.js') }}"></script>
<link rel="stylesheet" media="screen" href="https://fontlibrary.org//face/minecraftia" type="text/css"/>
</head>
<body>
{# TODO: Replace this with a proper wrapper for each tracker when developing TrackerAPI. #}
<div style="margin-bottom: 0.5rem">
<a href="{{ url_for("get_generic_game_tracker", tracker=room.tracker, tracked_team=team, tracked_player=player) }}">Switch To Generic Tracker</a>
</div>
<div id="player-tracker-wrapper" data-tracker="{{ room.tracker|suuid }}">
<table id="inventory-table">
<tr>
<td><img src="{{ tools_url }}" class="{{ 'acquired' }}" title="Progressive Tools" /></td>
<td><img src="{{ weapons_url }}" class="{{ 'acquired' }}" title="Progressive Weapons" /></td>
<td><img src="{{ armor_url }}" class="{{ 'acquired' }}" title="Progressive Armor" /></td>
<td><img src="{{ resource_crafting_url }}" class="{{ 'acquired' if 'Progressive Resource Crafting' in acquired_items }}"
title="Progressive Resource Crafting" /></td>
<td><img src="{{ icons['Brewing Stand'] }}" class="{{ 'acquired' if 'Brewing' in acquired_items }}" title="Brewing" /></td>
<td>
<div class="counted-item">
<img src="{{ icons['Ender Pearl'] }}" class="{{ 'acquired' if '3 Ender Pearls' in acquired_items }}" title="Ender Pearls" />
<div class="item-count">{{ pearls_count }}</div>
</div>
</td>
</tr>
<tr>
<td><img src="{{ icons['Bucket'] }}" class="{{ 'acquired' if 'Bucket' in acquired_items }}" title="Bucket" /></td>
<td><img src="{{ icons['Bow'] }}" class="{{ 'acquired' if 'Archery' in acquired_items }}" title="Archery" /></td>
<td><img src="{{ icons['Shield'] }}" class="{{ 'acquired' if 'Shield' in acquired_items }}" title="Shield" /></td>
<td><img src="{{ icons['Red Bed'] }}" class="{{ 'acquired' if 'Bed' in acquired_items }}" title="Bed" /></td>
<td><img src="{{ icons['Water Bottle'] }}" class="{{ 'acquired' if 'Bottles' in acquired_items }}" title="Bottles" /></td>
<td>
<div class="counted-item">
<img src="{{ icons['Netherite Scrap'] }}" class="{{ 'acquired' if '8 Netherite Scrap' in acquired_items }}" title="Netherite Scrap" />
<div class="item-count">{{ scrap_count }}</div>
</div>
</td>
</tr>
<tr>
<td><img src="{{ icons['Flint and Steel'] }}" class="{{ 'acquired' if 'Flint and Steel' in acquired_items }}" title="Flint and Steel" /></td>
<td><img src="{{ icons['Enchanting Table'] }}" class="{{ 'acquired' if 'Enchanting' in acquired_items }}" title="Enchanting" /></td>
<td><img src="{{ icons['Fishing Rod'] }}" class="{{ 'acquired' if 'Fishing Rod' in acquired_items }}" title="Fishing Rod" /></td>
<td><img src="{{ icons['Campfire'] }}" class="{{ 'acquired' if 'Campfire' in acquired_items }}" title="Campfire" /></td>
<td><img src="{{ icons['Spyglass'] }}" class="{{ 'acquired' if 'Spyglass' in acquired_items }}" title="Spyglass" /></td>
<td>
<div class="counted-item">
<img src="{{ icons['Dragon Egg Shard'] }}" class="{{ 'acquired' if 'Dragon Egg Shard' in acquired_items }}" title="Dragon Egg Shard" />
<div class="item-count">{{ shard_count }}</div>
</div>
</td>
</tr>
<tr>
<td><img src="{{ icons['Lead'] }}" class="{{ 'acquired' if 'Lead' in acquired_items }}" title="Lead" /></td>
<td><img src="{{ icons['Saddle'] }}" class="{{ 'acquired' if 'Saddle' in acquired_items }}" title="Saddle" /></td>
<td><img src="{{ icons['Channeling Book'] }}" class="{{ 'acquired' if 'Channeling Book' in acquired_items }}" title="Channeling Book" /></td>
<td><img src="{{ icons['Silk Touch Book'] }}" class="{{ 'acquired' if 'Silk Touch Book' in acquired_items }}" title="Silk Touch Book" /></td>
<td><img src="{{ icons['Piercing IV Book'] }}" class="{{ 'acquired' if 'Piercing IV Book' in acquired_items }}" title="Piercing IV Book" /></td>
</tr>
</table>
<table id="location-table">
{% for area in checks_done %}
<tr class="location-category" id="{{area}}-header">
<td>{{ area }} {{'▼' if area != 'Total'}}</td>
<td class="counter">{{ checks_done[area] }} / {{ checks_in_area[area] }}</td>
</tr>
<tbody class="locations hide" id="{{area}}">
{% for location in location_info[area] %}
<tr>
<td class="location-name">{{ location }}</td>
<td class="counter">{{ '✔' if location_info[area][location] else '' }}</td>
</tr>
{% endfor %}
</tbody>
{% endfor %}
</table>
</div>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,17 @@
{% extends 'pageWrapper.html' %}
{% block head %}
{% include 'header/'+theme+'Header.html' %}
<title>Archipelago</title>
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/markdown.css") }}" />
<script src="https://cdnjs.cloudflare.com/ajax/libs/showdown/1.9.1/showdown.min.js"
integrity="sha512-L03kznCrNOfVxOUovR6ESfCz9Gfny7gihUX/huVbQB9zjODtYpxaVtIaAkpetoiyV2eqWbvxMH9fiSv5enX7bw=="
crossorigin="anonymous"></script>
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/tutorial.js") }}"></script>
{% endblock %}
{% block body %}
<div id="tutorial-wrapper" class="markdown" data-game="{{ game | get_file_safe_name }}" data-file="{{ file | get_file_safe_name }}" data-lang="{{ lang }}">
<!-- Content generated by JavaScript -->
</div>
{% endblock %}

View File

@@ -3,32 +3,14 @@
{% block head %}
{% include 'header/grassHeader.html' %}
<title>Archipelago Guides</title>
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/markdown.css") }}"/>
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/tutorialLanding.css") }}"/>
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/markdown.css") }}" />
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/tutorialLanding.css") }}" />
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/tutorialLanding.js") }}"></script>
{% endblock %}
{% block body %}
<div id="tutorial-landing" class="markdown">
<h1>Archipelago Guides</h1>
{% for world_name, world_type in worlds.items() %}
<h2 id="{{ world_type.game | urlencode }}">{{ world_type.game }}</h2>
{% for tutorial_name, tutorial_data in tutorials[world_name].items() %}
<h3>{{ tutorial_name }}</h3>
<p>{{ tutorial_data.description }}</p>
<p>This guide is available in the following languages:</p>
<ul>
{% for file_name, file_data in tutorial_data.files.items() %}
<li>
<a href="{{ url_for("tutorial", game=world_name, file=file_name) }}">{{ file_data.language }}</a>
by
{% for author in file_data.authors %}
{{ author }}
{% if not loop.last %}, {% endif %}
{% endfor %}
</li>
{% endfor %}
</ul>
{% endfor %}
{% endfor %}
<div id="tutorial-landing" class="markdown" data-game="{{ game }}" data-file="{{ file }}" data-lang="{{ lang }}">
<h1 id="page-title">Archipelago Guides</h1>
<p id="loading">Loading...</p>
</div>
{% endblock %}
{% endblock %}

View File

@@ -29,8 +29,7 @@
<div id="user-content-wrapper" class="markdown">
<div id="user-content" class="grass-island">
<h1>User Content</h1>
Below is a list of all the content you have generated on this site. Rooms and seeds are listed separately.<br/>
Sessions can be saved or synced across devices using the <a href="{{url_for('show_session')}}">Sessions Page.</a>
Below is a list of all the content you have generated on this site. Rooms and seeds are listed separately.
<h2>Your Rooms</h2>
{% if rooms %}

View File

@@ -1,6 +1,5 @@
{% extends 'pageWrapper.html' %}
{% import "macros.html" as macros %}
{% set show_footer = True %}
{% block head %}
<title>View Seed {{ seed.id|suuid }}</title>
@@ -51,4 +50,5 @@
</table>
</div>
</div>
{% include 'islandFooter.html' %}
{% endblock %}

View File

@@ -1,12 +1,9 @@
{% extends 'pageWrapper.html' %}
{% import "macros.html" as macros %}
{% set show_footer = True %}
{% block head %}
<title>Generation in Progress</title>
<noscript>
<meta http-equiv="refresh" content="1">
</noscript>
<meta http-equiv="refresh" content="1">
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/waitSeed.css") }}"/>
{% endblock %}
@@ -18,34 +15,5 @@
Waiting for game to generate, this page auto-refreshes to check.
</div>
</div>
<script>
const waitSeedDiv = document.getElementById("wait-seed");
async function checkStatus() {
try {
const response = await fetch("{{ url_for('api.wait_seed_api', seed=seed_id) }}");
if (response.status !== 202) {
// Seed is ready; reload page to load seed page.
location.reload();
return;
}
const data = await response.json();
waitSeedDiv.innerHTML = `
<h1>Generation in Progress</h1>
<p>${data.text}</p>
`;
setTimeout(checkStatus, 1000); // Continue polling.
} catch (error) {
waitSeedDiv.innerHTML = `
<h1>Progress Unknown</h1>
<p>${error.message}<br />(Last checked: ${new Date().toLocaleTimeString()})</p>
`;
setTimeout(checkStatus, 1000);
}
}
setTimeout(checkStatus, 1000);
</script>
{% include 'islandFooter.html' %}
{% endblock %}

View File

@@ -113,18 +113,9 @@
{{ TextChoice(option_name, option) }}
{% endmacro %}
{% macro OptionCounter(option_name, option, world) %}
{% set relevant_keys = option.valid_keys %}
{% if not relevant_keys %}
{% if option.verify_item_name %}
{% set relevant_keys = world.item_names %}
{% elif option.verify_location_name %}
{% set relevant_keys = world.location_names %}
{% endif %}
{% endif %}
{% macro ItemDict(option_name, option, world) %}
<div class="dict-container">
{% for item_name in (relevant_keys if relevant_keys is ordered else relevant_keys|sort) %}
{% for item_name in (option.valid_keys|sort if (option.valid_keys|length > 0) else world.item_names|sort) %}
<div class="dict-entry">
<label for="{{ option_name }}-{{ item_name }}-qty">{{ item_name }}</label>
<input
@@ -139,7 +130,6 @@
{% endmacro %}
{% macro OptionList(option_name, option) %}
<input type="hidden" id="{{ option_name }}-{{ key }}-hidden" name="{{ option_name }}" value="_ensure-empty-list"/>
<div class="list-container">
{% for key in (option.valid_keys if option.valid_keys is ordered else option.valid_keys|sort) %}
<div class="list-entry">
@@ -159,7 +149,6 @@
{% endmacro %}
{% macro LocationSet(option_name, option, world) %}
<input type="hidden" id="{{ option_name }}-{{ key }}-hidden" name="{{ option_name }}" value="_ensure-empty-list"/>
<div class="set-container">
{% for group_name in world.location_name_groups.keys()|sort %}
{% if group_name != "Everywhere" %}
@@ -182,7 +171,6 @@
{% endmacro %}
{% macro ItemSet(option_name, option, world) %}
<input type="hidden" id="{{ option_name }}-{{ key }}-hidden" name="{{ option_name }}" value="_ensure-empty-list"/>
<div class="set-container">
{% for group_name in world.item_name_groups.keys()|sort %}
{% if group_name != "Everything" %}
@@ -205,7 +193,6 @@
{% endmacro %}
{% macro OptionSet(option_name, option) %}
<input type="hidden" id="{{ option_name }}-{{ key }}-hidden" name="{{ option_name }}" value="_ensure-empty-list"/>
<div class="set-container">
{% for key in (option.valid_keys if option.valid_keys is ordered else option.valid_keys|sort) %}
<div class="set-entry">

View File

@@ -83,10 +83,8 @@
{% elif issubclass(option, Options.FreeText) %}
{{ inputs.FreeText(option_name, option) }}
{% elif issubclass(option, Options.OptionCounter) and (
option.valid_keys or option.verify_item_name or option.verify_location_name
) %}
{{ inputs.OptionCounter(option_name, option, world) }}
{% elif issubclass(option, Options.ItemDict) and option.verify_item_name %}
{{ inputs.ItemDict(option_name, option, world) }}
{% elif issubclass(option, Options.OptionList) and option.valid_keys %}
{{ inputs.OptionList(option_name, option) }}
@@ -102,7 +100,7 @@
{% else %}
<div class="unsupported-option">
This option cannot be modified here. Please edit your .yaml file manually.
This option is not supported. Please edit your .yaml file manually.
</div>
{% endif %}

Some files were not shown because too many files have changed in this diff Show More