Compare commits

..

1 Commits

Author SHA1 Message Date
Fabian Dill
aa36271e02 Core: write playthrough is disabled, rather than an empty section 2023-10-03 22:38:46 +02:00
2146 changed files with 64597 additions and 323185 deletions

View File

@@ -1,5 +0,0 @@
[report]
exclude_lines =
pragma: no cover
if TYPE_CHECKING:
if typing.TYPE_CHECKING:

1
.gitattributes vendored
View File

@@ -1 +0,0 @@
worlds/blasphemous/region_data.py linguist-generated=true

31
.github/labeler.yml vendored
View File

@@ -1,31 +0,0 @@
'is: documentation':
- changed-files:
- all-globs-to-all-files: '{**/docs/**,**/README.md}'
'affects: webhost':
- changed-files:
- all-globs-to-any-file: 'WebHost.py'
- all-globs-to-any-file: 'WebHostLib/**/*'
'affects: core':
- changed-files:
- all-globs-to-any-file:
- '!*Client.py'
- '!README.md'
- '!LICENSE'
- '!*.yml'
- '!.gitignore'
- '!**/docs/**'
- '!typings/kivy/**'
- '!test/**'
- '!data/**'
- '!.run/**'
- '!.github/**'
- '!worlds_disabled/**'
- '!worlds/**'
- '!WebHost.py'
- '!WebHostLib/**'
- any-glob-to-any-file: # exceptions to the above rules of "stuff that isn't core"
- 'worlds/generic/**/*.py'
- 'worlds/*.py'
- 'CommonClient.py'

View File

@@ -1,27 +0,0 @@
{
"include": [
"type_check.py",
"../worlds/AutoSNIClient.py",
"../Patch.py"
],
"exclude": [
"**/__pycache__"
],
"stubPath": "../typings",
"typeCheckingMode": "strict",
"reportImplicitOverride": "error",
"reportMissingImports": true,
"reportMissingTypeStubs": true,
"pythonVersion": "3.8",
"pythonPlatform": "Windows",
"executionEnvironments": [
{
"root": ".."
}
]
}

15
.github/type_check.py vendored
View File

@@ -1,15 +0,0 @@
from pathlib import Path
import subprocess
config = Path(__file__).parent / "pyright-config.json"
command = ("pyright", "-p", str(config))
print(" ".join(command))
try:
result = subprocess.run(command)
except FileNotFoundError as e:
print(f"{e} - Is pyright installed?")
exit(1)
exit(result.returncode)

View File

@@ -25,7 +25,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v3
- name: "Determine modified files (pull_request)"
if: github.event_name == 'pull_request'
@@ -50,7 +50,7 @@ jobs:
run: |
echo "diff=." >> $GITHUB_ENV
- uses: actions/setup-python@v5
- uses: actions/setup-python@v4
if: env.diff != ''
with:
python-version: 3.8
@@ -71,7 +71,7 @@ jobs:
continue-on-error: true
if: env.diff != '' && matrix.task == 'flake8'
run: |
flake8 --count --max-complexity=14 --max-doc-length=120 --max-line-length=120 --statistics ${{ env.diff }}
flake8 --count --max-complexity=10 --max-doc-length=120 --max-line-length=120 --statistics ${{ env.diff }}
- name: "mypy: Type check modified files"
continue-on-error: true

View File

@@ -8,13 +8,11 @@ on:
- '.github/workflows/build.yml'
- 'setup.py'
- 'requirements.txt'
- '*.iss'
pull_request:
paths:
- '.github/workflows/build.yml'
- 'setup.py'
- 'requirements.txt'
- '*.iss'
workflow_dispatch:
env:
@@ -27,24 +25,19 @@ jobs:
build-win-py38: # RCs will still be built and signed by hand
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v3
- name: Install python
uses: actions/setup-python@v5
uses: actions/setup-python@v4
with:
python-version: '3.8'
- name: Download run-time dependencies
run: |
Invoke-WebRequest -Uri https://github.com/Ijwu/Enemizer/releases/download/${Env:ENEMIZER_VERSION}/win-x64.zip -OutFile enemizer.zip
Expand-Archive -Path enemizer.zip -DestinationPath EnemizerCLI -Force
choco install innosetup --version=6.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"
@@ -53,63 +46,25 @@ jobs:
cd build
Rename-Item "exe.$NAME" Archipelago
7z a -mx=9 -mhe=on -ms "../dist/$ZIP_NAME" Archipelago
Rename-Item Archipelago "exe.$NAME" # inno_setup.iss expects the original name
- name: Build Setup
run: |
& "${env:ProgramFiles(x86)}\Inno Setup 6\iscc.exe" inno_setup.iss /DNO_SIGNTOOL
if ( $? -eq $false ) {
Write-Error "Building setup failed!"
exit 1
}
$contents = Get-ChildItem -Path setups/*.exe -Force -Recurse
$SETUP_NAME=$contents[0].Name
echo "SETUP_NAME=$SETUP_NAME" >> $Env:GITHUB_ENV
- name: Check build loads expected worlds
shell: bash
run: |
cd build/exe*
mv Players/Templates/meta.yaml .
ls -1 Players/Templates | sort > setup-player-templates.txt
rm -R Players/Templates
timeout 30 ./ArchipelagoLauncher "Generate Template Options" || true
ls -1 Players/Templates | sort > generated-player-templates.txt
cmp setup-player-templates.txt generated-player-templates.txt \
|| diff setup-player-templates.txt generated-player-templates.txt
mv meta.yaml Players/Templates/
- name: Test Generate
shell: bash
run: |
cd build/exe*
cp Players/Templates/Clique.yaml Players/
timeout 30 ./ArchipelagoGenerate
- name: Store 7z
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v3
with:
name: ${{ env.ZIP_NAME }}
path: dist/${{ env.ZIP_NAME }}
compression-level: 0 # .7z is incompressible by zip
if-no-files-found: error
retention-days: 7 # keep for 7 days, should be enough
- name: Store Setup
uses: actions/upload-artifact@v4
with:
name: ${{ env.SETUP_NAME }}
path: setups/${{ env.SETUP_NAME }}
if-no-files-found: error
retention-days: 7 # keep for 7 days, should be enough
build-ubuntu2004:
runs-on: ubuntu-20.04
steps:
# - copy code below to release.yml -
- uses: actions/checkout@v4
- uses: actions/checkout@v3
- name: Install base dependencies
run: |
sudo apt update
sudo apt -y install build-essential p7zip xz-utils wget libglib2.0-0
sudo apt -y install python3-gi libgirepository1.0-dev # should pull dependencies for gi installation below
- name: Get a recent python
uses: actions/setup-python@v5
uses: actions/setup-python@v4
with:
python-version: '3.11'
- name: Install build-time dependencies
@@ -136,7 +91,7 @@ jobs:
echo -e "setup.py dist output:\n `ls dist`"
cd dist && export APPIMAGE_NAME="`ls *.AppImage`" && cd ..
export TAR_NAME="${APPIMAGE_NAME%.AppImage}.tar.gz"
(cd build && DIR_NAME="`ls | grep exe`" && mv "$DIR_NAME" Archipelago && tar -cv Archipelago | gzip -8 > ../dist/$TAR_NAME && mv Archipelago "$DIR_NAME")
(cd build && DIR_NAME="`ls | grep exe`" && mv "$DIR_NAME" Archipelago && tar -czvf ../dist/$TAR_NAME Archipelago && mv Archipelago "$DIR_NAME")
echo "APPIMAGE_NAME=$APPIMAGE_NAME" >> $GITHUB_ENV
echo "TAR_NAME=$TAR_NAME" >> $GITHUB_ENV
# - copy code above to release.yml -
@@ -144,36 +99,15 @@ jobs:
run: |
source venv/bin/activate
python setup.py build_exe --yes
- name: Check build loads expected worlds
shell: bash
run: |
cd build/exe*
mv Players/Templates/meta.yaml .
ls -1 Players/Templates | sort > setup-player-templates.txt
rm -R Players/Templates
timeout 30 ./ArchipelagoLauncher "Generate Template Options" || true
ls -1 Players/Templates | sort > generated-player-templates.txt
cmp setup-player-templates.txt generated-player-templates.txt \
|| diff setup-player-templates.txt generated-player-templates.txt
mv meta.yaml Players/Templates/
- name: Test Generate
shell: bash
run: |
cd build/exe*
cp Players/Templates/Clique.yaml Players/
timeout 30 ./ArchipelagoGenerate
- name: Store AppImage
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v3
with:
name: ${{ env.APPIMAGE_NAME }}
path: dist/${{ env.APPIMAGE_NAME }}
if-no-files-found: error
retention-days: 7
- name: Store .tar.gz
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v3
with:
name: ${{ env.TAR_NAME }}
path: dist/${{ env.TAR_NAME }}
compression-level: 0 # .gz is incompressible by zip
if-no-files-found: error
retention-days: 7

View File

@@ -43,11 +43,11 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v3
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v3
uses: github/codeql-action/init@v2
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
@@ -58,7 +58,7 @@ jobs:
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@v3
uses: github/codeql-action/autobuild@v2
# Command-line programs to run using the OS shell.
# 📚 https://git.io/JvXDl
@@ -72,4 +72,4 @@ jobs:
# make release
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v3
uses: github/codeql-action/analyze@v2

View File

@@ -1,54 +0,0 @@
# Run CMake / CTest C++ unit tests
name: ctest
on:
push:
paths:
- '**.cc?'
- '**.cpp'
- '**.cxx'
- '**.hh?'
- '**.hpp'
- '**.hxx'
- '**.CMakeLists'
- '.github/workflows/ctest.yml'
pull_request:
paths:
- '**.cc?'
- '**.cpp'
- '**.cxx'
- '**.hh?'
- '**.hpp'
- '**.hxx'
- '**.CMakeLists'
- '.github/workflows/ctest.yml'
jobs:
ctest:
runs-on: ${{ matrix.os }}
name: Test C++ ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest]
steps:
- uses: actions/checkout@v4
- uses: ilammy/msvc-dev-cmd@v1
if: startsWith(matrix.os,'windows')
- uses: Bacondish2023/setup-googletest@v1
with:
build-type: 'Release'
- name: Build tests
run: |
cd test/cpp
mkdir build
cmake -S . -B build/ -DCMAKE_BUILD_TYPE=Release
cmake --build build/ --config Release
ls
- name: Run tests
run: |
cd test/cpp
ctest --test-dir build/ -C Release --output-on-failure

View File

@@ -1,46 +0,0 @@
name: Label Pull Request
on:
pull_request_target:
types: ['opened', 'reopened', 'synchronize', 'ready_for_review', 'converted_to_draft', 'closed']
branches: ['main']
permissions:
contents: read
pull-requests: write
jobs:
labeler:
name: 'Apply content-based labels'
if: github.event.action == 'opened' || github.event.action == 'reopened' || github.event.action == 'synchronize'
runs-on: ubuntu-latest
steps:
- uses: actions/labeler@v5
with:
sync-labels: false
peer_review:
name: 'Apply peer review label'
needs: labeler
if: >-
(github.event.action == 'opened' || github.event.action == 'reopened' ||
github.event.action == 'ready_for_review') && !github.event.pull_request.draft
runs-on: ubuntu-latest
steps:
- name: 'Add label'
run: "gh pr edit \"$PR_URL\" --add-label 'waiting-on: peer-review'"
env:
PR_URL: ${{ github.event.pull_request.html_url }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
unblock_draft_prs:
name: 'Remove waiting-on labels'
needs: labeler
if: github.event.action == 'converted_to_draft' || github.event.action == 'closed'
runs-on: ubuntu-latest
steps:
- name: 'Remove labels'
run: |-
gh pr edit "$PR_URL" --remove-label 'waiting-on: peer-review' \
--remove-label 'waiting-on: core-review' \
--remove-label 'waiting-on: world-maintainer' \
--remove-label 'waiting-on: author'
env:
PR_URL: ${{ github.event.pull_request.html_url }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -18,7 +18,7 @@ jobs:
- name: Set env
run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV # tag x.y.z will become "Archipelago x.y.z"
- name: Create Release
uses: softprops/action-gh-release@975c1b265e11dd76618af1c374e7981f9a6ff44a
uses: softprops/action-gh-release@b7e450da2a4b4cb4bfbae528f788167786cfcedf
with:
draft: true # don't publish right away, especially since windows build is added by hand
prerelease: false
@@ -35,14 +35,14 @@ jobs:
- name: Set env
run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV
# - code below copied from build.yml -
- uses: actions/checkout@v4
- uses: actions/checkout@v3
- name: Install base dependencies
run: |
sudo apt update
sudo apt -y install build-essential p7zip xz-utils wget libglib2.0-0
sudo apt -y install python3-gi libgirepository1.0-dev # should pull dependencies for gi installation below
- name: Get a recent python
uses: actions/setup-python@v5
uses: actions/setup-python@v4
with:
python-version: '3.11'
- name: Install build-time dependencies
@@ -69,12 +69,12 @@ jobs:
echo -e "setup.py dist output:\n `ls dist`"
cd dist && export APPIMAGE_NAME="`ls *.AppImage`" && cd ..
export TAR_NAME="${APPIMAGE_NAME%.AppImage}.tar.gz"
(cd build && DIR_NAME="`ls | grep exe`" && mv "$DIR_NAME" Archipelago && tar -cv Archipelago | gzip -8 > ../dist/$TAR_NAME && mv Archipelago "$DIR_NAME")
(cd build && DIR_NAME="`ls | grep exe`" && mv "$DIR_NAME" Archipelago && tar -czvf ../dist/$TAR_NAME Archipelago && mv Archipelago "$DIR_NAME")
echo "APPIMAGE_NAME=$APPIMAGE_NAME" >> $GITHUB_ENV
echo "TAR_NAME=$TAR_NAME" >> $GITHUB_ENV
# - code above copied from build.yml -
- name: Add to Release
uses: softprops/action-gh-release@975c1b265e11dd76618af1c374e7981f9a6ff44a
uses: softprops/action-gh-release@b7e450da2a4b4cb4bfbae528f788167786cfcedf
with:
draft: true # see above
prerelease: false

View File

@@ -1,65 +0,0 @@
name: Native Code Static Analysis
on:
push:
paths:
- '**.c'
- '**.cc'
- '**.cpp'
- '**.cxx'
- '**.h'
- '**.hh'
- '**.hpp'
- '**.pyx'
- 'setup.py'
- 'requirements.txt'
- '.github/workflows/scan-build.yml'
pull_request:
paths:
- '**.c'
- '**.cc'
- '**.cpp'
- '**.cxx'
- '**.h'
- '**.hh'
- '**.hpp'
- '**.pyx'
- 'setup.py'
- 'requirements.txt'
- '.github/workflows/scan-build.yml'
jobs:
scan-build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
- name: Install newer Clang
run: |
wget https://apt.llvm.org/llvm.sh
chmod +x ./llvm.sh
sudo ./llvm.sh 17
- name: Install scan-build command
run: |
sudo apt install clang-tools-17
- name: Get a recent python
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install dependencies
run: |
python -m venv venv
source venv/bin/activate
python -m pip install --upgrade pip -r requirements.txt
- name: scan-build
run: |
source venv/bin/activate
scan-build-17 --status-bugs -o scan-build-reports -disable-checker deadcode.DeadStores python setup.py build -y
- name: Store report
if: failure()
uses: actions/upload-artifact@v4
with:
name: scan-build-reports
path: scan-build-reports

View File

@@ -1,33 +0,0 @@
name: type check
on:
pull_request:
paths:
- "**.py"
- ".github/pyright-config.json"
- ".github/workflows/strict-type-check.yml"
- "**.pyi"
push:
paths:
- "**.py"
- ".github/pyright-config.json"
- ".github/workflows/strict-type-check.yml"
- "**.pyi"
jobs:
pyright:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: "Install dependencies"
run: |
python -m pip install --upgrade pip pyright==1.1.358
python ModuleUpdate.py --append "WebHostLib/requirements.txt" --force --yes
- name: "pyright: strict check on specific files"
run: python .github/type_check.py

View File

@@ -24,7 +24,7 @@ on:
- '.github/workflows/unittests.yml'
jobs:
unit:
build:
runs-on: ${{ matrix.os }}
name: Test Python ${{ matrix.python.version }} ${{ matrix.os }}
@@ -37,19 +37,18 @@ jobs:
- {version: '3.9'}
- {version: '3.10'}
- {version: '3.11'}
- {version: '3.12'}
include:
- python: {version: '3.8'} # win7 compat
os: windows-latest
- python: {version: '3.12'} # current
- python: {version: '3.11'} # current
os: windows-latest
- python: {version: '3.12'} # current
- python: {version: '3.11'} # current
os: macos-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v3
- name: Set up Python ${{ matrix.python.version }}
uses: actions/setup-python@v5
uses: actions/setup-python@v4
with:
python-version: ${{ matrix.python.version }}
- name: Install dependencies
@@ -61,32 +60,3 @@ jobs:
- name: Unittests
run: |
pytest -n auto
hosting:
runs-on: ${{ matrix.os }}
name: Test hosting with ${{ matrix.python.version }} on ${{ matrix.os }}
strategy:
matrix:
os:
- ubuntu-latest
python:
- {version: '3.12'} # current
steps:
- uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python.version }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python.version }}
- name: Install dependencies
run: |
python -m venv venv
source venv/bin/activate
python -m pip install --upgrade pip
python ModuleUpdate.py --yes --force --append "WebHostLib/requirements.txt"
- name: Test hosting
run: |
source venv/bin/activate
export PYTHONPATH=$(pwd)
timeout 600 python test/hosting/__main__.py

6
.gitignore vendored
View File

@@ -9,14 +9,12 @@
*.apmc
*.apz5
*.aptloz
*.apemerald
*.pyc
*.pyd
*.sfc
*.z64
*.n64
*.nes
*.smc
*.sms
*.gb
*.gbc
@@ -62,7 +60,6 @@ Output Logs/
/installdelete.iss
/data/user.kv
/datapackage
/custom_worlds
# Byte-compiled / optimized / DLL files
__pycache__/
@@ -150,7 +147,7 @@ venv/
ENV/
env.bak/
venv.bak/
*.code-workspace
.code-workspace
shell.nix
# Spyder project settings
@@ -178,7 +175,6 @@ dmypy.json
cython_debug/
# Cython intermediates
_speedups.c
_speedups.cpp
_speedups.html

View File

@@ -1,18 +0,0 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="Archipelago Unittests" type="tests" factoryName="Unittests">
<module name="Archipelago" />
<option name="INTERPRETER_OPTIONS" value="" />
<option name="PARENT_ENVS" value="true" />
<option name="SDK_HOME" value="" />
<option name="WORKING_DIRECTORY" value="$PROJECT_DIR$" />
<option name="IS_MODULE_SDK" value="true" />
<option name="ADD_CONTENT_ROOTS" value="true" />
<option name="ADD_SOURCE_ROOTS" value="true" />
<EXTENSION ID="PythonCoverageRunConfigurationExtension" runner="coverage.py" />
<option name="_new_pattern" value="&quot;&quot;" />
<option name="_new_additionalArguments" value="&quot;&quot;" />
<option name="_new_target" value="&quot;$PROJECT_DIR$/test&quot;" />
<option name="_new_targetType" value="&quot;PATH&quot;" />
<method v="2" />
</configuration>
</component>

View File

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

View File

@@ -80,7 +80,7 @@ class AdventureContext(CommonContext):
self.local_item_locations = {}
self.dragon_speed_info = {}
options = Utils.get_settings()
options = Utils.get_options()
self.display_msgs = options["adventure_options"]["display_msgs"]
async def server_auth(self, password_requested: bool = False):
@@ -102,7 +102,7 @@ class AdventureContext(CommonContext):
def on_package(self, cmd: str, args: dict):
if cmd == 'Connected':
self.locations_array = None
if Utils.get_settings()["adventure_options"].get("death_link", False):
if Utils.get_options()["adventure_options"].get("death_link", False):
self.set_deathlink = True
async_start(self.get_freeincarnates_used())
elif cmd == "RoomInfo":
@@ -112,15 +112,14 @@ class AdventureContext(CommonContext):
if ': !' not in msg:
self._set_message(msg, SYSTEM_MESSAGE_ID)
elif cmd == "ReceivedItems":
msg = f"Received {', '.join([self.item_names.lookup_in_game(item.item) for item in args['items']])}"
msg = f"Received {', '.join([self.item_names[item.item] for item in args['items']])}"
self._set_message(msg, SYSTEM_MESSAGE_ID)
elif cmd == "Retrieved":
if f"adventure_{self.auth}_freeincarnates_used" in args["keys"]:
self.freeincarnates_used = args["keys"][f"adventure_{self.auth}_freeincarnates_used"]
if self.freeincarnates_used is None:
self.freeincarnates_used = 0
self.freeincarnates_used += self.freeincarnate_pending
self.send_pending_freeincarnates()
self.freeincarnates_used = args["keys"][f"adventure_{self.auth}_freeincarnates_used"]
if self.freeincarnates_used is None:
self.freeincarnates_used = 0
self.freeincarnates_used += self.freeincarnate_pending
self.send_pending_freeincarnates()
elif cmd == "SetReply":
if args["key"] == f"adventure_{self.auth}_freeincarnates_used":
self.freeincarnates_used = args["value"]
@@ -415,8 +414,8 @@ async def atari_sync_task(ctx: AdventureContext):
async def run_game(romfile):
auto_start = Utils.get_settings()["adventure_options"].get("rom_start", True)
rom_args = Utils.get_settings()["adventure_options"].get("rom_args")
auto_start = Utils.get_options()["adventure_options"].get("rom_start", True)
rom_args = Utils.get_options()["adventure_options"].get("rom_args")
if auto_start is True:
import webbrowser
webbrowser.open(romfile)

File diff suppressed because it is too large Load Diff

View File

@@ -1,10 +1,9 @@
from __future__ import annotations
import sys
import ModuleUpdate
ModuleUpdate.update()
from worlds._bizhawk.context import launch
if __name__ == "__main__":
launch(*sys.argv[1:])
launch()

View File

@@ -1,7 +1,4 @@
from __future__ import annotations
import collections
import copy
import logging
import asyncio
import urllib.parse
@@ -9,7 +6,6 @@ import sys
import typing
import time
import functools
import warnings
import ModuleUpdate
ModuleUpdate.update()
@@ -22,8 +18,8 @@ if __name__ == "__main__":
Utils.init_logging("TextClient", exception_logger="Client")
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, SlotType)
from NetUtils import Endpoint, decode, NetworkItem, encode, JSONtoTextParser, \
ClientStatus, Permission, NetworkSlot, RawJSONtoTextParser
from Utils import Version, stream_input, async_start
from worlds import network_data_package, AutoWorldRegister
import os
@@ -45,21 +41,10 @@ def get_ssl_context():
class ClientCommandProcessor(CommandProcessor):
"""
The Command Processor will parse every method of the class that starts with "_cmd_" as a command to be called
when parsing user input, i.e. _cmd_exit will be called when the user sends the command "/exit".
The decorator @mark_raw can be imported from MultiServer and tells the parser to only split on the first
space after the command i.e. "/exit one two three" will be passed in as method("one two three") with mark_raw
and method("one", "two", "three") without.
In addition all docstrings for command methods will be displayed to the user on launch and when using "/help"
"""
def __init__(self, ctx: CommonContext):
self.ctx = ctx
def output(self, text: str):
"""Helper function to abstract logging to the CommonClient UI"""
logger.info(text)
def _cmd_exit(self) -> bool:
@@ -72,7 +57,6 @@ class ClientCommandProcessor(CommandProcessor):
if address:
self.ctx.server_address = None
self.ctx.username = None
self.ctx.password = None
elif not self.ctx.server_address:
self.output("Please specify an address.")
return False
@@ -86,16 +70,9 @@ class ClientCommandProcessor(CommandProcessor):
def _cmd_received(self) -> bool:
"""List all received items"""
item: NetworkItem
self.output(f'{len(self.ctx.items_received)} received items, sorted by time:')
self.output(f'{len(self.ctx.items_received)} received items:')
for index, item in enumerate(self.ctx.items_received, 1):
parts = []
add_json_item(parts, item.item, self.ctx.slot, item.flags)
add_json_text(parts, " from ")
add_json_location(parts, item.location, item.player)
add_json_text(parts, " by ")
add_json_text(parts, item.player, type=JSONTypes.player_id)
self.ctx.on_print_json({"data": parts, "cmd": "PrintJSON"})
self.output(f"{self.ctx.item_names[item.item]} from {self.ctx.player_names[item.player]}")
return True
def _cmd_missing(self, filter_text = "") -> bool:
@@ -136,15 +113,6 @@ class ClientCommandProcessor(CommandProcessor):
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)
def _cmd_locations(self):
"""List all location names for the currently running game."""
if not self.ctx.game:
@@ -154,15 +122,6 @@ class ClientCommandProcessor(CommandProcessor):
for location_name in AutoWorldRegister.world_types[self.ctx.game].location_name_to_id:
self.output(location_name)
def _cmd_location_groups(self):
"""List all location group names for the currently running game."""
if not self.ctx.game:
self.output("No game set, cannot determine existing location groups.")
return False
self.output(f"Location Group Names for {self.ctx.game}")
for group_name in AutoWorldRegister.world_types[self.ctx.game].location_name_groups:
self.output(group_name)
def _cmd_ready(self):
"""Send ready status to server."""
self.ctx.ready = not self.ctx.ready
@@ -175,96 +134,28 @@ class ClientCommandProcessor(CommandProcessor):
async_start(self.ctx.send_msgs([{"cmd": "StatusUpdate", "status": state}]), name="send StatusUpdate")
def default(self, raw: str):
"""The default message parser to be used when parsing any messages that do not match a command"""
raw = self.ctx.on_user_say(raw)
if raw:
async_start(self.ctx.send_msgs([{"cmd": "Say", "text": raw}]), name="send Say")
class CommonContext:
# The following attributes are used to Connect and should be adjusted as needed in subclasses
# Should be adjusted as needed in subclasses
tags: typing.Set[str] = {"AP"}
game: typing.Optional[str] = None
items_handling: typing.Optional[int] = None
want_slot_data: bool = True # should slot_data be retrieved via Connect
class NameLookupDict:
"""A specialized dict, with helper methods, for id -> name item/location data package lookups by game."""
def __init__(self, ctx: CommonContext, lookup_type: typing.Literal["item", "location"]):
self.ctx: CommonContext = ctx
self.lookup_type: typing.Literal["item", "location"] = lookup_type
self._unknown_item: typing.Callable[[int], str] = lambda key: f"Unknown {lookup_type} (ID: {key})"
self._archipelago_lookup: typing.Dict[int, str] = {}
self._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]:
# 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:
return len(self._game_store)
def __iter__(self) -> typing.Iterator[str]:
return iter(self._game_store)
def __repr__(self) -> str:
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
omitted.
"""
if game_name is None:
game_name = self.ctx.game
assert game_name is not None, f"Attempted to lookup {self.lookup_type} with no game name available."
return self._game_store[game_name][code]
def lookup_in_slot(self, code: int, slot: typing.Optional[int] = None) -> str:
"""Returns the name for an item/location id in the context of a specific slot or own slot if `slot` is
omitted.
Use of `lookup_in_slot` should not be used when not connected to a server. If looking in own game, set
`ctx.game` and use `lookup_in_game` method instead.
"""
if slot is None:
slot = self.ctx.slot
assert slot is not None, f"Attempted to lookup {self.lookup_type} with no slot info available."
return self.lookup_in_game(code, self.ctx.slot_info[slot].game)
def update_game(self, game: str, name_to_id_lookup_table: typing.Dict[str, int]) -> None:
"""Overrides existing lookup tables for a particular game."""
id_to_name_lookup_table = Utils.KeyedDefaultDict(self._unknown_item)
id_to_name_lookup_table.update({code: name for name, code in name_to_id_lookup_table.items()})
self._game_store[game] = collections.ChainMap(self._archipelago_lookup, id_to_name_lookup_table)
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.
self._archipelago_lookup.clear()
self._archipelago_lookup.update(id_to_name_lookup_table)
# data package
# Contents in flux until connection to server is made, to download correct data for this multiworld.
item_names: typing.Dict[int, str] = Utils.KeyedDefaultDict(lambda code: f'Unknown item (ID:{code})')
location_names: typing.Dict[int, str] = Utils.KeyedDefaultDict(lambda code: f'Unknown location (ID:{code})')
# defaults
starting_reconnect_delay: int = 5
current_reconnect_delay: int = starting_reconnect_delay
command_processor: typing.Type[CommandProcessor] = ClientCommandProcessor
ui: typing.Optional["kvui.GameManager"] = None
ui = None
ui_task: typing.Optional["asyncio.Task[None]"] = None
input_task: typing.Optional["asyncio.Task[None]"] = None
keep_alive_task: typing.Optional["asyncio.Task[None]"] = None
@@ -275,7 +166,6 @@ class CommonContext:
server_version: Version = Version(0, 0, 0)
generator_version: Version = Version(0, 0, 0)
current_energy_link_value: typing.Optional[int] = None # to display in UI, gets set by server
max_size: int = 16*1024*1024 # 16 MB of max incoming packet size
last_death_link: float = time.time() # last send/received death link on AP layer
@@ -289,8 +179,6 @@ class CommonContext:
finished_game: bool
ready: bool
team: typing.Optional[int]
slot: typing.Optional[int]
auth: typing.Optional[str]
seed_name: typing.Optional[str]
@@ -313,7 +201,7 @@ class CommonContext:
# message box reporting a loss of connection
_messagebox_connection_loss: typing.Optional["kvui.MessageBox"] = None
def __init__(self, server_address: typing.Optional[str] = None, password: typing.Optional[str] = None) -> None:
def __init__(self, server_address: typing.Optional[str], password: typing.Optional[str]) -> None:
# server state
self.server_address = server_address
self.username = None
@@ -353,13 +241,7 @@ class CommonContext:
self.exit_event = asyncio.Event()
self.watcher_event = asyncio.Event()
self.item_names = self.NameLookupDict(self, "item")
self.location_names = self.NameLookupDict(self, "location")
self.versions = {}
self.checksums = {}
self.jsontotextparser = JSONtoTextParser(self)
self.rawjsontotextparser = RawJSONtoTextParser(self)
self.update_data_package(network_data_package)
# execution
@@ -443,10 +325,7 @@ class CommonContext:
self.auth = await self.console_input()
async def send_connect(self, **kwargs: typing.Any) -> None:
"""
Send a `Connect` packet to log in to the server,
additional keyword args can override any value in the connection packet
"""
""" send `Connect` packet to log in to server """
payload = {
'cmd': 'Connect',
'password': self.password, 'name': self.auth, 'version': Utils.version_tuple,
@@ -456,7 +335,6 @@ class CommonContext:
if kwargs:
payload.update(kwargs)
await self.send_msgs([payload])
await self.send_msgs([{"cmd": "Get", "keys": ["_read_race_mode"]}])
async def console_input(self) -> str:
if self.ui:
@@ -477,7 +355,6 @@ class CommonContext:
return False
def slot_concerns_self(self, slot) -> bool:
"""Helper function to abstract player groups, should be used instead of checking slot == self.slot directly."""
if slot == self.slot:
return True
if slot in self.slot_info:
@@ -485,7 +362,6 @@ class CommonContext:
return False
def is_echoed_chat(self, print_json_packet: dict) -> bool:
"""Helper function for filtering out messages sent by self."""
return print_json_packet.get("type", "") == "Chat" \
and print_json_packet.get("team", None) == self.team \
and print_json_packet.get("slot", None) == self.slot
@@ -501,13 +377,10 @@ class CommonContext:
def on_print_json(self, args: dict):
if self.ui:
# send copy to UI
self.ui.print_json(copy.deepcopy(args["data"]))
logging.getLogger("FileLog").info(self.rawjsontotextparser(copy.deepcopy(args["data"])),
extra={"NoStream": True})
logging.getLogger("StreamLog").info(self.jsontotextparser(copy.deepcopy(args["data"])),
extra={"NoFile": True})
self.ui.print_json(args["data"])
else:
text = self.jsontotextparser(args["data"])
logger.info(text)
def on_package(self, cmd: str, args: dict):
"""For custom package handling in subclasses."""
@@ -518,13 +391,7 @@ class CommonContext:
Returned text is sent, or sending is aborted if None is returned."""
return text
def on_ui_command(self, text: str) -> None:
"""Gets called by kivy when the user executes a command starting with `/` or `!`.
The command processor is still called; this is just intended for command echoing."""
self.ui.print_json([{"text": text, "type": "color", "color": "orange"}])
def update_permissions(self, permissions: typing.Dict[str, int]):
"""Internal method to parse and save server permissions from RoomInfo"""
for permission_name, permission_flag in permissions.items():
try:
flag = Permission(permission_flag)
@@ -536,7 +403,6 @@ class CommonContext:
async def shutdown(self):
self.server_address = ""
self.username = None
self.password = None
self.cancel_autoreconnect()
if self.server and not self.server.socket.closed:
await self.server.socket.close()
@@ -573,45 +439,38 @@ class CommonContext:
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 (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 or remote_version <= local_version and remote_version != 0)
and remote_checksum == local_checksum):
self.update_game(network_data_package["games"][game], game)
local_version: int = network_data_package["games"].get(game, {}).get("version", 0)
local_checksum: typing.Optional[str] = network_data_package["games"].get(game, {}).get("checksum")
# no action required if local version is new enough
if (not remote_checksum and (remote_version > local_version or remote_version == 0)) \
or remote_checksum != local_checksum:
cached_game = Utils.load_data_package_for_checksum(game, remote_checksum)
cache_version: int = cached_game.get("version", 0)
cache_checksum: typing.Optional[str] = cached_game.get("checksum")
# download remote version if cache is not new enough
if (not remote_checksum and (remote_version > cache_version or remote_version == 0)) \
or remote_checksum != cache_checksum:
needed_updates.add(game)
else:
cached_game = Utils.load_data_package_for_checksum(game, remote_checksum)
cache_version: int = cached_game.get("version", 0)
cache_checksum: typing.Optional[str] = cached_game.get("checksum")
# download remote version if cache is not new enough
if (not remote_checksum and (remote_version > cache_version or remote_version == 0)) \
or remote_checksum != cache_checksum:
needed_updates.add(game)
else:
self.update_game(cached_game, game)
self.update_game(cached_game)
if needed_updates:
await self.send_msgs([{"cmd": "GetDataPackage", "games": [game_name]} for game_name in needed_updates])
await self.send_msgs([{"cmd": "GetDataPackage", "games": list(needed_updates)}])
def update_game(self, game_package: dict, game: str):
self.item_names.update_game(game, game_package["item_name_to_id"])
self.location_names.update_game(game, game_package["location_name_to_id"])
self.versions[game] = game_package.get("version", 0)
self.checksums[game] = game_package.get("checksum")
def update_game(self, game_package: dict):
for item_name, item_id in game_package["item_name_to_id"].items():
self.item_names[item_id] = item_name
for location_name, location_id in game_package["location_name_to_id"].items():
self.location_names[location_id] = location_name
def update_data_package(self, data_package: dict):
for game, game_data in data_package["games"].items():
self.update_game(game_data, game)
self.update_game(game_data)
def consume_network_data_package(self, data_package: dict):
self.update_data_package(data_package)
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)
@@ -642,7 +501,6 @@ class CommonContext:
logger.info(f"DeathLink: Received from {data['source']}")
async def send_death(self, death_text: str = ""):
"""Helper function to send a deathlink using death_text as the unique death cause string."""
if self.server and self.server.socket:
logger.info("DeathLink: Sending death to your friends...")
self.last_death_link = time.time()
@@ -656,7 +514,6 @@ class CommonContext:
}])
async def update_death_link(self, death_link: bool):
"""Helper function to set Death Link connection tag on/off and update the connection if already connected."""
old_tags = self.tags.copy()
if death_link:
self.tags.add("DeathLink")
@@ -666,7 +523,7 @@ class CommonContext:
await self.send_msgs([{"cmd": "ConnectUpdate", "tags": self.tags}])
def gui_error(self, title: str, text: typing.Union[Exception, str]) -> typing.Optional["kvui.MessageBox"]:
"""Displays an error messagebox in the loaded Kivy UI. Override if using a different UI framework"""
"""Displays an error messagebox"""
if not self.ui:
return None
title = title or "Error"
@@ -693,28 +550,21 @@ class CommonContext:
logger.exception(msg, exc_info=exc_info, extra={'compact_gui': True})
self._messagebox_connection_loss = self.gui_error(msg, exc_info[1])
def make_gui(self) -> typing.Type["kvui.GameManager"]:
"""To return the Kivy App class needed for run_gui so it can be overridden before being built"""
def run_gui(self):
"""Import kivy UI system and start running it as self.ui_task."""
from kvui import GameManager
class TextManager(GameManager):
logging_pairs = [
("Client", "Archipelago")
]
base_title = "Archipelago Text Client"
return TextManager
def run_gui(self):
"""Import kivy UI system from make_gui() and start running it as self.ui_task."""
ui_class = self.make_gui()
self.ui = ui_class(self)
self.ui = TextManager(self)
self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI")
def run_cli(self):
if sys.stdin:
if sys.stdin.fileno() != 0:
from multiprocessing import parent_process
if parent_process():
return # ignore MultiProcessing pipe
# steam overlay breaks when starting console_loop
if 'gameoverlayrenderer' in os.environ.get('LD_PRELOAD', ''):
logger.info("Skipping terminal input, due to conflicting Steam Overlay detected. Please use GUI only.")
@@ -761,16 +611,15 @@ async def server_loop(ctx: CommonContext, address: typing.Optional[str] = None)
ctx.username = server_url.username
if server_url.password:
ctx.password = server_url.password
port = server_url.port or 38281
def reconnect_hint() -> str:
return ", type /connect to reconnect" if ctx.server_address else ""
logger.info(f'Connecting to Archipelago server at {address}')
try:
port = server_url.port or 38281 # raises ValueError if invalid
socket = await websockets.connect(address, port=port, ping_timeout=None, ping_interval=None,
ssl=get_ssl_context() if address.startswith("wss://") else None,
max_size=ctx.max_size)
ssl=get_ssl_context() if address.startswith("wss://") else None)
if ctx.ui is not None:
ctx.ui.update_address_bar(server_url.netloc)
ctx.server = Endpoint(socket)
@@ -872,19 +721,17 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
await ctx.server_auth(args['password'])
elif cmd == 'DataPackage':
logger.info("Got new ID/Name DataPackage")
ctx.consume_network_data_package(args['data'])
elif cmd == 'ConnectionRefused':
errors = args["errors"]
if 'InvalidSlot' in errors:
ctx.disconnected_intentionally = True
ctx.event_invalid_slot()
elif 'InvalidGame' in errors:
ctx.disconnected_intentionally = True
ctx.event_invalid_game()
elif 'IncompatibleVersion' in errors:
raise Exception('Server reported your client version as incompatible. '
'This probably means you have to update.')
raise Exception('Server reported your client version as incompatible')
elif 'InvalidItemsHandling' in errors:
raise Exception('The item handling flags requested by the client are not supported')
# last to check, recoverable problem
@@ -902,11 +749,9 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
ctx.team = args["team"]
ctx.slot = args["slot"]
# int keys get lost in JSON transfer
ctx.slot_info = {0: NetworkSlot("Archipelago", "Archipelago", SlotType.player)}
ctx.slot_info.update({int(pid): data for pid, data in args["slot_info"].items()})
ctx.slot_info = {int(pid): data for pid, data in args["slot_info"].items()}
ctx.hint_points = args.get("hint_points", 0)
ctx.consume_players_package(args["players"])
ctx.stored_data_notification_keys.add(f"_read_hints_{ctx.team}_{ctx.slot}")
msgs = []
if ctx.locations_checked:
msgs.append({"cmd": "LocationChecks",
@@ -985,14 +830,10 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
elif cmd == "Retrieved":
ctx.stored_data.update(args["keys"])
if ctx.ui and f"_read_hints_{ctx.team}_{ctx.slot}" in args["keys"]:
ctx.ui.update_hints()
elif cmd == "SetReply":
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 args["key"].startswith("EnergyLink"):
if args["key"].startswith("EnergyLink"):
ctx.current_energy_link_value = args["value"]
if ctx.ui:
ctx.ui.set_new_energy_link_value()
@@ -1023,7 +864,6 @@ async def console_loop(ctx: CommonContext):
def get_base_parser(description: typing.Optional[str] = None):
"""Base argument parser to be reused for components subclassing off of CommonClient"""
import argparse
parser = argparse.ArgumentParser(description=description)
parser.add_argument('--connect', default=None, help='Address of the multiworld host.')
@@ -1033,10 +873,10 @@ def get_base_parser(description: typing.Optional[str] = None):
return parser
def run_as_textclient(*args):
def run_as_textclient():
class TextContext(CommonContext):
# Text Mode to use !hint and such with games that have no text entry
tags = CommonContext.tags | {"TextOnly"}
tags = {"AP", "TextOnly"}
game = "" # empty matches any game since 0.3.2
items_handling = 0b111 # receive all items for /received
want_slot_data = False # Can't use game specific slot_data
@@ -1072,21 +912,16 @@ def run_as_textclient(*args):
parser = get_base_parser(description="Gameless Archipelago Client, for text interfacing.")
parser.add_argument('--name', default=None, help="Slot Name to connect as.")
parser.add_argument("url", nargs="?", help="Archipelago connection url")
args = parser.parse_args(args)
args = parser.parse_args()
# handle if text client is launched using the "archipelago://name:pass@host:port" url from webhost
if args.url:
url = urllib.parse.urlparse(args.url)
if url.scheme == "archipelago":
args.connect = url.netloc
if url.username:
args.name = urllib.parse.unquote(url.username)
if url.password:
args.password = urllib.parse.unquote(url.password)
else:
parser.error(f"bad url, found {args.url}, expected url in form of archipelago://archipelago.gg:38281")
args.connect = url.netloc
if url.username:
args.name = urllib.parse.unquote(url.username)
if url.password:
args.password = urllib.parse.unquote(url.password)
# use colorama to display colored text highlighting on windows
colorama.init()
asyncio.run(main(args))
@@ -1094,5 +929,4 @@ def run_as_textclient(*args):
if __name__ == '__main__':
logging.getLogger().setLevel(logging.INFO) # force log-level to work around log level resetting to WARNING
run_as_textclient(*sys.argv[1:]) # default value for parse_args
run_as_textclient()

413
Fill.py
View File

@@ -5,63 +5,47 @@ import typing
from collections import Counter, deque
from BaseClasses import CollectionState, Item, Location, LocationProgressType, MultiWorld
from Options import Accessibility
from worlds.AutoWorld import call_all
from worlds.generic.Rules import add_item_rule
class FillError(RuntimeError):
def __init__(self, *args: typing.Union[str, typing.Any], **kwargs) -> None:
if "multiworld" in kwargs and isinstance(args[0], str):
placements = (args[0] + f"\nAll Placements:\n" +
f"{[(loc, loc.item) for loc in kwargs['multiworld'].get_filled_locations()]}")
args = (placements, *args[1:])
super().__init__(*args)
pass
def _log_fill_progress(name: str, placed: int, total_items: int) -> None:
logging.info(f"Current fill step ({name}) at {placed}/{total_items} items placed.")
def sweep_from_pool(base_state: CollectionState, itempool: typing.Sequence[Item] = tuple(),
locations: typing.Optional[typing.List[Location]] = None) -> CollectionState:
def sweep_from_pool(base_state: CollectionState, itempool: typing.Sequence[Item] = tuple()) -> CollectionState:
new_state = base_state.copy()
for item in itempool:
new_state.collect(item, True)
new_state.sweep_for_advancements(locations=locations)
new_state.sweep_for_events()
return new_state
def fill_restrictive(multiworld: MultiWorld, base_state: CollectionState, locations: typing.List[Location],
def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations: typing.List[Location],
item_pool: typing.List[Item], single_player_placement: bool = False, lock: bool = False,
swap: bool = True, on_place: typing.Optional[typing.Callable[[Location], None]] = None,
allow_partial: bool = False, allow_excluded: bool = False, name: str = "Unknown") -> None:
allow_partial: bool = False, allow_excluded: bool = False) -> None:
"""
:param multiworld: Multiworld to be filled.
:param world: Multiworld to be filled.
:param base_state: State assumed before fill.
:param locations: Locations to be filled with item_pool, gets mutated by removing locations that get filled.
:param item_pool: Items to fill into the locations, gets mutated by removing items that get placed.
:param locations: Locations to be filled with item_pool
:param item_pool: Items to fill into the locations
:param single_player_placement: if true, can speed up placement if everything belongs to a single player
:param lock: locations are set to locked as they are filled
:param swap: if true, swaps of already place items are done in the event of a dead end
:param on_place: callback that is called when a placement happens
:param allow_partial: only place what is possible. Remaining items will be in the item_pool list.
:param allow_excluded: if true and placement fails, it is re-attempted while ignoring excluded on Locations
:param name: name of this fill step for progress logging purposes
"""
unplaced_items: typing.List[Item] = []
placements: typing.List[Location] = []
cleanup_required = False
swapped_items: typing.Counter[typing.Tuple[int, str, bool]] = Counter()
reachable_items: typing.Dict[int, typing.Deque[Item]] = {}
for item in item_pool:
reachable_items.setdefault(item.player, deque()).append(item)
# for progress logging
total = min(len(item_pool), len(locations))
placed = 0
while any(reachable_items.values()) and locations:
# grab one item per player
items_to_place = [items.pop()
@@ -72,10 +56,9 @@ def fill_restrictive(multiworld: MultiWorld, base_state: CollectionState, locati
item_pool.pop(p)
break
maximum_exploration_state = sweep_from_pool(
base_state, item_pool + unplaced_items, multiworld.get_filled_locations(item.player)
if single_player_placement else None)
base_state, item_pool + unplaced_items)
has_beaten_game = multiworld.has_beaten_game(maximum_exploration_state)
has_beaten_game = world.has_beaten_game(maximum_exploration_state)
while items_to_place:
# if we have run out of locations to fill,break out of this loop
@@ -87,8 +70,8 @@ def fill_restrictive(multiworld: MultiWorld, base_state: CollectionState, locati
spot_to_fill: typing.Optional[Location] = None
# 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,
if world.accessibility[item_to_place.player] == 'minimal':
perform_access_check = not world.has_beaten_game(maximum_exploration_state,
item_to_place.player) \
if single_player_placement else not has_beaten_game
else:
@@ -119,9 +102,7 @@ def fill_restrictive(multiworld: MultiWorld, base_state: CollectionState, locati
location.item = None
placed_item.location = None
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)
swap_state = sweep_from_pool(base_state, [placed_item] if unsafe else [])
# 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.
@@ -131,11 +112,11 @@ def fill_restrictive(multiworld: MultiWorld, base_state: CollectionState, locati
# Verify placing this item won't reduce available locations, which would be a useless swap.
prev_state = swap_state.copy()
prev_loc_count = len(
multiworld.get_reachable_locations(prev_state))
world.get_reachable_locations(prev_state))
swap_state.collect(item_to_place, True)
new_loc_count = len(
multiworld.get_reachable_locations(swap_state))
world.get_reachable_locations(swap_state))
if new_loc_count >= prev_loc_count:
# Add this item to the existing placement, and
@@ -165,25 +146,18 @@ def fill_restrictive(multiworld: MultiWorld, base_state: CollectionState, locati
else:
unplaced_items.append(item_to_place)
continue
multiworld.push_item(spot_to_fill, item_to_place, False)
world.push_item(spot_to_fill, item_to_place, False)
spot_to_fill.locked = lock
placements.append(spot_to_fill)
placed += 1
if not placed % 1000:
_log_fill_progress(name, placed, total)
spot_to_fill.event = item_to_place.advancement
if on_place:
on_place(spot_to_fill)
if total > 1000:
_log_fill_progress(name, placed, total)
if cleanup_required:
# validate all placements and remove invalid ones
state = sweep_from_pool(
base_state, [], multiworld.get_filled_locations(item.player)
if single_player_placement else None)
state = sweep_from_pool(base_state, [])
for placement in placements:
if multiworld.worlds[placement.item.player].options.accessibility != "minimal" and not placement.can_reach(state):
if world.accessibility[placement.item.player] != "minimal" and not placement.can_reach(state):
placement.item.location = None
unplaced_items.append(placement.item)
placement.item = None
@@ -198,7 +172,7 @@ def fill_restrictive(multiworld: MultiWorld, base_state: CollectionState, locati
if excluded_locations:
for location in excluded_locations:
location.progress_type = location.progress_type.DEFAULT
fill_restrictive(multiworld, base_state, excluded_locations, unplaced_items, single_player_placement, lock,
fill_restrictive(world, base_state, excluded_locations, unplaced_items, single_player_placement, lock,
swap, on_place, allow_partial, False)
for location in excluded_locations:
if not location.item:
@@ -206,32 +180,22 @@ def fill_restrictive(multiworld: MultiWorld, base_state: CollectionState, locati
if not allow_partial and len(unplaced_items) > 0 and len(locations) > 0:
# There are leftover unplaceable items and locations that won't accept them
if multiworld.can_beat_game():
if world.can_beat_game():
logging.warning(
f"Not all items placed. Game beatable anyway.\nCould not place:\n"
f"{', '.join(str(item) for item in unplaced_items)}")
f'Not all items placed. Game beatable anyway. (Could not place {unplaced_items})')
else:
raise FillError(f"No more spots to place {len(unplaced_items)} items. Remaining locations are invalid.\n"
f"Unplaced items:\n"
f"{', '.join(str(item) for item in unplaced_items)}\n"
f"Unfilled locations:\n"
f"{', '.join(str(location) for location in locations)}\n"
f"Already placed {len(placements)}:\n"
f"{', '.join(str(place) for place in placements)}", multiworld=multiworld)
raise FillError(f'No more spots to place {unplaced_items}, locations {locations} are invalid. '
f'Already placed {len(placements)}: {", ".join(str(place) for place in placements)}')
item_pool.extend(unplaced_items)
def remaining_fill(multiworld: MultiWorld,
def remaining_fill(world: MultiWorld,
locations: typing.List[Location],
itempool: typing.List[Item],
name: str = "Remaining",
move_unplaceable_to_start_inventory: bool = False) -> None:
itempool: typing.List[Item]) -> None:
unplaced_items: typing.List[Item] = []
placements: typing.List[Location] = []
swapped_items: typing.Counter[typing.Tuple[int, str]] = Counter()
total = min(len(itempool), len(locations))
placed = 0
while locations and itempool:
item_to_place = itempool.pop()
spot_to_fill: typing.Optional[Location] = None
@@ -279,49 +243,30 @@ def remaining_fill(multiworld: MultiWorld,
unplaced_items.append(item_to_place)
continue
multiworld.push_item(spot_to_fill, item_to_place, False)
world.push_item(spot_to_fill, item_to_place, False)
placements.append(spot_to_fill)
placed += 1
if not placed % 1000:
_log_fill_progress(name, placed, total)
if total > 1000:
_log_fill_progress(name, placed, total)
if unplaced_items and locations:
# There are leftover unplaceable items and locations that won't accept them
if move_unplaceable_to_start_inventory:
last_batch = []
for item in unplaced_items:
logging.debug(f"Moved {item} to start_inventory to prevent fill failure.")
multiworld.push_precollected(item)
last_batch.append(multiworld.worlds[item.player].create_filler())
remaining_fill(multiworld, locations, unplaced_items, name + " Start Inventory Retry")
else:
raise FillError(f"No more spots to place {len(unplaced_items)} items. Remaining locations are invalid.\n"
f"Unplaced items:\n"
f"{', '.join(str(item) for item in unplaced_items)}\n"
f"Unfilled locations:\n"
f"{', '.join(str(location) for location in locations)}\n"
f"Already placed {len(placements)}:\n"
f"{', '.join(str(place) for place in placements)}", multiworld=multiworld)
raise FillError(f'No more spots to place {unplaced_items}, locations {locations} are invalid. '
f'Already placed {len(placements)}: {", ".join(str(place) for place in placements)}')
itempool.extend(unplaced_items)
def fast_fill(multiworld: MultiWorld,
def fast_fill(world: MultiWorld,
item_pool: typing.List[Item],
fill_locations: typing.List[Location]) -> typing.Tuple[typing.List[Item], typing.List[Location]]:
placing = min(len(item_pool), len(fill_locations))
for item, location in zip(item_pool, fill_locations):
multiworld.push_item(location, item, False)
world.push_item(location, item, False)
return item_pool[placing:], fill_locations[placing:]
def accessibility_corrections(multiworld: MultiWorld, state: CollectionState, locations, pool=[]):
def accessibility_corrections(world: MultiWorld, state: CollectionState, locations, pool=[]):
maximum_exploration_state = sweep_from_pool(state, pool)
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 world.player_ids if world.accessibility[player] == "minimal"}
unreachable_locations = [location for location in world.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
@@ -329,41 +274,42 @@ def accessibility_corrections(multiworld: MultiWorld, state: CollectionState, lo
pool.append(location.item)
state.remove(location.item)
location.item = None
if location in state.advancements:
state.advancements.remove(location)
location.event = False
if location in state.events:
state.events.remove(location)
locations.append(location)
if pool and locations:
locations.sort(key=lambda loc: loc.progress_type != LocationProgressType.PRIORITY)
fill_restrictive(multiworld, state, locations, pool, name="Accessibility Corrections")
fill_restrictive(world, state, locations, pool)
def inaccessible_location_rules(multiworld: MultiWorld, state: CollectionState, locations):
def inaccessible_location_rules(world: MultiWorld, state: CollectionState, locations):
maximum_exploration_state = sweep_from_pool(state)
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 world.accessibility[item.player] != 'minimal')
for location in unreachable_locations:
add_item_rule(location, forbid_important_item_rule)
def distribute_early_items(multiworld: MultiWorld,
def distribute_early_items(world: MultiWorld,
fill_locations: typing.List[Location],
itempool: typing.List[Item]) -> typing.Tuple[typing.List[Location], typing.List[Item]]:
""" returns new fill_locations and itempool """
early_items_count: typing.Dict[typing.Tuple[str, int], typing.List[int]] = {}
for player in multiworld.player_ids:
items = itertools.chain(multiworld.early_items[player], multiworld.local_early_items[player])
for player in world.player_ids:
items = itertools.chain(world.early_items[player], world.local_early_items[player])
for item in items:
early_items_count[item, player] = [multiworld.early_items[player].get(item, 0),
multiworld.local_early_items[player].get(item, 0)]
early_items_count[item, player] = [world.early_items[player].get(item, 0),
world.local_early_items[player].get(item, 0)]
if early_items_count:
early_locations: typing.List[Location] = []
early_priority_locations: typing.List[Location] = []
loc_indexes_to_remove: typing.Set[int] = set()
base_state = multiworld.state.copy()
base_state.sweep_for_advancements(locations=(loc for loc in multiworld.get_filled_locations() if loc.address is None))
base_state = world.state.copy()
base_state.sweep_for_events(locations=(loc for loc in world.get_filled_locations() if loc.address is None))
for i, loc in enumerate(fill_locations):
if loc.can_reach(base_state):
if loc.progress_type == LocationProgressType.PRIORITY:
@@ -375,8 +321,8 @@ def distribute_early_items(multiworld: MultiWorld,
early_prog_items: typing.List[Item] = []
early_rest_items: typing.List[Item] = []
early_local_prog_items: typing.Dict[int, typing.List[Item]] = {player: [] for player in multiworld.player_ids}
early_local_rest_items: typing.Dict[int, typing.List[Item]] = {player: [] for player in multiworld.player_ids}
early_local_prog_items: typing.Dict[int, typing.List[Item]] = {player: [] for player in world.player_ids}
early_local_rest_items: typing.Dict[int, typing.List[Item]] = {player: [] for player in world.player_ids}
item_indexes_to_remove: typing.Set[int] = set()
for i, item in enumerate(itempool):
if (item.name, item.player) in early_items_count:
@@ -400,29 +346,27 @@ def distribute_early_items(multiworld: MultiWorld,
if len(early_items_count) == 0:
break
itempool = [item for i, item in enumerate(itempool) if i not in item_indexes_to_remove]
for player in multiworld.player_ids:
for player in world.player_ids:
player_local = early_local_rest_items[player]
fill_restrictive(multiworld, base_state,
fill_restrictive(world, base_state,
[loc for loc in early_locations if loc.player == player],
player_local, lock=True, allow_partial=True, name=f"Local Early Items P{player}")
player_local, lock=True, allow_partial=True)
if player_local:
logging.warning(f"Could not fulfill rules of early items: {player_local}")
early_rest_items.extend(early_local_rest_items[player])
early_locations = [loc for loc in early_locations if not loc.item]
fill_restrictive(multiworld, base_state, early_locations, early_rest_items, lock=True, allow_partial=True,
name="Early Items")
fill_restrictive(world, base_state, early_locations, early_rest_items, lock=True, allow_partial=True)
early_locations += early_priority_locations
for player in multiworld.player_ids:
for player in world.player_ids:
player_local = early_local_prog_items[player]
fill_restrictive(multiworld, base_state,
fill_restrictive(world, base_state,
[loc for loc in early_locations if loc.player == player],
player_local, lock=True, allow_partial=True, name=f"Local Early Progression P{player}")
player_local, lock=True, allow_partial=True)
if player_local:
logging.warning(f"Could not fulfill rules of early items: {player_local}")
early_prog_items.extend(player_local)
early_locations = [loc for loc in early_locations if not loc.item]
fill_restrictive(multiworld, base_state, early_locations, early_prog_items, lock=True, allow_partial=True,
name="Early Progression")
fill_restrictive(world, base_state, early_locations, early_prog_items, lock=True, allow_partial=True)
unplaced_early_items = early_rest_items + early_prog_items
if unplaced_early_items:
logging.warning("Ran out of early locations for early items. Failed to place "
@@ -430,19 +374,18 @@ def distribute_early_items(multiworld: MultiWorld,
itempool += unplaced_early_items
fill_locations.extend(early_locations)
multiworld.random.shuffle(fill_locations)
world.random.shuffle(fill_locations)
return fill_locations, itempool
def distribute_items_restrictive(multiworld: MultiWorld,
panic_method: typing.Literal["swap", "raise", "start_inventory"] = "swap") -> None:
fill_locations = sorted(multiworld.get_unfilled_locations())
multiworld.random.shuffle(fill_locations)
def distribute_items_restrictive(world: MultiWorld) -> None:
fill_locations = sorted(world.get_unfilled_locations())
world.random.shuffle(fill_locations)
# get items to distribute
itempool = sorted(multiworld.itempool)
multiworld.random.shuffle(itempool)
itempool = sorted(world.itempool)
world.random.shuffle(itempool)
fill_locations, itempool = distribute_early_items(multiworld, fill_locations, itempool)
fill_locations, itempool = distribute_early_items(world, fill_locations, itempool)
progitempool: typing.List[Item] = []
usefulitempool: typing.List[Item] = []
@@ -456,7 +399,7 @@ def distribute_items_restrictive(multiworld: MultiWorld,
else:
filleritempool.append(item)
call_all(multiworld, "fill_hook", progitempool, usefulitempool, filleritempool, fill_locations)
call_all(world, "fill_hook", progitempool, usefulitempool, filleritempool, fill_locations)
locations: typing.Dict[LocationProgressType, typing.List[Location]] = {
loc_type: [] for loc_type in LocationProgressType}
@@ -475,106 +418,76 @@ def distribute_items_restrictive(multiworld: MultiWorld,
nonlocal lock_later
lock_later.append(location)
single_player = multiworld.players == 1 and not multiworld.groups
if prioritylocations:
# "priority fill"
fill_restrictive(multiworld, multiworld.state, prioritylocations, progitempool,
single_player_placement=single_player, swap=False, on_place=mark_for_locking, name="Priority")
accessibility_corrections(multiworld, multiworld.state, prioritylocations, progitempool)
fill_restrictive(world, world.state, prioritylocations, progitempool, swap=False, on_place=mark_for_locking)
accessibility_corrections(world, world.state, prioritylocations, progitempool)
defaultlocations = prioritylocations + defaultlocations
if progitempool:
# "advancement/progression fill"
if panic_method == "swap":
fill_restrictive(multiworld, multiworld.state, defaultlocations, progitempool, swap=True,
name="Progression", single_player_placement=single_player)
elif panic_method == "raise":
fill_restrictive(multiworld, multiworld.state, defaultlocations, progitempool, swap=False,
name="Progression", single_player_placement=single_player)
elif panic_method == "start_inventory":
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:
logging.debug(f"Moved {item} to start_inventory to prevent fill failure.")
multiworld.push_precollected(item)
filleritempool.append(multiworld.worlds[item.player].create_filler())
logging.warning(f"{len(progitempool)} items moved to start inventory,"
f" due to failure in Progression fill step.")
progitempool[:] = []
else:
raise ValueError(f"Generator Panic Method {panic_method} not recognized.")
# "progression fill"
fill_restrictive(world, world.state, defaultlocations, progitempool)
if progitempool:
raise FillError(
f"Not enough locations for progression items. "
f"There are {len(progitempool)} more progression items than there are available locations.",
multiworld=multiworld,
)
accessibility_corrections(multiworld, multiworld.state, defaultlocations)
f'Not enough locations for progress items. There are {len(progitempool)} more items than locations')
accessibility_corrections(world, world.state, defaultlocations)
for location in lock_later:
if location.item:
location.locked = True
del mark_for_locking, lock_later
inaccessible_location_rules(multiworld, multiworld.state, defaultlocations)
remaining_fill(multiworld, excludedlocations, filleritempool, "Remaining Excluded",
move_unplaceable_to_start_inventory=panic_method=="start_inventory")
inaccessible_location_rules(world, world.state, defaultlocations)
remaining_fill(world, excludedlocations, filleritempool)
if excludedlocations:
raise FillError(
f"Not enough filler items for excluded locations. "
f"There are {len(excludedlocations)} more excluded locations than filler or trap items.",
multiworld=multiworld,
)
f"Not enough filler items for excluded locations. There are {len(excludedlocations)} more locations than items")
restitempool = filleritempool + usefulitempool
restitempool = usefulitempool + filleritempool
remaining_fill(multiworld, defaultlocations, restitempool,
move_unplaceable_to_start_inventory=panic_method=="start_inventory")
remaining_fill(world, defaultlocations, restitempool)
unplaced = restitempool
unfilled = defaultlocations
if unplaced or unfilled:
logging.warning(
f"Unplaced items({len(unplaced)}): {unplaced} - Unfilled Locations({len(unfilled)}): {unfilled}")
items_counter = Counter(location.item.player for location in multiworld.get_filled_locations())
locations_counter = Counter(location.player for location in multiworld.get_locations())
f'Unplaced items({len(unplaced)}): {unplaced} - Unfilled Locations({len(unfilled)}): {unfilled}')
items_counter = Counter(location.item.player for location in world.get_locations() if location.item)
locations_counter = Counter(location.player for location in world.get_locations())
items_counter.update(item.player for item in unplaced)
locations_counter.update(location.player for location in unfilled)
print_data = {"items": items_counter, "locations": locations_counter}
logging.info(f"Per-Player counts: {print_data})")
logging.info(f'Per-Player counts: {print_data})')
def flood_items(multiworld: MultiWorld) -> None:
def flood_items(world: MultiWorld) -> None:
# get items to distribute
multiworld.random.shuffle(multiworld.itempool)
itempool = multiworld.itempool
world.random.shuffle(world.itempool)
itempool = world.itempool
progress_done = False
# sweep once to pick up preplaced items
multiworld.state.sweep_for_advancements()
world.state.sweep_for_events()
# fill multiworld from top of itempool while we can
# fill world from top of itempool while we can
while not progress_done:
location_list = multiworld.get_unfilled_locations()
multiworld.random.shuffle(location_list)
location_list = world.get_unfilled_locations()
world.random.shuffle(location_list)
spot_to_fill = None
for location in location_list:
if location.can_fill(multiworld.state, itempool[0]):
if location.can_fill(world.state, itempool[0]):
spot_to_fill = location
break
if spot_to_fill:
item = itempool.pop(0)
multiworld.push_item(spot_to_fill, item, True)
world.push_item(spot_to_fill, item, True)
continue
# ran out of spots, check if we need to step in and correct things
if len(multiworld.get_reachable_locations()) == len(multiworld.get_locations()):
if len(world.get_reachable_locations()) == len(world.get_locations()):
progress_done = True
continue
@@ -584,7 +497,7 @@ def flood_items(multiworld: MultiWorld) -> None:
for item in itempool:
if item.advancement:
candidate_item_to_place = item
if multiworld.unlocks_new_location(item):
if world.unlocks_new_location(item):
item_to_place = item
break
@@ -594,23 +507,23 @@ def flood_items(multiworld: MultiWorld) -> None:
if candidate_item_to_place is not None:
item_to_place = candidate_item_to_place
else:
raise FillError('No more progress items left to place.', multiworld=multiworld)
raise FillError('No more progress items left to place.')
# find item to replace with progress item
location_list = multiworld.get_reachable_locations()
multiworld.random.shuffle(location_list)
location_list = world.get_reachable_locations()
world.random.shuffle(location_list)
for location in location_list:
if location.item is not None and not location.item.advancement:
# safe to replace
replace_item = location.item
replace_item.location = None
itempool.append(replace_item)
multiworld.push_item(location, item_to_place, True)
world.push_item(location, item_to_place, True)
itempool.remove(item_to_place)
break
def balance_multiworld_progression(multiworld: MultiWorld) -> None:
def balance_multiworld_progression(world: MultiWorld) -> None:
# A system to reduce situations where players have no checks remaining, popularly known as "BK mode."
# Overall progression balancing algorithm:
# Gather up all locations in a sphere.
@@ -618,28 +531,28 @@ def balance_multiworld_progression(multiworld: MultiWorld) -> None:
# If other players are below the threshold value, swap progression in this sphere into earlier spheres,
# which gives more locations available by this sphere.
balanceable_players: typing.Dict[int, float] = {
player: multiworld.worlds[player].options.progression_balancing / 100
for player in multiworld.player_ids
if multiworld.worlds[player].options.progression_balancing > 0
player: world.progression_balancing[player] / 100
for player in world.player_ids
if world.progression_balancing[player] > 0
}
if not balanceable_players:
logging.info('Skipping multiworld progression balancing.')
else:
logging.info(f'Balancing multiworld progression for {len(balanceable_players)} Players.')
logging.debug(balanceable_players)
state: CollectionState = CollectionState(multiworld)
state: CollectionState = CollectionState(world)
checked_locations: typing.Set[Location] = set()
unchecked_locations: typing.Set[Location] = set(multiworld.get_locations())
unchecked_locations: typing.Set[Location] = set(world.get_locations())
total_locations_count: typing.Counter[int] = Counter(
location.player
for location in multiworld.get_locations()
for location in world.get_locations()
if not location.locked
)
reachable_locations_count: typing.Dict[int, int] = {
player: 0
for player in multiworld.player_ids
if total_locations_count[player] and len(multiworld.get_filled_locations(player)) != 0
for player in world.player_ids
if total_locations_count[player] and len(world.get_filled_locations(player)) != 0
}
balanceable_players = {
player: balanceable_players[player]
@@ -651,6 +564,7 @@ def balance_multiworld_progression(multiworld: MultiWorld) -> None:
def get_sphere_locations(sphere_state: CollectionState,
locations: typing.Set[Location]) -> typing.Set[Location]:
sphere_state.sweep_for_events(key_only=True, locations=locations)
return {loc for loc in locations if sphere_state.can_reach(loc)}
def item_percentage(player: int, num: int) -> float:
@@ -702,7 +616,7 @@ def balance_multiworld_progression(multiworld: MultiWorld) -> None:
while True:
# Check locations in the current sphere and gather progression items to swap earlier
for location in balancing_sphere:
if location.advancement:
if location.event:
balancing_state.collect(location.item, True, location)
player = location.item.player
# only replace items that end up in another player's world
@@ -717,7 +631,7 @@ def balance_multiworld_progression(multiworld: MultiWorld) -> None:
balancing_unchecked_locations.remove(location)
if not location.locked:
balancing_reachables[location.player] += 1
if multiworld.has_beaten_game(balancing_state) or all(
if world.has_beaten_game(balancing_state) or all(
item_percentage(player, reachables) >= threshold_percentages[player]
for player, reachables in balancing_reachables.items()
if player in threshold_percentages):
@@ -734,7 +648,7 @@ def balance_multiworld_progression(multiworld: MultiWorld) -> None:
locations_to_test = unlocked_locations[player]
items_to_test = list(candidate_items[player])
items_to_test.sort()
multiworld.random.shuffle(items_to_test)
world.random.shuffle(items_to_test)
while items_to_test:
testing = items_to_test.pop()
reducing_state = state.copy()
@@ -744,10 +658,10 @@ def balance_multiworld_progression(multiworld: MultiWorld) -> None:
), items_to_test):
reducing_state.collect(location.item, True, location)
reducing_state.sweep_for_advancements(locations=locations_to_test)
reducing_state.sweep_for_events(locations=locations_to_test)
if multiworld.has_beaten_game(balancing_state):
if not multiworld.has_beaten_game(reducing_state):
if world.has_beaten_game(balancing_state):
if not world.has_beaten_game(reducing_state):
items_to_replace.append(testing)
else:
reduced_sphere = get_sphere_locations(reducing_state, locations_to_test)
@@ -755,32 +669,33 @@ def balance_multiworld_progression(multiworld: MultiWorld) -> None:
if p < threshold_percentages[player]:
items_to_replace.append(testing)
old_moved_item_count = moved_item_count
replaced_items = False
# sort then shuffle to maintain deterministic behaviour,
# while allowing use of set for better algorithm growth behaviour elsewhere
replacement_locations = sorted(l for l in checked_locations if not l.advancement and not l.locked)
multiworld.random.shuffle(replacement_locations)
replacement_locations = sorted(l for l in checked_locations if not l.event and not l.locked)
world.random.shuffle(replacement_locations)
items_to_replace.sort()
multiworld.random.shuffle(items_to_replace)
world.random.shuffle(items_to_replace)
# Start swapping items. Since we swap into earlier spheres, no need for accessibility checks.
while replacement_locations and items_to_replace:
old_location = items_to_replace.pop()
for i, new_location in enumerate(replacement_locations):
for new_location in replacement_locations:
if new_location.can_fill(state, old_location.item, False) and \
old_location.can_fill(state, new_location.item, False):
replacement_locations.pop(i)
replacement_locations.remove(new_location)
swap_location_item(old_location, new_location)
logging.debug(f"Progression balancing moved {new_location.item} to {new_location}, "
f"displacing {old_location.item} into {old_location}")
moved_item_count += 1
state.collect(new_location.item, True, new_location)
replaced_items = True
break
else:
logging.warning(f"Could not Progression Balance {old_location.item}")
if old_moved_item_count < moved_item_count:
if replaced_items:
logging.debug(f"Moved {moved_item_count} items so far\n")
unlocked = {fresh for player in balancing_players for fresh in unlocked_locations[player]}
for location in get_sphere_locations(state, unlocked):
@@ -790,11 +705,11 @@ def balance_multiworld_progression(multiworld: MultiWorld) -> None:
sphere_locations.add(location)
for location in sphere_locations:
if location.advancement:
if location.event:
state.collect(location.item, True, location)
checked_locations |= sphere_locations
if multiworld.has_beaten_game(state):
if world.has_beaten_game(state):
break
elif not sphere_locations:
logging.warning("Progression Balancing ran out of paths.")
@@ -811,9 +726,10 @@ def swap_location_item(location_1: Location, location_2: Location, check_locked:
location_2.item, location_1.item = location_1.item, location_2.item
location_1.item.location = location_1
location_2.item.location = location_2
location_1.event, location_2.event = location_2.event, location_1.event
def distribute_planned(multiworld: MultiWorld) -> None:
def distribute_planned(world: MultiWorld) -> None:
def warn(warning: str, force: typing.Union[bool, str]) -> None:
if force in [True, 'fail', 'failure', 'none', False, 'warn', 'warning']:
logging.warning(f'{warning}')
@@ -826,43 +742,40 @@ def distribute_planned(multiworld: MultiWorld) -> None:
else:
warn(warning, force)
swept_state = multiworld.state.copy()
swept_state.sweep_for_advancements()
reachable = frozenset(multiworld.get_reachable_locations(swept_state))
swept_state = world.state.copy()
swept_state.sweep_for_events()
reachable = frozenset(world.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():
for loc in world.get_unfilled_locations():
if loc in reachable:
early_locations[loc.player].append(loc.name)
else: # not reachable with swept state
non_early_locations[loc.player].append(loc.name)
world_name_lookup = multiworld.world_name_lookup
world_name_lookup = world.world_name_lookup
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)
player_ids = set(world.player_ids)
for player in player_ids:
for block in multiworld.plando_items[player]:
for block in world.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
if target_world is False or world.players == 1: # target own world
worlds: typing.Set[int] = {player}
elif target_world is True: # target any worlds besides own
worlds = set(multiworld.player_ids) - {player}
worlds = set(world.player_ids) - {player}
elif target_world is None: # target all worlds
worlds = set(multiworld.player_ids)
worlds = set(world.player_ids)
elif type(target_world) == list: # list of target worlds
worlds = set()
for listed_world in target_world:
@@ -872,9 +785,9 @@ def distribute_planned(multiworld: MultiWorld) -> None:
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):
if target_world not in range(1, world.players + 1):
failed(
f"Cannot place item in world {target_world} as it is not in range of (1, {multiworld.players})",
f"Cannot place item in world {target_world} as it is not in range of (1, {world.players})",
block['force'])
continue
worlds = {target_world}
@@ -902,7 +815,7 @@ def distribute_planned(multiworld: MultiWorld) -> None:
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))
value = world.itempool.count(world.worlds[player].create_item(key))
item_list += [key] * value
items = item_list
if isinstance(items, str):
@@ -932,7 +845,7 @@ def distribute_planned(multiworld: MultiWorld) -> None:
for target_player in worlds:
locations += non_early_locations[target_player]
block['locations'] = list(dict.fromkeys(locations))
block['locations'] = locations
if not block['count']:
block['count'] = (min(len(block['items']), len(block['locations'])) if
@@ -952,17 +865,17 @@ def distribute_planned(multiworld: MultiWorld) -> None:
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'])
block['count']['target'] = world.random.randint(block['count']['min'], block['count']['max'])
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)
world.random.shuffle(plando_blocks)
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']))
else len(world.get_unfilled_locations(player)) - block['count']['target']))
for placement in plando_blocks:
player = placement['player']
@@ -973,50 +886,48 @@ def distribute_planned(multiworld: MultiWorld) -> None:
maxcount = placement['count']['target']
from_pool = placement['from_pool']
candidates = list(multiworld.get_unfilled_locations_for_players(locations, sorted(worlds)))
multiworld.random.shuffle(candidates)
multiworld.random.shuffle(items)
candidates = list(world.get_unfilled_locations_for_players(locations, sorted(worlds)))
world.random.shuffle(candidates)
world.random.shuffle(items)
count = 0
err: typing.List[str] = []
successful_pairs: typing.List[typing.Tuple[Item, Location]] = []
for item_name in items:
item = multiworld.worlds[player].create_item(item_name)
item = world.worlds[player].create_item(item_name)
for location in reversed(candidates):
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((item, location))
candidates.remove(location)
count = count + 1
break
else:
err.append(f"Can't place item at {location} due to fill condition not met.")
if not location.item:
if location.item_rule(item):
if location.can_fill(world.state, item, False):
successful_pairs.append((item, location))
candidates.remove(location)
count = count + 1
break
else:
err.append(f"{item_name} not allowed at {location}.")
err.append(f"Can't place item at {location} due to fill condition not met.")
else:
err.append(f"Cannot place {item_name} into already filled location {location}.")
err.append(f"{item_name} not allowed at {location}.")
else:
err.append(f"Mismatch between {item_name} and {location}, only one is an event.")
err.append(f"Cannot place {item_name} into already filled location {location}.")
if count == maxcount:
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)}",
f"Plando block failed to place {m - count} of {m} item(s) for {world.player_name[player]}, error(s): {' '.join(err)}",
placement['force'])
for (item, location) in successful_pairs:
multiworld.push_item(location, item, collect=False)
world.push_item(location, item, collect=False)
location.event = True # flag location to be checked during fill
location.locked = True
logging.debug(f"Plando placed {item} at {location}")
if from_pool:
try:
multiworld.itempool.remove(item)
world.itempool.remove(item)
except ValueError:
warn(
f"Could not remove {item} from pool for {multiworld.player_name[player]} as it's already missing from it.",
f"Could not remove {item} from pool for {world.player_name[player]} as it's already missing from it.",
placement['force'])
except Exception as e:
raise Exception(
f"Error running plando for player {player} ({multiworld.player_name[player]})") from e
f"Error running plando for player {player} ({world.player_name[player]})") from e

View File

@@ -1,80 +1,78 @@
from __future__ import annotations
import argparse
import copy
import logging
import os
import random
import string
import sys
import urllib.parse
import urllib.request
from collections import Counter
from typing import Any, Dict, Tuple, Union
from itertools import chain
from collections import ChainMap, Counter
from typing import Any, Callable, Dict, Tuple, Union
import ModuleUpdate
ModuleUpdate.update()
import copy
import Utils
import Options
from BaseClasses import seeddigits, get_seed, PlandoOptions
from Utils import parse_yamls, version_tuple, __version__, tuplize_version
from Main import main as ERmain
from settings import get_settings
from Utils import parse_yamls, version_tuple, __version__, tuplize_version, user_path
from worlds.alttp import Options as LttPOptions
from worlds.alttp.EntranceRandomizer import parse_arguments
from worlds.alttp.Text import TextTable
from worlds.AutoWorld import AutoWorldRegister
from worlds.generic import PlandoConnection
def mystery_argparse():
from settings import get_settings
settings = get_settings()
defaults = settings.generator
options = get_settings()
defaults = options.generator
parser = argparse.ArgumentParser(description="CMD Generation Interface, defaults come from host.yaml.")
parser.add_argument('--weights_file_path', default=defaults.weights_file_path,
help='Path to the weights file to use for rolling game options, urls are also valid')
parser.add_argument('--sameoptions', help='Rolls options per weights file rather than per player',
help='Path to the weights file to use for rolling game settings, urls are also valid')
parser.add_argument('--samesettings', help='Rolls settings per weights file rather than per player',
action='store_true')
parser.add_argument('--player_files_path', default=defaults.player_files_path,
help="Input directory for player files.")
parser.add_argument('--seed', help='Define seed number to generate.', type=int)
parser.add_argument('--multi', default=defaults.players, type=lambda value: max(int(value), 1))
parser.add_argument('--spoiler', type=int, default=defaults.spoiler)
parser.add_argument('--outputpath', default=settings.general_options.output_path,
parser.add_argument('--outputpath', default=options.general_options.output_path,
help="Path to output folder. Absolute or relative to cwd.") # absolute or relative to cwd
parser.add_argument('--race', action='store_true', default=defaults.race)
parser.add_argument('--meta_file_path', default=defaults.meta_file_path)
parser.add_argument('--log_level', default='info', help='Sets log level')
parser.add_argument("--csv_output", action="store_true",
help="Output rolled player options to csv (made for async multiworld).")
parser.add_argument("--plando", default=defaults.plando_options,
help="List of options that can be set manually. Can be combined, for example \"bosses, items\"")
parser.add_argument('--yaml_output', default=0, type=lambda value: max(int(value), 0),
help='Output rolled mystery results to yaml up to specified number (made for async multiworld)')
parser.add_argument('--plando', default=defaults.plando_options,
help='List of options that can be set manually. Can be combined, for example "bosses, items"')
parser.add_argument("--skip_prog_balancing", action="store_true",
help="Skip progression balancing step during generation.")
parser.add_argument("--skip_output", action="store_true",
help="Skips generation assertion and output stages and skips multidata and spoiler output. "
"Intended for debugging and testing purposes.")
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
return args, options
def get_seed_name(random_source) -> str:
return f"{random_source.randint(0, pow(10, seeddigits) - 1)}".zfill(seeddigits)
def main(args=None) -> Tuple[argparse.Namespace, int]:
# __name__ == "__main__" check so unittests that already imported worlds don't trip this.
if __name__ == "__main__" and "worlds" in sys.modules:
raise Exception("Worlds system should not be loaded before logging init.")
def main(args=None, callback=ERmain):
if not args:
args = mystery_argparse()
args, options = mystery_argparse()
else:
options = get_settings()
seed = get_seed(args.seed)
Utils.init_logging(f"Generate_{seed}", loglevel=args.log_level)
random.seed(seed)
seed_name = get_seed_name(random)
@@ -102,15 +100,15 @@ def main(args=None) -> Tuple[argparse.Namespace, int]:
del(meta_weights["meta_description"])
except Exception as e:
raise ValueError("No meta description found for meta.yaml. Unable to verify.") from e
if args.sameoptions:
raise Exception("Cannot mix --sameoptions with --meta")
if args.samesettings:
raise Exception("Cannot mix --samesettings with --meta")
else:
meta_weights = None
player_id = 1
player_files = {}
for file in os.scandir(args.player_files_path):
fname = file.name
if file.is_file() and not fname.startswith(".") and not fname.lower().endswith(".ini") and \
if file.is_file() and not fname.startswith(".") and \
os.path.join(args.player_files_path, fname) not in {args.meta_file_path, args.weights_file_path}:
path = os.path.join(args.player_files_path, fname)
try:
@@ -119,7 +117,7 @@ def main(args=None) -> Tuple[argparse.Namespace, int]:
raise ValueError(f"File {fname} is invalid. Please fix your yaml.") from e
# sort dict for consistent results across platforms:
weights_cache = {key: value for key, value in sorted(weights_cache.items(), key=lambda k: k[0].casefold())}
weights_cache = {key: value for key, value in sorted(weights_cache.items())}
for filename, yaml_data in weights_cache.items():
if filename not in {args.meta_file_path, args.weights_file_path}:
for yaml in yaml_data:
@@ -129,13 +127,6 @@ def main(args=None) -> Tuple[argparse.Namespace, int]:
player_id += 1
args.multi = max(player_id - 1, args.multi)
if args.multi == 0:
raise ValueError(
"No individual player files found and number of players is 0. "
"Provide individual player files or specify the number of players via host.yaml or --multi."
)
logging.info(f"Generating for {args.multi} player{'s' if args.multi > 1 else ''}, "
f"{seed_name} Seed {seed} with plando: {args.plando}")
@@ -143,23 +134,18 @@ def main(args=None) -> Tuple[argparse.Namespace, int]:
raise Exception(f"No weights found. "
f"Provide a general weights file ({args.weights_file_path}) or individual player files. "
f"A mix is also permitted.")
from worlds.AutoWorld import AutoWorldRegister
from worlds.alttp.EntranceRandomizer import parse_arguments
erargs = parse_arguments(['--multi', str(args.multi)])
erargs.seed = seed
erargs.plando_options = args.plando
erargs.glitch_triforce = options.generator.glitch_triforce_room
erargs.spoiler = args.spoiler
erargs.race = args.race
erargs.outputname = seed_name
erargs.outputpath = args.outputpath
erargs.skip_prog_balancing = args.skip_prog_balancing
erargs.skip_output = args.skip_output
erargs.name = {}
erargs.csv_output = args.csv_output
settings_cache: Dict[str, Tuple[argparse.Namespace, ...]] = \
{fname: (tuple(roll_settings(yaml, args.plando) for yaml in yamls) if args.sameoptions else None)
{fname: (tuple(roll_settings(yaml, args.plando) for yaml in yamls) if args.samesettings else None)
for fname, yamls in weights_cache.items()}
if meta_weights:
@@ -171,8 +157,7 @@ def main(args=None) -> Tuple[argparse.Namespace, int]:
for yaml in weights_cache[path]:
if category_name is None:
for category in yaml:
if category in AutoWorldRegister.world_types and \
key in Options.CommonOptions.type_hints:
if category in AutoWorldRegister.world_types and key in Options.common_options:
yaml[category][key] = option
elif category_name not in yaml:
logging.warning(f"Meta: Category {category_name} is not present in {path}.")
@@ -183,7 +168,7 @@ 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()
erargs.player_options = {}
erargs.player_settings = {}
player = 1
while player <= args.multi:
@@ -204,7 +189,7 @@ def main(args=None) -> Tuple[argparse.Namespace, int]:
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
elif not erargs.name[player]: # if name was not specified, generate it from filename
erargs.name[player] = os.path.splitext(os.path.split(path)[-1])[0]
erargs.name[player] = handle_name(erargs.name[player], player, name_counter)
@@ -217,7 +202,29 @@ def main(args=None) -> Tuple[argparse.Namespace, int]:
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 erargs, seed
if args.yaml_output:
import yaml
important = {}
for option, player_settings in vars(erargs).items():
if type(player_settings) == dict:
if all(type(value) != list for value in player_settings.values()):
if len(player_settings.values()) > 1:
important[option] = {player: value for player, value in player_settings.items() if
player <= args.yaml_output}
else:
logging.debug(f"No player settings defined for option '{option}'")
else:
if player_settings != "": # is not empty name
important[option] = player_settings
else:
logging.debug(f"No player settings defined for option '{option}'")
if args.outputpath:
os.makedirs(args.outputpath, exist_ok=True)
with open(os.path.join(args.outputpath if args.outputpath else ".", f"generate_{seed_name}.yaml"), "wt") as f:
yaml.dump(important, f)
callback(erargs, seed)
def read_weights_yamls(path) -> Tuple[Any, ...]:
@@ -283,77 +290,71 @@ def handle_name(name: str, player: int, name_counter: Counter):
NUMBER=(number if number > 1 else ''),
player=player,
PLAYER=(player if player > 1 else '')))
# Run .strip twice for edge case where after the initial .slice new_name has a leading whitespace.
# Could cause issues for some clients that cannot handle the additional whitespace.
new_name = new_name.strip()[:16].strip()
new_name = new_name.strip()[:16]
if new_name == "Archipelago":
raise Exception(f"You cannot name yourself \"{new_name}\"")
return new_name
def prefer_int(input_data: str) -> Union[str, int]:
try:
return int(input_data)
except:
return input_data
goals = {
'ganon': 'ganon',
'crystals': 'crystals',
'bosses': 'bosses',
'pedestal': 'pedestal',
'ganon_pedestal': 'ganonpedestal',
'triforce_hunt': 'triforcehunt',
'local_triforce_hunt': 'localtriforcehunt',
'ganon_triforce_hunt': 'ganontriforcehunt',
'local_ganon_triforce_hunt': 'localganontriforcehunt',
'ice_rod_hunt': 'icerodhunt',
}
def roll_percentage(percentage: Union[int, float]) -> bool:
"""Roll a percentage chance.
percentage is expected to be in range [0, 100]"""
return random.random() < (float(percentage) / 100)
def update_weights(weights: dict, new_weights: dict, update_type: str, name: str) -> dict:
def update_weights(weights: dict, new_weights: dict, type: str, name: str) -> dict:
logging.debug(f'Applying {new_weights}')
cleaned_weights = {}
for option in new_weights:
option_name = option.lstrip("+-")
if option.startswith("+") and option_name in weights:
cleaned_value = weights[option_name]
new_value = new_weights[option]
if isinstance(new_value, set):
cleaned_value.update(new_value)
elif isinstance(new_value, list):
cleaned_value.extend(new_value)
elif isinstance(new_value, dict):
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__}.")
cleaned_weights[option_name] = cleaned_value
elif option.startswith("-") and option_name in weights:
cleaned_value = weights[option_name]
new_value = new_weights[option]
if isinstance(new_value, set):
cleaned_value.difference_update(new_value)
elif isinstance(new_value, list):
for element in new_value:
cleaned_value.remove(element)
elif isinstance(new_value, dict):
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:
cleaned_weights[option_name] = new_weights[option]
new_options = set(cleaned_weights) - set(weights)
weights.update(cleaned_weights)
new_options = set(new_weights) - set(weights)
weights.update(new_weights)
if new_options:
for new_option in new_options:
logging.warning(f'{update_type} Suboption "{new_option}" of "{name}" did not '
logging.warning(f'{type} Suboption "{new_option}" of "{name}" did not '
f'overwrite a root option. '
f'This is probably in error.')
return weights
def roll_meta_option(option_key, game: str, category_dict: Dict) -> Any:
from worlds import AutoWorldRegister
if not game:
return get_choice(option_key, category_dict)
if game in AutoWorldRegister.world_types:
game_world = AutoWorldRegister.world_types[game]
options = game_world.options_dataclass.type_hints
options = ChainMap(game_world.option_definitions, Options.per_game_common_options)
if option_key in options:
if options[option_key].supports_weighting:
return get_choice(option_key, category_dict)
return category_dict[option_key]
raise Options.OptionError(f"Error generating meta option {option_key} for {game}.")
if game == "A Link to the Past": # TODO wow i hate this
if option_key in {"glitches_required", "dark_room_logic", "entrance_shuffle", "goals", "triforce_pieces_mode",
"triforce_pieces_percentage", "triforce_pieces_available", "triforce_pieces_extra",
"triforce_pieces_required", "shop_shuffle", "mode", "item_pool", "item_functionality",
"boss_shuffle", "enemy_damage", "enemy_health", "timer", "countdown_start_time",
"red_clock_time", "blue_clock_time", "green_clock_time", "dungeon_counters", "shuffle_prizes",
"misery_mire_medallion", "turtle_rock_medallion", "sprite_pool", "sprite",
"random_sprite_on_event"}:
return get_choice(option_key, category_dict)
raise Exception(f"Error generating meta option {option_key} for {game}.")
def roll_linked_options(weights: dict) -> dict:
@@ -378,7 +379,7 @@ def roll_linked_options(weights: dict) -> dict:
return weights
def roll_triggers(weights: dict, triggers: list, valid_keys: set) -> dict:
def roll_triggers(weights: dict, triggers: list) -> dict:
weights = copy.deepcopy(weights) # make sure we don't write back to other weights sets in same_settings
weights["_Generator_Version"] = Utils.__version__
for i, option_set in enumerate(triggers):
@@ -401,7 +402,7 @@ def roll_triggers(weights: dict, triggers: list, valid_keys: set) -> dict:
if category_name:
currently_targeted_weights = currently_targeted_weights[category_name]
update_weights(currently_targeted_weights, category_options, "Triggered", option_set["option_name"])
valid_keys.add(key)
except Exception as e:
raise ValueError(f"Your trigger number {i + 1} is invalid. "
f"Please fix your triggers.") from e
@@ -409,31 +410,27 @@ def roll_triggers(weights: dict, triggers: list, valid_keys: set) -> dict:
def handle_option(ret: argparse.Namespace, game_weights: dict, option_key: str, option: type(Options.Option), plando_options: PlandoOptions):
try:
if option_key in game_weights:
if option_key in game_weights:
try:
if not option.supports_weighting:
player_option = option.from_any(game_weights[option_key])
else:
player_option = option.from_any(get_choice(option_key, game_weights))
setattr(ret, option_key, player_option)
except Exception as e:
raise Exception(f"Error generating option {option_key} in {ret.game}") from e
else:
player_option = option.from_any(option.default) # call the from_any here to support default "random"
setattr(ret, option_key, player_option)
except Exception as e:
raise Options.OptionError(f"Error generating option {option_key} in {ret.game}") from e
player_option.verify(AutoWorldRegister.world_types[ret.game], ret.name, plando_options)
else:
from worlds import AutoWorldRegister
player_option.verify(AutoWorldRegister.world_types[ret.game], ret.name, plando_options)
setattr(ret, option_key, option.from_any(option.default)) # call the from_any here to support default "random"
def roll_settings(weights: dict, plando_options: PlandoOptions = PlandoOptions.bosses):
from worlds import AutoWorldRegister
if "linked_options" in weights:
weights = roll_linked_options(weights)
valid_keys = set()
if "triggers" in weights:
weights = roll_triggers(weights, weights["triggers"], valid_keys)
weights = roll_triggers(weights, weights["triggers"])
requirements = weights.get("requires", {})
if requirements:
@@ -448,22 +445,13 @@ def roll_settings(weights: dict, plando_options: PlandoOptions = PlandoOptions.b
f"which is not enabled.")
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:
for option_key in Options.per_game_common_options:
if option_key in weights and option_key not in Options.common_options:
raise Exception(f"Option {option_key} has to be in a game's section, not on its own.")
ret.game = get_choice("game", weights)
if not isinstance(ret.game, str):
if ret.game is None:
raise Exception('"game" not specified')
raise Exception(f"Invalid game: {ret.game}")
if ret.game not in AutoWorldRegister.world_types:
from worlds import failed_world_loads
picks = Utils.get_fuzzy_results(ret.game, list(AutoWorldRegister.world_types) + failed_world_loads, limit=1)[0]
if picks[0] in failed_world_loads:
raise Exception(f"No functional world found to handle game {ret.game}. "
f"Did you mean '{picks[0]}' ({picks[1]}% sure)? "
f"If so, it appears the world failed to initialize correctly.")
picks = Utils.get_fuzzy_results(ret.game, AutoWorldRegister.world_types, limit=1)[0]
raise Exception(f"No world found to handle game {ret.game}. Did you mean '{picks[0]}' ({picks[1]}% sure)? "
f"Check your spelling or installation of that world.")
@@ -473,36 +461,161 @@ def roll_settings(weights: dict, plando_options: PlandoOptions = PlandoOptions.b
world_type = AutoWorldRegister.world_types[ret.game]
game_weights = weights[ret.game]
for weight in chain(game_weights, weights):
if weight.startswith("+"):
raise Exception(f"Merge tag cannot be used outside of trigger contexts. Found {weight}")
if weight.startswith("-"):
raise Exception(f"Remove tag cannot be used outside of trigger contexts. Found {weight}")
if "triggers" in game_weights:
weights = roll_triggers(weights, game_weights["triggers"], valid_keys)
weights = roll_triggers(weights, game_weights["triggers"])
game_weights = weights[ret.game]
ret.name = get_choice('name', weights)
for option_key, option in Options.CommonOptions.type_hints.items():
for option_key, option in Options.common_options.items():
setattr(ret, option_key, option.from_any(get_choice(option_key, weights, option.default)))
for option_key, option in world_type.options_dataclass.type_hints.items():
for option_key, option in world_type.option_definitions.items():
handle_option(ret, game_weights, option_key, option, plando_options)
valid_keys.add(option_key)
for option_key in game_weights:
if option_key in {"triggers", *valid_keys}:
continue
logging.warning(f"{option_key} is not a valid option name for {ret.game} and is not present in triggers.")
for option_key, option in Options.per_game_common_options.items():
# skip setting this option if already set from common_options, defaulting to root option
if option_key not in world_type.option_definitions and \
(option_key not in Options.common_options or option_key in game_weights):
handle_option(ret, game_weights, option_key, option, plando_options)
if PlandoOptions.items in plando_options:
ret.plando_items = copy.deepcopy(game_weights.get("plando_items", []))
if ret.game == "A Link to the Past":
roll_alttp_settings(ret, game_weights)
ret.plando_items = game_weights.get("plando_items", [])
if ret.game == "Minecraft" or ret.game == "Ocarina of Time":
# bad hardcoded behavior to make this work for now
ret.plando_connections = []
if PlandoOptions.connections in plando_options:
options = game_weights.get("plando_connections", [])
for placement in options:
if roll_percentage(get_choice("percentage", placement, 100)):
ret.plando_connections.append(PlandoConnection(
get_choice("entrance", placement),
get_choice("exit", placement),
get_choice("direction", placement)
))
elif ret.game == "A Link to the Past":
roll_alttp_settings(ret, game_weights, plando_options)
return ret
def roll_alttp_settings(ret: argparse.Namespace, weights):
def roll_alttp_settings(ret: argparse.Namespace, weights, plando_options):
if "dungeon_items" in weights and get_choice_legacy('dungeon_items', weights, "none") != "none":
raise Exception(f"dungeon_items key in A Link to the Past was removed, but is present in these weights as {get_choice_legacy('dungeon_items', weights, False)}.")
glitches_required = get_choice_legacy('glitches_required', weights)
if glitches_required not in [None, 'none', 'no_logic', 'overworld_glitches', 'hybrid_major_glitches', 'minor_glitches']:
logging.warning("Only NMG, OWG, HMG and No Logic supported")
glitches_required = 'none'
ret.logic = {None: 'noglitches', 'none': 'noglitches', 'no_logic': 'nologic', 'overworld_glitches': 'owglitches',
'minor_glitches': 'minorglitches', 'hybrid_major_glitches': 'hybridglitches'}[
glitches_required]
ret.dark_room_logic = get_choice_legacy("dark_room_logic", weights, "lamp")
if not ret.dark_room_logic: # None/False
ret.dark_room_logic = "none"
if ret.dark_room_logic == "sconces":
ret.dark_room_logic = "torches"
if ret.dark_room_logic not in {"lamp", "torches", "none"}:
raise ValueError(f"Unknown Dark Room Logic: \"{ret.dark_room_logic}\"")
entrance_shuffle = get_choice_legacy('entrance_shuffle', weights, 'vanilla')
if entrance_shuffle.startswith('none-'):
ret.shuffle = 'vanilla'
else:
ret.shuffle = entrance_shuffle if entrance_shuffle != 'none' else 'vanilla'
goal = get_choice_legacy('goals', weights, 'ganon')
ret.goal = goals[goal]
extra_pieces = get_choice_legacy('triforce_pieces_mode', weights, 'available')
ret.triforce_pieces_required = LttPOptions.TriforcePieces.from_any(get_choice_legacy('triforce_pieces_required', weights, 20))
# sum a percentage to required
if extra_pieces == 'percentage':
percentage = max(100, float(get_choice_legacy('triforce_pieces_percentage', weights, 150))) / 100
ret.triforce_pieces_available = int(round(ret.triforce_pieces_required * percentage, 0))
# vanilla mode (specify how many pieces are)
elif extra_pieces == 'available':
ret.triforce_pieces_available = LttPOptions.TriforcePieces.from_any(
get_choice_legacy('triforce_pieces_available', weights, 30))
# required pieces + fixed extra
elif extra_pieces == 'extra':
extra_pieces = max(0, int(get_choice_legacy('triforce_pieces_extra', weights, 10)))
ret.triforce_pieces_available = ret.triforce_pieces_required + extra_pieces
# change minimum to required pieces to avoid problems
ret.triforce_pieces_available = min(max(ret.triforce_pieces_required, int(ret.triforce_pieces_available)), 90)
ret.shop_shuffle = get_choice_legacy('shop_shuffle', weights, '')
if not ret.shop_shuffle:
ret.shop_shuffle = ''
ret.mode = get_choice_legacy("mode", weights)
ret.difficulty = get_choice_legacy('item_pool', weights)
ret.item_functionality = get_choice_legacy('item_functionality', weights)
ret.enemy_damage = {None: 'default',
'default': 'default',
'shuffled': 'shuffled',
'random': 'chaos', # to be removed
'chaos': 'chaos',
}[get_choice_legacy('enemy_damage', weights)]
ret.enemy_health = get_choice_legacy('enemy_health', weights)
ret.timer = {'none': False,
None: False,
False: False,
'timed': 'timed',
'timed_ohko': 'timed-ohko',
'ohko': 'ohko',
'timed_countdown': 'timed-countdown',
'display': 'display'}[get_choice_legacy('timer', weights, False)]
ret.countdown_start_time = int(get_choice_legacy('countdown_start_time', weights, 10))
ret.red_clock_time = int(get_choice_legacy('red_clock_time', weights, -2))
ret.blue_clock_time = int(get_choice_legacy('blue_clock_time', weights, 2))
ret.green_clock_time = int(get_choice_legacy('green_clock_time', weights, 4))
ret.dungeon_counters = get_choice_legacy('dungeon_counters', weights, 'default')
ret.shuffle_prizes = get_choice_legacy('shuffle_prizes', weights, "g")
ret.required_medallions = [get_choice_legacy("misery_mire_medallion", weights, "random"),
get_choice_legacy("turtle_rock_medallion", weights, "random")]
for index, medallion in enumerate(ret.required_medallions):
ret.required_medallions[index] = {"ether": "Ether", "quake": "Quake", "bombos": "Bombos", "random": "random"} \
.get(medallion.lower(), None)
if not ret.required_medallions[index]:
raise Exception(f"unknown Medallion {medallion} for {'misery mire' if index == 0 else 'turtle rock'}")
ret.plando_texts = {}
if PlandoOptions.texts in plando_options:
tt = TextTable()
tt.removeUnwantedText()
options = weights.get("plando_texts", [])
for placement in options:
if roll_percentage(get_choice_legacy("percentage", placement, 100)):
at = str(get_choice_legacy("at", placement))
if at not in tt:
raise Exception(f"No text target \"{at}\" found.")
ret.plando_texts[at] = str(get_choice_legacy("text", placement))
ret.plando_connections = []
if PlandoOptions.connections in plando_options:
options = weights.get("plando_connections", [])
for placement in options:
if roll_percentage(get_choice_legacy("percentage", placement, 100)):
ret.plando_connections.append(PlandoConnection(
get_choice_legacy("entrance", placement),
get_choice_legacy("exit", placement),
get_choice_legacy("direction", placement, "both")
))
ret.sprite_pool = weights.get('sprite_pool', [])
ret.sprite = get_choice_legacy('sprite', weights, "Link")
if 'random_sprite_on_event' in weights:
@@ -530,17 +643,6 @@ def roll_alttp_settings(ret: argparse.Namespace, weights):
if __name__ == '__main__':
import atexit
confirmation = atexit.register(input, "Press enter to close.")
erargs, seed = main()
from Main import main as ERmain
multiworld = ERmain(erargs, seed)
if __debug__:
import gc
import sys
import weakref
weak = weakref.ref(multiworld)
del multiworld
gc.collect() # need to collect to deref all hard references
assert not weak(), f"MultiWorld object was not de-allocated, it's referenced {sys.getrefcount(weak())} times." \
" This would be a memory leak."
main()
# in case of error-free exit should not need confirmation
atexit.unregister(confirmation)

View File

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

View File

@@ -1,8 +1,894 @@
import os
import asyncio
import ModuleUpdate
import json
import Utils
from worlds.kh2.Client import launch
from pymem import pymem
from worlds.kh2.Items import exclusionItem_table, CheckDupingItems
from worlds.kh2 import all_locations, item_dictionary_table, exclusion_table
from worlds.kh2.WorldLocations import *
from worlds import network_data_package
if __name__ == "__main__":
Utils.init_logging("KH2Client", exception_logger="Client")
from NetUtils import ClientStatus
from CommonClient import gui_enabled, logger, get_base_parser, ClientCommandProcessor, \
CommonContext, server_loop
ModuleUpdate.update()
kh2_loc_name_to_id = network_data_package["games"]["Kingdom Hearts 2"]["location_name_to_id"]
# class KH2CommandProcessor(ClientCommandProcessor):
class KH2Context(CommonContext):
# command_processor: int = KH2CommandProcessor
game = "Kingdom Hearts 2"
items_handling = 0b101 # Indicates you get items sent from other worlds.
def __init__(self, server_address, password):
super(KH2Context, self).__init__(server_address, password)
self.kh2LocalItems = None
self.ability = None
self.growthlevel = None
self.KH2_sync_task = None
self.syncing = False
self.kh2connected = False
self.serverconneced = False
self.item_name_to_data = {name: data for name, data, in item_dictionary_table.items()}
self.location_name_to_data = {name: data for name, data, in all_locations.items()}
self.lookup_id_to_item: typing.Dict[int, str] = {data.code: item_name for item_name, data in
item_dictionary_table.items() if data.code}
self.lookup_id_to_Location: typing.Dict[int, str] = {data.code: item_name for item_name, data in
all_locations.items() if data.code}
self.location_name_to_worlddata = {name: data for name, data, in all_world_locations.items()}
self.location_table = {}
self.collectible_table = {}
self.collectible_override_flags_address = 0
self.collectible_offsets = {}
self.sending = []
# list used to keep track of locations+items player has. Used for disoneccting
self.kh2seedsave = None
self.slotDataProgressionNames = {}
self.kh2seedname = None
self.kh2slotdata = None
self.itemamount = {}
# sora equipped, valor equipped, master equipped, final equipped
self.keybladeAnchorList = (0x24F0, 0x32F4, 0x339C, 0x33D4)
if "localappdata" in os.environ:
self.game_communication_path = os.path.expandvars(r"%localappdata%\KH2AP")
self.amountOfPieces = 0
# hooked object
self.kh2 = None
self.ItemIsSafe = False
self.game_connected = False
self.finalxemnas = False
self.worldid = {
# 1: {}, # world of darkness (story cutscenes)
2: TT_Checks,
# 3: {}, # destiny island doesn't have checks to ima put tt checks here
4: HB_Checks,
5: BC_Checks,
6: Oc_Checks,
7: AG_Checks,
8: LoD_Checks,
9: HundredAcreChecks,
10: PL_Checks,
11: DC_Checks, # atlantica isn't a supported world. if you go in atlantica it will check dc
12: DC_Checks,
13: TR_Checks,
14: HT_Checks,
15: HB_Checks, # world map, but you only go to the world map while on the way to goa so checking hb
16: PR_Checks,
17: SP_Checks,
18: TWTNW_Checks,
# 255: {}, # starting screen
}
# 0x2A09C00+0x40 is the sve anchor. +1 is the last saved room
self.sveroom = 0x2A09C00 + 0x41
# 0 not in battle 1 in yellow battle 2 red battle #short
self.inBattle = 0x2A0EAC4 + 0x40
self.onDeath = 0xAB9078
# PC Address anchors
self.Now = 0x0714DB8
self.Save = 0x09A70B0
self.Sys3 = 0x2A59DF0
self.Bt10 = 0x2A74880
self.BtlEnd = 0x2A0D3E0
self.Slot1 = 0x2A20C98
self.chest_set = set(exclusion_table["Chests"])
self.keyblade_set = set(CheckDupingItems["Weapons"]["Keyblades"])
self.staff_set = set(CheckDupingItems["Weapons"]["Staffs"])
self.shield_set = set(CheckDupingItems["Weapons"]["Shields"])
self.all_weapons = self.keyblade_set.union(self.staff_set).union(self.shield_set)
self.equipment_categories = CheckDupingItems["Equipment"]
self.armor_set = set(self.equipment_categories["Armor"])
self.accessories_set = set(self.equipment_categories["Accessories"])
self.all_equipment = self.armor_set.union(self.accessories_set)
self.Equipment_Anchor_Dict = {
"Armor": [0x2504, 0x2506, 0x2508, 0x250A],
"Accessories": [0x2514, 0x2516, 0x2518, 0x251A]}
self.AbilityQuantityDict = {}
self.ability_categories = CheckDupingItems["Abilities"]
self.sora_ability_set = set(self.ability_categories["Sora"])
self.donald_ability_set = set(self.ability_categories["Donald"])
self.goofy_ability_set = set(self.ability_categories["Goofy"])
self.all_abilities = self.sora_ability_set.union(self.donald_ability_set).union(self.goofy_ability_set)
self.boost_set = set(CheckDupingItems["Boosts"])
self.stat_increase_set = set(CheckDupingItems["Stat Increases"])
self.AbilityQuantityDict = {item: self.item_name_to_data[item].quantity for item in self.all_abilities}
# Growth:[level 1,level 4,slot]
self.growth_values_dict = {"High Jump": [0x05E, 0x061, 0x25DA],
"Quick Run": [0x62, 0x65, 0x25DC],
"Dodge Roll": [0x234, 0x237, 0x25DE],
"Aerial Dodge": [0x066, 0x069, 0x25E0],
"Glide": [0x6A, 0x6D, 0x25E2]}
self.boost_to_anchor_dict = {
"Power Boost": 0x24F9,
"Magic Boost": 0x24FA,
"Defense Boost": 0x24FB,
"AP Boost": 0x24F8}
self.AbilityCodeList = [self.item_name_to_data[item].code for item in exclusionItem_table["Ability"]]
self.master_growth = {"High Jump", "Quick Run", "Dodge Roll", "Aerial Dodge", "Glide"}
self.bitmask_item_code = [
0x130000, 0x130001, 0x130002, 0x130003, 0x130004, 0x130005, 0x130006, 0x130007
, 0x130008, 0x130009, 0x13000A, 0x13000B, 0x13000C
, 0x13001F, 0x130020, 0x130021, 0x130022, 0x130023
, 0x13002A, 0x13002B, 0x13002C, 0x13002D]
async def server_auth(self, password_requested: bool = False):
if password_requested and not self.password:
await super(KH2Context, self).server_auth(password_requested)
await self.get_username()
await self.send_connect()
async def connection_closed(self):
self.kh2connected = False
self.serverconneced = False
if self.kh2seedname is not None and self.auth is not None:
with open(os.path.join(self.game_communication_path, f"kh2save{self.kh2seedname}{self.auth}.json"),
'w') as f:
f.write(json.dumps(self.kh2seedsave, indent=4))
await super(KH2Context, self).connection_closed()
async def disconnect(self, allow_autoreconnect: bool = False):
self.kh2connected = False
self.serverconneced = False
if self.kh2seedname not in {None} and self.auth not in {None}:
with open(os.path.join(self.game_communication_path, f"kh2save{self.kh2seedname}{self.auth}.json"),
'w') as f:
f.write(json.dumps(self.kh2seedsave, indent=4))
await super(KH2Context, self).disconnect()
@property
def endpoints(self):
if self.server:
return [self.server]
else:
return []
async def shutdown(self):
if self.kh2seedname not in {None} and self.auth not in {None}:
with open(os.path.join(self.game_communication_path, f"kh2save{self.kh2seedname}{self.auth}.json"),
'w') as f:
f.write(json.dumps(self.kh2seedsave, indent=4))
await super(KH2Context, self).shutdown()
def on_package(self, cmd: str, args: dict):
if cmd in {"RoomInfo"}:
self.kh2seedname = args['seed_name']
if not os.path.exists(self.game_communication_path):
os.makedirs(self.game_communication_path)
if not os.path.exists(self.game_communication_path + f"\kh2save{self.kh2seedname}{self.auth}.json"):
self.kh2seedsave = {"itemIndex": -1,
# back of soras invo is 0x25E2. Growth should be moved there
# Character: [back of invo, front of invo]
"SoraInvo": [0x25D8, 0x2546],
"DonaldInvo": [0x26F4, 0x2658],
"GoofyInvo": [0x280A, 0x276C],
"AmountInvo": {
"ServerItems": {
"Ability": {},
"Amount": {},
"Growth": {"High Jump": 0, "Quick Run": 0, "Dodge Roll": 0,
"Aerial Dodge": 0,
"Glide": 0},
"Bitmask": [],
"Weapon": {"Sora": [], "Donald": [], "Goofy": []},
"Equipment": [],
"Magic": {},
"StatIncrease": {},
"Boost": {},
},
"LocalItems": {
"Ability": {},
"Amount": {},
"Growth": {"High Jump": 0, "Quick Run": 0, "Dodge Roll": 0,
"Aerial Dodge": 0, "Glide": 0},
"Bitmask": [],
"Weapon": {"Sora": [], "Donald": [], "Goofy": []},
"Equipment": [],
"Magic": {},
"StatIncrease": {},
"Boost": {},
}},
# 1,3,255 are in this list in case the player gets locations in those "worlds" and I need to still have them checked
"LocationsChecked": [],
"Levels": {
"SoraLevel": 0,
"ValorLevel": 0,
"WisdomLevel": 0,
"LimitLevel": 0,
"MasterLevel": 0,
"FinalLevel": 0,
},
"SoldEquipment": [],
"SoldBoosts": {"Power Boost": 0,
"Magic Boost": 0,
"Defense Boost": 0,
"AP Boost": 0}
}
with open(os.path.join(self.game_communication_path, f"kh2save{self.kh2seedname}{self.auth}.json"),
'wt') as f:
pass
self.locations_checked = set()
elif os.path.exists(self.game_communication_path + f"\kh2save{self.kh2seedname}{self.auth}.json"):
with open(self.game_communication_path + f"\kh2save{self.kh2seedname}{self.auth}.json", 'r') as f:
self.kh2seedsave = json.load(f)
self.locations_checked = set(self.kh2seedsave["LocationsChecked"])
self.serverconneced = True
if cmd in {"Connected"}:
self.kh2slotdata = args['slot_data']
self.kh2LocalItems = {int(location): item for location, item in self.kh2slotdata["LocalItems"].items()}
try:
self.kh2 = pymem.Pymem(process_name="KINGDOM HEARTS II FINAL MIX")
logger.info("You are now auto-tracking")
self.kh2connected = True
except Exception as e:
logger.info("Line 247")
if self.kh2connected:
logger.info("Connection Lost")
self.kh2connected = False
logger.info(e)
if cmd in {"ReceivedItems"}:
start_index = args["index"]
if start_index == 0:
# resetting everything that were sent from the server
self.kh2seedsave["SoraInvo"][0] = 0x25D8
self.kh2seedsave["DonaldInvo"][0] = 0x26F4
self.kh2seedsave["GoofyInvo"][0] = 0x280A
self.kh2seedsave["itemIndex"] = - 1
self.kh2seedsave["AmountInvo"]["ServerItems"] = {
"Ability": {},
"Amount": {},
"Growth": {"High Jump": 0, "Quick Run": 0, "Dodge Roll": 0,
"Aerial Dodge": 0,
"Glide": 0},
"Bitmask": [],
"Weapon": {"Sora": [], "Donald": [], "Goofy": []},
"Equipment": [],
"Magic": {},
"StatIncrease": {},
"Boost": {},
}
if start_index > self.kh2seedsave["itemIndex"]:
self.kh2seedsave["itemIndex"] = start_index
for item in args['items']:
asyncio.create_task(self.give_item(item.item))
if cmd in {"RoomUpdate"}:
if "checked_locations" in args:
new_locations = set(args["checked_locations"])
# TODO: make this take locations from other players on the same slot so proper coop happens
# items_to_give = [self.kh2slotdata["LocalItems"][str(location_id)] for location_id in new_locations if
# location_id in self.kh2LocalItems.keys()]
self.checked_locations |= new_locations
async def checkWorldLocations(self):
try:
currentworldint = int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + 0x0714DB8, 1), "big")
if currentworldint in self.worldid:
curworldid = self.worldid[currentworldint]
for location, data in curworldid.items():
locationId = kh2_loc_name_to_id[location]
if locationId not in self.locations_checked \
and (int.from_bytes(
self.kh2.read_bytes(self.kh2.base_address + self.Save + data.addrObtained, 1),
"big") & 0x1 << data.bitIndex) > 0:
self.sending = self.sending + [(int(locationId))]
except Exception as e:
logger.info("Line 285")
if self.kh2connected:
logger.info("Connection Lost.")
self.kh2connected = False
logger.info(e)
async def checkLevels(self):
try:
for location, data in SoraLevels.items():
currentLevel = int.from_bytes(
self.kh2.read_bytes(self.kh2.base_address + self.Save + 0x24FF, 1), "big")
locationId = kh2_loc_name_to_id[location]
if locationId not in self.locations_checked \
and currentLevel >= data.bitIndex:
if self.kh2seedsave["Levels"]["SoraLevel"] < currentLevel:
self.kh2seedsave["Levels"]["SoraLevel"] = currentLevel
self.sending = self.sending + [(int(locationId))]
formDict = {
0: ["ValorLevel", ValorLevels], 1: ["WisdomLevel", WisdomLevels], 2: ["LimitLevel", LimitLevels],
3: ["MasterLevel", MasterLevels], 4: ["FinalLevel", FinalLevels]}
for i in range(5):
for location, data in formDict[i][1].items():
formlevel = int.from_bytes(
self.kh2.read_bytes(self.kh2.base_address + self.Save + data.addrObtained, 1), "big")
locationId = kh2_loc_name_to_id[location]
if locationId not in self.locations_checked \
and formlevel >= data.bitIndex:
if formlevel > self.kh2seedsave["Levels"][formDict[i][0]]:
self.kh2seedsave["Levels"][formDict[i][0]] = formlevel
self.sending = self.sending + [(int(locationId))]
except Exception as e:
logger.info("Line 312")
if self.kh2connected:
logger.info("Connection Lost.")
self.kh2connected = False
logger.info(e)
async def checkSlots(self):
try:
for location, data in weaponSlots.items():
locationId = kh2_loc_name_to_id[location]
if locationId not in self.locations_checked:
if int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + self.Save + data.addrObtained, 1),
"big") > 0:
self.sending = self.sending + [(int(locationId))]
for location, data in formSlots.items():
locationId = kh2_loc_name_to_id[location]
if locationId not in self.locations_checked:
if int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + self.Save + data.addrObtained, 1),
"big") & 0x1 << data.bitIndex > 0:
# self.locations_checked
self.sending = self.sending + [(int(locationId))]
except Exception as e:
if self.kh2connected:
logger.info("Line 333")
logger.info("Connection Lost.")
self.kh2connected = False
logger.info(e)
async def verifyChests(self):
try:
for location in self.locations_checked:
locationName = self.lookup_id_to_Location[location]
if locationName in self.chest_set:
if locationName in self.location_name_to_worlddata.keys():
locationData = self.location_name_to_worlddata[locationName]
if int.from_bytes(
self.kh2.read_bytes(self.kh2.base_address + self.Save + locationData.addrObtained, 1),
"big") & 0x1 << locationData.bitIndex == 0:
roomData = int.from_bytes(
self.kh2.read_bytes(self.kh2.base_address + self.Save + locationData.addrObtained,
1), "big")
self.kh2.write_bytes(self.kh2.base_address + self.Save + locationData.addrObtained,
(roomData | 0x01 << locationData.bitIndex).to_bytes(1, 'big'), 1)
except Exception as e:
if self.kh2connected:
logger.info("Line 350")
logger.info("Connection Lost.")
self.kh2connected = False
logger.info(e)
async def verifyLevel(self):
for leveltype, anchor in {"SoraLevel": 0x24FF,
"ValorLevel": 0x32F6,
"WisdomLevel": 0x332E,
"LimitLevel": 0x3366,
"MasterLevel": 0x339E,
"FinalLevel": 0x33D6}.items():
if int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + self.Save + anchor, 1), "big") < \
self.kh2seedsave["Levels"][leveltype]:
self.kh2.write_bytes(self.kh2.base_address + self.Save + anchor,
(self.kh2seedsave["Levels"][leveltype]).to_bytes(1, 'big'), 1)
async def give_item(self, item, ItemType="ServerItems"):
try:
itemname = self.lookup_id_to_item[item]
itemcode = self.item_name_to_data[itemname]
if itemcode.ability:
abilityInvoType = 0
TwilightZone = 2
if ItemType == "LocalItems":
abilityInvoType = 1
TwilightZone = -2
if itemname in {"High Jump", "Quick Run", "Dodge Roll", "Aerial Dodge", "Glide"}:
self.kh2seedsave["AmountInvo"][ItemType]["Growth"][itemname] += 1
return
if itemname not in self.kh2seedsave["AmountInvo"][ItemType]["Ability"]:
self.kh2seedsave["AmountInvo"][ItemType]["Ability"][itemname] = []
# appending the slot that the ability should be in
if len(self.kh2seedsave["AmountInvo"][ItemType]["Ability"][itemname]) < \
self.AbilityQuantityDict[itemname]:
if itemname in self.sora_ability_set:
self.kh2seedsave["AmountInvo"][ItemType]["Ability"][itemname].append(
self.kh2seedsave["SoraInvo"][abilityInvoType])
self.kh2seedsave["SoraInvo"][abilityInvoType] -= TwilightZone
elif itemname in self.donald_ability_set:
self.kh2seedsave["AmountInvo"][ItemType]["Ability"][itemname].append(
self.kh2seedsave["DonaldInvo"][abilityInvoType])
self.kh2seedsave["DonaldInvo"][abilityInvoType] -= TwilightZone
else:
self.kh2seedsave["AmountInvo"][ItemType]["Ability"][itemname].append(
self.kh2seedsave["GoofyInvo"][abilityInvoType])
self.kh2seedsave["GoofyInvo"][abilityInvoType] -= TwilightZone
elif itemcode.code in self.bitmask_item_code:
if itemname not in self.kh2seedsave["AmountInvo"][ItemType]["Bitmask"]:
self.kh2seedsave["AmountInvo"][ItemType]["Bitmask"].append(itemname)
elif itemcode.memaddr in {0x3594, 0x3595, 0x3596, 0x3597, 0x35CF, 0x35D0}:
if itemname in self.kh2seedsave["AmountInvo"][ItemType]["Magic"]:
self.kh2seedsave["AmountInvo"][ItemType]["Magic"][itemname] += 1
else:
self.kh2seedsave["AmountInvo"][ItemType]["Magic"][itemname] = 1
elif itemname in self.all_equipment:
self.kh2seedsave["AmountInvo"][ItemType]["Equipment"].append(itemname)
elif itemname in self.all_weapons:
if itemname in self.keyblade_set:
self.kh2seedsave["AmountInvo"][ItemType]["Weapon"]["Sora"].append(itemname)
elif itemname in self.staff_set:
self.kh2seedsave["AmountInvo"][ItemType]["Weapon"]["Donald"].append(itemname)
else:
self.kh2seedsave["AmountInvo"][ItemType]["Weapon"]["Goofy"].append(itemname)
elif itemname in self.boost_set:
if itemname in self.kh2seedsave["AmountInvo"][ItemType]["Boost"]:
self.kh2seedsave["AmountInvo"][ItemType]["Boost"][itemname] += 1
else:
self.kh2seedsave["AmountInvo"][ItemType]["Boost"][itemname] = 1
elif itemname in self.stat_increase_set:
if itemname in self.kh2seedsave["AmountInvo"][ItemType]["StatIncrease"]:
self.kh2seedsave["AmountInvo"][ItemType]["StatIncrease"][itemname] += 1
else:
self.kh2seedsave["AmountInvo"][ItemType]["StatIncrease"][itemname] = 1
else:
if itemname in self.kh2seedsave["AmountInvo"][ItemType]["Amount"]:
self.kh2seedsave["AmountInvo"][ItemType]["Amount"][itemname] += 1
else:
self.kh2seedsave["AmountInvo"][ItemType]["Amount"][itemname] = 1
except Exception as e:
if self.kh2connected:
logger.info("Line 398")
logger.info("Connection Lost.")
self.kh2connected = False
logger.info(e)
def run_gui(self):
"""Import kivy UI system and start running it as self.ui_task."""
from kvui import GameManager
class KH2Manager(GameManager):
logging_pairs = [
("Client", "Archipelago")
]
base_title = "Archipelago KH2 Client"
self.ui = KH2Manager(self)
self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI")
async def IsInShop(self, sellable, master_boost):
# journal = 0x741230 shop = 0x741320
# if journal=-1 and shop = 5 then in shop
# if journam !=-1 and shop = 10 then journal
journal = self.kh2.read_short(self.kh2.base_address + 0x741230)
shop = self.kh2.read_short(self.kh2.base_address + 0x741320)
if (journal == -1 and shop == 5) or (journal != -1 and shop == 10):
# print("your in the shop")
sellable_dict = {}
for itemName in sellable:
itemdata = self.item_name_to_data[itemName]
amount = int.from_bytes(
self.kh2.read_bytes(self.kh2.base_address + self.Save + itemdata.memaddr, 1), "big")
sellable_dict[itemName] = amount
while (journal == -1 and shop == 5) or (journal != -1 and shop == 10):
journal = self.kh2.read_short(self.kh2.base_address + 0x741230)
shop = self.kh2.read_short(self.kh2.base_address + 0x741320)
await asyncio.sleep(0.5)
for item, amount in sellable_dict.items():
itemdata = self.item_name_to_data[item]
afterShop = int.from_bytes(
self.kh2.read_bytes(self.kh2.base_address + self.Save + itemdata.memaddr, 1), "big")
if afterShop < amount:
if item in master_boost:
self.kh2seedsave["SoldBoosts"][item] += (amount - afterShop)
else:
self.kh2seedsave["SoldEquipment"].append(item)
async def verifyItems(self):
try:
local_amount = set(self.kh2seedsave["AmountInvo"]["LocalItems"]["Amount"].keys())
server_amount = set(self.kh2seedsave["AmountInvo"]["ServerItems"]["Amount"].keys())
master_amount = local_amount | server_amount
local_ability = set(self.kh2seedsave["AmountInvo"]["LocalItems"]["Ability"].keys())
server_ability = set(self.kh2seedsave["AmountInvo"]["ServerItems"]["Ability"].keys())
master_ability = local_ability | server_ability
local_bitmask = set(self.kh2seedsave["AmountInvo"]["LocalItems"]["Bitmask"])
server_bitmask = set(self.kh2seedsave["AmountInvo"]["ServerItems"]["Bitmask"])
master_bitmask = local_bitmask | server_bitmask
local_keyblade = set(self.kh2seedsave["AmountInvo"]["LocalItems"]["Weapon"]["Sora"])
local_staff = set(self.kh2seedsave["AmountInvo"]["LocalItems"]["Weapon"]["Donald"])
local_shield = set(self.kh2seedsave["AmountInvo"]["LocalItems"]["Weapon"]["Goofy"])
server_keyblade = set(self.kh2seedsave["AmountInvo"]["ServerItems"]["Weapon"]["Sora"])
server_staff = set(self.kh2seedsave["AmountInvo"]["ServerItems"]["Weapon"]["Donald"])
server_shield = set(self.kh2seedsave["AmountInvo"]["ServerItems"]["Weapon"]["Goofy"])
master_keyblade = local_keyblade | server_keyblade
master_staff = local_staff | server_staff
master_shield = local_shield | server_shield
local_equipment = set(self.kh2seedsave["AmountInvo"]["LocalItems"]["Equipment"])
server_equipment = set(self.kh2seedsave["AmountInvo"]["ServerItems"]["Equipment"])
master_equipment = local_equipment | server_equipment
local_magic = set(self.kh2seedsave["AmountInvo"]["LocalItems"]["Magic"].keys())
server_magic = set(self.kh2seedsave["AmountInvo"]["ServerItems"]["Magic"].keys())
master_magic = local_magic | server_magic
local_stat = set(self.kh2seedsave["AmountInvo"]["LocalItems"]["StatIncrease"].keys())
server_stat = set(self.kh2seedsave["AmountInvo"]["ServerItems"]["StatIncrease"].keys())
master_stat = local_stat | server_stat
local_boost = set(self.kh2seedsave["AmountInvo"]["LocalItems"]["Boost"].keys())
server_boost = set(self.kh2seedsave["AmountInvo"]["ServerItems"]["Boost"].keys())
master_boost = local_boost | server_boost
master_sell = master_equipment | master_staff | master_shield | master_boost
await asyncio.create_task(self.IsInShop(master_sell, master_boost))
for itemName in master_amount:
itemData = self.item_name_to_data[itemName]
amountOfItems = 0
if itemName in local_amount:
amountOfItems += self.kh2seedsave["AmountInvo"]["LocalItems"]["Amount"][itemName]
if itemName in server_amount:
amountOfItems += self.kh2seedsave["AmountInvo"]["ServerItems"]["Amount"][itemName]
if itemName == "Torn Page":
# Torn Pages are handled differently because they can be consumed.
# Will check the progression in 100 acre and - the amount of visits
# amountofitems-amount of visits done
for location, data in tornPageLocks.items():
if int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + self.Save + data.addrObtained, 1),
"big") & 0x1 << data.bitIndex > 0:
amountOfItems -= 1
if int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + self.Save + itemData.memaddr, 1),
"big") != amountOfItems and amountOfItems >= 0:
self.kh2.write_bytes(self.kh2.base_address + self.Save + itemData.memaddr,
amountOfItems.to_bytes(1, 'big'), 1)
for itemName in master_keyblade:
itemData = self.item_name_to_data[itemName]
# if the inventory slot for that keyblade is less than the amount they should have
if int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + self.Save + itemData.memaddr, 1),
"big") != 1 and int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + 0x1CFF, 1),
"big") != 13:
# Checking form anchors for the keyblade
if self.kh2.read_short(self.kh2.base_address + self.Save + 0x24F0) == itemData.kh2id \
or self.kh2.read_short(self.kh2.base_address + self.Save + 0x32F4) == itemData.kh2id \
or self.kh2.read_short(self.kh2.base_address + self.Save + 0x339C) == itemData.kh2id \
or self.kh2.read_short(self.kh2.base_address + self.Save + 0x33D4) == itemData.kh2id:
self.kh2.write_bytes(self.kh2.base_address + self.Save + itemData.memaddr,
(0).to_bytes(1, 'big'), 1)
else:
self.kh2.write_bytes(self.kh2.base_address + self.Save + itemData.memaddr,
(1).to_bytes(1, 'big'), 1)
for itemName in master_staff:
itemData = self.item_name_to_data[itemName]
if int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + self.Save + itemData.memaddr, 1),
"big") != 1 \
and self.kh2.read_short(self.kh2.base_address + self.Save + 0x2604) != itemData.kh2id \
and itemName not in self.kh2seedsave["SoldEquipment"]:
self.kh2.write_bytes(self.kh2.base_address + self.Save + itemData.memaddr,
(1).to_bytes(1, 'big'), 1)
for itemName in master_shield:
itemData = self.item_name_to_data[itemName]
if int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + self.Save + itemData.memaddr, 1),
"big") != 1 \
and self.kh2.read_short(self.kh2.base_address + self.Save + 0x2718) != itemData.kh2id \
and itemName not in self.kh2seedsave["SoldEquipment"]:
self.kh2.write_bytes(self.kh2.base_address + self.Save + itemData.memaddr,
(1).to_bytes(1, 'big'), 1)
for itemName in master_ability:
itemData = self.item_name_to_data[itemName]
ability_slot = []
if itemName in local_ability:
ability_slot += self.kh2seedsave["AmountInvo"]["LocalItems"]["Ability"][itemName]
if itemName in server_ability:
ability_slot += self.kh2seedsave["AmountInvo"]["ServerItems"]["Ability"][itemName]
for slot in ability_slot:
current = self.kh2.read_short(self.kh2.base_address + self.Save + slot)
ability = current & 0x0FFF
if ability | 0x8000 != (0x8000 + itemData.memaddr):
if current - 0x8000 > 0:
self.kh2.write_short(self.kh2.base_address + self.Save + slot, (0x8000 + itemData.memaddr))
else:
self.kh2.write_short(self.kh2.base_address + self.Save + slot, itemData.memaddr)
# removes the duped ability if client gave faster than the game.
for charInvo in {"SoraInvo", "DonaldInvo", "GoofyInvo"}:
if self.kh2.read_short(self.kh2.base_address + self.Save + self.kh2seedsave[charInvo][1]) != 0 and \
self.kh2seedsave[charInvo][1] + 2 < self.kh2seedsave[charInvo][0]:
self.kh2.write_short(self.kh2.base_address + self.Save + self.kh2seedsave[charInvo][1], 0)
# remove the dummy level 1 growths if they are in these invo slots.
for inventorySlot in {0x25CE, 0x25D0, 0x25D2, 0x25D4, 0x25D6, 0x25D8}:
current = self.kh2.read_short(self.kh2.base_address + self.Save + inventorySlot)
ability = current & 0x0FFF
if 0x05E <= ability <= 0x06D:
self.kh2.write_short(self.kh2.base_address + self.Save + inventorySlot, 0)
for itemName in self.master_growth:
growthLevel = self.kh2seedsave["AmountInvo"]["ServerItems"]["Growth"][itemName] \
+ self.kh2seedsave["AmountInvo"]["LocalItems"]["Growth"][itemName]
if growthLevel > 0:
slot = self.growth_values_dict[itemName][2]
min_growth = self.growth_values_dict[itemName][0]
max_growth = self.growth_values_dict[itemName][1]
if growthLevel > 4:
growthLevel = 4
current_growth_level = self.kh2.read_short(self.kh2.base_address + self.Save + slot)
ability = current_growth_level & 0x0FFF
# if the player should be getting a growth ability
if ability | 0x8000 != 0x8000 + min_growth - 1 + growthLevel:
# if it should be level one of that growth
if 0x8000 + min_growth - 1 + growthLevel <= 0x8000 + min_growth or ability < min_growth:
self.kh2.write_short(self.kh2.base_address + self.Save + slot, min_growth)
# if it is already in the inventory
elif ability | 0x8000 < (0x8000 + max_growth):
self.kh2.write_short(self.kh2.base_address + self.Save + slot, current_growth_level + 1)
for itemName in master_bitmask:
itemData = self.item_name_to_data[itemName]
itemMemory = int.from_bytes(
self.kh2.read_bytes(self.kh2.base_address + self.Save + itemData.memaddr, 1), "big")
if (int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + self.Save + itemData.memaddr, 1),
"big") & 0x1 << itemData.bitmask) == 0:
# when getting a form anti points should be reset to 0 but bit-shift doesn't trigger the game.
if itemName in {"Valor Form", "Wisdom Form", "Limit Form", "Master Form", "Final Form"}:
self.kh2.write_bytes(self.kh2.base_address + self.Save + 0x3410,
(0).to_bytes(1, 'big'), 1)
self.kh2.write_bytes(self.kh2.base_address + self.Save + itemData.memaddr,
(itemMemory | 0x01 << itemData.bitmask).to_bytes(1, 'big'), 1)
for itemName in master_equipment:
itemData = self.item_name_to_data[itemName]
isThere = False
if itemName in self.accessories_set:
Equipment_Anchor_List = self.Equipment_Anchor_Dict["Accessories"]
else:
Equipment_Anchor_List = self.Equipment_Anchor_Dict["Armor"]
# Checking form anchors for the equipment
for slot in Equipment_Anchor_List:
if self.kh2.read_short(self.kh2.base_address + self.Save + slot) == itemData.kh2id:
isThere = True
if int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + self.Save + itemData.memaddr, 1),
"big") != 0:
self.kh2.write_bytes(self.kh2.base_address + self.Save + itemData.memaddr,
(0).to_bytes(1, 'big'), 1)
break
if not isThere and itemName not in self.kh2seedsave["SoldEquipment"]:
if int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + self.Save + itemData.memaddr, 1),
"big") != 1:
self.kh2.write_bytes(self.kh2.base_address + self.Save + itemData.memaddr,
(1).to_bytes(1, 'big'), 1)
for itemName in master_magic:
itemData = self.item_name_to_data[itemName]
amountOfItems = 0
if itemName in local_magic:
amountOfItems += self.kh2seedsave["AmountInvo"]["LocalItems"]["Magic"][itemName]
if itemName in server_magic:
amountOfItems += self.kh2seedsave["AmountInvo"]["ServerItems"]["Magic"][itemName]
if int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + self.Save + itemData.memaddr, 1),
"big") != amountOfItems \
and int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + 0x741320, 1), "big") in {10, 8}:
self.kh2.write_bytes(self.kh2.base_address + self.Save + itemData.memaddr,
amountOfItems.to_bytes(1, 'big'), 1)
for itemName in master_stat:
itemData = self.item_name_to_data[itemName]
amountOfItems = 0
if itemName in local_stat:
amountOfItems += self.kh2seedsave["AmountInvo"]["LocalItems"]["StatIncrease"][itemName]
if itemName in server_stat:
amountOfItems += self.kh2seedsave["AmountInvo"]["ServerItems"]["StatIncrease"][itemName]
# 0x130293 is Crit_1's location id for touching the computer
if int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + self.Save + itemData.memaddr, 1),
"big") != amountOfItems \
and int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + self.Slot1 + 0x1B2, 1),
"big") >= 5 and int.from_bytes(
self.kh2.read_bytes(self.kh2.base_address + self.Save + 0x23DF, 1),
"big") > 0:
self.kh2.write_bytes(self.kh2.base_address + self.Save + itemData.memaddr,
amountOfItems.to_bytes(1, 'big'), 1)
for itemName in master_boost:
itemData = self.item_name_to_data[itemName]
amountOfItems = 0
if itemName in local_boost:
amountOfItems += self.kh2seedsave["AmountInvo"]["LocalItems"]["Boost"][itemName]
if itemName in server_boost:
amountOfItems += self.kh2seedsave["AmountInvo"]["ServerItems"]["Boost"][itemName]
amountOfBoostsInInvo = int.from_bytes(
self.kh2.read_bytes(self.kh2.base_address + self.Save + itemData.memaddr, 1),
"big")
amountOfUsedBoosts = int.from_bytes(
self.kh2.read_bytes(self.kh2.base_address + self.Save + self.boost_to_anchor_dict[itemName], 1),
"big")
# Ap Boots start at +50 for some reason
if itemName == "AP Boost":
amountOfUsedBoosts -= 50
totalBoosts = (amountOfBoostsInInvo + amountOfUsedBoosts)
if totalBoosts <= amountOfItems - self.kh2seedsave["SoldBoosts"][
itemName] and amountOfBoostsInInvo < 255:
self.kh2.write_bytes(self.kh2.base_address + self.Save + itemData.memaddr,
(amountOfBoostsInInvo + 1).to_bytes(1, 'big'), 1)
except Exception as e:
logger.info("Line 573")
if self.kh2connected:
logger.info("Connection Lost.")
self.kh2connected = False
logger.info(e)
def finishedGame(ctx: KH2Context, message):
if ctx.kh2slotdata['FinalXemnas'] == 1:
if 0x1301ED in message[0]["locations"]:
ctx.finalxemnas = True
# three proofs
if ctx.kh2slotdata['Goal'] == 0:
if int.from_bytes(ctx.kh2.read_bytes(ctx.kh2.base_address + ctx.Save + 0x36B2, 1), "big") > 0 \
and int.from_bytes(ctx.kh2.read_bytes(ctx.kh2.base_address + ctx.Save + 0x36B3, 1), "big") > 0 \
and int.from_bytes(ctx.kh2.read_bytes(ctx.kh2.base_address + ctx.Save + 0x36B4, 1), "big") > 0:
if ctx.kh2slotdata['FinalXemnas'] == 1:
if ctx.finalxemnas:
return True
else:
return False
else:
return True
else:
return False
elif ctx.kh2slotdata['Goal'] == 1:
if int.from_bytes(ctx.kh2.read_bytes(ctx.kh2.base_address + ctx.Save + 0x3641, 1), "big") >= \
ctx.kh2slotdata['LuckyEmblemsRequired']:
ctx.kh2.write_bytes(ctx.kh2.base_address + ctx.Save + 0x36B2, (1).to_bytes(1, 'big'), 1)
ctx.kh2.write_bytes(ctx.kh2.base_address + ctx.Save + 0x36B3, (1).to_bytes(1, 'big'), 1)
ctx.kh2.write_bytes(ctx.kh2.base_address + ctx.Save + 0x36B4, (1).to_bytes(1, 'big'), 1)
if ctx.kh2slotdata['FinalXemnas'] == 1:
if ctx.finalxemnas:
return True
else:
return False
else:
return True
else:
return False
elif ctx.kh2slotdata['Goal'] == 2:
for boss in ctx.kh2slotdata["hitlist"]:
if boss in message[0]["locations"]:
ctx.amountOfPieces += 1
if ctx.amountOfPieces >= ctx.kh2slotdata["BountyRequired"]:
ctx.kh2.write_bytes(ctx.kh2.base_address + ctx.Save + 0x36B2, (1).to_bytes(1, 'big'), 1)
ctx.kh2.write_bytes(ctx.kh2.base_address + ctx.Save + 0x36B3, (1).to_bytes(1, 'big'), 1)
ctx.kh2.write_bytes(ctx.kh2.base_address + ctx.Save + 0x36B4, (1).to_bytes(1, 'big'), 1)
if ctx.kh2slotdata['FinalXemnas'] == 1:
if ctx.finalxemnas:
return True
else:
return False
else:
return True
else:
return False
async def kh2_watcher(ctx: KH2Context):
while not ctx.exit_event.is_set():
try:
if ctx.kh2connected and ctx.serverconneced:
ctx.sending = []
await asyncio.create_task(ctx.checkWorldLocations())
await asyncio.create_task(ctx.checkLevels())
await asyncio.create_task(ctx.checkSlots())
await asyncio.create_task(ctx.verifyChests())
await asyncio.create_task(ctx.verifyItems())
await asyncio.create_task(ctx.verifyLevel())
message = [{"cmd": 'LocationChecks', "locations": ctx.sending}]
if finishedGame(ctx, message):
await ctx.send_msgs([{"cmd": "StatusUpdate", "status": ClientStatus.CLIENT_GOAL}])
ctx.finished_game = True
location_ids = []
location_ids = [location for location in message[0]["locations"] if location not in location_ids]
for location in location_ids:
if location not in ctx.locations_checked:
ctx.locations_checked.add(location)
ctx.kh2seedsave["LocationsChecked"].append(location)
if location in ctx.kh2LocalItems:
item = ctx.kh2slotdata["LocalItems"][str(location)]
await asyncio.create_task(ctx.give_item(item, "LocalItems"))
await ctx.send_msgs(message)
elif not ctx.kh2connected and ctx.serverconneced:
logger.info("Game is not open. Disconnecting from Server.")
await ctx.disconnect()
except Exception as e:
logger.info("Line 661")
if ctx.kh2connected:
logger.info("Connection Lost.")
ctx.kh2connected = False
logger.info(e)
await asyncio.sleep(0.5)
if __name__ == '__main__':
Utils.init_logging("KH2Client", exception_logger="Client")
launch()
async def main(args):
ctx = KH2Context(args.connect, args.password)
ctx.server_task = asyncio.create_task(server_loop(ctx), name="server loop")
if gui_enabled:
ctx.run_gui()
ctx.run_cli()
progression_watcher = asyncio.create_task(
kh2_watcher(ctx), name="KH2ProgressionWatcher")
await ctx.exit_event.wait()
ctx.server_address = None
await progression_watcher
await ctx.shutdown()
import colorama
parser = get_base_parser(description="KH2 Client, for text interfacing.")
args, rest = parser.parse_known_args()
colorama.init()
asyncio.run(main(args))
colorama.deinit()

View File

@@ -16,27 +16,25 @@ import multiprocessing
import shlex
import subprocess
import sys
import urllib.parse
import webbrowser
from os.path import isfile
from shutil import which
from typing import Callable, Optional, Sequence, Tuple, Union
from typing import Sequence, Union, Optional
import Utils
import settings
from worlds.LauncherComponents import Component, components, Type, SuffixIdentifier, icon_paths
if __name__ == "__main__":
import ModuleUpdate
ModuleUpdate.update()
import settings
import Utils
from Utils import (init_logging, is_frozen, is_linux, is_macos, is_windows, local_path, messagebox, open_filename,
user_path)
from worlds.LauncherComponents import Component, components, icon_paths, SuffixIdentifier, Type
from Utils import is_frozen, user_path, local_path, init_logging, open_filename, messagebox, \
is_windows, is_macos, is_linux
def open_host_yaml():
s = settings.get_settings()
file = s.filename
s.save()
file = settings.get_settings().filename
assert file, "host.yaml missing"
if is_linux:
exe = which('sensible-editor') or which('gedit') or \
@@ -102,94 +100,14 @@ components.extend([
# Functions
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("Generate Template Settings", func=generate_yamls),
Component("Discord Server", icon="discord", func=lambda: webbrowser.open("https://discord.gg/8Z65BR2")),
Component("Unrated/18+ Discord Server", icon="discord", func=lambda: webbrowser.open("https://discord.gg/fqvNCCRsu4")),
Component("18+ Discord Server", icon="discord", func=lambda: webbrowser.open("https://discord.gg/fqvNCCRsu4")),
Component("Browse Files", func=browse_files),
])
def handle_uri(path: str, launch_args: Tuple[str, ...]) -> None:
url = urllib.parse.urlparse(path)
queries = urllib.parse.parse_qs(url.query)
launch_args = (path, *launch_args)
client_component = None
text_client_component = None
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_component = component
elif component.display_name == "Text Client":
text_client_component = component
from kvui import App, Button, BoxLayout, Label, Clock, Window
class Popup(App):
timer_label: Label
remaining_time: Optional[int]
def __init__(self):
self.title = "Connect to Multiworld"
self.icon = r"data/icon.png"
super().__init__()
def build(self):
layout = BoxLayout(orientation="vertical")
if client_component is None:
self.remaining_time = 7
label_text = (f"A game client able to parse URIs was not detected for {game}.\n"
f"Launching Text Client in 7 seconds...")
self.timer_label = Label(text=label_text)
layout.add_widget(self.timer_label)
Clock.schedule_interval(self.update_label, 1)
else:
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 update_label(self, dt):
if self.remaining_time > 1:
# countdown the timer and string replace the number
self.remaining_time -= 1
self.timer_label.text = self.timer_label.text.replace(
str(self.remaining_time + 1), str(self.remaining_time)
)
else:
# our timer is finished so launch text client and close down
run_component(text_client_component, *launch_args)
Clock.unschedule(self.update_label)
App.get_running_app().stop()
Window.close()
def _stop(self, *largs):
# see run_gui Launcher _stop comment for details
self.root_window.close()
super()._stop(*largs)
Popup().run()
def identify(path: Union[None, str]) -> Tuple[Union[None, str], Union[None, Component]]:
def identify(path: Union[None, str]):
if path is None:
return None, None
for component in components:
@@ -242,12 +160,8 @@ def launch(exe, in_terminal=False):
subprocess.Popen(exe)
refresh_components: Optional[Callable[[], None]] = None
def run_gui():
from kvui import App, ContainerLayout, GridLayout, Button, Label, ScrollBox, Widget
from kivy.core.window import Window
from kvui import App, ContainerLayout, GridLayout, Button, Label
from kivy.uix.image import AsyncImage
from kivy.uix.relativelayout import RelativeLayout
@@ -255,18 +169,27 @@ def run_gui():
base_title: str = "Archipelago Launcher"
container: ContainerLayout
grid: GridLayout
_tool_layout: Optional[ScrollBox] = None
_client_layout: Optional[ScrollBox] = None
_tools = {c.display_name: c for c in components if c.type == Type.TOOL}
_clients = {c.display_name: c for c in components if c.type == Type.CLIENT}
_adjusters = {c.display_name: c for c in components if c.type == Type.ADJUSTER}
_miscs = {c.display_name: c for c in components if c.type == Type.MISC}
def __init__(self, ctx=None):
self.title = self.base_title + " " + Utils.__version__
self.title = self.base_title
self.ctx = ctx
self.icon = r"data/icon.png"
super().__init__()
def _refresh_components(self) -> None:
def build(self):
self.container = ContainerLayout()
self.grid = GridLayout(cols=2)
self.container.add_widget(self.grid)
self.grid.add_widget(Label(text="General"))
self.grid.add_widget(Label(text="Clients"))
button_layout = self.grid # make buttons fill the window
def build_button(component: Component) -> Widget:
def build_button(component: Component):
"""
Builds a button widget for a given component.
@@ -277,61 +200,31 @@ def run_gui():
None. The button is added to the parent grid layout.
"""
button = Button(text=component.display_name, size_hint_y=None, height=40)
button = Button(text=component.display_name)
button.component = component
button.bind(on_release=self.component_action)
if component.icon != "icon":
image = AsyncImage(source=icon_paths[component.icon],
size=(38, 38), size_hint=(None, 1), pos=(5, 0))
box_layout = RelativeLayout(size_hint_y=None, height=40)
box_layout = RelativeLayout()
box_layout.add_widget(button)
box_layout.add_widget(image)
return box_layout
return button
# clear before repopulating
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._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)
_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}
button_layout.add_widget(box_layout)
else:
button_layout.add_widget(button)
for (tool, client) in itertools.zip_longest(itertools.chain(
_tools.items(), _miscs.items(), _adjusters.items()
), _clients.items()):
self._tools.items(), self._miscs.items(), self._adjusters.items()), self._clients.items()):
# column 1
if tool:
self._tool_layout.layout.add_widget(build_button(tool[1]))
build_button(tool[1])
else:
button_layout.add_widget(Label())
# column 2
if client:
self._client_layout.layout.add_widget(build_button(client[1]))
def build(self):
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)
build_button(client[1])
else:
button_layout.add_widget(Label())
return self.container
@@ -342,14 +235,6 @@ def run_gui():
else:
launch(get_exe(button.component), button.component.cli)
def _on_drop_file(self, window: Window, filename: bytes, x: int, y: int) -> None:
""" When a patch file is dropped into the window, run the associated component. """
file, component = identify(filename.decode())
if file and component:
run_component(component, file)
else:
logging.warning(f"unable to identify component for {file}")
def _stop(self, *largs):
# ran into what appears to be https://groups.google.com/g/kivy-users/c/saWDLoYCSZ4 with PyCharm.
# Closing the window explicitly cleans it up.
@@ -358,17 +243,10 @@ def run_gui():
Launcher().run()
# avoiding Launcher reference leak
# and don't try to do something with widgets after window closed
global refresh_components
refresh_components = None
def run_component(component: Component, *args):
if component.func:
component.func(*args)
if refresh_components:
refresh_components()
elif component.script_name:
subprocess.run([*get_exe(component.script_name), *args])
else:
@@ -381,24 +259,20 @@ def main(args: Optional[Union[argparse.Namespace, dict]] = None):
elif not args:
args = {}
path = args.get("Patch|Game|Component|url", None)
if path is not None:
if path.startswith("archipelago://"):
handle_uri(path, args.get("args", ()))
return
file, component = identify(path)
if "Patch|Game|Component" in args:
file, component = identify(args["Patch|Game|Component"])
if file:
args['file'] = file
if component:
args['component'] = component
if not component:
logging.warning(f"Could not identify Component responsible for {path}")
logging.warning(f"Could not identify Component responsible for {args['Patch|Game|Component']}")
if args["update_settings"]:
update_settings()
if "file" in args:
if 'file' in args:
run_component(args["component"], args["file"], *args["args"])
elif "component" in args:
elif 'component' in args:
run_component(args["component"], *args["args"])
elif not args["update_settings"]:
run_gui()
@@ -408,16 +282,12 @@ if __name__ == '__main__':
init_logging('Launcher')
Utils.freeze_support()
multiprocessing.set_start_method("spawn") # if launched process uses kivy, fork won't work
parser = argparse.ArgumentParser(
description='Archipelago Launcher',
usage="[-h] [--update_settings] [Patch|Game|Component] [-- component args here]"
)
parser = argparse.ArgumentParser(description='Archipelago Launcher')
run_group = parser.add_argument_group("Run")
run_group.add_argument("--update_settings", action="store_true",
help="Update host.yaml and exit.")
run_group.add_argument("Patch|Game|Component|url", type=str, nargs="?",
help="Pass either a patch file, a generated game, the component name to run, or a url to "
"connect with.")
run_group.add_argument("Patch|Game|Component", type=str, nargs="?",
help="Pass either a patch file, a generated game or the name of a component to run.")
run_group.add_argument("args", nargs="*",
help="Arguments to pass to component.")
main(parser.parse_args())

View File

@@ -348,8 +348,7 @@ class LinksAwakeningClient():
await asyncio.sleep(1.0)
continue
self.stop_bizhawk_spam = False
logger.info(f"Connected to Retroarch {version.decode('ascii', errors='replace')} "
f"running {rom_name.decode('ascii', errors='replace')}")
logger.info(f"Connected to Retroarch {version.decode('ascii')} running {rom_name.decode('ascii')}")
return
except (BlockingIOError, TimeoutError, ConnectionResetError):
await asyncio.sleep(1.0)
@@ -467,8 +466,6 @@ class LinksAwakeningContext(CommonContext):
def __init__(self, server_address: typing.Optional[str], password: typing.Optional[str], magpie: typing.Optional[bool]) -> None:
self.client = LinksAwakeningClient()
self.slot_data = {}
if magpie:
self.magpie_enabled = True
self.magpie = MagpieBridge()
@@ -566,8 +563,6 @@ class LinksAwakeningContext(CommonContext):
def on_package(self, cmd: str, args: dict):
if cmd == "Connected":
self.game = self.slot_info[self.slot].game
self.slot_data = args.get("slot_data", {})
# TODO - use watcher_event
if cmd == "ReceivedItems":
for index, item in enumerate(args["items"], start=args["index"]):
@@ -632,7 +627,6 @@ class LinksAwakeningContext(CommonContext):
self.magpie.set_checks(self.client.tracker.all_checks)
await self.magpie.set_item_tracker(self.client.item_tracker)
await self.magpie.send_gps(self.client.gps_tracker)
self.magpie.slot_data = self.slot_data
except Exception:
# Don't let magpie errors take out the client
pass

View File

@@ -14,7 +14,7 @@ import tkinter as tk
from argparse import Namespace
from concurrent.futures import as_completed, ThreadPoolExecutor
from glob import glob
from tkinter import Tk, Frame, Label, StringVar, Entry, filedialog, messagebox, Button, Radiobutton, LEFT, X, BOTH, TOP, LabelFrame, \
from tkinter import Tk, Frame, Label, StringVar, Entry, filedialog, messagebox, Button, Radiobutton, LEFT, X, TOP, LabelFrame, \
IntVar, Checkbutton, E, W, OptionMenu, Toplevel, BOTTOM, RIGHT, font as font, PhotoImage
from tkinter.constants import DISABLED, NORMAL
from urllib.parse import urlparse
@@ -29,8 +29,7 @@ from Utils import output_path, local_path, user_path, open_file, get_cert_none_s
GAME_ALTTP = "A Link to the Past"
WINDOW_MIN_HEIGHT = 525
WINDOW_MIN_WIDTH = 425
class AdjusterWorld(object):
def __init__(self, sprite_pool):
@@ -243,17 +242,16 @@ def adjustGUI():
from argparse import Namespace
from Utils import __version__ as MWVersion
adjustWindow = Tk()
adjustWindow.minsize(WINDOW_MIN_WIDTH, WINDOW_MIN_HEIGHT)
adjustWindow.wm_title("Archipelago %s LttP Adjuster" % MWVersion)
set_icon(adjustWindow)
rom_options_frame, rom_vars, set_sprite = get_rom_options_frame(adjustWindow)
bottomFrame2 = Frame(adjustWindow, padx=8, pady=2)
bottomFrame2 = Frame(adjustWindow)
romFrame, romVar = get_rom_frame(adjustWindow)
romDialogFrame = Frame(adjustWindow, padx=8, pady=2)
romDialogFrame = Frame(adjustWindow)
baseRomLabel2 = Label(romDialogFrame, text='Rom to adjust')
romVar2 = StringVar()
romEntry2 = Entry(romDialogFrame, textvariable=romVar2)
@@ -263,9 +261,9 @@ def adjustGUI():
romVar2.set(rom)
romSelectButton2 = Button(romDialogFrame, text='Select Rom', command=RomSelect2)
romDialogFrame.pack(side=TOP, expand=False, fill=X)
baseRomLabel2.pack(side=LEFT, expand=False, fill=X, padx=(0, 8))
romEntry2.pack(side=LEFT, expand=True, fill=BOTH, pady=1)
romDialogFrame.pack(side=TOP, expand=True, fill=X)
baseRomLabel2.pack(side=LEFT)
romEntry2.pack(side=LEFT, expand=True, fill=X)
romSelectButton2.pack(side=LEFT)
def adjustRom():
@@ -333,11 +331,12 @@ def adjustGUI():
messagebox.showinfo(title="Success", message="Settings saved to persistent storage")
adjustButton = Button(bottomFrame2, text='Adjust Rom', command=adjustRom)
rom_options_frame.pack(side=TOP, padx=8, pady=8, fill=BOTH, expand=True)
rom_options_frame.pack(side=TOP)
adjustButton.pack(side=LEFT, padx=(5,5))
saveButton = Button(bottomFrame2, text='Save Settings', command=saveGUISettings)
saveButton.pack(side=LEFT, padx=(5,5))
bottomFrame2.pack(side=TOP, pady=(5,5))
tkinter_center_window(adjustWindow)
@@ -577,7 +576,7 @@ class AttachTooltip(object):
def get_rom_frame(parent=None):
adjuster_settings = get_adjuster_settings(GAME_ALTTP)
romFrame = Frame(parent, padx=8, pady=8)
romFrame = Frame(parent)
baseRomLabel = Label(romFrame, text='LttP Base Rom: ')
romVar = StringVar(value=adjuster_settings.baserom)
romEntry = Entry(romFrame, textvariable=romVar)
@@ -597,19 +596,20 @@ def get_rom_frame(parent=None):
romSelectButton = Button(romFrame, text='Select Rom', command=RomSelect)
baseRomLabel.pack(side=LEFT)
romEntry.pack(side=LEFT, expand=True, fill=BOTH, pady=1)
romEntry.pack(side=LEFT, expand=True, fill=X)
romSelectButton.pack(side=LEFT)
romFrame.pack(side=TOP, fill=X)
romFrame.pack(side=TOP, expand=True, fill=X)
return romFrame, romVar
def get_rom_options_frame(parent=None):
adjuster_settings = get_adjuster_settings(GAME_ALTTP)
romOptionsFrame = LabelFrame(parent, text="Rom options", padx=8, pady=8)
romOptionsFrame = LabelFrame(parent, text="Rom options")
romOptionsFrame.columnconfigure(0, weight=1)
romOptionsFrame.columnconfigure(1, weight=1)
for i in range(5):
romOptionsFrame.rowconfigure(i, weight=0, pad=4)
romOptionsFrame.rowconfigure(i, weight=1)
vars = Namespace()
vars.MusicVar = IntVar()
@@ -660,7 +660,7 @@ def get_rom_options_frame(parent=None):
spriteSelectButton = Button(spriteDialogFrame, text='...', command=SpriteSelect)
baseSpriteLabel.pack(side=LEFT)
spriteEntry.pack(side=LEFT, expand=True, fill=X)
spriteEntry.pack(side=LEFT)
spriteSelectButton.pack(side=LEFT)
oofDialogFrame = Frame(romOptionsFrame)
@@ -1004,7 +1004,6 @@ class SpriteSelector():
self.add_to_sprite_pool(sprite)
def icon_section(self, frame_label, path, no_results_label):
os.makedirs(path, exist_ok=True)
frame = LabelFrame(self.window, labelwidget=frame_label, padx=5, pady=5)
frame.pack(side=TOP, fill=X)

View File

@@ -58,7 +58,7 @@ class MMBN3CommandProcessor(ClientCommandProcessor):
class MMBN3Context(CommonContext):
command_processor = MMBN3CommandProcessor
game = "MegaMan Battle Network 3"
items_handling = 0b101 # full local except starting items
items_handling = 0b001 # full local
def __init__(self, server_address, password):
super().__init__(server_address, password)

382
Main.py
View File

@@ -11,11 +11,10 @@ from typing import Dict, List, Optional, Set, Tuple, Union
import worlds
from BaseClasses import CollectionState, Item, Location, LocationProgressType, MultiWorld, Region
from Fill import FillError, balance_multiworld_progression, distribute_items_restrictive, distribute_planned, \
flood_items
from Fill import balance_multiworld_progression, distribute_items_restrictive, distribute_planned, flood_items
from Options import StartInventoryPool
from Utils import __version__, output_path, version_tuple, get_settings
from settings import get_settings
from Utils import __version__, output_path, version_tuple
from worlds import AutoWorld
from worlds.generic.Rules import exclusion_rules, locality_rules
@@ -31,27 +30,49 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
output_path.cached_path = args.outputpath
start = time.perf_counter()
# initialize the multiworld
multiworld = MultiWorld(args.multi)
# initialize the world
world = MultiWorld(args.multi)
logger = logging.getLogger()
multiworld.set_seed(seed, args.race, str(args.outputname) if args.outputname else None)
multiworld.plando_options = args.plando_options
multiworld.plando_items = args.plando_items.copy()
multiworld.plando_texts = args.plando_texts.copy()
multiworld.plando_connections = args.plando_connections.copy()
multiworld.game = args.game.copy()
multiworld.player_name = args.name.copy()
multiworld.sprite = args.sprite.copy()
multiworld.sprite_pool = args.sprite_pool.copy()
world.set_seed(seed, args.race, str(args.outputname) if args.outputname else None)
world.plando_options = args.plando_options
world.spoiler.level = args.spoiler
world.shuffle = args.shuffle.copy()
world.logic = args.logic.copy()
world.mode = args.mode.copy()
world.difficulty = args.difficulty.copy()
world.item_functionality = args.item_functionality.copy()
world.timer = args.timer.copy()
world.goal = args.goal.copy()
world.boss_shuffle = args.shufflebosses.copy()
world.enemy_health = args.enemy_health.copy()
world.enemy_damage = args.enemy_damage.copy()
world.beemizer_total_chance = args.beemizer_total_chance.copy()
world.beemizer_trap_chance = args.beemizer_trap_chance.copy()
world.countdown_start_time = args.countdown_start_time.copy()
world.red_clock_time = args.red_clock_time.copy()
world.blue_clock_time = args.blue_clock_time.copy()
world.green_clock_time = args.green_clock_time.copy()
world.dungeon_counters = args.dungeon_counters.copy()
world.triforce_pieces_available = args.triforce_pieces_available.copy()
world.triforce_pieces_required = args.triforce_pieces_required.copy()
world.shop_shuffle = args.shop_shuffle.copy()
world.shuffle_prizes = args.shuffle_prizes.copy()
world.sprite_pool = args.sprite_pool.copy()
world.dark_room_logic = args.dark_room_logic.copy()
world.plando_items = args.plando_items.copy()
world.plando_texts = args.plando_texts.copy()
world.plando_connections = args.plando_connections.copy()
world.required_medallions = args.required_medallions.copy()
world.game = args.game.copy()
world.player_name = args.name.copy()
world.sprite = args.sprite.copy()
world.glitch_triforce = args.glitch_triforce # This is enabled/disabled globally, no per player option.
multiworld.set_options(args)
if args.csv_output:
from Options import dump_player_options
dump_player_options(multiworld)
multiworld.set_item_links()
multiworld.state = CollectionState(multiworld)
logger.info('Archipelago Version %s - Seed: %s\n', __version__, multiworld.seed)
world.set_options(args)
world.set_item_links()
world.state = CollectionState(world)
logger.info('Archipelago Version %s - Seed: %s\n', __version__, world.seed)
logger.info(f"Found {len(AutoWorld.AutoWorldRegister.world_types)} World Types:")
longest_name = max(len(text) for text in AutoWorld.AutoWorldRegister.world_types)
@@ -80,172 +101,219 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
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:
AutoWorld.call_stage(multiworld, "assert_generate")
AutoWorld.call_stage(world, "assert_generate")
AutoWorld.call_all(multiworld, "generate_early")
AutoWorld.call_all(world, "generate_early")
logger.info('')
for player in multiworld.player_ids:
for item_name, count in multiworld.worlds[player].options.start_inventory.value.items():
for player in world.player_ids:
for item_name, count in world.start_inventory[player].value.items():
for _ in range(count):
multiworld.push_precollected(multiworld.create_item(item_name, player))
world.push_precollected(world.create_item(item_name, player))
for item_name, count in getattr(multiworld.worlds[player].options,
"start_inventory_from_pool",
StartInventoryPool({})).value.items():
for item_name, count in world.start_inventory_from_pool.setdefault(player, StartInventoryPool({})).value.items():
for _ in range(count):
multiworld.push_precollected(multiworld.create_item(item_name, player))
# remove from_pool items also from early items handling, as starting is plenty early.
early = multiworld.early_items[player].get(item_name, 0)
if early:
multiworld.early_items[player][item_name] = max(0, early-count)
remaining_count = count-early
if remaining_count > 0:
local_early = multiworld.local_early_items[player].get(item_name, 0)
if local_early:
multiworld.early_items[player][item_name] = max(0, local_early - remaining_count)
del local_early
del early
world.push_precollected(world.create_item(item_name, player))
logger.info('Creating MultiWorld.')
AutoWorld.call_all(multiworld, "create_regions")
logger.info('Creating World.')
AutoWorld.call_all(world, "create_regions")
logger.info('Creating Items.')
AutoWorld.call_all(multiworld, "create_items")
AutoWorld.call_all(world, "create_items")
# All worlds should have finished creating all regions, locations, and entrances.
# Recache to ensure that they are all visible for locality rules.
world._recache()
logger.info('Calculating Access Rules.')
for player in multiworld.player_ids:
for player in world.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])
world.non_local_items[player].value -= world.local_items[player].value
world.non_local_items[player].value -= set(world.local_early_items[player])
AutoWorld.call_all(multiworld, "set_rules")
AutoWorld.call_all(world, "set_rules")
for player in multiworld.player_ids:
exclusion_rules(multiworld, player, multiworld.worlds[player].options.exclude_locations.value)
multiworld.worlds[player].options.priority_locations.value -= multiworld.worlds[player].options.exclude_locations.value
world_excluded_locations = set()
for location_name in multiworld.worlds[player].options.priority_locations.value:
for player in world.player_ids:
exclusion_rules(world, player, world.exclude_locations[player].value)
world.priority_locations[player].value -= world.exclude_locations[player].value
for location_name in world.priority_locations[player].value:
try:
location = multiworld.get_location(location_name, player)
except KeyError:
continue
if location.progress_type != LocationProgressType.EXCLUDED:
location.progress_type = LocationProgressType.PRIORITY
location = world.get_location(location_name, player)
except KeyError as e: # failed to find the given location. Check if it's a legitimate location
if location_name not in world.worlds[player].location_name_to_id:
raise Exception(f"Unable to prioritize location {location_name} in player {player}'s world.") from e
else:
logger.warning(f"Unable to prioritize location \"{location_name}\" in player {player}'s world because the world excluded it.")
world_excluded_locations.add(location_name)
multiworld.worlds[player].options.priority_locations.value -= world_excluded_locations
location.progress_type = LocationProgressType.PRIORITY
# Set local and non-local item rules.
if multiworld.players > 1:
locality_rules(multiworld)
if world.players > 1:
locality_rules(world)
else:
multiworld.worlds[1].options.non_local_items.value = set()
multiworld.worlds[1].options.local_items.value = set()
world.non_local_items[1].value = set()
world.local_items[1].value = set()
AutoWorld.call_all(multiworld, "generate_basic")
AutoWorld.call_all(world, "generate_basic")
# remove starting inventory from pool items.
# Because some worlds don't actually create items during create_items this has to be as late as possible.
if any(getattr(multiworld.worlds[player].options, "start_inventory_from_pool", None) for player in multiworld.player_ids):
if any(world.start_inventory_from_pool[player].value for player in world.player_ids):
new_items: List[Item] = []
old_items: List[Item] = []
depletion_pool: Dict[int, Dict[str, int]] = {
player: getattr(multiworld.worlds[player].options,
"start_inventory_from_pool",
StartInventoryPool({})).value.copy()
for player in multiworld.player_ids
}
player: world.start_inventory_from_pool[player].value.copy() for player in world.player_ids}
for player, items in depletion_pool.items():
player_world: AutoWorld.World = multiworld.worlds[player]
player_world: AutoWorld.World = world.worlds[player]
for count in items.values():
for _ in range(count):
new_items.append(player_world.create_filler())
target: int = sum(sum(items.values()) for items in depletion_pool.values())
for i, item in enumerate(multiworld.itempool):
for i, item in enumerate(world.itempool):
if depletion_pool[item.player].get(item.name, 0):
target -= 1
depletion_pool[item.player][item.name] -= 1
# quick abort if we have found all items
if not target:
old_items.extend(multiworld.itempool[i+1:])
new_items.extend(world.itempool[i+1:])
break
else:
old_items.append(item)
new_items.append(item)
# leftovers?
if target:
for player, remaining_items in depletion_pool.items():
remaining_items = {name: count for name, count in remaining_items.items() if count}
if remaining_items:
logger.warning(f"{multiworld.get_player_name(player)}"
raise Exception(f"{world.get_player_name(player)}"
f" is trying to remove items from their pool that don't exist: {remaining_items}")
# find all filler we generated for the current player and remove until it matches
removables = [item for item in new_items if item.player == player]
for _ in range(sum(remaining_items.values())):
new_items.remove(removables.pop())
assert len(multiworld.itempool) == len(new_items + old_items), "Item Pool amounts should not change."
multiworld.itempool[:] = new_items + old_items
assert len(world.itempool) == len(new_items), "Item Pool amounts should not change."
world.itempool[:] = new_items
multiworld.link_items()
# temporary home for item links, should be moved out of Main
for group_id, group in world.groups.items():
def find_common_pool(players: Set[int], shared_pool: Set[str]) -> Tuple[
Optional[Dict[int, Dict[str, int]]], Optional[Dict[str, int]]
]:
classifications: Dict[str, int] = collections.defaultdict(int)
counters = {player: {name: 0 for name in shared_pool} for player in players}
for item in world.itempool:
if item.player in counters and item.name in shared_pool:
counters[item.player][item.name] += 1
classifications[item.name] |= item.classification
if any(multiworld.item_links.values()):
multiworld._all_state = None
for player in players.copy():
if all([counters[player][item] == 0 for item in shared_pool]):
players.remove(player)
del (counters[player])
logger.info("Running Item Plando.")
if not players:
return None, None
distribute_planned(multiworld)
for item in shared_pool:
count = min(counters[player][item] for player in players)
if count:
for player in players:
counters[player][item] = count
else:
for player in players:
del (counters[player][item])
return counters, classifications
common_item_count, classifications = find_common_pool(group["players"], group["item_pool"])
if not common_item_count:
continue
new_itempool: List[Item] = []
for item_name, item_count in next(iter(common_item_count.values())).items():
for _ in range(item_count):
new_item = group["world"].create_item(item_name)
# mangle together all original classification bits
new_item.classification |= classifications[item_name]
new_itempool.append(new_item)
region = Region("Menu", group_id, world, "ItemLink")
world.regions.append(region)
locations = region.locations = []
for item in world.itempool:
count = common_item_count.get(item.player, {}).get(item.name, 0)
if count:
loc = Location(group_id, f"Item Link: {item.name} -> {world.player_name[item.player]} {count}",
None, region)
loc.access_rule = lambda state, item_name = item.name, group_id_ = group_id, count_ = count: \
state.has(item_name, group_id_, count_)
locations.append(loc)
loc.place_locked_item(item)
common_item_count[item.player][item.name] -= 1
else:
new_itempool.append(item)
itemcount = len(world.itempool)
world.itempool = new_itempool
while itemcount > len(world.itempool):
items_to_add = []
for player in group["players"]:
if group["link_replacement"]:
item_player = group_id
else:
item_player = player
if group["replacement_items"][player]:
items_to_add.append(AutoWorld.call_single(world, "create_item", item_player,
group["replacement_items"][player]))
else:
items_to_add.append(AutoWorld.call_single(world, "create_filler", item_player))
world.random.shuffle(items_to_add)
world.itempool.extend(items_to_add[:itemcount - len(world.itempool)])
if any(world.item_links.values()):
world._recache()
world._all_state = None
logger.info("Running Item Plando")
distribute_planned(world)
logger.info('Running Pre Main Fill.')
AutoWorld.call_all(multiworld, "pre_fill")
AutoWorld.call_all(world, "pre_fill")
logger.info(f'Filling the multiworld with {len(multiworld.itempool)} items.')
logger.info(f'Filling the world with {len(world.itempool)} items.')
if multiworld.algorithm == 'flood':
flood_items(multiworld) # different algo, biased towards early game progress items
elif multiworld.algorithm == 'balanced':
distribute_items_restrictive(multiworld, get_settings().generator.panic_method)
if world.algorithm == 'flood':
flood_items(world) # different algo, biased towards early game progress items
elif world.algorithm == 'balanced':
distribute_items_restrictive(world)
AutoWorld.call_all(multiworld, 'post_fill')
AutoWorld.call_all(world, 'post_fill')
if multiworld.players > 1 and not args.skip_prog_balancing:
balance_multiworld_progression(multiworld)
if world.players > 1 and not args.skip_prog_balancing:
balance_multiworld_progression(world)
else:
logger.info("Progression balancing skipped.")
# we're about to output using multithreading, so we're removing the global random state to prevent accidental use
multiworld.random.passthrough = False
if args.skip_output:
logger.info('Done. Skipped output/spoiler generation. Total Time: %s', time.perf_counter() - start)
return multiworld
logger.info(f'Beginning output...')
outfilebase = 'AP_' + multiworld.seed_name
# we're about to output using multithreading, so we're removing the global random state to prevent accidental use
world.random.passthrough = False
outfilebase = 'AP_' + world.seed_name
output = tempfile.TemporaryDirectory()
with output as temp_dir:
output_players = [player for player in multiworld.player_ids if AutoWorld.World.generate_output.__code__
is not multiworld.worlds[player].generate_output.__code__]
with concurrent.futures.ThreadPoolExecutor(len(output_players) + 2) as pool:
check_accessibility_task = pool.submit(multiworld.fulfills_accessibility)
with concurrent.futures.ThreadPoolExecutor(world.players + 2) as pool:
check_accessibility_task = pool.submit(world.fulfills_accessibility)
output_file_futures = [pool.submit(AutoWorld.call_stage, multiworld, "generate_output", temp_dir)]
for player in output_players:
output_file_futures = [pool.submit(AutoWorld.call_stage, world, "generate_output", temp_dir)]
for player in world.player_ids:
# skip starting a thread for methods that say "pass".
output_file_futures.append(
pool.submit(AutoWorld.call_single, multiworld, "generate_output", player, temp_dir))
if AutoWorld.World.generate_output.__code__ is not world.worlds[player].generate_output.__code__:
output_file_futures.append(
pool.submit(AutoWorld.call_single, world, "generate_output", player, temp_dir))
# collect ER hint info
er_hint_data: Dict[int, Dict[int, str]] = {}
AutoWorld.call_all(multiworld, 'extend_hint_information', er_hint_data)
AutoWorld.call_all(world, 'extend_hint_information', er_hint_data)
def write_multidata():
import NetUtils
@@ -254,78 +322,64 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
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]
names = [[name for player, name in sorted(world.player_name.items())]]
for slot in world.player_ids:
player_world: AutoWorld.World = world.worlds[slot]
minimum_versions["server"] = max(minimum_versions["server"], player_world.required_server_version)
client_versions[slot] = player_world.required_client_version
games[slot] = multiworld.game[slot]
slot_info[slot] = NetUtils.NetworkSlot(names[0][slot - 1], multiworld.game[slot],
multiworld.player_types[slot])
for slot, group in multiworld.groups.items():
games[slot] = multiworld.game[slot]
slot_info[slot] = NetUtils.NetworkSlot(group["name"], multiworld.game[slot], multiworld.player_types[slot],
games[slot] = world.game[slot]
slot_info[slot] = NetUtils.NetworkSlot(names[0][slot - 1], world.game[slot],
world.player_types[slot])
for slot, group in world.groups.items():
games[slot] = world.game[slot]
slot_info[slot] = NetUtils.NetworkSlot(group["name"], world.game[slot], world.player_types[slot],
group_members=sorted(group["players"]))
precollected_items = {player: [item.code for item in world_precollected if type(item.code) == int]
for player, world_precollected in multiworld.precollected_items.items()}
precollected_hints = {player: set() for player in range(1, multiworld.players + 1 + len(multiworld.groups))}
for player, world_precollected in world.precollected_items.items()}
precollected_hints = {player: set() for player in range(1, world.players + 1 + len(world.groups))}
for slot in multiworld.player_ids:
slot_data[slot] = multiworld.worlds[slot].fill_slot_data()
for slot in world.player_ids:
slot_data[slot] = world.worlds[slot].fill_slot_data()
def precollect_hint(location):
entrance = er_hint_data.get(location.player, {}).get(location.address, "")
hint = NetUtils.Hint(location.item.player, location.player, location.address,
location.item.code, False, entrance, location.item.flags)
precollected_hints[location.player].add(hint)
if location.item.player not in multiworld.groups:
if location.item.player not in world.groups:
precollected_hints[location.item.player].add(hint)
else:
for player in multiworld.groups[location.item.player]["players"]:
for player in world.groups[location.item.player]["players"]:
precollected_hints[player].add(hint)
locations_data: Dict[int, Dict[int, Tuple[int, int, int]]] = {player: {} for player in multiworld.player_ids}
for location in multiworld.get_filled_locations():
locations_data: Dict[int, Dict[int, Tuple[int, int, int]]] = {player: {} for player in world.player_ids}
for location in world.get_filled_locations():
if type(location.address) == int:
assert location.item.code is not None, "item code None should be event, " \
"location.address should then also be None. Location: " \
f" {location}"
assert location.address not in locations_data[location.player], (
f"Locations with duplicate address. {location} and "
f"{locations_data[location.player][location.address]}")
locations_data[location.player][location.address] = \
location.item.code, location.item.player, location.item.flags
if location.name in multiworld.worlds[location.player].options.start_location_hints:
if location.name in world.start_location_hints[location.player]:
precollect_hint(location)
elif location.item.name in multiworld.worlds[location.item.player].options.start_hints:
elif location.item.name in world.start_hints[location.item.player]:
precollect_hint(location)
elif any([location.item.name in multiworld.worlds[player].options.start_hints
for player in multiworld.groups.get(location.item.player, {}).get("players", [])]):
elif any([location.item.name in world.start_hints[player]
for player in world.groups.get(location.item.player, {}).get("players", [])]):
precollect_hint(location)
# embedded data package
data_package = {
game_world.game: worlds.network_data_package["games"][game_world.game]
for game_world in multiworld.worlds.values()
for game_world in world.worlds.values()
}
checks_in_area: Dict[int, Dict[str, Union[int, List[int]]]] = {}
# get spheres -> filter address==None -> skip empty
spheres: List[Dict[int, Set[int]]] = []
for sphere in multiworld.get_spheres():
current_sphere: Dict[int, Set[int]] = collections.defaultdict(set)
for sphere_location in sphere:
if type(sphere_location.address) is int:
current_sphere[sphere_location.player].add(sphere_location.address)
if current_sphere:
spheres.append(dict(current_sphere))
multidata = {
"slot_data": slot_data,
"slot_info": slot_info,
"connect_names": {name: (0, player) for player, name in multiworld.player_name.items()},
"connect_names": {name: (0, player) for player, name in world.player_name.items()},
"locations": locations_data,
"checks_in_area": checks_in_area,
"server_options": baked_server_options,
@@ -335,12 +389,10 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
"version": tuple(version_tuple),
"tags": ["AP"],
"minimum_versions": minimum_versions,
"seed_name": multiworld.seed_name,
"spheres": spheres,
"seed_name": world.seed_name,
"datapackage": data_package,
"race_mode": int(multiworld.is_race),
}
AutoWorld.call_all(multiworld, "modify_multidata", multidata)
AutoWorld.call_all(world, "modify_multidata", multidata)
multidata = zlib.compress(pickle.dumps(multidata), 9)
@@ -350,8 +402,8 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
output_file_futures.append(pool.submit(write_multidata))
if not check_accessibility_task.result():
if not multiworld.can_beat_game():
raise FillError("Game appears as unbeatable. Aborting.", multiworld=multiworld)
if not world.can_beat_game():
raise Exception("Game appears as unbeatable. Aborting.")
else:
logger.warning("Location Accessibility requirements not fulfilled.")
@@ -363,12 +415,12 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
if args.spoiler > 1:
logger.info('Calculating playthrough.')
multiworld.spoiler.create_playthrough(create_paths=args.spoiler > 2)
world.spoiler.create_playthrough(create_paths=args.spoiler > 2)
if args.spoiler:
multiworld.spoiler.to_file(os.path.join(temp_dir, '%s_Spoiler.txt' % outfilebase))
world.spoiler.to_file(os.path.join(temp_dir, '%s_Spoiler.txt' % outfilebase))
zipfilename = output_path(f"AP_{multiworld.seed_name}.zip")
zipfilename = output_path(f"AP_{world.seed_name}.zip")
logger.info(f"Creating final archive at {zipfilename}")
with zipfile.ZipFile(zipfilename, mode="w", compression=zipfile.ZIP_DEFLATED,
compresslevel=9) as zf:
@@ -376,4 +428,4 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
zf.write(file.path, arcname=file.name)
logger.info('Done. Enjoy. Total Time: %s', time.perf_counter() - start)
return multiworld
return world

View File

@@ -4,29 +4,14 @@ import subprocess
import multiprocessing
import warnings
local_dir = os.path.dirname(__file__)
requirements_files = {os.path.join(local_dir, 'requirements.txt')}
if sys.version_info < (3, 8, 6):
raise RuntimeError("Incompatible Python Version. 3.8.7+ is supported.")
# don't run update if environment is frozen/compiled or if not the parent process (skip in subprocess)
_skip_update = bool(getattr(sys, "frozen", False) or multiprocessing.parent_process())
update_ran = _skip_update
class RequirementsSet(set):
def add(self, e):
global update_ran
update_ran &= _skip_update
super().add(e)
def update(self, *s):
global update_ran
update_ran &= _skip_update
super().update(*s)
local_dir = os.path.dirname(__file__)
requirements_files = RequirementsSet((os.path.join(local_dir, 'requirements.txt'),))
update_ran = getattr(sys, "frozen", False) or multiprocessing.parent_process()
if not update_ran:
for entry in os.scandir(os.path.join(local_dir, "worlds")):
@@ -70,35 +55,26 @@ def install_pkg_resources(yes=False):
subprocess.call([sys.executable, "-m", "pip", "install", "--upgrade", "setuptools"])
def update(yes: bool = False, force: bool = False) -> None:
def update(yes=False, force=False):
global update_ran
if not update_ran:
update_ran = True
install_pkg_resources(yes=yes)
import pkg_resources
if force:
update_command()
return
prev = "" # if a line ends in \ we store here and merge later
install_pkg_resources(yes=yes)
import pkg_resources
for req_file in requirements_files:
path = os.path.join(os.path.dirname(sys.argv[0]), req_file)
if not os.path.exists(path):
path = os.path.join(os.path.dirname(__file__), req_file)
with open(path) as requirementsfile:
for line in requirementsfile:
if not line or line.lstrip(" \t")[0] == "#":
if not prev:
continue # ignore comments
line = ""
elif line.rstrip("\r\n").endswith("\\"):
prev = prev + line.rstrip("\r\n")[:-1] + " " # continue on next line
continue
line = prev + line
line = line.split("--hash=")[0] # remove hashes from requirement for version checking
prev = ""
if not line or line[0] == "#":
continue # ignore comments
if line.startswith(("https://", "git+https://")):
# extract name and version for url
rest = line.split('/')[-1]

View File

@@ -2,20 +2,17 @@ from __future__ import annotations
import argparse
import asyncio
import collections
import contextlib
import copy
import collections
import datetime
import functools
import hashlib
import inspect
import itertools
import logging
import math
import operator
import pickle
import random
import shlex
import threading
import time
import typing
@@ -39,7 +36,7 @@ except ImportError:
import NetUtils
import Utils
from Utils import version_tuple, restricted_loads, Version, async_start, get_intended_text
from Utils import version_tuple, restricted_loads, Version, async_start
from NetUtils import Endpoint, ClientStatus, NetworkItem, decode, encode, NetworkPlayer, Permission, NetworkSlot, \
SlotType, LocationStore
@@ -68,42 +65,23 @@ def update_dict(dictionary, entries):
return dictionary
def queue_gc():
import gc
from threading import Thread
gc_thread: typing.Optional[Thread] = getattr(queue_gc, "_thread", None)
def async_collect():
time.sleep(2)
setattr(queue_gc, "_thread", None)
gc.collect()
if not gc_thread:
gc_thread = Thread(target=async_collect)
setattr(queue_gc, "_thread", gc_thread)
gc_thread.start()
# functions callable on storable data on the server by clients
modify_functions = {
# generic:
"replace": lambda old, new: new,
"default": lambda old, new: old,
# numeric:
"add": operator.add, # add together two objects, using python's "+" operator (works on strings and lists as append)
"mul": operator.mul,
"pow": operator.pow,
"mod": operator.mod,
"floor": lambda value, _: math.floor(value),
"ceil": lambda value, _: math.ceil(value),
"max": max,
"min": min,
"replace": lambda old, new: new,
"default": lambda old, new: old,
"pow": operator.pow,
# bitwise:
"xor": operator.xor,
"or": operator.or_,
"and": operator.and_,
"left_shift": operator.lshift,
"right_shift": operator.rshift,
# lists/dicts:
# lists/dicts
"remove": remove_from_list,
"pop": pop_from_container,
"update": update_dict,
@@ -185,22 +163,18 @@ class Context:
slot_info: typing.Dict[int, NetworkSlot]
generator_version = Version(0, 0, 0)
checksums: typing.Dict[str, str]
item_names: typing.Dict[str, typing.Dict[int, str]]
item_names: typing.Dict[int, str] = Utils.KeyedDefaultDict(lambda code: f'Unknown item (ID:{code})')
item_name_groups: typing.Dict[str, typing.Dict[str, typing.Set[str]]]
location_names: typing.Dict[str, typing.Dict[int, str]]
location_names: typing.Dict[int, str] = Utils.KeyedDefaultDict(lambda code: f'Unknown location (ID:{code})')
location_name_groups: typing.Dict[str, typing.Dict[str, typing.Set[str]]]
all_item_and_group_names: typing.Dict[str, typing.Set[str]]
all_location_and_group_names: typing.Dict[str, typing.Set[str]]
non_hintable_names: typing.Dict[str, typing.AbstractSet[str]]
spheres: typing.List[typing.Dict[int, typing.Set[int]]]
""" each sphere is { player: { location_id, ... } } """
logger: logging.Logger
non_hintable_names: typing.Dict[str, typing.Set[str]]
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",
remaining_mode: str = "disabled", auto_shutdown: typing.SupportsFloat = 0, compatibility: int = 2,
log_network: bool = False, logger: logging.Logger = logging.getLogger()):
self.logger = logger
log_network: bool = False):
super(Context, self).__init__()
self.slot_info = {}
self.log_network = log_network
@@ -245,7 +219,7 @@ class Context:
self.embedded_blacklist = {"host", "port"}
self.client_ids: typing.Dict[typing.Tuple[int, int], datetime.datetime] = {}
self.auto_save_interval = 60 # in seconds
self.auto_saver_thread: typing.Optional[threading.Thread] = None
self.auto_saver_thread = None
self.save_dirty = False
self.tags = ['AP']
self.games: typing.Dict[int, str] = {}
@@ -257,7 +231,6 @@ class Context:
self.stored_data = {}
self.stored_data_notification_clients = collections.defaultdict(weakref.WeakSet)
self.read_data = {}
self.spheres = []
# init empty to satisfy linter, I suppose
self.gamespackage = {}
@@ -266,10 +239,6 @@ class Context:
self.location_name_groups = {}
self.all_item_and_group_names = {}
self.all_location_and_group_names = {}
self.item_names = collections.defaultdict(
lambda: Utils.KeyedDefaultDict(lambda code: f'Unknown item (ID:{code})'))
self.location_names = collections.defaultdict(
lambda: Utils.KeyedDefaultDict(lambda code: f'Unknown location (ID:{code})'))
self.non_hintable_names = collections.defaultdict(frozenset)
self._load_game_data()
@@ -286,31 +255,19 @@ class Context:
for world_name, world in worlds.AutoWorldRegister.world_types.items():
self.non_hintable_names[world_name] = world.hint_blacklist
for game_package in self.gamespackage.values():
# remove groups from data sent to clients
del game_package["item_name_groups"]
del game_package["location_name_groups"]
def _init_game_data(self):
for game_name, game_package in self.gamespackage.items():
if "checksum" in game_package:
self.checksums[game_name] = game_package["checksum"]
for item_name, item_id in game_package["item_name_to_id"].items():
self.item_names[game_name][item_id] = item_name
self.item_names[item_id] = item_name
for location_name, location_id in game_package["location_name_to_id"].items():
self.location_names[game_name][location_id] = location_name
self.location_names[location_id] = location_name
self.all_item_and_group_names[game_name] = \
set(game_package["item_name_to_id"]) | set(self.item_name_groups[game_name])
self.all_location_and_group_names[game_name] = \
set(game_package["location_name_to_id"]) | set(self.location_name_groups.get(game_name, []))
archipelago_item_names = self.item_names["Archipelago"]
archipelago_location_names = self.location_names["Archipelago"]
for game in [game_name for game_name in self.gamespackage if game_name != "Archipelago"]:
# Add Archipelago items and locations to each data package.
self.item_names[game].update(archipelago_item_names)
self.location_names[game].update(archipelago_location_names)
def item_names_for_game(self, game: str) -> typing.Optional[typing.Dict[str, int]]:
return self.gamespackage[game]["item_name_to_id"] if game in self.gamespackage else None
@@ -325,12 +282,12 @@ class Context:
try:
await endpoint.socket.send(msg)
except websockets.ConnectionClosed:
self.logger.exception(f"Exception during send_msgs, could not send {msg}")
logging.exception(f"Exception during send_msgs, could not send {msg}")
await self.disconnect(endpoint)
return False
else:
if self.log_network:
self.logger.info(f"Outgoing message: {msg}")
logging.info(f"Outgoing message: {msg}")
return True
async def send_encoded_msgs(self, endpoint: Endpoint, msg: str) -> bool:
@@ -339,12 +296,12 @@ class Context:
try:
await endpoint.socket.send(msg)
except websockets.ConnectionClosed:
self.logger.exception("Exception during send_encoded_msgs")
logging.exception("Exception during send_encoded_msgs")
await self.disconnect(endpoint)
return False
else:
if self.log_network:
self.logger.info(f"Outgoing message: {msg}")
logging.info(f"Outgoing message: {msg}")
return True
async def broadcast_send_encoded_msgs(self, endpoints: typing.Iterable[Endpoint], msg: str) -> bool:
@@ -355,11 +312,11 @@ class Context:
try:
websockets.broadcast(sockets, msg)
except RuntimeError:
self.logger.exception("Exception during broadcast_send_encoded_msgs")
logging.exception("Exception during broadcast_send_encoded_msgs")
return False
else:
if self.log_network:
self.logger.info(f"Outgoing broadcast: {msg}")
logging.info(f"Outgoing broadcast: {msg}")
return True
def broadcast_all(self, msgs: typing.List[dict]):
@@ -368,7 +325,7 @@ class Context:
async_start(self.broadcast_send_encoded_msgs(endpoints, msgs))
def broadcast_text_all(self, text: str, additional_arguments: dict = {}):
self.logger.info("Notice (all): %s" % text)
logging.info("Notice (all): %s" % text)
self.broadcast_all([{**{"cmd": "PrintJSON", "data": [{ "text": text }]}, **additional_arguments}])
def broadcast_team(self, team: int, msgs: typing.List[dict]):
@@ -390,7 +347,7 @@ class Context:
def notify_client(self, client: Client, text: str, additional_arguments: dict = {}):
if not client.auth:
return
self.logger.info("Notice (Player %s in team %d): %s" % (client.name, client.team + 1, text))
logging.info("Notice (Player %s in team %d): %s" % (client.name, client.team + 1, text))
async_start(self.send_msgs(client, [{"cmd": "PrintJSON", "data": [{ "text": text }], **additional_arguments}]))
def notify_client_multiple(self, client: Client, texts: typing.List[str], additional_arguments: dict = {}):
@@ -429,8 +386,6 @@ class Context:
use_embedded_server_options: bool):
self.read_data = {}
# there might be a better place to put this.
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},"
@@ -457,8 +412,6 @@ class Context:
self.player_name_lookup[slot_info.name] = 0, slot_id
self.read_data[f"hints_{0}_{slot_id}"] = lambda local_team=0, local_player=slot_id: \
list(self.get_rechecked_hints(local_team, local_player))
self.read_data[f"client_status_{0}_{slot_id}"] = lambda local_team=0, local_player=slot_id: \
self.client_game_state[local_team, local_player]
self.seed_name = decoded_obj["seed_name"]
self.random.seed(self.seed_name)
@@ -491,7 +444,7 @@ class Context:
for game_name, data in decoded_obj.get("datapackage", {}).items():
if game_name in game_data_packages:
data = game_data_packages[game_name]
self.logger.info(f"Loading embedded data package for game {game_name}")
logging.info(f"Loading embedded data package for game {game_name}")
self.gamespackage[game_name] = data
self.item_name_groups[game_name] = data["item_name_groups"]
if "location_name_groups" in data:
@@ -504,9 +457,6 @@ class Context:
for game_name, data in self.location_name_groups.items():
self.read_data[f"location_name_groups_{game_name}"] = lambda lgame=game_name: self.location_name_groups[lgame]
# sorted access spheres
self.spheres = decoded_obj.get("spheres", [])
# saving
def save(self, now=False) -> bool:
@@ -526,7 +476,7 @@ class Context:
with open(self.save_filename, "wb") as f:
f.write(zlib.compress(encoded_save))
except Exception as e:
self.logger.exception(e)
logging.exception(e)
return False
else:
return True
@@ -544,12 +494,12 @@ class Context:
save_data = restricted_loads(zlib.decompress(f.read()))
self.set_save(save_data)
except FileNotFoundError:
self.logger.error('No save data found, starting a new game')
logging.error('No save data found, starting a new game')
except Exception as e:
self.logger.exception(e)
logging.exception(e)
self._start_async_saving()
def _start_async_saving(self, atexit_save: bool = True):
def _start_async_saving(self):
if not self.auto_saver_thread:
def save_regularly():
# time.time() is platform dependent, so using the expensive datetime method instead
@@ -563,22 +513,18 @@ class Context:
next_wakeup = (second - get_datetime_second()) % self.auto_save_interval
time.sleep(max(1.0, next_wakeup))
if self.save_dirty:
self.logger.debug("Saving via thread.")
logging.debug("Saving via thread.")
self._save()
except OperationalError as e:
self.logger.exception(e)
self.logger.info(f"Saving failed. Retry in {self.auto_save_interval} seconds.")
logging.exception(e)
logging.info(f"Saving failed. Retry in {self.auto_save_interval} seconds.")
else:
self.save_dirty = False
if not atexit_save: # if atexit is used, that keeps a reference anyway
queue_gc()
self.auto_saver_thread = threading.Thread(target=save_regularly, daemon=True)
self.auto_saver_thread.start()
if atexit_save:
import atexit
atexit.register(self._save, True) # make sure we save on exit too
import atexit
atexit.register(self._save, True) # make sure we save on exit too
def get_save(self) -> dict:
self.recheck_hints()
@@ -633,7 +579,7 @@ class Context:
self.location_check_points = savedata["game_options"]["location_check_points"]
self.server_password = savedata["game_options"]["server_password"]
self.password = savedata["game_options"]["password"]
self.release_mode = savedata["game_options"]["release_mode"]
self.release_mode = savedata["game_options"].get("release_mode", savedata["game_options"].get("forfeit_mode", "goal"))
self.remaining_mode = savedata["game_options"]["remaining_mode"]
self.collect_mode = savedata["game_options"]["collect_mode"]
self.item_cheat = savedata["game_options"]["item_cheat"]
@@ -645,7 +591,7 @@ class Context:
if "stored_data" in savedata:
self.stored_data = savedata["stored_data"]
# count items and slots from lists for items_handling = remote
self.logger.info(
logging.info(
f'Loaded save file with {sum([len(v) for k, v in self.received_items.items() if k[2]])} received items '
f'for {sum(k[2] for k in self.received_items)} players')
@@ -668,16 +614,6 @@ class Context:
self.recheck_hints(team, slot)
return self.hints[team, slot]
def get_sphere(self, player: int, location_id: int) -> int:
"""Get sphere of a location, -1 if spheres are not available."""
if self.spheres:
for i, sphere in enumerate(self.spheres):
if location_id in sphere.get(player, set()):
return i
raise KeyError(f"No Sphere found for location ID {location_id} belonging to player {player}. "
f"Location or player may not exist.")
return -1
def get_players_package(self):
return [NetworkPlayer(t, p, self.get_aliased_name(t, p), n) for (t, p), n in self.player_names.items()]
@@ -688,6 +624,8 @@ class Context:
def _set_options(self, server_options: dict):
for key, value in server_options.items():
if key == "forfeit_mode":
key = "release_mode"
data_type = self.simple_options.get(key, None)
if data_type is not None:
if value not in {False, True, None}: # some can be boolean OR text, such as password
@@ -697,13 +635,13 @@ class Context:
try:
raise Exception(f"Could not set server option {key}, skipping.") from e
except Exception as e:
self.logger.exception(e)
self.logger.debug(f"Setting server option {key} to {value} from supplied multidata")
logging.exception(e)
logging.debug(f"Setting server option {key} to {value} from supplied multidata")
setattr(self, key, value)
elif key == "disable_item_cheat":
self.item_cheat = not bool(value)
else:
self.logger.debug(f"Unrecognized server option {key}")
logging.debug(f"Unrecognized server option {key}")
def get_aliased_name(self, team: int, slot: int):
if (team, slot) in self.name_aliases:
@@ -711,8 +649,7 @@ class Context:
else:
return self.player_names[team, slot]
def notify_hints(self, team: int, hints: typing.List[NetUtils.Hint], only_new: bool = False,
recipients: typing.Sequence[int] = None):
def notify_hints(self, team: int, hints: typing.List[NetUtils.Hint], only_new: bool = False):
"""Send and remember hints."""
if only_new:
hints = [hint for hint in hints if hint not in self.hints[team, hint.finding_player]]
@@ -727,27 +664,26 @@ class Context:
if not hint.local and data not in concerns[hint.finding_player]:
concerns[hint.finding_player].append(data)
# remember hints in all cases
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]:
self.hints[team, hint.finding_player].add(hint)
new_hint_events.add(hint.finding_player)
for player in self.slot_set(hint.receiving_player):
self.hints[team, player].add(hint)
new_hint_events.add(player)
# 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]:
self.hints[team, hint.finding_player].add(hint)
new_hint_events.add(hint.finding_player)
for player in self.slot_set(hint.receiving_player):
self.hints[team, player].add(hint)
new_hint_events.add(player)
self.logger.info("Notice (Team #%d): %s" % (team + 1, format_hint(self, team, hint)))
logging.info("Notice (Team #%d): %s" % (team + 1, format_hint(self, team, hint)))
for slot in new_hint_events:
self.on_new_hint(team, slot)
for slot, hint_data in concerns.items():
if recipients is None or slot in recipients:
clients = self.clients[team].get(slot)
if not clients:
continue
client_hints = [datum[1] for datum in sorted(hint_data, key=lambda x: x[0].finding_player != slot)]
for client in clients:
async_start(self.send_msgs(client, client_hints))
clients = self.clients[team].get(slot)
if not clients:
continue
client_hints = [datum[1] for datum in sorted(hint_data, key=lambda x: x[0].finding_player == slot)]
for client in clients:
async_start(self.send_msgs(client, client_hints))
# "events"
@@ -762,23 +698,14 @@ class Context:
self.save() # save goal completion flag
def on_new_hint(self, team: int, slot: int):
self.on_changed_hints(team, slot)
self.broadcast(self.clients[team][slot], [{
"cmd": "RoomUpdate",
"hint_points": get_slot_points(self, team, slot)
}])
def on_changed_hints(self, team: int, slot: int):
key: str = f"_read_hints_{team}_{slot}"
targets: typing.Set[Client] = set(self.stored_data_notification_clients[key])
if targets:
self.broadcast(targets, [{"cmd": "SetReply", "key": key, "value": self.hints[team, slot]}])
def on_client_status_change(self, team: int, slot: int):
key: str = f"_read_client_status_{team}_{slot}"
targets: typing.Set[Client] = set(self.stored_data_notification_clients[key])
if targets:
self.broadcast(targets, [{"cmd": "SetReply", "key": key, "value": self.client_game_state[team, slot]}])
self.broadcast(self.clients[team][slot], [{
"cmd": "RoomUpdate",
"hint_points": get_slot_points(self, team, slot)
}])
def update_aliases(ctx: Context, team: int):
@@ -796,21 +723,21 @@ async def server(websocket, path: str = "/", ctx: Context = None):
try:
if ctx.log_network:
ctx.logger.info("Incoming connection")
logging.info("Incoming connection")
await on_client_connected(ctx, client)
if ctx.log_network:
ctx.logger.info("Sent Room Info")
logging.info("Sent Room Info")
async for data in websocket:
if ctx.log_network:
ctx.logger.info(f"Incoming message: {data}")
logging.info(f"Incoming message: {data}")
for msg in decode(data):
await process_client_cmd(ctx, client, msg)
except Exception as e:
if not isinstance(e, websockets.WebSocketException):
ctx.logger.exception(e)
logging.exception(e)
finally:
if ctx.log_network:
ctx.logger.info("Disconnected")
logging.info("Disconnected")
await ctx.disconnect(client)
@@ -820,7 +747,10 @@ async def on_client_connected(ctx: Context, client: Client):
for slot, connected_clients in clients.items():
if connected_clients:
name = ctx.player_names[team, slot]
players.append(NetworkPlayer(team, slot, ctx.name_aliases.get((team, slot), name), name))
players.append(
NetworkPlayer(team, slot,
ctx.name_aliases.get((team, slot), name), name)
)
games = {ctx.games[x] for x in range(1, len(ctx.games) + 1)}
games.add("Archipelago")
await ctx.send_msgs(client, [{
@@ -835,6 +765,8 @@ async def on_client_connected(ctx: Context, client: Client):
'permissions': get_permissions(ctx),
'hint_cost': ctx.hint_cost,
'location_check_points': ctx.location_check_points,
'datapackage_versions': {game: game_data["version"] for game, game_data
in ctx.gamespackage.items() if game in games},
'datapackage_checksums': {game: game_data["checksum"] for game, game_data
in ctx.gamespackage.items() if game in games and "checksum" in game_data},
'seed_name': ctx.seed_name,
@@ -855,25 +787,14 @@ async def on_client_disconnected(ctx: Context, client: Client):
await on_client_left(ctx, client)
_non_game_messages = {"HintGame": "hinting", "Tracker": "tracking", "TextOnly": "viewing"}
""" { tag: ui_message } """
async def on_client_joined(ctx: Context, client: Client):
if ctx.client_game_state[client.team, client.slot] == ClientStatus.CLIENT_UNKNOWN:
update_client_status(ctx, client, ClientStatus.CLIENT_CONNECTED)
version_str = '.'.join(str(x) for x in client.version)
for tag, verb in _non_game_messages.items():
if tag in client.tags:
final_verb = verb
break
else:
final_verb = "playing"
verb = "tracking" if "Tracker" in client.tags else "playing"
ctx.broadcast_text_all(
f"{ctx.get_aliased_name(client.team, client.slot)} (Team #{client.team + 1}) "
f"{final_verb} {ctx.games[client.slot]} has joined. "
f"{verb} {ctx.games[client.slot]} has joined. "
f"Client({version_str}), {client.tags}.",
{"type": "Join", "team": client.team, "slot": client.slot, "tags": client.tags})
ctx.notify_client(client, "Now that you are connected, "
@@ -888,19 +809,8 @@ async def on_client_left(ctx: Context, client: Client):
if len(ctx.clients[client.team][client.slot]) < 1:
update_client_status(ctx, client, ClientStatus.CLIENT_UNKNOWN)
ctx.client_connection_timers[client.team, client.slot] = datetime.datetime.now(datetime.timezone.utc)
version_str = '.'.join(str(x) for x in client.version)
for tag, verb in _non_game_messages.items():
if tag in client.tags:
final_verb = f"stopped {verb}"
break
else:
final_verb = "left"
ctx.broadcast_text_all(
f"{ctx.get_aliased_name(client.team, client.slot)} (Team #{client.team + 1}) has {final_verb} the game. "
f"Client({version_str}), {client.tags}.",
"%s (Team #%d) has left the game" % (ctx.get_aliased_name(client.team, client.slot), client.team + 1),
{"type": "Part", "team": client.team, "slot": client.slot})
@@ -1013,7 +923,7 @@ def collect_player(ctx: Context, team: int, slot: int, is_group: bool = False):
collect_player(ctx, team, group, True)
def get_remaining(ctx: Context, team: int, slot: int) -> typing.List[typing.Tuple[int, int]]:
def get_remaining(ctx: Context, team: int, slot: int) -> typing.List[int]:
return ctx.locations.get_remaining(ctx.location_checks, team, slot)
@@ -1037,9 +947,9 @@ def register_location_checks(ctx: Context, team: int, slot: int, locations: typi
new_item = NetworkItem(item_id, location, slot, flags)
send_items_to(ctx, team, target_player, new_item)
ctx.logger.info('(Team #%d) %s sent %s to %s (%s)' % (
team + 1, ctx.player_names[(team, slot)], ctx.item_names[ctx.slot_info[target_player].game][item_id],
ctx.player_names[(team, target_player)], ctx.location_names[ctx.slot_info[slot].game][location]))
logging.info('(Team #%d) %s sent %s to %s (%s)' % (
team + 1, ctx.player_names[(team, slot)], ctx.item_names[item_id],
ctx.player_names[(team, target_player)], ctx.location_names[location]))
info_text = json_format_send_event(new_item, target_player)
ctx.broadcast_team(team, [info_text])
@@ -1050,10 +960,7 @@ def register_location_checks(ctx: Context, team: int, slot: int, locations: typi
"hint_points": get_slot_points(ctx, team, slot),
"checked_locations": new_locations, # send back new checks only
}])
old_hints = ctx.hints[team, slot].copy()
ctx.recheck_hints(team, slot)
if old_hints != ctx.hints[team, slot]:
ctx.on_changed_hints(team, slot)
ctx.save()
@@ -1093,8 +1000,8 @@ def collect_hint_location_id(ctx: Context, team: int, slot: int, seeked_location
def format_hint(ctx: Context, team: int, hint: NetUtils.Hint) -> str:
text = f"[Hint]: {ctx.player_names[team, hint.receiving_player]}'s " \
f"{ctx.item_names[ctx.slot_info[hint.receiving_player].game][hint.item]} is " \
f"at {ctx.location_names[ctx.slot_info[hint.finding_player].game][hint.location]} " \
f"{ctx.item_names[hint.item]} is " \
f"at {ctx.location_names[hint.location]} " \
f"in {ctx.player_names[team, hint.finding_player]}'s World"
if hint.entrance:
@@ -1123,6 +1030,26 @@ def json_format_send_event(net_item: NetworkItem, receiving_player: int):
"item": net_item}
def get_intended_text(input_text: str, possible_answers) -> typing.Tuple[str, bool, str]:
picks = Utils.get_fuzzy_results(input_text, possible_answers, limit=2)
if len(picks) > 1:
dif = picks[0][1] - picks[1][1]
if picks[0][1] == 100:
return picks[0][0], True, "Perfect Match"
elif picks[0][1] < 75:
return picks[0][0], False, f"Didn't find something that closely matches, " \
f"did you mean {picks[0][0]}? ({picks[0][1]}% sure)"
elif dif > 5:
return picks[0][0], True, "Close Match"
else:
return picks[0][0], False, f"Too many close matches, did you mean {picks[0][0]}? ({picks[0][1]}% sure)"
else:
if picks[0][1] > 90:
return picks[0][0], True, "Only Option Match"
else:
return picks[0][0], False, f"Did you mean {picks[0][0]}? ({picks[0][1]}% sure)"
class CommandMeta(type):
def __new__(cls, name, bases, attrs):
commands = attrs["commands"] = {}
@@ -1154,10 +1081,7 @@ class CommandProcessor(metaclass=CommandMeta):
if not raw:
return
try:
try:
command = shlex.split(raw, comments=False)
except ValueError: # most likely: "ValueError: No closing quotation"
command = raw.split()
command = raw.split()
basecommand = command[0]
if basecommand[0] == self.marker:
method = self.commands.get(basecommand[1:].lower(), None)
@@ -1228,10 +1152,6 @@ class CommonCommandProcessor(CommandProcessor):
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
@@ -1379,10 +1299,10 @@ class ClientMessageProcessor(CommonCommandProcessor):
def _cmd_remaining(self) -> bool:
"""List remaining items in your game, but not their location or recipient"""
if self.ctx.remaining_mode == "enabled":
rest_locations = get_remaining(self.ctx, self.client.team, self.client.slot)
if rest_locations:
self.output("Remaining items: " + ", ".join(self.ctx.item_names[self.ctx.games[slot]][item_id]
for slot, item_id in rest_locations))
remaining_item_ids = get_remaining(self.ctx, self.client.team, self.client.slot)
if remaining_item_ids:
self.output("Remaining items: " + ", ".join(self.ctx.item_names[item_id]
for item_id in remaining_item_ids))
else:
self.output("No remaining items found.")
return True
@@ -1392,10 +1312,10 @@ class ClientMessageProcessor(CommonCommandProcessor):
return False
else: # is goal
if self.ctx.client_game_state[self.client.team, self.client.slot] == ClientStatus.CLIENT_GOAL:
rest_locations = get_remaining(self.ctx, self.client.team, self.client.slot)
if rest_locations:
self.output("Remaining items: " + ", ".join(self.ctx.item_names[self.ctx.games[slot]][item_id]
for slot, item_id in rest_locations))
remaining_item_ids = get_remaining(self.ctx, self.client.team, self.client.slot)
if remaining_item_ids:
self.output("Remaining items: " + ", ".join(self.ctx.item_names[item_id]
for item_id in remaining_item_ids))
else:
self.output("No remaining items found.")
return True
@@ -1404,7 +1324,6 @@ class ClientMessageProcessor(CommonCommandProcessor):
"Sorry, !remaining requires you to have beaten the game on this server")
return False
@mark_raw
def _cmd_missing(self, filter_text="") -> bool:
"""List all missing location checks from the server's perspective.
Can be given text, which will be used as filter."""
@@ -1412,14 +1331,9 @@ class ClientMessageProcessor(CommonCommandProcessor):
locations = get_missing_checks(self.ctx, self.client.team, self.client.slot)
if locations:
game = self.ctx.slot_info[self.client.slot].game
names = [self.ctx.location_names[game][location] for location in locations]
names = [self.ctx.location_names[location] for location in locations]
if filter_text:
location_groups = self.ctx.location_name_groups[self.ctx.games[self.client.slot]]
if filter_text in location_groups: # location group name
names = [name for name in names if name in location_groups[filter_text]]
else:
names = [name for name in names if filter_text in name]
names = [name for name in names if filter_text in name]
texts = [f'Missing: {name}' for name in names]
if filter_text:
texts.append(f"Found {len(locations)} missing location checks, displaying {len(names)} of them.")
@@ -1430,7 +1344,6 @@ class ClientMessageProcessor(CommonCommandProcessor):
self.output("No missing location checks found.")
return True
@mark_raw
def _cmd_checked(self, filter_text="") -> bool:
"""List all done location checks from the server's perspective.
Can be given text, which will be used as filter."""
@@ -1438,14 +1351,9 @@ class ClientMessageProcessor(CommonCommandProcessor):
locations = get_checked_checks(self.ctx, self.client.team, self.client.slot)
if locations:
game = self.ctx.slot_info[self.client.slot].game
names = [self.ctx.location_names[game][location] for location in locations]
names = [self.ctx.location_names[location] for location in locations]
if filter_text:
location_groups = self.ctx.location_name_groups[self.ctx.games[self.client.slot]]
if filter_text in location_groups: # location group name
names = [name for name in names if name in location_groups[filter_text]]
else:
names = [name for name in names if filter_text in name]
names = [name for name in names if filter_text in name]
texts = [f'Checked: {name}' for name in names]
if filter_text:
texts.append(f"Found {len(locations)} done location checks, displaying {len(names)} of them.")
@@ -1508,22 +1416,18 @@ class ClientMessageProcessor(CommonCommandProcessor):
hints = {hint.re_check(self.ctx, self.client.team) for hint in
self.ctx.hints[self.client.team, self.client.slot]}
self.ctx.hints[self.client.team, self.client.slot] = hints
self.ctx.notify_hints(self.client.team, list(hints), recipients=(self.client.slot,))
self.ctx.notify_hints(self.client.team, list(hints))
self.output(f"A hint costs {self.ctx.get_hint_cost(self.client.slot)} points. "
f"You have {points_available} points.")
if hints and Utils.version_tuple < (0, 5, 0):
self.output("It was recently changed, so that the above hints are only shown to you. "
"If you meant to alert another player of an above hint, "
"please let them know of the content or to run !hint themselves.")
return True
elif input_text.isnumeric():
game = self.ctx.games[self.client.slot]
hint_id = int(input_text)
hint_name = self.ctx.item_names[game][hint_id] \
if not for_location and hint_id in self.ctx.item_names[game] \
else self.ctx.location_names[game][hint_id] \
if for_location and hint_id in self.ctx.location_names[game] \
hint_name = self.ctx.item_names[hint_id] \
if not for_location and hint_id in self.ctx.item_names \
else self.ctx.location_names[hint_id] \
if for_location and hint_id in self.ctx.location_names \
else None
if hint_name in self.ctx.non_hintable_names[game]:
self.output(f"Sorry, \"{hint_name}\" is marked as non-hintable.")
@@ -1568,13 +1472,15 @@ class ClientMessageProcessor(CommonCommandProcessor):
if hints:
new_hints = set(hints) - self.ctx.hints[self.client.team, self.client.slot]
old_hints = list(set(hints) - new_hints)
if old_hints and not new_hints:
self.ctx.notify_hints(self.client.team, old_hints)
self.output("Hint was previously used, no points deducted.")
old_hints = set(hints) - new_hints
if old_hints:
self.ctx.notify_hints(self.client.team, list(old_hints))
if not new_hints:
self.output("Hint was previously used, no points deducted.")
if new_hints:
found_hints = [hint for hint in new_hints if hint.found]
not_found_hints = [hint for hint in new_hints if not hint.found]
if not not_found_hints: # everything's been found, no need to pay
can_pay = 1000
elif cost:
@@ -1585,11 +1491,8 @@ class ClientMessageProcessor(CommonCommandProcessor):
self.ctx.random.shuffle(not_found_hints)
# By popular vote, make hints prefer non-local placements
not_found_hints.sort(key=lambda hint: int(hint.receiving_player != hint.finding_player))
# By another popular vote, prefer early sphere
not_found_hints.sort(key=lambda hint: self.ctx.get_sphere(hint.finding_player, hint.location),
reverse=True)
hints = found_hints + old_hints
hints = found_hints
while can_pay > 0:
if not not_found_hints:
break
@@ -1597,10 +1500,9 @@ class ClientMessageProcessor(CommonCommandProcessor):
hints.append(hint)
can_pay -= 1
self.ctx.hints_used[self.client.team, self.client.slot] += 1
self.ctx.notify_hints(self.client.team, hints)
if not_found_hints:
points_available = get_client_points(self.ctx, self.client)
if not_found_hints:
if hints and cost and int((points_available // cost) == 0):
self.output(
f"There may be more hintables, however, you cannot afford to pay for any more. "
@@ -1613,6 +1515,7 @@ class ClientMessageProcessor(CommonCommandProcessor):
self.output(f"You can't afford the hint. "
f"You have {points_available} points and need at least "
f"{self.ctx.get_hint_cost(self.client.slot)}.")
self.ctx.notify_hints(self.client.team, hints)
self.ctx.save()
return True
@@ -1667,7 +1570,7 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
try:
cmd: str = args["cmd"]
except:
ctx.logger.exception(f"Could not get command from {args}")
logging.exception(f"Could not get command from {args}")
await ctx.send_msgs(client, [{'cmd': 'InvalidPacket', "type": "cmd", "original_cmd": None,
"text": f"Could not get command from {args} at `cmd`"}])
raise
@@ -1693,9 +1596,7 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
else:
team, slot = ctx.connect_names[args['name']]
game = ctx.games[slot]
ignore_game = not args.get("game") and any(tag in _non_game_messages for tag in args["tags"])
ignore_game = ("TextOnly" in args["tags"] or "Tracker" in args["tags"]) and not args.get("game")
if not ignore_game and args['game'] != game:
errors.add('InvalidGame')
minver = min_client_version if ignore_game else ctx.minimum_client_versions[slot]
@@ -1710,7 +1611,7 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
if ctx.compatibility == 0 and args['version'] != version_tuple:
errors.add('IncompatibleVersion')
if errors:
ctx.logger.info(f"A client connection was refused due to: {errors}, the sent connect information was {args}.")
logging.info(f"A client connection was refused due to: {errors}, the sent connect information was {args}.")
await ctx.send_msgs(client, [{"cmd": "ConnectionRefused", "errors": list(errors)}])
else:
team, slot = ctx.connect_names[args['name']]
@@ -1911,14 +1812,8 @@ def update_client_status(ctx: Context, client: Client, new_status: ClientStatus)
if current != ClientStatus.CLIENT_GOAL: # can't undo goal completion
if new_status == ClientStatus.CLIENT_GOAL:
ctx.on_goal_achieved(client)
# if player has yet to ever connect to the server, they will not be in client_game_state
if all(player in ctx.client_game_state and ctx.client_game_state[player] == ClientStatus.CLIENT_GOAL
for player in ctx.player_names
if player[0] == client.team and player[1] != client.slot):
ctx.broadcast_text_all(f"Team #{client.team + 1} has completed all of their games! Congratulations!")
ctx.client_game_state[client.team, client.slot] = new_status
ctx.on_client_status_change(client.team, client.slot)
ctx.save()
@@ -1960,16 +1855,16 @@ class ServerCommandProcessor(CommonCommandProcessor):
def _cmd_exit(self) -> bool:
"""Shutdown the server"""
try:
self.ctx.server.ws_server.close()
finally:
self.ctx.exit_event.set()
self.ctx.server.ws_server.close()
if self.ctx.shutdown_task:
self.ctx.shutdown_task.cancel()
self.ctx.exit_event.set()
return True
@mark_raw
def _cmd_alias(self, player_name_then_alias_name):
"""Set a player's alias, by listing their base name and then their intended alias."""
player_name, _, alias_name = player_name_then_alias_name.partition(" ")
player_name, alias_name = player_name_then_alias_name.split(" ", 1)
player_name, usable, response = get_intended_text(player_name, self.ctx.player_names.values())
if usable:
for (team, slot), name in self.ctx.player_names.items():
@@ -2049,7 +1944,7 @@ class ServerCommandProcessor(CommonCommandProcessor):
@mark_raw
def _cmd_forbid_release(self, player_name: str) -> bool:
"""Disallow the specified player from using the !release command."""
""""Disallow the specified player from using the !release command."""
player = self.resolve_player(player_name)
if player:
team, slot, name = player
@@ -2070,8 +1965,6 @@ class ServerCommandProcessor(CommonCommandProcessor):
item_name, usable, response = get_intended_text(item_name, names)
if usable:
amount: int = int(amount)
if amount > 100:
raise ValueError(f"{amount} is invalid. Maximum is 100.")
new_items = [NetworkItem(names[item_name], -1, 0) for _ in range(int(amount))]
send_items_to(self.ctx, team, slot, *new_items)
@@ -2171,8 +2064,8 @@ class ServerCommandProcessor(CommonCommandProcessor):
if full_name.isnumeric():
location, usable, response = int(full_name), True, None
elif game in self.ctx.all_location_and_group_names:
location, usable, response = get_intended_text(full_name, self.ctx.all_location_and_group_names[game])
elif self.ctx.location_names_for_game(game) is not None:
location, usable, response = get_intended_text(full_name, self.ctx.location_names_for_game(game))
else:
self.output("Can't look up location for unknown game. Hint for ID instead.")
return False
@@ -2180,11 +2073,6 @@ class ServerCommandProcessor(CommonCommandProcessor):
if usable:
if isinstance(location, int):
hints = collect_hint_location_id(self.ctx, team, slot, location)
elif game in self.ctx.location_name_groups and location in self.ctx.location_name_groups[game]:
hints = []
for loc_name_from_group in self.ctx.location_name_groups[game][location]:
if loc_name_from_group in self.ctx.location_names_for_game(game):
hints.extend(collect_hint_location_name(self.ctx, team, slot, loc_name_from_group))
else:
hints = collect_hint_location_name(self.ctx, team, slot, location)
if hints:
@@ -2200,47 +2088,32 @@ class ServerCommandProcessor(CommonCommandProcessor):
self.output(response)
return False
def _cmd_option(self, option_name: str, option_value: str):
"""Set an option for the server."""
value_type = self.ctx.simple_options.get(option_name, None)
if not value_type:
known_options = (f"{option}: {option_type}" for option, option_type in self.ctx.simple_options.items())
self.output(f"Unrecognized option '{option_name}', known: {', '.join(known_options)}")
def _cmd_option(self, option_name: str, option: str):
"""Set options for the server."""
attrtype = self.ctx.simple_options.get(option_name, None)
if attrtype:
if attrtype == bool:
def attrtype(input_text: str):
return input_text.lower() not in {"off", "0", "false", "none", "null", "no"}
elif attrtype == str and option_name.endswith("password"):
def attrtype(input_text: str):
if input_text.lower() in {"null", "none", '""', "''"}:
return None
return input_text
setattr(self.ctx, option_name, attrtype(option))
self.output(f"Set option {option_name} to {getattr(self.ctx, option_name)}")
if option_name in {"release_mode", "remaining_mode", "collect_mode"}:
self.ctx.broadcast_all([{"cmd": "RoomUpdate", 'permissions': get_permissions(self.ctx)}])
elif option_name in {"hint_cost", "location_check_points"}:
self.ctx.broadcast_all([{"cmd": "RoomUpdate", option_name: getattr(self.ctx, option_name)}])
return True
else:
known = (f"{option}:{otype}" for option, otype in self.ctx.simple_options.items())
self.output(f"Unrecognized Option {option_name}, known: "
f"{', '.join(known)}")
return False
if value_type == bool:
def value_type(input_text: str):
return input_text.lower() not in {"off", "0", "false", "none", "null", "no"}
elif value_type == str and option_name.endswith("password"):
def value_type(input_text: str):
return None if input_text.lower() in {"null", "none", '""', "''"} else input_text
elif value_type == str and option_name.endswith("mode"):
valid_values = {"goal", "enabled", "disabled"}
valid_values.update(("auto", "auto_enabled") if option_name != "remaining_mode" else [])
if option_value.lower() not in valid_values:
self.output(f"Unrecognized {option_name} value '{option_value}', known: {', '.join(valid_values)}")
return False
setattr(self.ctx, option_name, value_type(option_value))
self.output(f"Set option {option_name} to {getattr(self.ctx, option_name)}")
if option_name in {"release_mode", "remaining_mode", "collect_mode"}:
self.ctx.broadcast_all([{"cmd": "RoomUpdate", 'permissions': get_permissions(self.ctx)}])
elif option_name in {"hint_cost", "location_check_points"}:
self.ctx.broadcast_all([{"cmd": "RoomUpdate", option_name: getattr(self.ctx, option_name)}])
return True
def _cmd_datastore(self):
"""Debug Tool: list writable datastorage keys and approximate the size of their values with pickle."""
total: int = 0
texts = []
for key, value in self.ctx.stored_data.items():
size = len(pickle.dumps(value))
total += size
texts.append(f"Key: {key} | Size: {size}B")
texts.insert(0, f"Found {len(self.ctx.stored_data)} keys, "
f"approximately totaling {Utils.format_SI_prefix(total, power=1024)}B")
self.output("\n".join(texts))
async def console(ctx: Context):
import sys
@@ -2264,7 +2137,7 @@ async def console(ctx: Context):
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser()
defaults = Utils.get_settings()["server_options"].as_dict()
defaults = Utils.get_options()["server_options"].as_dict()
parser.add_argument('multidata', nargs="?", default=defaults["multidata"])
parser.add_argument('--host', default=defaults["host"])
parser.add_argument('--port', default=defaults["port"], type=int)
@@ -2322,29 +2195,28 @@ def parse_args() -> argparse.Namespace:
async def auto_shutdown(ctx, to_cancel=None):
with contextlib.suppress(asyncio.TimeoutError):
await asyncio.wait_for(ctx.exit_event.wait(), ctx.auto_shutdown)
def inactivity_shutdown():
ctx.server.ws_server.close()
ctx.exit_event.set()
if to_cancel:
for task in to_cancel:
task.cancel()
ctx.logger.info("Shutting down due to inactivity.")
await asyncio.sleep(ctx.auto_shutdown)
while not ctx.exit_event.is_set():
if not ctx.client_activity_timers.values():
inactivity_shutdown()
ctx.server.ws_server.close()
ctx.exit_event.set()
if to_cancel:
for task in to_cancel:
task.cancel()
logging.info("Shutting down due to inactivity.")
else:
newest_activity = max(ctx.client_activity_timers.values())
delta = datetime.datetime.now(datetime.timezone.utc) - newest_activity
seconds = ctx.auto_shutdown - delta.total_seconds()
if seconds < 0:
inactivity_shutdown()
ctx.server.ws_server.close()
ctx.exit_event.set()
if to_cancel:
for task in to_cancel:
task.cancel()
logging.info("Shutting down due to inactivity.")
else:
with contextlib.suppress(asyncio.TimeoutError):
await asyncio.wait_for(ctx.exit_event.wait(), seconds)
await asyncio.sleep(seconds)
def load_server_cert(path: str, cert_key: typing.Optional[str]) -> "ssl.SSLContext":

View File

@@ -79,7 +79,6 @@ class NetworkItem(typing.NamedTuple):
item: int
location: int
player: int
""" Sending player, except in LocationInfo (from LocationScouts), where it is the receiving player. """
flags: int = 0
@@ -199,8 +198,7 @@ class JSONtoTextParser(metaclass=HandlerMeta):
"slateblue": "6D8BE8",
"plum": "AF99EF",
"salmon": "FA8072",
"white": "FFFFFF",
"orange": "FF7700",
"white": "FFFFFF"
}
def __init__(self, ctx):
@@ -249,7 +247,7 @@ class JSONtoTextParser(metaclass=HandlerMeta):
def _handle_item_id(self, node: JSONMessagePart):
item_id = int(node["text"])
node["text"] = self.ctx.item_names.lookup_in_slot(item_id, node["player"])
node["text"] = self.ctx.item_names[item_id]
return self._handle_item_name(node)
def _handle_location_name(self, node: JSONMessagePart):
@@ -257,8 +255,8 @@ class JSONtoTextParser(metaclass=HandlerMeta):
return self._handle_color(node)
def _handle_location_id(self, node: JSONMessagePart):
location_id = int(node["text"])
node["text"] = self.ctx.location_names.lookup_in_slot(location_id, node["player"])
item_id = int(node["text"])
node["text"] = self.ctx.location_names[item_id]
return self._handle_location_name(node)
def _handle_entrance_name(self, node: JSONMessagePart):
@@ -273,8 +271,7 @@ class RawJSONtoTextParser(JSONtoTextParser):
color_codes = {'reset': 0, 'bold': 1, 'underline': 4, 'black': 30, 'red': 31, 'green': 32, 'yellow': 33, 'blue': 34,
'magenta': 35, 'cyan': 36, 'white': 37, 'black_bg': 40, 'red_bg': 41, 'green_bg': 42, 'yellow_bg': 43,
'blue_bg': 44, 'magenta_bg': 45, 'cyan_bg': 46, 'white_bg': 47,
'plum': 35, 'slateblue': 34, 'salmon': 31,} # convert ui colors to terminal colors
'blue_bg': 44, 'magenta_bg': 45, 'cyan_bg': 46, 'white_bg': 47}
def color_code(*args):
@@ -293,8 +290,8 @@ def add_json_item(parts: list, item_id: int, player: int = 0, item_flags: int =
parts.append({"text": str(item_id), "player": player, "flags": item_flags, "type": JSONTypes.item_id, **kwargs})
def add_json_location(parts: list, location_id: int, player: int = 0, **kwargs) -> None:
parts.append({"text": str(location_id), "player": player, "type": JSONTypes.location_id, **kwargs})
def add_json_location(parts: list, item_id: int, player: int = 0, **kwargs) -> None:
parts.append({"text": str(item_id), "player": player, "type": JSONTypes.location_id, **kwargs})
class Hint(typing.NamedTuple):
@@ -399,12 +396,12 @@ class _LocationStore(dict, typing.MutableMapping[int, typing.Dict[int, typing.Tu
location_id not in checked]
def get_remaining(self, state: typing.Dict[typing.Tuple[int, int], typing.Set[int]], team: int, slot: int
) -> typing.List[typing.Tuple[int, int]]:
) -> typing.List[int]:
checked = state[team, slot]
player_locations = self[slot]
return sorted([(player_locations[location_id][1], player_locations[location_id][0]) for
location_id in player_locations if
location_id not in checked])
return sorted([player_locations[location_id][0] for
location_id in player_locations if
location_id not in checked])
if typing.TYPE_CHECKING: # type-check with pure python implementation until we have a typing stub

View File

@@ -195,10 +195,10 @@ def set_icon(window):
window.tk.call('wm', 'iconphoto', window._w, logo)
def adjust(args):
# Create a fake multiworld and OOTWorld to use as a base
multiworld = MultiWorld(1)
multiworld.per_slot_randoms = {1: random}
ootworld = OOTWorld(multiworld, 1)
# Create a fake world and OOTWorld to use as a base
world = MultiWorld(1)
world.per_slot_randoms = {1: random}
ootworld = OOTWorld(world, 1)
# Set options in the fake OOTWorld
for name, option in chain(cosmetic_options.items(), sfx_options.items()):
result = getattr(args, name, None)

View File

@@ -1,41 +1,23 @@
from __future__ import annotations
import abc
import functools
import logging
import math
import numbers
import random
import typing
import enum
from collections import defaultdict
from copy import deepcopy
from dataclasses import dataclass
from schema import And, Optional, Or, Schema
from typing_extensions import Self
from Utils import get_file_safe_name, get_fuzzy_results, is_iterable_except_str, output_path
from Utils import get_fuzzy_results
if typing.TYPE_CHECKING:
from BaseClasses import MultiWorld, PlandoOptions
from BaseClasses import PlandoOptions
from worlds.AutoWorld import World
import pathlib
class OptionError(ValueError):
pass
class Visibility(enum.IntFlag):
none = 0b0000
template = 0b0001
simple_ui = 0b0010 # show option in simple menus, such as player-options
complex_ui = 0b0100 # show option in complex menus, such as weighted-options
spoiler = 0b1000
all = 0b1111
class AssembleOptions(abc.ABCMeta):
def __new__(mcs, name, bases, attrs):
options = attrs["options"] = {}
@@ -54,14 +36,9 @@ class AssembleOptions(abc.ABCMeta):
attrs["name_lookup"].update({option_id: name for name, option_id in new_options.items()})
options.update(new_options)
# apply aliases, without name_lookup
aliases = attrs["aliases"] = {name[6:].lower(): option_id for name, option_id in attrs.items() if
name.startswith("alias_")}
aliases = {name[6:].lower(): option_id for name, option_id in attrs.items() if
name.startswith("alias_")}
assert (
name in {"Option", "VerifyKeys"} or # base abstract classes don't need default
"default" in attrs or
any(hasattr(base, "default") for base in bases)
), f"Option class {name} needs default value"
assert "random" not in aliases, "Choice option 'random' cannot be manually assigned."
# auto-alias Off and On being parsed as True and False
@@ -79,7 +56,6 @@ class AssembleOptions(abc.ABCMeta):
def verify(self, *args, **kwargs) -> None:
for f in verifiers:
f(self, *args, **kwargs)
attrs["verify"] = verify
else:
assert verifiers, "class Option is supposed to implement def verify"
@@ -117,8 +93,7 @@ T = typing.TypeVar('T')
class Option(typing.Generic[T], metaclass=AssembleOptions):
value: T
default: typing.ClassVar[typing.Any] # something that __init__ will be able to convert to the correct type
visibility = Visibility.all
default = 0
# convert option_name_long into Name Long as display_name, otherwise name_long is the result.
# Handled in get_option_name()
@@ -127,28 +102,9 @@ class Option(typing.Generic[T], metaclass=AssembleOptions):
# can be weighted between selections
supports_weighting = True
rich_text_doc: typing.Optional[bool] = None
"""Whether the WebHost should render the Option's docstring as rich text.
If this is True, the Option's docstring is interpreted as reStructuredText_,
the standard Python markup format. In the WebHost, it's rendered to HTML so
that lists, emphasis, and other rich text features are displayed properly.
If this is False, the docstring is instead interpreted as plain text, and
displayed as-is on the WebHost with whitespace preserved.
If this is None, it inherits the value of `World.rich_text_options_doc`. For
backwards compatibility, this defaults to False, but worlds are encouraged to
set it to True and use reStructuredText for their Option documentation.
.. _reStructuredText: https://docutils.sourceforge.io/rst.html
"""
# filled by AssembleOptions:
name_lookup: typing.ClassVar[typing.Dict[T, str]] # type: ignore
# https://github.com/python/typing/discussions/1460 the reason for this type: ignore
options: typing.ClassVar[typing.Dict[str, int]]
aliases: typing.ClassVar[typing.Dict[str, int]]
name_lookup: typing.Dict[T, str]
options: typing.Dict[str, int]
def __repr__(self) -> str:
return f"{self.__class__.__name__}({self.current_option_name})"
@@ -160,6 +116,12 @@ class Option(typing.Generic[T], metaclass=AssembleOptions):
def current_key(self) -> str:
return self.name_lookup[self.value]
def get_current_option_name(self) -> str:
"""Deprecated. use current_option_name instead. TODO remove around 0.4"""
logging.warning(DeprecationWarning(f"get_current_option_name for {self.__class__.__name__} is deprecated."
f" use current_option_name instead. Worlds should use {self}.current_key"))
return self.current_option_name
@property
def current_option_name(self) -> str:
"""For display purposes. Worlds should be using current_key."""
@@ -195,8 +157,6 @@ class FreeText(Option[str]):
"""Text option that allows users to enter strings.
Needs to be validated by the world or option definition."""
default = ""
def __init__(self, value: str):
assert isinstance(value, str), "value of FreeText must be a string"
self.value = value
@@ -217,18 +177,9 @@ class FreeText(Option[str]):
def get_option_name(cls, value: str) -> str:
return value
def __eq__(self, other):
if isinstance(other, self.__class__):
return other.value == self.value
elif isinstance(other, str):
return other == self.value
else:
raise TypeError(f"Can't compare {self.__class__.__name__} with {other.__class__.__name__}")
class NumericOption(Option[int], numbers.Integral, abc.ABC):
default = 0
# note: some of the `typing.Any`` here is a result of unresolved issue in python standards
# `int` is not a `numbers.Integral` according to the official typestubs
# (even though isinstance(5, numbers.Integral) == True)
@@ -260,12 +211,6 @@ class NumericOption(Option[int], numbers.Integral, abc.ABC):
else:
return self.value > other
def __ge__(self, other: typing.Union[int, NumericOption]) -> bool:
if isinstance(other, NumericOption):
return self.value >= other.value
else:
return self.value >= other
def __bool__(self) -> bool:
return bool(self.value)
@@ -402,8 +347,7 @@ class Toggle(NumericOption):
default = 0
def __init__(self, value: int):
# if user puts in an invalid value, make it valid
value = int(bool(value))
assert value == 0 or value == 1, "value of Toggle can only be 0 or 1"
self.value = value
@classmethod
@@ -645,7 +589,7 @@ class PlandoBosses(TextChoice, metaclass=BossMeta):
if isinstance(self.value, int):
return
from BaseClasses import PlandoOptions
if not (PlandoOptions.bosses & plando_options):
if not(PlandoOptions.bosses & plando_options):
# plando is disabled but plando options were given so pull the option and change it to an int
option = self.value.split(";")[-1]
self.value = self.options[option]
@@ -743,25 +687,11 @@ class Range(NumericOption):
return int(round(random.triangular(lower, end, tri), 0))
class NamedRange(Range):
class SpecialRange(Range):
special_range_cutoff = 0
special_range_names: typing.Dict[str, int] = {}
"""Special Range names have to be all lowercase as matching is done with text.lower()"""
def __init__(self, value: int) -> None:
if value < self.range_start and value not in self.special_range_names.values():
raise Exception(f"{value} is lower than minimum {self.range_start} for option {self.__class__.__name__} " +
f"and is also not one of the supported named special values: {self.special_range_names}")
elif value > self.range_end and value not in self.special_range_names.values():
raise Exception(f"{value} is higher than maximum {self.range_end} for option {self.__class__.__name__} " +
f"and is also not one of the supported named special values: {self.special_range_names}")
# See docstring
for key in self.special_range_names:
if key != key.lower():
raise Exception(f"{self.__class__.__name__} has an invalid special_range_names key: {key}. "
f"NamedRange keys must use only lowercase letters, and ideally should be snake_case.")
self.value = value
@classmethod
def from_text(cls, text: str) -> Range:
text = text.lower()
@@ -769,10 +699,27 @@ class NamedRange(Range):
return cls(cls.special_range_names[text])
return super().from_text(text)
@classmethod
def weighted_range(cls, text) -> Range:
if text == "random-low":
return cls(cls.triangular(cls.special_range_cutoff, cls.range_end, cls.special_range_cutoff))
elif text == "random-high":
return cls(cls.triangular(cls.special_range_cutoff, cls.range_end, cls.range_end))
elif text == "random-middle":
return cls(cls.triangular(cls.special_range_cutoff, cls.range_end))
elif text.startswith("random-range-"):
return cls.custom_range(text)
elif text == "random":
return cls(random.randint(cls.special_range_cutoff, cls.range_end))
else:
raise Exception(f"random text \"{text}\" did not resolve to a recognized pattern. "
f"Acceptable values are: random, random-high, random-middle, random-low, "
f"random-range-low-<min>-<max>, random-range-middle-<min>-<max>, "
f"random-range-high-<min>-<max>, or random-range-<min>-<max>.")
class FreezeValidKeys(AssembleOptions):
def __new__(mcs, name, bases, attrs):
assert not "_valid_keys" in attrs, "'_valid_keys' gets set by FreezeValidKeys, define 'valid_keys' instead."
if "valid_keys" in attrs:
attrs["_valid_keys"] = frozenset(attrs["valid_keys"])
return super(FreezeValidKeys, mcs).__new__(mcs, name, bases, attrs)
@@ -787,22 +734,17 @@ class VerifyKeys(metaclass=FreezeValidKeys):
verify_location_name: bool = False
value: typing.Any
def verify_keys(self) -> None:
if self.valid_keys:
data = set(self.value)
dataset = set(word.casefold() for word in data) if self.valid_keys_casefold else set(data)
extra = dataset - self._valid_keys
@classmethod
def verify_keys(cls, data: typing.List[str]):
if cls.valid_keys:
data = set(data)
dataset = set(word.casefold() for word in data) if cls.valid_keys_casefold else set(data)
extra = dataset - cls._valid_keys
if extra:
raise OptionError(
f"Found unexpected key {', '.join(extra)} in {getattr(self, 'display_name', self)}. "
f"Allowed keys: {self._valid_keys}."
)
raise Exception(f"Found unexpected key {', '.join(extra)} in {cls}. "
f"Allowed keys: {cls._valid_keys}.")
def verify(self, world: typing.Type[World], player_name: str, plando_options: "PlandoOptions") -> None:
try:
self.verify_keys()
except OptionError as validation_error:
raise OptionError(f"Player {player_name} has invalid option keys:\n{validation_error}")
if self.convert_name_groups and self.verify_item_name:
new_value = type(self.value)() # empty container of whatever value is
for item_name in self.value:
@@ -830,7 +772,7 @@ class VerifyKeys(metaclass=FreezeValidKeys):
class OptionDict(Option[typing.Dict[str, typing.Any]], VerifyKeys, typing.Mapping[str, typing.Any]):
default = {}
default: typing.Dict[str, typing.Any] = {}
supports_weighting = False
def __init__(self, value: typing.Dict[str, typing.Any]):
@@ -839,6 +781,7 @@ class OptionDict(Option[typing.Dict[str, typing.Any]], VerifyKeys, typing.Mappin
@classmethod
def from_any(cls, data: typing.Dict[str, typing.Any]) -> OptionDict:
if type(data) == dict:
cls.verify_keys(data)
return cls(data)
else:
raise NotImplementedError(f"Cannot Convert from non-dictionary, got {type(data)}")
@@ -870,11 +813,11 @@ class OptionList(Option[typing.List[typing.Any]], VerifyKeys):
# If only unique entries are needed and input order of elements does not matter, OptionSet should be used instead.
# Not a docstring so it doesn't get grabbed by the options system.
default = ()
default: typing.List[typing.Any] = []
supports_weighting = False
def __init__(self, value: typing.Iterable[typing.Any]):
self.value = list(deepcopy(value))
def __init__(self, value: typing.List[typing.Any]):
self.value = deepcopy(value)
super(OptionList, self).__init__()
@classmethod
@@ -883,7 +826,8 @@ class OptionList(Option[typing.List[typing.Any]], VerifyKeys):
@classmethod
def from_any(cls, data: typing.Any):
if is_iterable_except_str(data):
if type(data) == list:
cls.verify_keys(data)
return cls(data)
return cls.from_text(str(data))
@@ -895,7 +839,7 @@ class OptionList(Option[typing.List[typing.Any]], VerifyKeys):
class OptionSet(Option[typing.Set[str]], VerifyKeys):
default = frozenset()
default: typing.Union[typing.Set[str], typing.FrozenSet[str]] = frozenset()
supports_weighting = False
def __init__(self, value: typing.Iterable[str]):
@@ -908,7 +852,8 @@ class OptionSet(Option[typing.Set[str]], VerifyKeys):
@classmethod
def from_any(cls, data: typing.Any):
if is_iterable_except_str(data):
if isinstance(data, (list, set, frozenset)):
cls.verify_keys(data)
return cls(data)
return cls.from_text(str(data))
@@ -924,297 +869,26 @@ class ItemSet(OptionSet):
convert_name_groups = True
class PlandoText(typing.NamedTuple):
at: str
text: typing.List[str]
percentage: int = 100
PlandoTextsFromAnyType = typing.Union[
typing.Iterable[typing.Union[typing.Mapping[str, typing.Any], PlandoText, typing.Any]], typing.Any
]
class PlandoTexts(Option[typing.List[PlandoText]], VerifyKeys):
default = ()
supports_weighting = False
display_name = "Plando Texts"
def __init__(self, value: typing.Iterable[PlandoText]) -> None:
self.value = list(deepcopy(value))
super().__init__()
def verify(self, world: typing.Type[World], player_name: str, plando_options: "PlandoOptions") -> None:
from BaseClasses import PlandoOptions
if self.value and not (PlandoOptions.texts & plando_options):
# plando is disabled but plando options were given so overwrite the options
self.value = []
logging.warning(f"The plando texts module is turned off, "
f"so text for {player_name} will be ignored.")
else:
super().verify(world, player_name, plando_options)
def verify_keys(self) -> None:
if self.valid_keys:
data = set(text.at for text in self)
dataset = set(word.casefold() for word in data) if self.valid_keys_casefold else set(data)
extra = dataset - self._valid_keys
if extra:
raise OptionError(
f"Invalid \"at\" placement {', '.join(extra)} in {getattr(self, 'display_name', self)}. "
f"Allowed placements: {self._valid_keys}."
)
@classmethod
def from_any(cls, data: PlandoTextsFromAnyType) -> Self:
texts: typing.List[PlandoText] = []
if isinstance(data, typing.Iterable):
for text in data:
if isinstance(text, typing.Mapping):
if random.random() < float(text.get("percentage", 100)/100):
at = text.get("at", None)
if at is not None:
if isinstance(at, dict):
if at:
at = random.choices(list(at.keys()),
weights=list(at.values()), k=1)[0]
else:
raise OptionError("\"at\" must be a valid string or weighted list of strings!")
given_text = text.get("text", [])
if isinstance(given_text, dict):
if not given_text:
given_text = []
else:
given_text = random.choices(list(given_text.keys()),
weights=list(given_text.values()), k=1)
if isinstance(given_text, str):
given_text = [given_text]
texts.append(PlandoText(
at,
given_text,
text.get("percentage", 100)
))
else:
raise OptionError("\"at\" must be a valid string or weighted list of strings!")
elif isinstance(text, PlandoText):
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)}")
return cls(texts)
else:
raise NotImplementedError(f"Cannot Convert from non-list, got {type(data)}")
@classmethod
def get_option_name(cls, value: typing.List[PlandoText]) -> str:
return str({text.at: " ".join(text.text) for text in value})
def __iter__(self) -> typing.Iterator[PlandoText]:
yield from self.value
def __getitem__(self, index: typing.SupportsIndex) -> PlandoText:
return self.value.__getitem__(index)
def __len__(self) -> int:
return self.value.__len__()
class ConnectionsMeta(AssembleOptions):
def __new__(mcs, name: str, bases: tuple[type, ...], attrs: dict[str, typing.Any]):
if name != "PlandoConnections":
assert "entrances" in attrs, f"Please define valid entrances for {name}"
attrs["entrances"] = frozenset((connection.lower() for connection in attrs["entrances"]))
assert "exits" in attrs, f"Please define valid exits for {name}"
attrs["exits"] = frozenset((connection.lower() for connection in attrs["exits"]))
if "__doc__" not in attrs:
attrs["__doc__"] = PlandoConnections.__doc__
cls = super().__new__(mcs, name, bases, attrs)
return cls
class PlandoConnection(typing.NamedTuple):
class Direction:
entrance = "entrance"
exit = "exit"
both = "both"
entrance: str
exit: str
direction: typing.Literal["entrance", "exit", "both"] # TODO: convert Direction to StrEnum once 3.8 is dropped
percentage: int = 100
PlandoConFromAnyType = typing.Union[
typing.Iterable[typing.Union[typing.Mapping[str, typing.Any], PlandoConnection, typing.Any]], typing.Any
]
class PlandoConnections(Option[typing.List[PlandoConnection]], metaclass=ConnectionsMeta):
"""Generic connections plando. Format is:
- entrance: "Entrance Name"
exit: "Exit Name"
direction: "Direction"
percentage: 100
Direction must be one of 'entrance', 'exit', or 'both', and defaults to 'both' if omitted.
Percentage is an integer from 1 to 100, and defaults to 100 when omitted."""
display_name = "Plando Connections"
default = ()
supports_weighting = False
entrances: typing.ClassVar[typing.AbstractSet[str]]
exits: typing.ClassVar[typing.AbstractSet[str]]
duplicate_exits: bool = False
"""Whether or not exits should be allowed to be duplicate."""
def __init__(self, value: typing.Iterable[PlandoConnection]):
self.value = list(deepcopy(value))
super(PlandoConnections, self).__init__()
@classmethod
def validate_entrance_name(cls, entrance: str) -> bool:
return entrance.lower() in cls.entrances
@classmethod
def validate_exit_name(cls, exit: str) -> bool:
return exit.lower() in cls.exits
@classmethod
def can_connect(cls, entrance: str, exit: str) -> bool:
"""Checks that a given entrance can connect to a given exit.
By default, this will always return true unless overridden."""
return True
@classmethod
def validate_plando_connections(cls, connections: typing.Iterable[PlandoConnection]) -> None:
used_entrances: typing.List[str] = []
used_exits: typing.List[str] = []
for connection in connections:
entrance = connection.entrance
exit = connection.exit
direction = connection.direction
if direction not in (PlandoConnection.Direction.entrance,
PlandoConnection.Direction.exit,
PlandoConnection.Direction.both):
raise ValueError(f"Unknown direction: {direction}")
if entrance in used_entrances:
raise ValueError(f"Duplicate Entrance {entrance} not allowed.")
if not cls.duplicate_exits and exit in used_exits:
raise ValueError(f"Duplicate Exit {exit} not allowed.")
used_entrances.append(entrance)
used_exits.append(exit)
if not cls.validate_entrance_name(entrance):
raise ValueError(f"{entrance.title()} is not a valid entrance.")
if not cls.validate_exit_name(exit):
raise ValueError(f"{exit.title()} is not a valid exit.")
if not cls.can_connect(entrance, exit):
raise ValueError(f"Connection between {entrance.title()} and {exit.title()} is invalid.")
@classmethod
def from_any(cls, data: PlandoConFromAnyType) -> Self:
if not isinstance(data, typing.Iterable):
raise Exception(f"Cannot create plando connections from non-List value, got {type(data)}.")
value: typing.List[PlandoConnection] = []
for connection in data:
if isinstance(connection, typing.Mapping):
percentage = connection.get("percentage", 100)
if random.random() < float(percentage / 100):
entrance = connection.get("entrance", None)
if is_iterable_except_str(entrance):
entrance = random.choice(sorted(entrance))
exit = connection.get("exit", None)
if is_iterable_except_str(exit):
exit = random.choice(sorted(exit))
direction = connection.get("direction", "both")
if not entrance or not exit:
raise Exception("Plando connection must have an entrance and an exit.")
value.append(PlandoConnection(
entrance,
exit,
direction,
percentage
))
elif isinstance(connection, PlandoConnection):
if random.random() < float(connection.percentage / 100):
value.append(connection)
else:
raise Exception(f"Cannot create connection from non-Dict type, got {type(connection)}.")
cls.validate_plando_connections(value)
return cls(value)
def verify(self, world: typing.Type[World], player_name: str, plando_options: "PlandoOptions") -> None:
from BaseClasses import PlandoOptions
if self.value and not (PlandoOptions.connections & plando_options):
# plando is disabled but plando options were given so overwrite the options
self.value = []
logging.warning(f"The plando connections module is turned off, "
f"so connections for {player_name} will be ignored.")
@classmethod
def get_option_name(cls, value: typing.List[PlandoConnection]) -> str:
return ", ".join(["%s %s %s" % (connection.entrance,
"<=>" if connection.direction == PlandoConnection.Direction.both else
"<=" if connection.direction == PlandoConnection.Direction.exit else
"=>",
connection.exit) for connection in value])
def __getitem__(self, index: typing.SupportsIndex) -> PlandoConnection:
return self.value.__getitem__(index)
def __iter__(self) -> typing.Iterator[PlandoConnection]:
yield from self.value
def __len__(self) -> int:
return len(self.value)
class Accessibility(Choice):
"""
Set rules for reachability of your items/locations.
**Full:** ensure everything can be reached and acquired.
**Minimal:** ensure what is needed to reach your goal can be acquired.
"""
"""Set rules for reachability of your items/locations.
Locations: ensure everything can be reached and acquired.
Items: ensure all logically relevant items can be acquired.
Minimal: ensure what is needed to reach your goal can be acquired."""
display_name = "Accessibility"
rich_text_doc = True
option_full = 0
option_locations = 0
option_items = 1
option_minimal = 2
alias_none = 2
alias_locations = 0
alias_items = 0
default = 0
class ItemsAccessibility(Accessibility):
"""
Set rules for reachability of your items/locations.
**Full:** ensure everything can be reached and acquired.
**Minimal:** ensure what is needed to reach your goal can be acquired.
**Items:** ensure all logically relevant items can be acquired. Some items, such as keys, may be self-locking, and
some locations may be inaccessible.
"""
option_items = 1
default = 1
class ProgressionBalancing(NamedRange):
class ProgressionBalancing(SpecialRange):
"""A system that can move progression earlier, to try and prevent the player from getting stuck and bored early.
A lower setting means more getting stuck. A higher setting means less getting stuck.
"""
A lower setting means more getting stuck. A higher setting means less getting stuck."""
default = 50
range_start = 0
range_end = 99
display_name = "Progression Balancing"
rich_text_doc = True
special_range_names = {
"disabled": 0,
"normal": 50,
@@ -1222,94 +896,38 @@ class ProgressionBalancing(NamedRange):
}
class OptionsMetaProperty(type):
def __new__(mcs,
name: str,
bases: typing.Tuple[type, ...],
attrs: typing.Dict[str, typing.Any]) -> "OptionsMetaProperty":
for attr_type in attrs.values():
assert not isinstance(attr_type, AssembleOptions), \
f"Options for {name} should be type hinted on the class, not assigned"
return super().__new__(mcs, name, bases, attrs)
@property
@functools.lru_cache(maxsize=None)
def type_hints(cls) -> typing.Dict[str, typing.Type[Option[typing.Any]]]:
"""Returns type hints of the class as a dictionary."""
return typing.get_type_hints(cls)
@dataclass
class CommonOptions(metaclass=OptionsMetaProperty):
progression_balancing: ProgressionBalancing
accessibility: Accessibility
def as_dict(self, *option_names: str, casing: str = "snake") -> typing.Dict[str, typing.Any]:
"""
Returns a dictionary of [str, Option.value]
:param option_names: names of the options to return
:param casing: case of the keys to return. Supports `snake`, `camel`, `pascal`, `kebab`
"""
assert option_names, "options.as_dict() was used without any option names."
option_results = {}
for option_name in option_names:
if option_name in type(self).type_hints:
if casing == "snake":
display_name = option_name
elif casing == "camel":
split_name = [name.title() for name in option_name.split("_")]
split_name[0] = split_name[0].lower()
display_name = "".join(split_name)
elif casing == "pascal":
display_name = "".join([name.title() for name in option_name.split("_")])
elif casing == "kebab":
display_name = option_name.replace("_", "-")
else:
raise ValueError(f"{casing} is invalid casing for as_dict. "
"Valid names are 'snake', 'camel', 'pascal', 'kebab'.")
value = getattr(self, option_name).value
if isinstance(value, set):
value = sorted(value)
option_results[display_name] = value
else:
raise ValueError(f"{option_name} not found in {tuple(type(self).type_hints)}")
return option_results
common_options = {
"progression_balancing": ProgressionBalancing,
"accessibility": Accessibility
}
class LocalItems(ItemSet):
"""Forces these items to be in their native world."""
display_name = "Local Items"
rich_text_doc = True
class NonLocalItems(ItemSet):
"""Forces these items to be outside their native world."""
display_name = "Non-local Items"
rich_text_doc = True
display_name = "Not Local Items"
class StartInventory(ItemDict):
"""Start with these items."""
verify_item_name = True
display_name = "Start Inventory"
rich_text_doc = True
class StartInventoryPool(StartInventory):
"""Start with these items and don't place them in the world.
The game decides what the replacement items will be.
"""
The game decides what the replacement items will be."""
verify_item_name = True
display_name = "Start Inventory from Pool"
rich_text_doc = True
class StartHints(ItemSet):
"""Start with these item's locations prefilled into the ``!hint`` command."""
"""Start with these item's locations prefilled into the !hint command."""
display_name = "Start Hints"
rich_text_doc = True
class LocationSet(OptionSet):
@@ -1318,33 +936,28 @@ class LocationSet(OptionSet):
class StartLocationHints(LocationSet):
"""Start with these locations and their item prefilled into the ``!hint`` command."""
"""Start with these locations and their item prefilled into the !hint command"""
display_name = "Start Location Hints"
rich_text_doc = True
class ExcludeLocations(LocationSet):
"""Prevent these locations from having an important item."""
"""Prevent these locations from having an important item"""
display_name = "Excluded Locations"
rich_text_doc = True
class PriorityLocations(LocationSet):
"""Prevent these locations from having an unimportant item."""
"""Prevent these locations from having an unimportant item"""
display_name = "Priority Locations"
rich_text_doc = True
class DeathLink(Toggle):
"""When you die, everyone who enabled death link dies. Of course, the reverse is true too."""
"""When you die, everyone dies. Of course the reverse is true too."""
display_name = "Death Link"
rich_text_doc = True
class ItemLinks(OptionList):
"""Share part of your item pool with other players."""
display_name = "Item Links"
rich_text_doc = True
default = []
schema = Schema([
{
@@ -1359,8 +972,7 @@ class ItemLinks(OptionList):
])
@staticmethod
def verify_items(items: typing.List[str], item_link: str, pool_name: str, world,
allow_item_groups: bool = True) -> typing.Set:
def verify_items(items: typing.List[str], item_link: str, pool_name: str, world, allow_item_groups: bool = True) -> typing.Set:
pool = set()
for item_name in items:
if item_name not in world.item_names and (not allow_item_groups or item_name not in world.item_name_groups):
@@ -1406,79 +1018,22 @@ class ItemLinks(OptionList):
raise Exception(f"item_link {link['name']} has {intersection} "
f"items in both its local_items and non_local_items pool.")
link.setdefault("link_replacement", None)
link["item_pool"] = list(pool)
class Removed(FreeText):
"""This Option has been Removed."""
rich_text_doc = True
default = ""
visibility = Visibility.none
def __init__(self, value: str):
if value:
raise Exception("Option removed, please update your options file.")
super().__init__(value)
per_game_common_options = {
**common_options, # can be overwritten per-game
"local_items": LocalItems,
"non_local_items": NonLocalItems,
"start_inventory": StartInventory,
"start_hints": StartHints,
"start_location_hints": StartLocationHints,
"exclude_locations": ExcludeLocations,
"priority_locations": PriorityLocations,
"item_links": ItemLinks
}
@dataclass
class PerGameCommonOptions(CommonOptions):
local_items: LocalItems
non_local_items: NonLocalItems
start_inventory: StartInventory
start_hints: StartHints
start_location_hints: StartLocationHints
exclude_locations: ExcludeLocations
priority_locations: PriorityLocations
item_links: ItemLinks
@dataclass
class DeathLinkMixin:
death_link: DeathLink
class OptionGroup(typing.NamedTuple):
"""Define a grouping of options."""
name: str
"""Name of the group to categorize these options in for display on the WebHost and in generated YAMLS."""
options: typing.List[typing.Type[Option[typing.Any]]]
"""Options to be in the defined group."""
start_collapsed: bool = False
"""Whether the group will start collapsed on the WebHost options pages."""
item_and_loc_options = [LocalItems, NonLocalItems, StartInventory, StartInventoryPool, StartHints,
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
it.
"""
def get_option_groups(world: typing.Type[World], visibility_level: Visibility = Visibility.template) -> typing.Dict[
str, typing.Dict[str, typing.Type[Option[typing.Any]]]]:
"""Generates and returns a dictionary for the option groups of a specified world."""
option_groups = {option: option_group.name
for option_group in world.web.option_groups
for option in option_group.options}
# add a default option group for uncategorized options to get thrown into
ordered_groups = ["Game Options"]
[ordered_groups.append(group) for group in option_groups.values() if group not in ordered_groups]
grouped_options = {group: {} for group in ordered_groups}
for option_name, option in world.options_dataclass.type_hints.items():
if visibility_level & option.visibility:
grouped_options[option_groups.get(option, "Game Options")][option_name] = option
# if the world doesn't have any ungrouped options, this group will be empty so just remove it
if not grouped_options["Game Options"]:
del grouped_options["Game Options"]
return grouped_options
def generate_yaml_templates(target_folder: typing.Union[str, "pathlib.Path"], generate_hidden: bool = True) -> None:
def generate_yaml_templates(target_folder: typing.Union[str, "pathlib.Path"], generate_hidden: bool = True):
import os
import yaml
@@ -1497,7 +1052,7 @@ def generate_yaml_templates(target_folder: typing.Union[str, "pathlib.Path"], ge
if os.path.isfile(full_path) and full_path.endswith(".yaml"):
os.unlink(full_path)
def dictify_range(option: Range):
def dictify_range(option: typing.Union[Range, SpecialRange]):
data = {option.default: 50}
for sub_option in ["random", "random-low", "random-high"]:
if sub_option != option.default:
@@ -1514,61 +1069,50 @@ def generate_yaml_templates(target_folder: typing.Union[str, "pathlib.Path"], ge
return data, notes
def yaml_dump_scalar(scalar) -> str:
# yaml dump may add end of document marker and newlines.
return yaml.dump(scalar).replace("...\n", "").strip()
for game_name, world in AutoWorldRegister.world_types.items():
if not world.hidden or generate_hidden:
option_groups = get_option_groups(world)
all_options: typing.Dict[str, AssembleOptions] = {
**per_game_common_options,
**world.option_definitions
}
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, yaml_dump=yaml_dump_scalar,
options=all_options,
__version__=__version__, game=game_name, yaml_dump=yaml.dump,
dictify_range=dictify_range,
)
del file_data
with open(os.path.join(target_folder, get_file_safe_name(game_name) + ".yaml"), "w", encoding="utf-8-sig") as f:
with open(os.path.join(target_folder, game_name + ".yaml"), "w", encoding="utf-8-sig") as f:
f.write(res)
def dump_player_options(multiworld: MultiWorld) -> None:
from csv import DictWriter
if __name__ == "__main__":
game_players = defaultdict(list)
for player, game in multiworld.game.items():
game_players[game].append(player)
game_players = dict(sorted(game_players.items()))
from worlds.alttp.Options import Logic
import argparse
output = []
per_game_option_names = [
getattr(option, "display_name", option_key)
for option_key, option in PerGameCommonOptions.type_hints.items()
]
all_option_names = per_game_option_names.copy()
for game, players in game_players.items():
game_option_names = per_game_option_names.copy()
for player in players:
world = multiworld.worlds[player]
player_output = {
"Game": multiworld.game[player],
"Name": multiworld.get_player_name(player),
}
output.append(player_output)
for option_key, option in world.options_dataclass.type_hints.items():
if issubclass(Removed, option):
continue
display_name = getattr(option, "display_name", option_key)
player_output[display_name] = getattr(world.options, option_key).current_option_name
if display_name not in game_option_names:
all_option_names.append(display_name)
game_option_names.append(display_name)
with open(output_path(f"generate_{multiworld.seed_name}.csv"), mode="w", newline="") as file:
fields = ["Game", "Name", *all_option_names]
writer = DictWriter(file, fields)
writer.writeheader()
writer.writerows(output)
map_shuffle = Toggle
compass_shuffle = Toggle
key_shuffle = Toggle
big_key_shuffle = Toggle
hints = Toggle
test = argparse.Namespace()
test.logic = Logic.from_text("no_logic")
test.map_shuffle = map_shuffle.from_text("ON")
test.hints = hints.from_text('OFF')
try:
test.logic = Logic.from_text("overworld_glitches_typo")
except KeyError as e:
print(e)
try:
test.logic_owg = Logic.from_text("owg")
except KeyError as e:
print(e)
if test.map_shuffle:
print("map_shuffle is on")
print(f"Hints are {bool(test.hints)}")
print(test)

View File

@@ -8,7 +8,7 @@ if __name__ == "__main__":
import ModuleUpdate
ModuleUpdate.update()
from worlds.Files import AutoPatchRegister, APAutoPatchInterface
from worlds.Files import AutoPatchRegister, APDeltaPatch
class RomMeta(TypedDict):
@@ -20,7 +20,7 @@ class RomMeta(TypedDict):
def create_rom_file(patch_file: str) -> Tuple[RomMeta, str]:
auto_handler = AutoPatchRegister.get_handler(patch_file)
if auto_handler:
handler: APAutoPatchInterface = auto_handler(patch_file)
handler: APDeltaPatch = auto_handler(patch_file)
target = os.path.splitext(patch_file)[0]+handler.result_file_ending
handler.patch(target)
return {"server": handler.server,

382
PokemonClient.py Normal file
View File

@@ -0,0 +1,382 @@
import asyncio
import json
import time
import os
import bsdiff4
import subprocess
import zipfile
from asyncio import StreamReader, StreamWriter
from typing import List
import Utils
from Utils import async_start
from CommonClient import CommonContext, server_loop, gui_enabled, ClientCommandProcessor, logger, \
get_base_parser
from worlds.pokemon_rb.locations import location_data
from worlds.pokemon_rb.rom import RedDeltaPatch, BlueDeltaPatch
location_map = {"Rod": {}, "EventFlag": {}, "Missable": {}, "Hidden": {}, "list": {}, "DexSanityFlag": {}}
location_bytes_bits = {}
for location in location_data:
if location.ram_address is not None:
if type(location.ram_address) == list:
location_map[type(location.ram_address).__name__][(location.ram_address[0].flag, location.ram_address[1].flag)] = location.address
location_bytes_bits[location.address] = [{'byte': location.ram_address[0].byte, 'bit': location.ram_address[0].bit},
{'byte': location.ram_address[1].byte, 'bit': location.ram_address[1].bit}]
else:
location_map[type(location.ram_address).__name__][location.ram_address.flag] = location.address
location_bytes_bits[location.address] = {'byte': location.ram_address.byte, 'bit': location.ram_address.bit}
location_name_to_id = {location.name: location.address for location in location_data if location.type == "Item"
and location.address is not None}
SYSTEM_MESSAGE_ID = 0
CONNECTION_TIMING_OUT_STATUS = "Connection timing out. Please restart your emulator, then restart pkmn_rb.lua"
CONNECTION_REFUSED_STATUS = "Connection Refused. Please start your emulator and make sure pkmn_rb.lua is running"
CONNECTION_RESET_STATUS = "Connection was reset. Please restart your emulator, then restart pkmn_rb.lua"
CONNECTION_TENTATIVE_STATUS = "Initial Connection Made"
CONNECTION_CONNECTED_STATUS = "Connected"
CONNECTION_INITIAL_STATUS = "Connection has not been initiated"
DISPLAY_MSGS = True
SCRIPT_VERSION = 3
class GBCommandProcessor(ClientCommandProcessor):
def __init__(self, ctx: CommonContext):
super().__init__(ctx)
def _cmd_gb(self):
"""Check Gameboy Connection State"""
if isinstance(self.ctx, GBContext):
logger.info(f"Gameboy Status: {self.ctx.gb_status}")
class GBContext(CommonContext):
command_processor = GBCommandProcessor
game = 'Pokemon Red and Blue'
def __init__(self, server_address, password):
super().__init__(server_address, password)
self.gb_streams: (StreamReader, StreamWriter) = None
self.gb_sync_task = None
self.messages = {}
self.locations_array = None
self.gb_status = CONNECTION_INITIAL_STATUS
self.awaiting_rom = False
self.display_msgs = True
self.deathlink_pending = False
self.set_deathlink = False
self.client_compatibility_mode = 0
self.items_handling = 0b001
self.sent_release = False
self.sent_collect = False
self.auto_hints = set()
async def server_auth(self, password_requested: bool = False):
if password_requested and not self.password:
await super(GBContext, self).server_auth(password_requested)
if not self.auth:
self.awaiting_rom = True
logger.info('Awaiting connection to EmuHawk to get Player information')
return
await self.send_connect()
def _set_message(self, msg: str, msg_id: int):
if DISPLAY_MSGS:
self.messages[(time.time(), msg_id)] = msg
def on_package(self, cmd: str, args: dict):
if cmd == 'Connected':
self.locations_array = None
if 'death_link' in args['slot_data'] and args['slot_data']['death_link']:
self.set_deathlink = True
elif cmd == "RoomInfo":
self.seed_name = args['seed_name']
elif cmd == 'Print':
msg = args['text']
if ': !' not in msg:
self._set_message(msg, SYSTEM_MESSAGE_ID)
elif cmd == "ReceivedItems":
msg = f"Received {', '.join([self.item_names[item.item] for item in args['items']])}"
self._set_message(msg, SYSTEM_MESSAGE_ID)
def on_deathlink(self, data: dict):
self.deathlink_pending = True
super().on_deathlink(data)
def run_gui(self):
from kvui import GameManager
class GBManager(GameManager):
logging_pairs = [
("Client", "Archipelago")
]
base_title = "Archipelago Pokémon Client"
self.ui = GBManager(self)
self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI")
def get_payload(ctx: GBContext):
current_time = time.time()
ret = json.dumps(
{
"items": [item.item for item in ctx.items_received],
"messages": {f'{key[0]}:{key[1]}': value for key, value in ctx.messages.items()
if key[0] > current_time - 10},
"deathlink": ctx.deathlink_pending,
"options": ((ctx.permissions['release'] in ('goal', 'enabled')) * 2) + (ctx.permissions['collect'] in ('goal', 'enabled'))
}
)
ctx.deathlink_pending = False
return ret
async def parse_locations(data: List, ctx: GBContext):
locations = []
flags = {"EventFlag": data[:0x140], "Missable": data[0x140:0x140 + 0x20],
"Hidden": data[0x140 + 0x20: 0x140 + 0x20 + 0x0E],
"Rod": data[0x140 + 0x20 + 0x0E:0x140 + 0x20 + 0x0E + 0x01]}
if len(data) > 0x140 + 0x20 + 0x0E + 0x01:
flags["DexSanityFlag"] = data[0x140 + 0x20 + 0x0E + 0x01:]
else:
flags["DexSanityFlag"] = [0] * 19
for flag_type, loc_map in location_map.items():
for flag, loc_id in loc_map.items():
if flag_type == "list":
if (flags["EventFlag"][location_bytes_bits[loc_id][0]['byte']] & 1 << location_bytes_bits[loc_id][0]['bit']
and flags["Missable"][location_bytes_bits[loc_id][1]['byte']] & 1 << location_bytes_bits[loc_id][1]['bit']):
locations.append(loc_id)
elif flags[flag_type][location_bytes_bits[loc_id]['byte']] & 1 << location_bytes_bits[loc_id]['bit']:
locations.append(loc_id)
hints = []
if flags["EventFlag"][280] & 16:
hints.append("Cerulean Bicycle Shop")
if flags["EventFlag"][280] & 32:
hints.append("Route 2 Gate - Oak's Aide")
if flags["EventFlag"][280] & 64:
hints.append("Route 11 Gate 2F - Oak's Aide")
if flags["EventFlag"][280] & 128:
hints.append("Route 15 Gate 2F - Oak's Aide")
if flags["EventFlag"][281] & 1:
hints += ["Celadon Prize Corner - Item Prize 1", "Celadon Prize Corner - Item Prize 2",
"Celadon Prize Corner - Item Prize 3"]
if (location_name_to_id["Fossil - Choice A"] in ctx.checked_locations and location_name_to_id["Fossil - Choice B"]
not in ctx.checked_locations):
hints.append("Fossil - Choice B")
elif (location_name_to_id["Fossil - Choice B"] in ctx.checked_locations and location_name_to_id["Fossil - Choice A"]
not in ctx.checked_locations):
hints.append("Fossil - Choice A")
hints = [
location_name_to_id[loc] for loc in hints if location_name_to_id[loc] not in ctx.auto_hints and
location_name_to_id[loc] in ctx.missing_locations and location_name_to_id[loc] not in ctx.locations_checked
]
if hints:
await ctx.send_msgs([{"cmd": "LocationScouts", "locations": hints, "create_as_hint": 2}])
ctx.auto_hints.update(hints)
if flags["EventFlag"][280] & 1 and not ctx.finished_game:
await ctx.send_msgs([
{"cmd": "StatusUpdate",
"status": 30}
])
ctx.finished_game = True
if locations == ctx.locations_array:
return
ctx.locations_array = locations
if locations is not None:
await ctx.send_msgs([{"cmd": "LocationChecks", "locations": locations}])
async def gb_sync_task(ctx: GBContext):
logger.info("Starting GB connector. Use /gb for status information")
while not ctx.exit_event.is_set():
error_status = None
if ctx.gb_streams:
(reader, writer) = ctx.gb_streams
msg = get_payload(ctx).encode()
writer.write(msg)
writer.write(b'\n')
try:
await asyncio.wait_for(writer.drain(), timeout=1.5)
try:
# Data will return a dict with up to two fields:
# 1. A keepalive response of the Players Name (always)
# 2. An array representing the memory values of the locations area (if in game)
data = await asyncio.wait_for(reader.readline(), timeout=5)
data_decoded = json.loads(data.decode())
if 'scriptVersion' not in data_decoded or data_decoded['scriptVersion'] != SCRIPT_VERSION:
msg = "You are connecting with an incompatible Lua script version. Ensure your connector Lua " \
"and PokemonClient are from the same Archipelago installation."
logger.info(msg, extra={'compact_gui': True})
ctx.gui_error('Error', msg)
error_status = CONNECTION_RESET_STATUS
ctx.client_compatibility_mode = data_decoded['clientCompatibilityVersion']
if ctx.client_compatibility_mode == 0:
ctx.items_handling = 0b101 # old patches will not have local start inventory, must be requested
if ctx.seed_name and ctx.seed_name != ''.join([chr(i) for i in data_decoded['seedName'] if i != 0]):
msg = "The server is running a different multiworld than your client is. (invalid seed_name)"
logger.info(msg, extra={'compact_gui': True})
ctx.gui_error('Error', msg)
error_status = CONNECTION_RESET_STATUS
ctx.seed_name = ''.join([chr(i) for i in data_decoded['seedName'] if i != 0])
if not ctx.auth:
ctx.auth = ''.join([chr(i) for i in data_decoded['playerName'] if i != 0])
if ctx.auth == '':
msg = "Invalid ROM detected. No player name built into the ROM."
logger.info(msg, extra={'compact_gui': True})
ctx.gui_error('Error', msg)
error_status = CONNECTION_RESET_STATUS
if ctx.awaiting_rom:
await ctx.server_auth(False)
if 'locations' in data_decoded and ctx.game and ctx.gb_status == CONNECTION_CONNECTED_STATUS \
and not error_status and ctx.auth:
# Not just a keep alive ping, parse
async_start(parse_locations(data_decoded['locations'], ctx))
if 'deathLink' in data_decoded and data_decoded['deathLink'] and 'DeathLink' in ctx.tags:
await ctx.send_death(ctx.auth + " is out of usable Pokémon! " + ctx.auth + " blacked out!")
if 'options' in data_decoded:
msgs = []
if data_decoded['options'] & 4 and not ctx.sent_release:
ctx.sent_release = True
msgs.append({"cmd": "Say", "text": "!release"})
if data_decoded['options'] & 8 and not ctx.sent_collect:
ctx.sent_collect = True
msgs.append({"cmd": "Say", "text": "!collect"})
if msgs:
await ctx.send_msgs(msgs)
if ctx.set_deathlink:
await ctx.update_death_link(True)
except asyncio.TimeoutError:
logger.debug("Read Timed Out, Reconnecting")
error_status = CONNECTION_TIMING_OUT_STATUS
writer.close()
ctx.gb_streams = None
except ConnectionResetError as e:
logger.debug("Read failed due to Connection Lost, Reconnecting")
error_status = CONNECTION_RESET_STATUS
writer.close()
ctx.gb_streams = None
except TimeoutError:
logger.debug("Connection Timed Out, Reconnecting")
error_status = CONNECTION_TIMING_OUT_STATUS
writer.close()
ctx.gb_streams = None
except ConnectionResetError:
logger.debug("Connection Lost, Reconnecting")
error_status = CONNECTION_RESET_STATUS
writer.close()
ctx.gb_streams = None
if ctx.gb_status == CONNECTION_TENTATIVE_STATUS:
if not error_status:
logger.info("Successfully Connected to Gameboy")
ctx.gb_status = CONNECTION_CONNECTED_STATUS
else:
ctx.gb_status = f"Was tentatively connected but error occured: {error_status}"
elif error_status:
ctx.gb_status = error_status
logger.info("Lost connection to Gameboy and attempting to reconnect. Use /gb for status updates")
else:
try:
logger.debug("Attempting to connect to Gameboy")
ctx.gb_streams = await asyncio.wait_for(asyncio.open_connection("localhost", 17242), timeout=10)
ctx.gb_status = CONNECTION_TENTATIVE_STATUS
except TimeoutError:
logger.debug("Connection Timed Out, Trying Again")
ctx.gb_status = CONNECTION_TIMING_OUT_STATUS
continue
except ConnectionRefusedError:
logger.debug("Connection Refused, Trying Again")
ctx.gb_status = CONNECTION_REFUSED_STATUS
continue
async def run_game(romfile):
auto_start = Utils.get_options()["pokemon_rb_options"].get("rom_start", True)
if auto_start is True:
import webbrowser
webbrowser.open(romfile)
elif os.path.isfile(auto_start):
subprocess.Popen([auto_start, romfile],
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
async def patch_and_run_game(game_version, patch_file, ctx):
base_name = os.path.splitext(patch_file)[0]
comp_path = base_name + '.gb'
if game_version == "blue":
delta_patch = BlueDeltaPatch
else:
delta_patch = RedDeltaPatch
try:
base_rom = delta_patch.get_source_data()
except Exception as msg:
logger.info(msg, extra={'compact_gui': True})
ctx.gui_error('Error', msg)
with zipfile.ZipFile(patch_file, 'r') as patch_archive:
with patch_archive.open('delta.bsdiff4', 'r') as stream:
patch = stream.read()
patched_rom_data = bsdiff4.patch(base_rom, patch)
with open(comp_path, "wb") as patched_rom_file:
patched_rom_file.write(patched_rom_data)
async_start(run_game(comp_path))
if __name__ == '__main__':
Utils.init_logging("PokemonClient")
options = Utils.get_options()
async def main():
parser = get_base_parser()
parser.add_argument('patch_file', default="", type=str, nargs="?",
help='Path to an APRED or APBLUE patch file')
args = parser.parse_args()
ctx = GBContext(args.connect, args.password)
ctx.server_task = asyncio.create_task(server_loop(ctx), name="ServerLoop")
if gui_enabled:
ctx.run_gui()
ctx.run_cli()
ctx.gb_sync_task = asyncio.create_task(gb_sync_task(ctx), name="GB Sync")
if args.patch_file:
ext = args.patch_file.split(".")[len(args.patch_file.split(".")) - 1].lower()
if ext == "apred":
logger.info("APRED file supplied, beginning patching process...")
async_start(patch_and_run_game("red", args.patch_file, ctx))
elif ext == "apblue":
logger.info("APBLUE file supplied, beginning patching process...")
async_start(patch_and_run_game("blue", args.patch_file, ctx))
else:
logger.warning(f"Unknown patch file extension {ext}")
await ctx.exit_event.wait()
ctx.server_address = None
await ctx.shutdown()
if ctx.gb_sync_task:
await ctx.gb_sync_task
import colorama
colorama.init()
asyncio.run(main())
colorama.deinit()

View File

@@ -1,10 +1,8 @@
# [Archipelago](https://archipelago.gg) ![Discord Shield](https://discordapp.com/api/guilds/731205301247803413/widget.png?style=shield) | [Install](https://github.com/ArchipelagoMW/Archipelago/releases)
Archipelago provides a generic framework for developing multiworld capability for game randomizers. In all cases,
presently, Archipelago is also the randomizer itself.
Archipelago provides a generic framework for developing multiworld capability for game randomizers. In all cases, presently, Archipelago is also the randomizer itself.
Currently, the following games are supported:
* The Legend of Zelda: A Link to the Past
* Factorio
* Minecraft
@@ -27,7 +25,7 @@ Currently, the following games are supported:
* Hollow Knight
* The Witness
* Sonic Adventure 2: Battle
* Starcraft 2
* Starcraft 2: Wings of Liberty
* Donkey Kong Country 3
* Dark Souls 3
* Super Mario World
@@ -53,29 +51,6 @@ Currently, the following games are supported:
* Muse Dash
* DOOM 1993
* Terraria
* Lingo
* Pokémon Emerald
* DOOM II
* Shivers
* Heretic
* Landstalker: The Treasures of King Nole
* Final Fantasy Mystic Quest
* TUNIC
* Kirby's Dream Land 3
* Celeste 64
* Zork Grand Inquisitor
* Castlevania 64
* A Short Hike
* Yoshi's Island
* Mario & Luigi: Superstar Saga
* Bomb Rush Cyberfunk
* Aquaria
* Yu-Gi-Oh! Ultimate Masters: World Championship Tournament 2006
* A Hat in Time
* Old School Runescape
* Kingdom Hearts 1
* Mega Man 2
* Yacht Dice
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
@@ -83,57 +58,36 @@ windows binaries.
## History
Archipelago is built upon a strong legacy of brilliant hobbyists. We want to honor that legacy by showing it here.
The repositories which Archipelago is built upon, inspired by, or otherwise owes its gratitude to are:
Archipelago is built upon a strong legacy of brilliant hobbyists. We want to honor that legacy by showing it here. The repositories which Archipelago is built upon, inspired by, or otherwise owes its gratitude to are:
* [bonta0's MultiWorld](https://github.com/Bonta0/ALttPEntranceRandomizer/tree/multiworld_31)
* [AmazingAmpharos' Entrance Randomizer](https://github.com/AmazingAmpharos/ALttPEntranceRandomizer)
* [VT Web Randomizer](https://github.com/sporchia/alttp_vt_randomizer)
* [Dessyreqt's alttprandomizer](https://github.com/Dessyreqt/alttprandomizer)
* [Zarby89's](https://github.com/Ijwu/Enemizer/commits?author=Zarby89)
and [sosuke3's](https://github.com/Ijwu/Enemizer/commits?author=sosuke3) contributions to Enemizer, which make up the
vast majority of Enemizer contributions.
* [Zarby89's](https://github.com/Ijwu/Enemizer/commits?author=Zarby89) and [sosuke3's](https://github.com/Ijwu/Enemizer/commits?author=sosuke3) contributions to Enemizer, which make the vast majority of Enemizer contributions.
We recognize that there is a strong community of incredibly smart people that have come before us and helped pave the
path. Just because one person's name may be in a repository title does not mean that only one person made that project
happen. We can't hope to perfectly cover every single contribution that lead up to Archipelago, but we hope to honor
them fairly.
We recognize that there is a strong community of incredibly smart people that have come before us and helped pave the path. Just because one person's name may be in a repository title does not mean that only one person made that project happen. We can't hope to perfectly cover every single contribution that lead up to Archipelago but we hope to honor them fairly.
### Path to the Archipelago
Archipelago was directly forked from bonta0's `multiworld_31` branch of ALttPEntranceRandomizer (this project has a
long legacy of its own, please check it out linked above) on January 12, 2020. The repository was then named to
_MultiWorld-Utilities_ to better encompass its intended function. As Archipelago matured, then known as
"Berserker's MultiWorld" by some, we found it necessary to transform our repository into a root level repository
(as opposed to a 'forked repo') and change the name (which came later) to better reflect our project.
Archipelago was directly forked from bonta0's `multiworld_31` branch of ALttPEntranceRandomizer (this project has a long legacy of its own, please check it out linked above) on January 12, 2020. The repository was then named to _MultiWorld-Utilities_ to better encompass its intended function. As Archipelago matured, then known as "Berserker's MultiWorld" by some, we found it necessary to transform our repository into a root level repository (as opposed to a 'forked repo') and change the name (which came later) to better reflect our project.
## Running Archipelago
For most people all you need to do is head over to the [releases](https://github.com/ArchipelagoMW/Archipelago/releases) page then download and run the appropriate installer. The installers function on Windows only.
For most people, all you need to do is head over to
the [releases page](https://github.com/ArchipelagoMW/Archipelago/releases), then download and run the appropriate
installer, or AppImage for Linux-based systems.
If you are a developer or are running on a platform with no compiled releases available, please see our doc on
[running Archipelago from source](docs/running%20from%20source.md).
If you are running Archipelago from a non-Windows system then the likely scenario is that you are comfortable running source code directly. Please see our doc on [running Archipelago from source](docs/running%20from%20source.md).
## Related Repositories
This project makes use of multiple other projects. We wouldn't be here without these other repositories and the
contributions of their developers, past and present.
This project makes use of multiple other projects. We wouldn't be here without these other repositories and the contributions of their developers, past and present.
* [z3randomizer](https://github.com/ArchipelagoMW/z3randomizer)
* [Enemizer](https://github.com/Ijwu/Enemizer)
* [Ocarina of Time Randomizer](https://github.com/TestRunnerSRL/OoT-Randomizer)
## Contributing
To contribute to Archipelago, including the WebHost, core program, or by adding a new game, see our
[Contributing guidelines](/docs/contributing.md).
For contribution guidelines, please see our [Contributing doc.](/docs/contributing.md)
## FAQ
For Frequently asked questions, please see the website's [FAQ Page](https://archipelago.gg/faq/en/).
For Frequently asked questions, please see the website's [FAQ Page.](https://archipelago.gg/faq/en/)
## Code of Conduct
Please refer to our [code of conduct](/docs/code_of_conduct.md).
Please refer to our [code of conduct.](/docs/code_of_conduct.md)

View File

@@ -85,7 +85,6 @@ class SNIClientCommandProcessor(ClientCommandProcessor):
"""Close connection to a currently connected snes"""
self.ctx.snes_reconnect_address = None
self.ctx.cancel_snes_autoreconnect()
self.ctx.snes_state = SNESState.SNES_DISCONNECTED
if self.ctx.snes_socket and not self.ctx.snes_socket.closed:
async_start(self.ctx.snes_socket.close())
return True
@@ -208,12 +207,12 @@ class SNIContext(CommonContext):
self.killing_player_task = asyncio.create_task(deathlink_kill_player(self))
super(SNIContext, self).on_deathlink(data)
async def handle_deathlink_state(self, currently_dead: bool, death_text: str = "") -> None:
async def handle_deathlink_state(self, currently_dead: bool) -> None:
# in this state we only care about triggering a death send
if self.death_state == DeathState.alive:
if currently_dead:
self.death_state = DeathState.dead
await self.send_death(death_text)
await self.send_death()
# in this state we care about confirming a kill, to move state to dead
elif self.death_state == DeathState.killing_player:
# this is being handled in deathlink_kill_player(ctx) already
@@ -282,7 +281,7 @@ class SNESState(enum.IntEnum):
def launch_sni() -> None:
sni_path = Utils.get_settings()["sni_options"]["sni_path"]
sni_path = Utils.get_options()["sni_options"]["sni_path"]
if not os.path.isdir(sni_path):
sni_path = Utils.local_path(sni_path)
@@ -565,12 +564,16 @@ async def snes_write(ctx: SNIContext, write_list: typing.List[typing.Tuple[int,
PutAddress_Request: SNESRequest = {"Opcode": "PutAddress", "Operands": [], 'Space': 'SNES'}
try:
for address, data in write_list:
PutAddress_Request['Operands'] = [hex(address)[2:], hex(len(data))[2:]]
if ctx.snes_socket is not None:
await ctx.snes_socket.send(dumps(PutAddress_Request))
await ctx.snes_socket.send(data)
else:
snes_logger.warning(f"Could not send data to SNES: {data}")
while data:
# Divide the write into packets of 256 bytes.
PutAddress_Request['Operands'] = [hex(address)[2:], hex(min(len(data), 256))[2:]]
if ctx.snes_socket is not None:
await ctx.snes_socket.send(dumps(PutAddress_Request))
await ctx.snes_socket.send(data[:256])
address += 256
data = data[256:]
else:
snes_logger.warning(f"Could not send data to SNES: {data}")
except ConnectionClosed:
return False
@@ -633,13 +636,7 @@ async def game_watcher(ctx: SNIContext) -> None:
if not ctx.client_handler:
continue
try:
rom_validated = await ctx.client_handler.validate_rom(ctx)
except Exception as e:
snes_logger.error(f"An error occurred, see logs for details: {e}")
text_file_logger = logging.getLogger()
text_file_logger.exception(e)
rom_validated = False
rom_validated = await ctx.client_handler.validate_rom(ctx)
if not rom_validated or (ctx.auth and ctx.auth != ctx.rom):
snes_logger.warning("ROM change detected, please reconnect to the multiworld server")
@@ -655,18 +652,12 @@ async def game_watcher(ctx: SNIContext) -> None:
perf_counter = time.perf_counter()
try:
await ctx.client_handler.game_watcher(ctx)
except Exception as e:
snes_logger.error(f"An error occurred, see logs for details: {e}")
text_file_logger = logging.getLogger()
text_file_logger.exception(e)
await snes_disconnect(ctx)
await ctx.client_handler.game_watcher(ctx)
async def run_game(romfile: str) -> None:
auto_start = typing.cast(typing.Union[bool, str],
Utils.get_settings()["sni_options"].get("snes_rom_start", True))
Utils.get_options()["sni_options"].get("snes_rom_start", True))
if auto_start is True:
import webbrowser
webbrowser.open(romfile)

View File

@@ -3,7 +3,7 @@ from __future__ import annotations
import ModuleUpdate
ModuleUpdate.update()
from worlds.sc2.Client import launch
from worlds.sc2wol.Client import launch
import Utils
if __name__ == "__main__":

View File

@@ -27,14 +27,14 @@ class UndertaleCommandProcessor(ClientCommandProcessor):
self.ctx.syncing = True
def _cmd_patch(self):
"""Patch the game. Only use this command if /auto_patch fails."""
"""Patch the game."""
if isinstance(self.ctx, UndertaleContext):
os.makedirs(name=Utils.user_path("Undertale"), exist_ok=True)
os.makedirs(name=os.path.join(os.getcwd(), "Undertale"), exist_ok=True)
self.ctx.patch_game()
self.output("Patched.")
def _cmd_savepath(self, directory: str):
"""Redirect to proper save data folder. This is necessary for Linux users to use before connecting."""
"""Redirect to proper save data folder. (Use before connecting!)"""
if isinstance(self.ctx, UndertaleContext):
self.ctx.save_game_folder = directory
self.output("Changed to the following directory: " + self.ctx.save_game_folder)
@@ -43,7 +43,7 @@ class UndertaleCommandProcessor(ClientCommandProcessor):
def _cmd_auto_patch(self, steaminstall: typing.Optional[str] = None):
"""Patch the game automatically."""
if isinstance(self.ctx, UndertaleContext):
os.makedirs(name=Utils.user_path("Undertale"), exist_ok=True)
os.makedirs(name=os.path.join(os.getcwd(), "Undertale"), exist_ok=True)
tempInstall = steaminstall
if not os.path.isfile(os.path.join(tempInstall, "data.win")):
tempInstall = None
@@ -62,12 +62,12 @@ class UndertaleCommandProcessor(ClientCommandProcessor):
for file_name in os.listdir(tempInstall):
if file_name != "steam_api.dll":
shutil.copy(os.path.join(tempInstall, file_name),
Utils.user_path("Undertale", file_name))
os.path.join(os.getcwd(), "Undertale", file_name))
self.ctx.patch_game()
self.output("Patching successful!")
def _cmd_online(self):
"""Toggles seeing other Undertale players."""
"""Makes you no longer able to see other Undertale players."""
if isinstance(self.ctx, UndertaleContext):
self.ctx.update_online_mode(not ("Online" in self.ctx.tags))
if "Online" in self.ctx.tags:
@@ -111,12 +111,12 @@ class UndertaleContext(CommonContext):
self.save_game_folder = os.path.expandvars(r"%localappdata%/UNDERTALE")
def patch_game(self):
with open(Utils.user_path("Undertale", "data.win"), "rb") as f:
with open(os.path.join(os.getcwd(), "Undertale", "data.win"), "rb") as f:
patchedFile = bsdiff4.patch(f.read(), undertale.data_path("patch.bsdiff"))
with open(Utils.user_path("Undertale", "data.win"), "wb") as f:
with open(os.path.join(os.getcwd(), "Undertale", "data.win"), "wb") as f:
f.write(patchedFile)
os.makedirs(name=Utils.user_path("Undertale", "Custom Sprites"), exist_ok=True)
with open(os.path.expandvars(Utils.user_path("Undertale", "Custom Sprites",
os.makedirs(name=os.path.join(os.getcwd(), "Undertale", "Custom Sprites"), exist_ok=True)
with open(os.path.expandvars(os.path.join(os.getcwd(), "Undertale", "Custom Sprites",
"Which Character.txt")), "w") as f:
f.writelines(["// Put the folder name of the sprites you want to play as, make sure it is the only "
"line other than this one.\n", "frisk"])
@@ -247,8 +247,8 @@ async def process_undertale_cmd(ctx: UndertaleContext, cmd: str, args: dict):
with open(os.path.join(ctx.save_game_folder, filename), "w") as f:
toDraw = ""
for i in range(20):
if i < len(str(ctx.item_names.lookup_in_game(l.item))):
toDraw += str(ctx.item_names.lookup_in_game(l.item))[i]
if i < len(str(ctx.item_names[l.item])):
toDraw += str(ctx.item_names[l.item])[i]
else:
break
f.write(toDraw)

275
Utils.py
View File

@@ -5,7 +5,6 @@ import json
import typing
import builtins
import os
import itertools
import subprocess
import sys
import pickle
@@ -18,21 +17,20 @@ import warnings
from argparse import Namespace
from settings import Settings, get_settings
from time import sleep
from typing import BinaryIO, Coroutine, Optional, Set, Dict, Any, Union
from typing_extensions import TypeGuard
from yaml import load, load_all, dump
from yaml import load, load_all, dump, SafeLoader
try:
from yaml import CLoader as UnsafeLoader, CSafeLoader as SafeLoader, CDumper as Dumper
from yaml import CLoader as UnsafeLoader
from yaml import CDumper as Dumper
except ImportError:
from yaml import Loader as UnsafeLoader, SafeLoader, Dumper
from yaml import Loader as UnsafeLoader
from yaml import Dumper
if typing.TYPE_CHECKING:
import tkinter
import pathlib
from BaseClasses import Region
import multiprocessing
def tuplize_version(version: str) -> Version:
@@ -48,7 +46,7 @@ class Version(typing.NamedTuple):
return ".".join(str(item) for item in self)
__version__ = "0.5.1"
__version__ = "0.4.3"
version_tuple = tuplize_version(__version__)
is_linux = sys.platform.startswith("linux")
@@ -75,8 +73,6 @@ def snes_to_pc(value: int) -> int:
RetType = typing.TypeVar("RetType")
S = typing.TypeVar("S")
T = typing.TypeVar("T")
def cache_argsless(function: typing.Callable[[], RetType]) -> typing.Callable[[], RetType]:
@@ -94,30 +90,6 @@ def cache_argsless(function: typing.Callable[[], RetType]) -> typing.Callable[[]
return _wrap
def cache_self1(function: typing.Callable[[S, T], RetType]) -> typing.Callable[[S, T], RetType]:
"""Specialized cache for self + 1 arg. Does not keep global ref to self and skips building a dict key tuple."""
assert function.__code__.co_argcount == 2, "Can only cache 2 argument functions with this cache."
cache_name = f"__cache_{function.__name__}__"
@functools.wraps(function)
def wrap(self: S, arg: T) -> RetType:
cache: Optional[Dict[T, RetType]] = getattr(self, cache_name, None)
if cache is None:
res = function(self, arg)
setattr(self, cache_name, {arg: res})
return res
try:
return cache[arg]
except KeyError:
res = function(self, arg)
cache[arg] = res
return res
return wrap
def is_frozen() -> bool:
return typing.cast(bool, getattr(sys, 'frozen', False))
@@ -174,16 +146,12 @@ def user_path(*path: str) -> str:
if user_path.cached_path != local_path():
import filecmp
if not os.path.exists(user_path("manifest.json")) or \
not os.path.exists(local_path("manifest.json")) or \
not filecmp.cmp(local_path("manifest.json"), user_path("manifest.json"), shallow=True):
import shutil
for dn in ("Players", "data/sprites", "data/lua"):
for dn in ("Players", "data/sprites"):
shutil.copytree(local_path(dn), user_path(dn), dirs_exist_ok=True)
if not os.path.exists(local_path("manifest.json")):
warnings.warn(f"Upgrading {user_path()} from something that is not a proper install")
else:
shutil.copy2(local_path("manifest.json"), user_path("manifest.json"))
os.makedirs(user_path("worlds"), exist_ok=True)
for fn in ("manifest.json",):
shutil.copy2(local_path(fn), user_path(fn))
return os.path.join(user_path.cached_path, *path)
@@ -202,7 +170,7 @@ def cache_path(*path: str) -> str:
def output_path(*path: str) -> str:
if hasattr(output_path, 'cached_path'):
return os.path.join(output_path.cached_path, *path)
output_path.cached_path = user_path(get_settings()["general_options"]["output_path"])
output_path.cached_path = user_path(get_options()["general_options"]["output_path"])
path = os.path.join(output_path.cached_path, *path)
os.makedirs(os.path.dirname(path), exist_ok=True)
return path
@@ -210,11 +178,10 @@ def output_path(*path: str) -> str:
def open_file(filename: typing.Union[str, "pathlib.Path"]) -> None:
if is_windows:
os.startfile(filename) # type: ignore
os.startfile(filename)
else:
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."
subprocess.call([open_command, filename])
@@ -227,9 +194,6 @@ class UniqueKeyLoader(SafeLoader):
if key in mapping:
logging.error(f"YAML duplicates sanity check failed{key_node.start_mark}")
raise KeyError(f"Duplicate key {key} found in YAML. Already found keys: {mapping}.")
if (str(key).startswith("+") and (str(key)[1:] in mapping)) or (f"+{key}" in mapping):
logging.error(f"YAML merge duplicates sanity check failed{key_node.start_mark}")
raise KeyError(f"Equivalent key {key} found in YAML. Already found keys: {mapping}.")
mapping.add(key)
return super().construct_mapping(node, deep)
@@ -293,30 +257,32 @@ def get_public_ipv6() -> str:
return ip
OptionsType = Settings # TODO: remove when removing get_options
OptionsType = Settings # TODO: remove ~2 versions after 0.4.1
def get_options() -> Settings:
# 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()
@cache_argsless
def get_default_options() -> Settings: # TODO: remove ~2 versions after 0.4.1
return Settings(None)
def persistent_store(category: str, key: str, value: typing.Any):
get_options = get_settings # TODO: add a warning ~2 versions after 0.4.1 and remove once all games are ported
def persistent_store(category: str, key: typing.Any, value: typing.Any):
path = user_path("_persistent_storage.yaml")
storage = persistent_load()
category_dict = storage.setdefault(category, {})
category_dict[key] = value
storage: dict = persistent_load()
category = storage.setdefault(category, {})
category[key] = value
with open(path, "wt") as f:
f.write(dump(storage, Dumper=Dumper))
def persistent_load() -> Dict[str, Dict[str, Any]]:
storage: Union[Dict[str, Dict[str, Any]], None] = getattr(persistent_load, "storage", None)
def persistent_load() -> typing.Dict[str, dict]:
storage = getattr(persistent_load, "storage", None)
if storage:
return storage
path = user_path("_persistent_storage.yaml")
storage = {}
storage: dict = {}
if os.path.exists(path):
try:
with open(path, "r") as f:
@@ -325,7 +291,7 @@ def persistent_load() -> Dict[str, Dict[str, Any]]:
logging.debug(f"Could not read store: {e}")
if storage is None:
storage = {}
setattr(persistent_load, "storage", storage)
persistent_load.storage = storage
return storage
@@ -367,7 +333,6 @@ def store_data_package_for_checksum(game: str, data: typing.Dict[str, Any]) -> N
except Exception as e:
logging.debug(f"Could not store data package: {e}")
def get_default_adjuster_settings(game_name: str) -> Namespace:
import LttPAdjuster
adjuster_settings = Namespace()
@@ -386,9 +351,7 @@ def get_adjuster_settings(game_name: str) -> Namespace:
default_settings = get_default_adjuster_settings(game_name)
# Fill in any arguments from the argparser that we haven't seen before
return Namespace(**vars(adjuster_settings), **{
k: v for k, v in vars(default_settings).items() if k not in vars(adjuster_settings)
})
return Namespace(**vars(adjuster_settings), **{k:v for k,v in vars(default_settings).items() if k not in vars(adjuster_settings)})
@cache_argsless
@@ -412,20 +375,20 @@ safe_builtins = frozenset((
class RestrictedUnpickler(pickle.Unpickler):
generic_properties_module: Optional[object]
def __init__(self, *args: Any, **kwargs: Any) -> None:
def __init__(self, *args, **kwargs):
super(RestrictedUnpickler, self).__init__(*args, **kwargs)
self.options_module = importlib.import_module("Options")
self.net_utils_module = importlib.import_module("NetUtils")
self.generic_properties_module = None
def find_class(self, module: str, name: str) -> type:
def find_class(self, module, name):
if module == "builtins" and name in safe_builtins:
return getattr(builtins, name)
# used by MultiServer -> savegame/multidata
if module == "NetUtils" and name in {"NetworkItem", "ClientStatus", "Hint", "SlotType", "NetworkSlot"}:
return getattr(self.net_utils_module, name)
# Options and Plando are unpickled by WebHost -> Generate
if module == "worlds.generic" and name == "PlandoItem":
if module == "worlds.generic" and name in {"PlandoItem", "PlandoConnection"}:
if not self.generic_properties_module:
self.generic_properties_module = importlib.import_module("worlds.generic")
return getattr(self.generic_properties_module, name)
@@ -436,13 +399,13 @@ class RestrictedUnpickler(pickle.Unpickler):
else:
mod = importlib.import_module(module)
obj = getattr(mod, name)
if issubclass(obj, (self.options_module.Option, self.options_module.PlandoConnection)):
if issubclass(obj, self.options_module.Option):
return obj
# Forbid everything else.
raise pickle.UnpicklingError(f"global '{module}.{name}' is forbidden")
def restricted_loads(s: bytes) -> Any:
def restricted_loads(s):
"""Helper function analogous to pickle.loads()."""
return RestrictedUnpickler(io.BytesIO(s)).load()
@@ -460,15 +423,6 @@ class KeyedDefaultDict(collections.defaultdict):
"""defaultdict variant that uses the missing key as argument to default_factory"""
default_factory: typing.Callable[[typing.Any], typing.Any]
def __init__(self,
default_factory: typing.Callable[[Any], Any] = None,
seq: typing.Union[typing.Mapping, typing.Iterable, None] = None,
**kwargs):
if seq is not None:
super().__init__(default_factory, seq, **kwargs)
else:
super().__init__(default_factory, **kwargs)
def __missing__(self, key):
self[key] = value = self.default_factory(key)
return value
@@ -505,21 +459,11 @@ def init_logging(name: str, loglevel: typing.Union[str, int] = logging.INFO, wri
write_mode,
encoding="utf-8-sig")
file_handler.setFormatter(logging.Formatter(log_format))
class Filter(logging.Filter):
def __init__(self, filter_name: str, condition: typing.Callable[[logging.LogRecord], bool]) -> None:
super().__init__(filter_name)
self.condition = condition
def filter(self, record: logging.LogRecord) -> bool:
return self.condition(record)
file_handler.addFilter(Filter("NoStream", lambda record: not getattr(record, "NoFile", False)))
root_logger.addHandler(file_handler)
if sys.stdout:
stream_handler = logging.StreamHandler(sys.stdout)
stream_handler.addFilter(Filter("NoFile", lambda record: not getattr(record, "NoStream", False)))
root_logger.addHandler(stream_handler)
root_logger.addHandler(
logging.StreamHandler(sys.stdout)
)
# Relay unhandled exceptions to logger.
if not getattr(sys.excepthook, "_wrapped", False): # skip if already modified
@@ -555,11 +499,10 @@ def init_logging(name: str, loglevel: typing.Union[str, int] = logging.INFO, wri
f"Archipelago ({__version__}) logging initialized"
f" on {platform.platform()}"
f" running Python {sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}"
f"{' (frozen)' if is_frozen() else ''}"
)
def stream_input(stream: typing.TextIO, queue: "asyncio.Queue[str]"):
def stream_input(stream, queue):
def queuer():
while 1:
try:
@@ -569,8 +512,6 @@ def stream_input(stream: typing.TextIO, queue: "asyncio.Queue[str]"):
else:
if text:
queue.put_nowait(text)
else:
sleep(0.01) # non-blocking stream
from threading import Thread
thread = Thread(target=queuer, name=f"Stream handler for {stream.name}", daemon=True)
@@ -589,7 +530,7 @@ class VersionException(Exception):
pass
def chaining_prefix(index: int, labels: typing.Sequence[str]) -> str:
def chaining_prefix(index: int, labels: typing.Tuple[str]) -> str:
text = ""
max_label = len(labels) - 1
while index > max_label:
@@ -612,7 +553,7 @@ def format_SI_prefix(value, power=1000, power_labels=("", "k", "M", "G", "T", "P
return f"{value.quantize(decimal.Decimal('1.00'))} {chaining_prefix(n, power_labels)}"
def get_fuzzy_results(input_word: str, word_list: typing.Collection[str], limit: typing.Optional[int] = None) \
def get_fuzzy_results(input_word: str, wordlist: typing.Sequence[str], limit: typing.Optional[int] = None) \
-> typing.List[typing.Tuple[str, int]]:
import jellyfish
@@ -620,71 +561,22 @@ def get_fuzzy_results(input_word: str, word_list: typing.Collection[str], limit:
return (1 - jellyfish.damerau_levenshtein_distance(word1.lower(), word2.lower())
/ max(len(word1), len(word2)))
limit = limit if limit else len(word_list)
limit: int = limit if limit else len(wordlist)
return list(
map(
lambda container: (container[0], int(container[1]*100)), # convert up to limit to int %
sorted(
map(lambda candidate: (candidate, get_fuzzy_ratio(input_word, candidate)), word_list),
map(lambda candidate:
(candidate, get_fuzzy_ratio(input_word, candidate)),
wordlist),
key=lambda element: element[1],
reverse=True
)[0:limit]
reverse=True)[0:limit]
)
)
def get_intended_text(input_text: str, possible_answers) -> typing.Tuple[str, bool, str]:
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] == 100:
return picks[0][0], True, "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)"
elif dif > 5:
return picks[0][0], True, "Close Match"
else:
return picks[0][0], False, f"Too many close matches for '{input_text}', " \
f"did you mean '{picks[0][0]}'? ({picks[0][1]}% sure)"
else:
if picks[0][1] > 90:
return picks[0][0], True, "Only Option Match"
else:
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)"
def get_input_text_from_response(text: str, command: str) -> typing.Optional[str]:
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}"
elif text.startswith("Missing: "):
return text.replace("Missing: ", "!hint_location ")
return None
def is_kivy_running() -> bool:
if "kivy" in sys.modules:
from kivy.app import App
return App.get_running_app() is not None
return False
def _mp_open_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(open_filename(*args))
def open_filename(title: str, filetypes: typing.Iterable[typing.Tuple[str, typing.Iterable[str]]], suggest: str = "") \
def open_filename(title: str, filetypes: typing.Sequence[typing.Tuple[str, typing.Sequence[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
@@ -710,13 +602,6 @@ def open_filename(title: str, filetypes: typing.Iterable[typing.Tuple[str, typin
f'This attempt was made because open_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_open_filename, args=(res, title, filetypes, suggest)).start()
return res.get()
try:
root = tkinter.Tk()
except tkinter.TclError:
@@ -726,12 +611,6 @@ def open_filename(title: str, filetypes: typing.Iterable[typing.Tuple[str, typin
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]:
def run(*args: str):
return subprocess.run(args, capture_output=True, text=True).stdout.split("\n", 1)[0] or None
@@ -755,16 +634,9 @@ def open_directory(title: str, suggest: str = "") -> typing.Optional[str]:
import tkinter.filedialog
except Exception as e:
logging.error('Could not load tkinter, which is likely not installed. '
f'This attempt was made because open_directory was used for "{title}".')
f'This attempt was made because open_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_open_directory, args=(res, title, suggest)).start()
return res.get()
try:
root = tkinter.Tk()
except tkinter.TclError:
@@ -777,6 +649,12 @@ 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
def is_kivy_running():
if "kivy" in sys.modules:
from kivy.app import App
return App.get_running_app() is not None
return False
if is_kivy_running():
from kvui import MessageBox
MessageBox(title, text, error).open()
@@ -792,11 +670,6 @@ def messagebox(title: str, text: str, error: bool = False) -> None:
if zenity:
return run(zenity, f"--title={title}", f"--text={text}", "--error" if error else "--info")
elif is_windows:
import ctypes
style = 0x10 if error else 0x0
return ctypes.windll.user32.MessageBoxW(0, text, title, style)
# fall back to tk
try:
import tkinter
@@ -812,7 +685,7 @@ def messagebox(title: str, text: str, error: bool = False) -> None:
root.update()
def title_sorted(data: typing.Iterable, key=None, ignore: typing.AbstractSet[str] = frozenset(("a", "the"))):
def title_sorted(data: typing.Sequence, key=None, ignore: typing.Set = frozenset(("a", "the"))):
"""Sorts a sequence of text ignoring typical articles like "a" or "the" in the beginning."""
def sorter(element: Union[str, Dict[str, Any]]) -> str:
if (not isinstance(element, str)):
@@ -861,25 +734,6 @@ def deprecate(message: str):
import warnings
warnings.warn(message)
class DeprecateDict(dict):
log_message: str
should_error: bool
def __init__(self, message: str, error: bool = False) -> None:
self.log_message = message
self.should_error = error
super().__init__()
def __getitem__(self, item: Any) -> Any:
if self.should_error:
deprecate(self.log_message)
elif __debug__:
import warnings
warnings.warn(self.log_message)
return super().__getitem__(item)
def _extend_freeze_support() -> None:
"""Extend multiprocessing.freeze_support() to also work on Non-Windows for spawn."""
# upstream issue: https://github.com/python/cpython/issues/76327
@@ -953,8 +807,8 @@ def visualize_regions(root_region: Region, file_name: str, *,
Example usage in Main code:
from Utils import visualize_regions
for player in multiworld.player_ids:
visualize_regions(multiworld.get_region("Menu", player), f"{multiworld.get_out_file_name_base(player)}.puml")
for player in world.player_ids:
visualize_regions(world.get_region("Menu", player), f"{world.get_out_file_name_base(player)}.puml")
"""
assert root_region.multiworld, "The multiworld attribute of root_region has to be filled"
from BaseClasses import Entrance, Item, Location, LocationProgressType, MultiWorld, Region
@@ -1036,24 +890,3 @@ def visualize_regions(root_region: Region, file_name: str, *,
with open(file_name, "wt", encoding="utf-8") as f:
f.write("\n".join(uml))
class RepeatableChain:
def __init__(self, iterable: typing.Iterable):
self.iterable = iterable
def __iter__(self):
return itertools.chain.from_iterable(self.iterable)
def __bool__(self):
return any(sub_iterable for sub_iterable in self.iterable)
def __len__(self):
return sum(len(iterable) for iterable in self.iterable)
def is_iterable_except_str(obj: object) -> TypeGuard[typing.Iterable[typing.Any]]:
""" `str` is `Iterable`, but that's not what we want """
if isinstance(obj, str):
return False
return isinstance(obj, typing.Iterable)

View File

@@ -113,9 +113,6 @@ class WargrooveContext(CommonContext):
async def connection_closed(self):
await super(WargrooveContext, self).connection_closed()
self.remove_communication_files()
self.checked_locations.clear()
self.server_locations.clear()
self.finished_game = False
@property
def endpoints(self):
@@ -127,9 +124,6 @@ class WargrooveContext(CommonContext):
async def shutdown(self):
await super(WargrooveContext, self).shutdown()
self.remove_communication_files()
self.checked_locations.clear()
self.server_locations.clear()
self.finished_game = False
def remove_communication_files(self):
for root, dirs, files in os.walk(self.game_communication_path):
@@ -176,7 +170,7 @@ class WargrooveContext(CommonContext):
if not os.path.isfile(path):
open(path, 'w').close()
# Announcing commander unlocks
item_name = self.item_names.lookup_in_game(network_item.item)
item_name = self.item_names[network_item.item]
if item_name in faction_table.keys():
for commander in faction_table[item_name]:
logger.info(f"{commander.name} has been unlocked!")
@@ -197,7 +191,7 @@ class WargrooveContext(CommonContext):
open(print_path, 'w').close()
with open(print_path, 'w') as f:
f.write("Received " +
self.item_names.lookup_in_game(network_item.item) +
self.item_names[network_item.item] +
" from " +
self.player_names[network_item.player])
f.close()
@@ -267,7 +261,9 @@ class WargrooveContext(CommonContext):
def build(self):
container = super().build()
self.add_client_tab("Wargroove", self.build_tracker())
panel = TabbedPanelItem(text="Wargroove")
panel.content = self.build_tracker()
self.tabs.add_widget(panel)
return container
def build_tracker(self) -> TrackerLayout:
@@ -340,7 +336,7 @@ class WargrooveContext(CommonContext):
faction_items = 0
faction_item_names = [faction + ' Commanders' for faction in faction_table.keys()]
for network_item in self.items_received:
if self.item_names.lookup_in_game(network_item.item) in faction_item_names:
if self.item_names[network_item.item] in faction_item_names:
faction_items += 1
starting_groove = (faction_items - 1) * self.starting_groove_multiplier
# Must be an integer larger than 0
@@ -406,10 +402,8 @@ async def game_watcher(ctx: WargrooveContext):
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)

View File

@@ -1,4 +1,3 @@
import argparse
import os
import multiprocessing
import logging
@@ -12,10 +11,6 @@ ModuleUpdate.update()
# in case app gets imported by something like gunicorn
import Utils
import settings
from Utils import get_file_safe_name
if typing.TYPE_CHECKING:
from flask import Flask
Utils.local_path.cached_path = os.path.dirname(__file__) or "." # py3.8 is not abs. remove "." when dropping 3.8
settings.no_gui = True
@@ -24,30 +19,21 @@ if not os.path.exists(configpath): # fall back to config.yaml in home
configpath = os.path.abspath(Utils.user_path('config.yaml'))
def get_app() -> "Flask":
def get_app():
from WebHostLib import register, cache, app as raw_app
from WebHostLib.models import db
register()
app = raw_app
if os.path.exists(configpath) and not app.config["TESTING"]:
import yaml
app.config.from_file(configpath, yaml.safe_load)
logging.info(f"Updated config from {configpath}")
# inside get_app() so it's usable in systems like gunicorn, which do not run WebHost.py, but import it.
parser = argparse.ArgumentParser()
parser.add_argument('--config_override', default=None,
help="Path to yaml config file that overrules config.yaml.")
args = parser.parse_known_args()[0]
if args.config_override:
import yaml
app.config.from_file(os.path.abspath(args.config_override), yaml.safe_load)
logging.info(f"Updated config from {args.config_override}")
if not app.config["HOST_ADDRESS"]:
logging.info("Getting public IP, as HOST_ADDRESS is empty.")
app.config["HOST_ADDRESS"] = Utils.get_public_ipv4()
logging.info(f"HOST_ADDRESS was set to {app.config['HOST_ADDRESS']}")
register()
cache.init_app(app)
db.bind(**app.config["PONY"])
db.generate_mapping(create_tables=True)
@@ -69,10 +55,9 @@ def create_ordered_tutorials_file() -> typing.List[typing.Dict[str, typing.Any]]
worlds[game] = world
base_target_path = Utils.local_path("WebHostLib", "static", "generated", "docs")
shutil.rmtree(base_target_path, ignore_errors=True)
for game, world in worlds.items():
# copy files from world's docs folder to the generated folder
target_path = os.path.join(base_target_path, get_file_safe_name(game))
target_path = os.path.join(base_target_path, game)
os.makedirs(target_path, exist_ok=True)
if world.zip_path:
@@ -132,7 +117,7 @@ if __name__ == "__main__":
logging.basicConfig(format='[%(asctime)s] %(message)s', level=logging.INFO)
from WebHostLib.lttpsprites import update_sprites_lttp
from WebHostLib.autolauncher import autohost, autogen, stop
from WebHostLib.autolauncher import autohost, autogen
from WebHostLib.options import create as create_options_files
try:
@@ -153,11 +138,3 @@ if __name__ == "__main__":
else:
from waitress import serve
serve(app, port=app.config["PORT"], threads=app.config["WAITRESS_THREADS"])
else:
from time import sleep
try:
while True:
sleep(1) # wait for process to be killed
except (SystemExit, KeyboardInterrupt):
pass
stop() # stop worker threads

View File

@@ -9,7 +9,7 @@ from flask_compress import Compress
from pony.flask import Pony
from werkzeug.routing import BaseConverter
from Utils import title_sorted, get_file_safe_name
from Utils import title_sorted
UPLOAD_FOLDER = os.path.relpath('uploads')
LOGS_FOLDER = os.path.relpath('logs')
@@ -20,11 +20,9 @@ Pony(app)
app.jinja_env.filters['any'] = any
app.jinja_env.filters['all'] = all
app.jinja_env.filters['get_file_safe_name'] = get_file_safe_name
app.config["SELFHOST"] = True # application process is in charge of running the websites
app.config["GENERATORS"] = 8 # maximum concurrent world gens
app.config["HOSTERS"] = 8 # maximum concurrent room hosters
app.config["SELFLAUNCH"] = True # application process is in charge of launching Rooms.
app.config["SELFLAUNCHCERT"] = None # can point to a SSL Certificate to encrypt Room websocket connections
app.config["SELFLAUNCHKEY"] = None # can point to a SSL Certificate Key to encrypt Room websocket connections
@@ -52,8 +50,8 @@ app.config["PONY"] = {
}
app.config["MAX_ROLL"] = 20
app.config["CACHE_TYPE"] = "SimpleCache"
app.config["JSON_AS_ASCII"] = False
app.config["HOST_ADDRESS"] = ""
app.config["ASSET_RIGHTS"] = False
cache = Cache()
Compress(app)
@@ -85,6 +83,6 @@ def register():
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
from . import tracker, upload, landing, check, generate, downloads, api, stats, misc
app.register_blueprint(api.api_endpoints)

View File

@@ -1,15 +1,59 @@
"""API endpoints package."""
from typing import List, Tuple
from uuid import UUID
from flask import Blueprint
from flask import Blueprint, abort
from ..models import Seed
from .. import cache
from ..models import Room, Seed
api_endpoints = Blueprint('api', __name__, url_prefix="/api")
# unsorted/misc endpoints
def get_players(seed: Seed) -> List[Tuple[str, str]]:
return [(slot.player_name, slot.game) for slot in seed.slots]
from . import datapackage, generate, room, user # trigger registration
@api_endpoints.route('/room_status/<suuid:room>')
def room_info(room: UUID):
room = Room.get(id=room)
if room is None:
return abort(404)
return {
"tracker": room.tracker,
"players": get_players(room.seed),
"last_port": room.last_port,
"last_activity": room.last_activity,
"timeout": room.timeout
}
@api_endpoints.route('/datapackage')
@cache.cached()
def get_datapackage():
from worlds import network_data_package
return network_data_package
@api_endpoints.route('/datapackage_version')
@cache.cached()
def get_datapackage_versions():
from worlds import AutoWorldRegister
version_package = {game: world.data_version for game, world in AutoWorldRegister.world_types.items()}
return version_package
@api_endpoints.route('/datapackage_checksum')
@cache.cached()
def get_datapackage_checksums():
from worlds import network_data_package
version_package = {
game: game_data["checksum"] for game, game_data in network_data_package["games"].items()
}
return version_package
from . import generate, user # trigger registration

View File

@@ -1,32 +0,0 @@
from flask import abort
from Utils import restricted_loads
from WebHostLib import cache
from WebHostLib.models import GameDataPackage
from . import api_endpoints
@api_endpoints.route('/datapackage')
@cache.cached()
def get_datapackage():
from worlds import network_data_package
return network_data_package
@api_endpoints.route('/datapackage/<string:checksum>')
@cache.memoize(timeout=3600)
def get_datapackage_by_checksum(checksum: str):
package = GameDataPackage.get(checksum=checksum)
if package:
return restricted_loads(package.data)
return abort(404)
@api_endpoints.route('/datapackage_checksum')
@cache.cached()
def get_datapackage_checksums():
from worlds import network_data_package
version_package = {
game: game_data["checksum"] for game, game_data in network_data_package["games"].items()
}
return version_package

View File

@@ -20,8 +20,8 @@ def generate_api():
race = False
meta_options_source = {}
if 'file' in request.files:
files = request.files.getlist('file')
options = get_yaml_data(files)
file = request.files['file']
options = get_yaml_data(file)
if isinstance(options, Markup):
return {"text": options.striptags()}, 400
if isinstance(options, str):

View File

@@ -1,42 +0,0 @@
from typing import Any, Dict
from uuid import UUID
from flask import abort, url_for
import worlds.Files
from . import api_endpoints, get_players
from ..models import Room
@api_endpoints.route('/room_status/<suuid:room_id>')
def room_info(room_id: UUID) -> Dict[str, Any]:
room = Room.get(id=room_id)
if room is None:
return abort(404)
def supports_apdeltapatch(game: str) -> bool:
return game in worlds.Files.AutoPatchRegister.patch_types
downloads = []
for slot in sorted(room.seed.slots):
if slot.data and not supports_apdeltapatch(slot.game):
slot_download = {
"slot": slot.player_id,
"download": url_for("download_slot_file", room_id=room.id, player_id=slot.player_id)
}
downloads.append(slot_download)
elif slot.data:
slot_download = {
"slot": slot.player_id,
"download": url_for("download_patch", patch_id=slot.id, room_id=room.id)
}
downloads.append(slot_download)
return {
"tracker": room.tracker,
"players": get_players(room.seed),
"last_port": room.last_port,
"last_activity": room.last_activity,
"timeout": room.timeout,
"downloads": downloads,
}

View File

@@ -3,25 +3,25 @@ from __future__ import annotations
import json
import logging
import multiprocessing
import threading
import time
import typing
from datetime import timedelta, datetime
from threading import Event, Thread
from uuid import UUID
from pony.orm import db_session, select, commit
from Utils import restricted_loads
from .locker import Locker, AlreadyRunningException
_stop_event = Event()
def launch_room(room: Room, config: dict):
# requires db_session!
if room.last_activity >= datetime.utcnow() - timedelta(seconds=room.timeout):
multiworld = multiworlds.get(room.id, None)
if not multiworld:
multiworld = MultiworldInstance(room, config)
def stop():
"""Stops previously launched threads"""
global _stop_event
stop_event = _stop_event
_stop_event = Event() # new event for new threads
stop_event.set()
multiworld.start()
def handle_generation_success(seed_id):
@@ -58,50 +58,29 @@ def init_db(pony_config: dict):
db.generate_mapping()
def cleanup():
"""delete unowned user-content"""
with db_session:
# >>> bool(uuid.UUID(int=0))
# True
rooms = Room.select(lambda room: room.owner == UUID(int=0)).delete(bulk=True)
seeds = Seed.select(lambda seed: seed.owner == UUID(int=0) and not seed.rooms).delete(bulk=True)
slots = Slot.select(lambda slot: not slot.seed).delete(bulk=True)
# Command gets deleted by ponyorm Cascade Delete, as Room is Required
if rooms or seeds or slots:
logging.info(f"{rooms} Rooms, {seeds} Seeds and {slots} Slots have been deleted.")
def autohost(config: dict):
def keep_running():
stop_event = _stop_event
try:
with Locker("autohost"):
cleanup()
hosters = []
for x in range(config["HOSTERS"]):
hoster = MultiworldInstance(config, x)
hosters.append(hoster)
hoster.start()
while not stop_event.wait(0.1):
run_guardian()
while 1:
time.sleep(0.1)
with db_session:
rooms = select(
room for room in Room if
room.last_activity >= datetime.utcnow() - timedelta(days=3))
for room in rooms:
# we have to filter twice, as the per-room timeout can't currently be PonyORM transpiled.
if room.last_activity >= datetime.utcnow() - timedelta(seconds=room.timeout + 5):
hosters[room.id.int % len(hosters)].start_room(room.id)
launch_room(room, config)
except AlreadyRunningException:
logging.info("Autohost reports as already running, not starting another.")
Thread(target=keep_running, name="AP_Autohost").start()
import threading
threading.Thread(target=keep_running, name="AP_Autohost").start()
def autogen(config: dict):
def keep_running():
stop_event = _stop_event
try:
with Locker("autogen"):
@@ -122,7 +101,8 @@ def autogen(config: dict):
commit()
select(generation for generation in Generation if generation.state == STATE_ERROR).delete()
while not stop_event.wait(0.1):
while 1:
time.sleep(0.1)
with db_session:
# for update locks the database row(s) during transaction, preventing writes from elsewhere
to_start = select(
@@ -133,45 +113,37 @@ def autogen(config: dict):
except AlreadyRunningException:
logging.info("Autogen reports as already running, not starting another.")
Thread(target=keep_running, name="AP_Autogen").start()
import threading
threading.Thread(target=keep_running, name="AP_Autogen").start()
multiworlds: typing.Dict[type(Room.id), MultiworldInstance] = {}
class MultiworldInstance():
def __init__(self, config: dict, id: int):
self.room_ids = set()
def __init__(self, room: Room, config: dict):
self.room_id = room.id
self.process: typing.Optional[multiprocessing.Process] = None
with guardian_lock:
multiworlds[self.room_id] = self
self.ponyconfig = config["PONY"]
self.cert = config["SELFLAUNCHCERT"]
self.key = config["SELFLAUNCHKEY"]
self.host = config["HOST_ADDRESS"]
self.rooms_to_start = multiprocessing.Queue()
self.rooms_shutting_down = multiprocessing.Queue()
self.name = f"MultiHoster{id}"
def start(self):
if self.process and self.process.is_alive():
return False
logging.info(f"Spinning up {self.room_id}")
process = multiprocessing.Process(group=None, target=run_server_process,
args=(self.name, self.ponyconfig, get_static_server_data(),
self.cert, self.key, self.host,
self.rooms_to_start, self.rooms_shutting_down),
name=self.name)
args=(self.room_id, self.ponyconfig, get_static_server_data(),
self.cert, self.key, self.host),
name="MultiHost")
process.start()
# bind after start to prevent thread sync issues with guardian.
self.process = process
def start_room(self, room_id):
while not self.rooms_shutting_down.empty():
self.room_ids.remove(self.rooms_shutting_down.get(block=True, timeout=None))
if room_id in self.room_ids:
pass # should already be hosted currently.
else:
self.room_ids.add(room_id)
self.rooms_to_start.put(room_id)
def stop(self):
if self.process:
self.process.terminate()
@@ -185,6 +157,40 @@ class MultiworldInstance():
self.process = None
from .models import Room, Generation, STATE_QUEUED, STATE_STARTED, STATE_ERROR, db, Seed, Slot
guardian = None
guardian_lock = threading.Lock()
def run_guardian():
global guardian
global multiworlds
with guardian_lock:
if not guardian:
try:
import resource
except ModuleNotFoundError:
pass # unix only module
else:
# Each Server is another file handle, so request as many as we can from the system
file_limit = resource.getrlimit(resource.RLIMIT_NOFILE)[1]
# set soft limit to hard limit
resource.setrlimit(resource.RLIMIT_NOFILE, (file_limit, file_limit))
def guard():
while 1:
time.sleep(1)
done = []
with guardian_lock:
for key, instance in multiworlds.items():
if instance.done():
instance.collect()
done.append(key)
for key in done:
del (multiworlds[key])
guardian = threading.Thread(name="Guardian", target=guard)
from .models import Room, Generation, STATE_QUEUED, STATE_STARTED, STATE_ERROR, db, Seed
from .customserver import run_server_process, get_static_server_data
from .generate import gen_game

View File

@@ -1,13 +1,17 @@
import os
import zipfile
import base64
from typing import Union, Dict, Set, Tuple
from typing import *
from flask import request, flash, redirect, url_for, render_template
from markupsafe import Markup
from WebHostLib import app
from WebHostLib.upload import allowed_options, allowed_options_extensions, banned_file
banned_zip_contents = (".sfc",)
def allowed_file(filename):
return filename.endswith(('.txt', ".yaml", ".zip"))
from Generate import roll_settings, PlandoOptions
from Utils import parse_yamls
@@ -26,15 +30,7 @@ def check():
flash(options)
else:
results, _ = roll_options(options)
if len(options) > 1:
# offer combined file back
combined_yaml = "\n---\n".join(f"# original filename: {file_name}\n{file_content.decode('utf-8-sig')}"
for file_name, file_content in options.items())
combined_yaml = base64.b64encode(combined_yaml.encode("utf-8-sig")).decode()
else:
combined_yaml = ""
return render_template("checkResult.html",
results=results, combined_yaml=combined_yaml)
return render_template("checkResult.html", results=results)
return render_template("check.html")
@@ -45,42 +41,33 @@ def mysterycheck():
def get_yaml_data(files) -> Union[Dict[str, str], str, Markup]:
options = {}
for uploaded_file in files:
if banned_file(uploaded_file.filename):
return ("Uploaded data contained a rom file, which is likely to contain copyrighted material. "
"Your file was deleted.")
# If the user does not select file, the browser will still submit an empty string without a file name.
elif uploaded_file.filename == "":
return "No selected file."
elif uploaded_file.filename in options:
return f"Conflicting files named {uploaded_file.filename} submitted."
elif uploaded_file and allowed_options(uploaded_file.filename):
if uploaded_file.filename.endswith(".zip"):
if not zipfile.is_zipfile(uploaded_file):
return f"Uploaded file {uploaded_file.filename} is not a valid .zip file and cannot be opened."
for file in files:
# if user does not select file, browser also
# submit an empty part without filename
if file.filename == '':
return 'No selected file'
elif file.filename in options:
return f'Conflicting files named {file.filename} submitted'
elif file and allowed_file(file.filename):
if file.filename.endswith(".zip"):
uploaded_file.seek(0) # offset from is_zipfile check
with zipfile.ZipFile(uploaded_file, "r") as zfile:
for file in zfile.infolist():
# Remove folder pathing from str (e.g. "__MACOSX/" folder paths from archives created by macOS).
base_filename = os.path.basename(file.filename)
with zipfile.ZipFile(file, 'r') as zfile:
infolist = zfile.infolist()
if base_filename.endswith(".archipelago"):
return Markup("Error: Your .zip file contains an .archipelago file. "
'Did you mean to <a href="/uploads">host a game</a>?')
elif base_filename.endswith(".zip"):
return "Nested .zip files inside a .zip are not supported."
elif banned_file(base_filename):
return ("Uploaded data contained a rom file, which is likely to contain copyrighted "
"material. Your file was deleted.")
# Ignore dot-files.
elif not base_filename.startswith(".") and allowed_options(base_filename):
if any(file.filename.endswith(".archipelago") for file in infolist):
return Markup("Error: Your .zip file contains an .archipelago file. "
'Did you mean to <a href="/uploads">host a game</a>?')
for file in infolist:
if file.filename.endswith(banned_zip_contents):
return "Uploaded data contained a rom file, which is likely to contain copyrighted material. " \
"Your file was deleted."
elif file.filename.endswith((".yaml", ".json", ".yml", ".txt")):
options[file.filename] = zfile.open(file, "r").read()
else:
options[uploaded_file.filename] = uploaded_file.read()
options[file.filename] = file.read()
if not options:
return f"Did not find any valid files to process. Accepted formats: {allowed_options_extensions}"
return "Did not find a .yaml file to process."
return options
@@ -108,10 +95,7 @@ def roll_options(options: Dict[str, Union[dict, str]],
rolled_results[f"{filename}/{i + 1}"] = roll_settings(yaml_data,
plando_options=plando_options)
except Exception as e:
if e.__cause__:
results[filename] = f"Failed to generate options in {filename}: {e} - {e.__cause__}"
else:
results[filename] = f"Failed to generate options in {filename}: {e}"
results[filename] = f"Failed to generate options in {filename}: {e}"
else:
results[filename] = True
return results, rolled_results

View File

@@ -5,7 +5,6 @@ import collections
import datetime
import functools
import logging
import multiprocessing
import pickle
import random
import socket
@@ -28,10 +27,8 @@ from .models import Command, GameDataPackage, Room, db
class CustomClientMessageProcessor(ClientMessageProcessor):
ctx: WebHostContext
def _cmd_video(self, platform: str, user: str):
"""Set a link for your name in the WebHostLib tracker pointing to a video stream.
Currently, only YouTube and Twitch platforms are supported.
"""
def _cmd_video(self, platform, user):
"""Set a link for your name in the WebHostLib tracker pointing to a video stream"""
if platform.lower().startswith("t"): # twitch
self.ctx.video[self.client.team, self.client.slot] = "Twitch", user
self.ctx.save()
@@ -54,35 +51,24 @@ del MultiServer
class DBCommandProcessor(ServerCommandProcessor):
def output(self, text: str):
self.ctx.logger.info(text)
logging.info(text)
class WebHostContext(Context):
room_id: int
def __init__(self, static_server_data: dict, logger: logging.Logger):
def __init__(self, static_server_data: dict):
# static server data is used during _load_game_data to load required data,
# without needing to import worlds system, which takes quite a bit of memory
self.static_server_data = static_server_data
super(WebHostContext, self).__init__("", 0, "", "", 1,
40, True, "enabled", "enabled",
"enabled", 0, 2, logger=logger)
super(WebHostContext, self).__init__("", 0, "", "", 1, 40, True, "enabled", "enabled", "enabled", 0, 2)
del self.static_server_data
self.main_loop = asyncio.get_running_loop()
self.video = {}
self.tags = ["AP", "WebHost"]
def __del__(self):
try:
import psutil
from Utils import format_SI_prefix
self.logger.debug(f"Context destroyed, Mem: {format_SI_prefix(psutil.Process().memory_info().rss, 1024)}iB")
except ImportError:
self.logger.debug("Context destroyed")
def _load_game_data(self):
for key, value in self.static_server_data.items():
# NOTE: attributes are mutable and shared, so they will have to be copied before being modified
setattr(self, key, value)
self.non_hintable_names = collections.defaultdict(frozenset, self.non_hintable_names)
@@ -110,37 +96,18 @@ class WebHostContext(Context):
multidata = self.decompress(room.seed.multidata)
game_data_packages = {}
static_gamespackage = self.gamespackage # this is shared across all rooms
static_item_name_groups = self.item_name_groups
static_location_name_groups = self.location_name_groups
self.gamespackage = {"Archipelago": static_gamespackage.get("Archipelago", {})} # this may be modified by _load
self.item_name_groups = {"Archipelago": static_item_name_groups.get("Archipelago", {})}
self.location_name_groups = {"Archipelago": static_location_name_groups.get("Archipelago", {})}
for game in list(multidata.get("datapackage", {})):
game_data = multidata["datapackage"][game]
if "checksum" in game_data:
if static_gamespackage.get(game, {}).get("checksum") == game_data["checksum"]:
# non-custom. remove from multidata and use static data
if self.gamespackage.get(game, {}).get("checksum") == game_data["checksum"]:
# non-custom. remove from multidata
# games package could be dropped from static data once all rooms embed data package
del multidata["datapackage"][game]
else:
row = GameDataPackage.get(checksum=game_data["checksum"])
if row: # None if rolled on >= 0.3.9 but uploaded to <= 0.3.8. multidata should be complete
game_data_packages[game] = Utils.restricted_loads(row.data)
continue
else:
self.logger.warning(f"Did not find game_data_package for {game}: {game_data['checksum']}")
self.gamespackage[game] = static_gamespackage.get(game, {})
self.item_name_groups[game] = static_item_name_groups.get(game, {})
self.location_name_groups[game] = static_location_name_groups.get(game, {})
if not game_data_packages:
# all static -> use the static dicts directly
self.gamespackage = static_gamespackage
self.item_name_groups = static_item_name_groups
self.location_name_groups = static_location_name_groups
return self._load(multidata, game_data_packages, True)
@db_session
@@ -150,7 +117,7 @@ class WebHostContext(Context):
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)
self._start_async_saving()
threading.Thread(target=self.listen_to_db_commands, daemon=True).start()
@db_session
@@ -176,180 +143,80 @@ def get_random_port():
def get_static_server_data() -> dict:
import worlds
data = {
"non_hintable_names": {
world_name: world.hint_blacklist
for world_name, world in worlds.AutoWorldRegister.world_types.items()
},
"gamespackage": {
world_name: {
key: value
for key, value in game_package.items()
if key not in ("item_name_groups", "location_name_groups")
}
for world_name, game_package in worlds.network_data_package["games"].items()
},
"item_name_groups": {
world_name: world.item_name_groups
for world_name, world in worlds.AutoWorldRegister.world_types.items()
},
"location_name_groups": {
world_name: world.location_name_groups
for world_name, world in worlds.AutoWorldRegister.world_types.items()
},
"non_hintable_names": {},
"gamespackage": worlds.network_data_package["games"],
"item_name_groups": {world_name: world.item_name_groups for world_name, world in
worlds.AutoWorldRegister.world_types.items()},
"location_name_groups": {world_name: world.location_name_groups for world_name, world in
worlds.AutoWorldRegister.world_types.items()},
}
for world_name, world in worlds.AutoWorldRegister.world_types.items():
data["non_hintable_names"][world_name] = world.hint_blacklist
return data
def set_up_logging(room_id) -> logging.Logger:
import os
# logger setup
logger = logging.getLogger(f"RoomLogger {room_id}")
# this *should* be empty, but just in case.
for handler in logger.handlers[:]:
logger.removeHandler(handler)
handler.close()
file_handler = logging.FileHandler(
os.path.join(Utils.user_path("logs"), f"{room_id}.txt"),
"a",
encoding="utf-8-sig")
file_handler.setFormatter(logging.Formatter("[%(asctime)s]: %(message)s"))
logger.setLevel(logging.INFO)
logger.addHandler(file_handler)
return logger
def run_server_process(name: str, ponyconfig: dict, static_server_data: dict,
def run_server_process(room_id, ponyconfig: dict, static_server_data: dict,
cert_file: typing.Optional[str], cert_key_file: typing.Optional[str],
host: str, rooms_to_run: multiprocessing.Queue, rooms_shutting_down: multiprocessing.Queue):
Utils.init_logging(name)
try:
import resource
except ModuleNotFoundError:
pass # unix only module
else:
# Each Server is another file handle, so request as many as we can from the system
file_limit = resource.getrlimit(resource.RLIMIT_NOFILE)[1]
# set soft limit to hard limit
resource.setrlimit(resource.RLIMIT_NOFILE, (file_limit, file_limit))
del resource, file_limit
host: str):
# establish DB connection for multidata and multisave
db.bind(**ponyconfig)
db.generate_mapping(check_tables=False)
if "worlds" in sys.modules:
raise Exception("Worlds system should not be loaded in the custom server.")
async def main():
if "worlds" in sys.modules:
raise Exception("Worlds system should not be loaded in the custom server.")
import gc
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
import gc
Utils.init_logging(str(room_id), write_mode="a")
ctx = WebHostContext(static_server_data)
ctx.load(room_id)
ctx.init_save()
ssl_context = load_server_cert(cert_file, cert_key_file) if cert_file else None
gc.collect() # free intermediate objects used during setup
try:
ctx.server = websockets.serve(functools.partial(server, ctx=ctx), ctx.host, ctx.port, ssl=ssl_context)
loop = asyncio.get_event_loop()
await ctx.server
except OSError: # likely port in use
ctx.server = websockets.serve(functools.partial(server, ctx=ctx), ctx.host, 0, ssl=ssl_context)
async def start_room(room_id):
with Locker(f"RoomLocker {room_id}"):
try:
logger = set_up_logging(room_id)
ctx = WebHostContext(static_server_data, logger)
ctx.load(room_id)
ctx.init_save()
assert ctx.server is None
try:
ctx.server = websockets.serve(
functools.partial(server, ctx=ctx), ctx.host, ctx.port, ssl=ssl_context)
await ctx.server
port = 0
for wssocket in ctx.server.ws_server.sockets:
socketname = wssocket.getsockname()
if wssocket.family == socket.AF_INET6:
# Prefer IPv4, as most users seem to not have working ipv6 support
if not port:
port = socketname[1]
elif wssocket.family == socket.AF_INET:
port = socketname[1]
if port:
logging.info(f'Hosting game at {host}:{port}')
with db_session:
room = Room.get(id=ctx.room_id)
room.last_port = port
else:
logging.exception("Could not determine port. Likely hosting failure.")
with db_session:
ctx.auto_shutdown = Room.get(id=room_id).timeout
ctx.shutdown_task = asyncio.create_task(auto_shutdown(ctx, []))
await ctx.shutdown_task
logging.info("Shutting down")
await ctx.server
except OSError: # likely port in use
ctx.server = websockets.serve(
functools.partial(server, ctx=ctx), ctx.host, 0, ssl=ssl_context)
await ctx.server
port = 0
for wssocket in ctx.server.ws_server.sockets:
socketname = wssocket.getsockname()
if wssocket.family == socket.AF_INET6:
# Prefer IPv4, as most users seem to not have working ipv6 support
if not port:
port = socketname[1]
elif wssocket.family == socket.AF_INET:
port = socketname[1]
if port:
ctx.logger.info(f'Hosting game at {host}:{port}')
with db_session:
room = Room.get(id=ctx.room_id)
room.last_port = port
else:
ctx.logger.exception("Could not determine port. Likely hosting failure.")
with db_session:
ctx.auto_shutdown = Room.get(id=room_id).timeout
if ctx.saving:
setattr(asyncio.current_task(), "save", lambda: ctx._save(True))
assert ctx.shutdown_task is None
ctx.shutdown_task = asyncio.create_task(auto_shutdown(ctx, []))
await ctx.shutdown_task
except (KeyboardInterrupt, SystemExit):
if ctx.saving:
ctx._save()
setattr(asyncio.current_task(), "save", None)
except Exception as e:
with db_session:
room = Room.get(id=room_id)
room.last_port = -1
logger.exception(e)
raise
else:
if ctx.saving:
ctx._save()
setattr(asyncio.current_task(), "save", None)
finally:
try:
ctx.save_dirty = False # make sure the saving thread does not write to DB after final wakeup
ctx.exit_event.set() # make sure the saving thread stops at some point
# NOTE: async saving should probably be an async task and could be merged with shutdown_task
with (db_session):
# ensure the Room does not spin up again on its own, minute of safety buffer
room = Room.get(id=room_id)
room.last_activity = datetime.datetime.utcnow() - \
datetime.timedelta(minutes=1, seconds=room.timeout)
logging.info(f"Shutting down room {room_id} on {name}.")
finally:
await asyncio.sleep(5)
rooms_shutting_down.put(room_id)
class Starter(threading.Thread):
_tasks: typing.List[asyncio.Future]
def __init__(self):
super().__init__()
self._tasks = []
def _done(self, task: asyncio.Future):
self._tasks.remove(task)
task.result()
def run(self):
while 1:
next_room = rooms_to_run.get(block=True, timeout=None)
gc.collect()
task = asyncio.run_coroutine_threadsafe(start_room(next_room), loop)
self._tasks.append(task)
task.add_done_callback(self._done)
logging.info(f"Starting room {next_room} on {name}.")
del task # delete reference to task object
starter = Starter()
starter.daemon = True
starter.start()
try:
loop.run_forever()
finally:
# save all tasks that want to be saved during shutdown
for task in asyncio.all_tasks(loop):
save: typing.Optional[typing.Callable[[], typing.Any]] = getattr(task, "save", None)
if save:
save()
with Locker(room_id):
try:
asyncio.run(main())
except (KeyboardInterrupt, SystemExit):
with db_session:
room = Room.get(id=room_id)
# ensure the Room does not spin up again on its own, minute of safety buffer
room.last_activity = datetime.datetime.utcnow() - datetime.timedelta(minutes=1, seconds=room.timeout)
except Exception:
with db_session:
room = Room.get(id=room_id)
room.last_port = -1
# ensure the Room does not spin up again on its own, minute of safety buffer
room.last_activity = datetime.datetime.utcnow() - datetime.timedelta(minutes=1, seconds=room.timeout)
raise

View File

@@ -90,8 +90,6 @@ def download_slot_file(room_id, player_id: int):
fname = f"AP_{app.jinja_env.filters['suuid'](room_id)}.json"
elif slot_data.game == "Kingdom Hearts 2":
fname = f"AP_{app.jinja_env.filters['suuid'](room_id)}_P{slot_data.player_id}_{slot_data.player_name}.zip"
elif slot_data.game == "Final Fantasy Mystic Quest":
fname = f"AP+{app.jinja_env.filters['suuid'](room_id)}_P{slot_data.player_id}_{slot_data.player_name}.apmq"
else:
return "Game download not supported."
return send_file(io.BytesIO(slot_data.data), as_attachment=True, download_name=fname)

View File

@@ -1,22 +1,21 @@
import concurrent.futures
import json
import os
import pickle
import random
import tempfile
import zipfile
import concurrent.futures
from collections import Counter
from typing import Any, Dict, List, Optional, Union, Set
from typing import Dict, Optional, Any, Union, List
from flask import flash, redirect, render_template, request, session, url_for
from flask import request, flash, redirect, url_for, session, render_template
from pony.orm import commit, db_session
from BaseClasses import get_seed, seeddigits
from Generate import PlandoOptions, handle_name
from BaseClasses import seeddigits, get_seed
from Generate import handle_name, PlandoOptions
from Main import main as ERmain
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
@@ -24,22 +23,25 @@ from .upload import upload_zip_to_db
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)
plando_options = {
options_source.get("plando_bosses", ""),
options_source.get("plando_items", ""),
options_source.get("plando_connections", ""),
options_source.get("plando_texts", "")
}
plando_options -= {""}
server_options = {
"hint_cost": int(options_source.get("hint_cost", ServerOptions.hint_cost)),
"release_mode": options_source.get("release_mode", ServerOptions.release_mode),
"remaining_mode": options_source.get("remaining_mode", ServerOptions.remaining_mode),
"collect_mode": options_source.get("collect_mode", ServerOptions.collect_mode),
"item_cheat": bool(int(options_source.get("item_cheat", not ServerOptions.disable_item_cheat))),
"hint_cost": int(options_source.get("hint_cost", 10)),
"release_mode": options_source.get("release_mode", "goal"),
"remaining_mode": options_source.get("remaining_mode", "disabled"),
"collect_mode": options_source.get("collect_mode", "disabled"),
"item_cheat": bool(int(options_source.get("item_cheat", 1))),
"server_password": options_source.get("server_password", None),
}
generator_options = {
"spoiler": int(options_source.get("spoiler", GeneratorOptions.spoiler)),
"race": race,
"spoiler": int(options_source.get("spoiler", 0)),
"race": race
}
if race:
@@ -68,42 +70,37 @@ def generate(race=False):
flash(options)
else:
meta = get_meta(request.form, race)
return start_generation(options, meta)
results, gen_options = roll_options(options, set(meta["plando_options"]))
if any(type(result) == str for result in results.values()):
return render_template("checkResult.html", results=results)
elif len(gen_options) > app.config["MAX_ROLL"]:
flash(f"Sorry, generating of multiworlds is limited to {app.config['MAX_ROLL']} players. "
f"If you have a larger group, please generate it yourself and upload it.")
elif len(gen_options) >= app.config["JOB_THRESHOLD"]:
gen = Generation(
options=pickle.dumps({name: vars(options) for name, options in gen_options.items()}),
# convert to json compatible
meta=json.dumps(meta),
state=STATE_QUEUED,
owner=session["_id"])
commit()
return redirect(url_for("wait_seed", seed=gen.id))
else:
try:
seed_id = gen_game({name: vars(options) for name, options in gen_options.items()},
meta=meta, owner=session["_id"].int)
except BaseException as e:
from .autolauncher import handle_generation_failure
handle_generation_failure(e)
return render_template("seedError.html", seed_error=(e.__class__.__name__ + ": " + str(e)))
return redirect(url_for("view_seed", seed=seed_id))
return render_template("generate.html", race=race, version=__version__)
def start_generation(options: Dict[str, Union[dict, str]], meta: Dict[str, Any]):
results, gen_options = roll_options(options, set(meta["plando_options"]))
if any(type(result) == str for result in results.values()):
return render_template("checkResult.html", results=results)
elif len(gen_options) > app.config["MAX_ROLL"]:
flash(f"Sorry, generating of multiworlds is limited to {app.config['MAX_ROLL']} players. "
f"If you have a larger group, please generate it yourself and upload it.")
return redirect(url_for(request.endpoint, **(request.view_args or {})))
elif len(gen_options) >= app.config["JOB_THRESHOLD"]:
gen = Generation(
options=pickle.dumps({name: vars(options) for name, options in gen_options.items()}),
# convert to json compatible
meta=json.dumps(meta),
state=STATE_QUEUED,
owner=session["_id"])
commit()
return redirect(url_for("wait_seed", seed=gen.id))
else:
try:
seed_id = gen_game({name: vars(options) for name, options in gen_options.items()},
meta=meta, owner=session["_id"].int)
except BaseException as e:
from .autolauncher import handle_generation_failure
handle_generation_failure(e)
return render_template("seedError.html", seed_error=(e.__class__.__name__ + ": " + str(e)))
return redirect(url_for("view_seed", seed=seed_id))
def gen_game(gen_options: dict, meta: Optional[Dict[str, Any]] = None, owner=None, sid=None):
if not meta:
meta: Dict[str, Any] = {}
@@ -134,8 +131,6 @@ def gen_game(gen_options: dict, meta: Optional[Dict[str, Any]] = None, owner=Non
erargs.plando_options = PlandoOptions.from_set(meta.setdefault("plando_options",
{"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):

View File

@@ -1,11 +1,10 @@
import datetime
import os
from typing import Any, IO, Dict, Iterator, List, Tuple, Union
from typing import List, Dict, Union
import jinja2.exceptions
from flask import request, redirect, url_for, render_template, Response, session, abort, send_from_directory
from pony.orm import count, commit, db_session
from werkzeug.utils import secure_filename
from worlds.AutoWorld import AutoWorldRegister
from . import app, cache
@@ -38,6 +37,19 @@ def start_playing():
return render_template(f"startPlaying.html")
@app.route('/weighted-settings')
@cache.cached()
def weighted_settings():
return render_template(f"weighted-settings.html")
# Player settings pages
@app.route('/games/<string:game>/player-settings')
@cache.cached()
def player_settings(game):
return render_template(f"player-settings.html", game=game, theme=get_world_theme(game))
# Game Info Pages
@app.route('/games/<string:game>/info/<string:lang>')
@cache.cached()
@@ -70,40 +82,14 @@ def tutorial_landing():
@app.route('/faq/<string:lang>/')
@cache.cached()
def faq(lang: str):
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=markdown.markdown(
document,
extensions=["toc", "mdx_breakless_lists"],
extension_configs={
"toc": {"anchorlink": True}
}
),
)
def faq(lang):
return render_template("faq.html", lang=lang)
@app.route('/glossary/<string:lang>/')
@cache.cached()
def glossary(lang: str):
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=markdown.markdown(
document,
extensions=["toc", "mdx_breakless_lists"],
extension_configs={
"toc": {"anchorlink": True}
}
),
)
def terms(lang):
return render_template("glossary.html", lang=lang)
@app.route('/seed/<suuid:seed>')
@@ -124,91 +110,48 @@ def new_room(seed: UUID):
return redirect(url_for("host_room", room=room.id))
def _read_log(log: IO[Any], offset: int = 0) -> Iterator[bytes]:
marker = log.read(3) # skip optional BOM
if marker != b'\xEF\xBB\xBF':
log.seek(0, os.SEEK_SET)
log.seek(offset, os.SEEK_CUR)
yield from log
log.close() # free file handle as soon as possible
def _read_log(path: str):
if os.path.exists(path):
with open(path, encoding="utf-8-sig") as log:
yield from log
else:
yield f"Logfile {path} does not exist. " \
f"Likely a crash during spinup of multiworld instance or it is still spinning up."
@app.route('/log/<suuid:room>')
def display_log(room: UUID) -> Union[str, Response, Tuple[str, int]]:
def display_log(room: UUID):
room = Room.get(id=room)
if room is None:
return abort(404)
if room.owner == session["_id"]:
file_path = os.path.join("logs", str(room.id) + ".txt")
try:
log = open(file_path, "rb")
range_header = request.headers.get("Range")
if range_header:
range_type, range_values = range_header.split('=')
start, end = map(str.strip, range_values.split('-', 1))
if range_type != "bytes" or end != "":
return "Unsupported range", 500
# NOTE: we skip Content-Range in the response here, which isn't great but works for our JS
return Response(_read_log(log, int(start)), mimetype="text/plain", status=206)
return Response(_read_log(log), mimetype="text/plain")
except FileNotFoundError:
return Response(f"Logfile {file_path} does not exist. "
f"Likely a crash during spinup of multiworld instance or it is still spinning up.",
mimetype="text/plain")
if os.path.exists(file_path):
return Response(_read_log(file_path), mimetype="text/plain;charset=UTF-8")
return "Log File does not exist."
return "Access Denied", 403
@app.post("/room/<suuid:room>")
def host_room_command(room: UUID):
room: Room = Room.get(id=room)
if room is None:
return abort(404)
if room.owner == session["_id"]:
cmd = request.form["cmd"]
if cmd:
Command(room=room, commandtext=cmd)
commit()
return redirect(url_for("host_room", room=room.id))
@app.get("/room/<suuid:room>")
@app.route('/room/<suuid:room>', methods=['GET', 'POST'])
def host_room(room: UUID):
room: Room = Room.get(id=room)
if room is None:
return abort(404)
if request.method == "POST":
if room.owner == session["_id"]:
cmd = request.form["cmd"]
if cmd:
Command(room=room, commandtext=cmd)
commit()
now = datetime.datetime.utcnow()
# indicate that the page should reload to get the assigned port
should_refresh = ((not room.last_port and now - room.creation_time < datetime.timedelta(seconds=3))
or room.last_activity < now - datetime.timedelta(seconds=room.timeout))
should_refresh = not room.last_port and now - room.creation_time < datetime.timedelta(seconds=3)
with db_session:
room.last_activity = now # will trigger a spinup, if it's not already running
browser_tokens = "Mozilla", "Chrome", "Safari"
automated = ("update" in request.args
or "Discordbot" in request.user_agent.string
or not any(browser_token in request.user_agent.string for browser_token in browser_tokens))
def get_log(max_size: int = 0 if automated else 1024000) -> str:
if max_size == 0:
return ""
try:
with open(os.path.join("logs", str(room.id) + ".txt"), "rb") as log:
raw_size = 0
fragments: List[str] = []
for block in _read_log(log):
if raw_size + len(block) > max_size:
fragments.append("")
break
raw_size += len(block)
fragments.append(block.decode("utf-8"))
return "".join(fragments)
except FileNotFoundError:
return ""
return render_template("hostRoom.html", room=room, should_refresh=should_refresh, get_log=get_log)
return render_template("hostRoom.html", room=room, should_refresh=should_refresh)
@app.route('/favicon.ico')
@@ -238,6 +181,6 @@ def get_sitemap():
available_games: List[Dict[str, Union[str, bool]]] = []
for game, world in AutoWorldRegister.world_types.items():
if not world.hidden:
has_settings: bool = isinstance(world.web.options_page, bool) and world.web.options_page
has_settings: bool = isinstance(world.web.settings_page, bool) and world.web.settings_page
available_games.append({ 'title': game, 'has_settings': has_settings })
return render_template("siteMap.html", games=available_games)

View File

@@ -1,281 +1,148 @@
import collections.abc
import json
import logging
import os
from textwrap import dedent
from typing import Dict, Union
from docutils.core import publish_parts
import typing
import yaml
from flask import redirect, render_template, request, Response
from jinja2 import Template
import Options
from Utils import local_path
from Utils import __version__, local_path
from worlds.AutoWorld import AutoWorldRegister
from . import app, cache
from .generate import get_meta
handled_in_js = {"start_inventory", "local_items", "non_local_items", "start_hints", "start_location_hints",
"exclude_locations", "priority_locations"}
def create() -> None:
def create():
target_folder = local_path("WebHostLib", "static", "generated")
yaml_folder = os.path.join(target_folder, "configs")
Options.generate_yaml_templates(yaml_folder)
def get_html_doc(option_type: type(Options.Option)) -> str:
if not option_type.__doc__:
return "Please document me!"
return "\n".join(line.strip() for line in option_type.__doc__.split("\n")).strip()
def get_world_theme(game_name: str) -> str:
if game_name in AutoWorldRegister.world_types:
return AutoWorldRegister.world_types[game_name].web.theme
return 'grass'
weighted_settings = {
"baseOptions": {
"description": "Generated by https://archipelago.gg/",
"name": "Player",
"game": {},
},
"games": {},
}
for game_name, world in AutoWorldRegister.world_types.items():
def render_options_page(template: str, world_name: str, is_complex: bool = False) -> Union[Response, str]:
world = AutoWorldRegister.world_types[world_name]
if world.hidden or world.web.options_page is False:
return redirect("games")
visibility_flag = Options.Visibility.complex_ui if is_complex else Options.Visibility.simple_ui
start_collapsed = {"Game Options": False}
for group in world.web.option_groups:
start_collapsed[group.name] = group.start_collapsed
return render_template(
template,
world_name=world_name,
world=world,
option_groups=Options.get_option_groups(world, visibility_level=visibility_flag),
start_collapsed=start_collapsed,
issubclass=issubclass,
Options=Options,
theme=get_world_theme(world_name),
)
def generate_game(options: Dict[str, Union[dict, str]]) -> Union[Response, str]:
from .generate import start_generation
return start_generation(options, get_meta({}))
def send_yaml(player_name: str, formatted_options: dict) -> Response:
response = Response(yaml.dump(formatted_options, sort_keys=False))
response.headers["Content-Type"] = "text/yaml"
response.headers["Content-Disposition"] = f"attachment; filename={player_name}.yaml"
return response
@app.template_filter("dedent")
def filter_dedent(text: str) -> str:
return dedent(text).strip("\n ")
@app.template_filter("rst_to_html")
def filter_rst_to_html(text: str) -> str:
"""Converts reStructuredText (such as a Python docstring) to HTML."""
if text.startswith(" ") or text.startswith("\t"):
text = dedent(text)
elif "\n" in text:
lines = text.splitlines()
text = lines[0] + "\n" + dedent("\n".join(lines[1:]))
return publish_parts(text, writer_name='html', settings=None, settings_overrides={
'raw_enable': False,
'file_insertion_enabled': False,
'output_encoding': 'unicode'
})['body']
@app.template_test("ordered")
def test_ordered(obj):
return isinstance(obj, collections.abc.Sequence)
@app.route("/games/<string:game>/option-presets", methods=["GET"])
@cache.cached()
def option_presets(game: str) -> Response:
world = AutoWorldRegister.world_types[game]
presets = {}
for preset_name, preset in world.web.options_presets.items():
presets[preset_name] = {}
for preset_option_name, preset_option in preset.items():
if preset_option == "random":
presets[preset_name][preset_option_name] = preset_option
continue
option = world.options_dataclass.type_hints[preset_option_name].from_any(preset_option)
if isinstance(option, Options.NamedRange) and isinstance(preset_option, str):
assert preset_option in option.special_range_names, \
f"Invalid preset value '{preset_option}' for '{preset_option_name}' in '{preset_name}'. " \
f"Expected {option.special_range_names.keys()} or {option.range_start}-{option.range_end}."
presets[preset_name][preset_option_name] = option.value
elif isinstance(option, (Options.Range, Options.OptionSet, Options.OptionList, Options.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
assert option.name_lookup[option.value] == preset_option, \
f"Invalid option value '{preset_option}' for '{preset_option_name}' in preset '{preset_name}'. " \
f"Values must not be resolved to a different option via option.from_text (or an alias)."
# Use the name of the option
presets[preset_name][preset_option_name] = option.current_key
else:
# Use the name of the option
presets[preset_name][preset_option_name] = option.current_key
class SetEncoder(json.JSONEncoder):
def default(self, obj):
from collections.abc import Set
if isinstance(obj, Set):
return list(obj)
return json.JSONEncoder.default(self, obj)
json_data = json.dumps(presets, cls=SetEncoder)
response = Response(json_data)
response.headers["Content-Type"] = "application/json"
return response
@app.route("/weighted-options")
def weighted_options_old():
return redirect("games", 301)
@app.route("/games/<string:game>/weighted-options")
@cache.cached()
def weighted_options(game: str):
return render_options_page("weightedOptions/weightedOptions.html", game, is_complex=True)
@app.route("/games/<string:game>/generate-weighted-yaml", methods=["POST"])
def generate_weighted_yaml(game: str):
if request.method == "POST":
intent_generate = False
options = {}
for key, val in request.form.items():
if "||" not in key:
if len(str(val)) == 0:
continue
options[key] = val
else:
if int(val) == 0:
continue
[option, setting] = key.split("||")
options.setdefault(option, {})[setting] = int(val)
# Error checking
if "name" not in options:
return "Player name is required."
# Remove POST data irrelevant to YAML
if "intent-generate" in options:
intent_generate = True
del options["intent-generate"]
if "intent-export" in options:
del options["intent-export"]
# Properly format YAML output
player_name = options["name"]
del options["name"]
formatted_options = {
"name": player_name,
"game": game,
"description": f"Generated by https://archipelago.gg/ for {game}",
game: options,
all_options: typing.Dict[str, Options.AssembleOptions] = {
**Options.per_game_common_options,
**world.option_definitions
}
if intent_generate:
return generate_game({player_name: formatted_options})
else:
return send_yaml(player_name, formatted_options)
# Player options pages
@app.route("/games/<string:game>/player-options")
@cache.cached()
def player_options(game: str):
return render_options_page("playerOptions/playerOptions.html", game, is_complex=False)
# YAML generator for player-options
@app.route("/games/<string:game>/generate-yaml", methods=["POST"])
def generate_yaml(game: str):
if request.method == "POST":
options = {}
intent_generate = False
for key, val in request.form.items(multi=True):
if key in options:
if not isinstance(options[key], list):
options[key] = [options[key]]
options[key].append(val)
else:
options[key] = val
for key, val in options.copy().items():
key_parts = key.rsplit("||", 2)
# 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 != "0":
options[key_parts[0]][key_parts[1]] = int(val)
del options[key]
# Detect keys which end with -custom, indicating a TextChoice with a possible custom value
elif key_parts[-1].endswith("-custom"):
if val:
options[key_parts[-1][:-7]] = val
del options[key]
# Detect keys which end with -range, indicating a NamedRange with a possible custom value
elif key_parts[-1].endswith("-range"):
if options[key_parts[-1][:-6]] == "custom":
options[key_parts[-1][:-6]] = val
del options[key]
# Detect random-* keys and set their options accordingly
for key, val in options.copy().items():
if key.startswith("random-"):
options[key.removeprefix("random-")] = "random"
del options[key]
# Error checking
if not options["name"]:
return "Player name is required."
# Remove POST data irrelevant to YAML
preset_name = 'default'
if "intent-generate" in options:
intent_generate = True
del options["intent-generate"]
if "intent-export" in options:
del options["intent-export"]
if "game-options-preset" in options:
preset_name = options["game-options-preset"]
del options["game-options-preset"]
# Properly format YAML output
player_name = options["name"]
del options["name"]
description = f"Generated by https://archipelago.gg/ for {game}"
if preset_name != 'default' and preset_name != 'custom':
description += f" using {preset_name} preset"
formatted_options = {
"name": player_name,
"game": game,
"description": description,
game: options,
# Generate JSON files for player-settings pages
player_settings = {
"baseOptions": {
"description": f"Generated by https://archipelago.gg/ for {game_name}",
"game": game_name,
"name": "Player",
},
}
if intent_generate:
return generate_game({player_name: formatted_options})
game_options = {}
for option_name, option in all_options.items():
if option_name in handled_in_js:
pass
else:
return send_yaml(player_name, formatted_options)
elif issubclass(option, Options.Choice) or issubclass(option, Options.Toggle):
game_options[option_name] = this_option = {
"type": "select",
"displayName": option.display_name if hasattr(option, "display_name") else option_name,
"description": get_html_doc(option),
"defaultValue": None,
"options": []
}
for sub_option_id, sub_option_name in option.name_lookup.items():
if sub_option_name != "random":
this_option["options"].append({
"name": option.get_option_name(sub_option_id),
"value": sub_option_name,
})
if sub_option_id == option.default:
this_option["defaultValue"] = sub_option_name
if not this_option["defaultValue"]:
this_option["defaultValue"] = "random"
elif issubclass(option, Options.Range):
game_options[option_name] = {
"type": "range",
"displayName": option.display_name if hasattr(option, "display_name") else option_name,
"description": get_html_doc(option),
"defaultValue": option.default if hasattr(
option, "default") and option.default != "random" else option.range_start,
"min": option.range_start,
"max": option.range_end,
}
if issubclass(option, Options.SpecialRange):
game_options[option_name]["type"] = 'special_range'
game_options[option_name]["value_names"] = {}
for key, val in option.special_range_names.items():
game_options[option_name]["value_names"][key] = val
elif issubclass(option, Options.ItemSet):
game_options[option_name] = {
"type": "items-list",
"displayName": option.display_name if hasattr(option, "display_name") else option_name,
"description": get_html_doc(option),
"defaultValue": list(option.default)
}
elif issubclass(option, Options.LocationSet):
game_options[option_name] = {
"type": "locations-list",
"displayName": option.display_name if hasattr(option, "display_name") else option_name,
"description": get_html_doc(option),
"defaultValue": list(option.default)
}
elif issubclass(option, Options.VerifyKeys) and not issubclass(option, Options.OptionDict):
if option.valid_keys:
game_options[option_name] = {
"type": "custom-list",
"displayName": option.display_name if hasattr(option, "display_name") else option_name,
"description": get_html_doc(option),
"options": list(option.valid_keys),
"defaultValue": list(option.default) if hasattr(option, "default") else []
}
else:
logging.debug(f"{option} not exported to Web Settings.")
player_settings["gameOptions"] = game_options
os.makedirs(os.path.join(target_folder, 'player-settings'), exist_ok=True)
with open(os.path.join(target_folder, 'player-settings', game_name + ".json"), "w") as f:
json.dump(player_settings, f, indent=2, separators=(',', ': '))
if not world.hidden and world.web.settings_page is True:
# Add the random option to Choice, TextChoice, and Toggle settings
for option in game_options.values():
if option["type"] == "select":
option["options"].append({"name": "Random", "value": "random"})
if not option["defaultValue"]:
option["defaultValue"] = "random"
weighted_settings["baseOptions"]["game"][game_name] = 0
weighted_settings["games"][game_name] = {}
weighted_settings["games"][game_name]["gameSettings"] = game_options
weighted_settings["games"][game_name]["gameItems"] = tuple(world.item_names)
weighted_settings["games"][game_name]["gameLocations"] = tuple(world.location_names)
with open(os.path.join(target_folder, 'weighted-settings.json'), "w") as f:
json.dump(weighted_settings, f, indent=2, separators=(',', ': '))

View File

@@ -1,13 +1,9 @@
flask>=3.0.3
werkzeug>=3.0.6
pony>=0.7.19
waitress>=3.0.0
Flask-Caching>=2.3.0
Flask-Compress>=1.15
Flask-Limiter>=3.8.0
flask>=2.2.3
pony>=0.7.17
waitress>=2.1.2
Flask-Caching>=2.0.2
Flask-Compress>=1.14
Flask-Limiter>=3.5.0
bokeh>=3.1.1; python_version <= '3.8'
bokeh>=3.4.3; python_version == '3.9'
bokeh>=3.5.2; python_version >= '3.10'
markupsafe>=2.1.5
Markdown>=3.7
mdx-breakless-lists>=1.0.1
bokeh>=3.2.2; python_version >= '3.9'
markupsafe>=2.1.3

View File

@@ -1,15 +0,0 @@
from WebHostLib import app
from flask import abort
from . import cache
@cache.cached()
@app.route('/robots.txt')
def robots():
# If this host is not official, do not allow search engine crawling
if not app.config["ASSET_RIGHTS"]:
# filename changed in case the path is intercepted and served by an outside service
return app.send_static_file('robots_file.txt')
# Send 404 if the host has affirmed this to be the official WebHost
abort(404)

View File

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

View File

@@ -1,80 +0,0 @@
# Frequently Asked Questions
## What is a randomizer?
A randomizer is a modification of a game which reorganizes the items required to progress through that game. A
normal play-through might require you to use item A to unlock item B, then C, and so forth. In a randomized
game, you might first find item C, then A, then B.
This transforms the game from a linear experience into a puzzle, presenting players with a new challenge each time they
play. Putting items in non-standard locations can require the player to think about the game world and the items they
encounter in new and interesting ways.
## What is a multiworld?
While a randomizer shuffles a game, a multiworld randomizer shuffles that game for multiple players. For example, in a
two player multiworld, players A and B each get their own randomized version of a game, called a world. In each
player's game, they may find items which belong to the other player. If player A finds an item which belongs to
player B, the item will be sent to player B's world over the internet. This creates a cooperative experience, requiring
players to rely upon each other to complete their game.
## What does multi-game mean?
While a multiworld game traditionally requires all players to be playing the same game, a multi-game multiworld allows
players to randomize any of the supported games, and send items between them. This allows players of different
games to interact with one another in a single multiplayer environment. Archipelago supports multi-game multiworld.
Here is a list of our [Supported Games](https://archipelago.gg/games).
## Can I generate a single-player game with Archipelago?
Yes. All of our supported games can be generated as single-player experiences both on the website and by installing
the Archipelago generator software. The fastest way to do this is on the website. Find the Supported Game you wish to
play, open the Settings Page, pick your settings, and click Generate Game.
## How do I get started?
We have a [Getting Started](https://archipelago.gg/tutorial/Archipelago/setup/en) guide that will help you get the
software set up. You can use that guide to learn how to generate multiworlds. There are also basic instructions for
including multiple games, and hosting multiworlds on the website for ease and convenience.
If you are ready to start randomizing games, or want to start playing your favorite randomizer with others, please join
our discord server at the [Archipelago Discord](https://discord.gg/8Z65BR2). There are always people ready to answer
any questions you might have.
## What are some common terms I should know?
As randomizers and multiworld randomizers have been around for a while now, there are quite a few common terms used
by the communities surrounding them. A list of Archipelago jargon and terms commonly used by the community can be
found in the [Glossary](/glossary/en).
## Does everyone need to be connected at the same time?
There are two different play-styles that are common for Archipelago multiworld sessions. These sessions can either
be considered synchronous (or "sync"), where everyone connects and plays at the same time, or asynchronous (or "async"),
where players connect and play at their own pace. The setup for both is identical. The difference in play-style is how
you and your friends choose to organize and play your multiworld. Most groups decide on the format before creating
their multiworld.
If a player must leave early, they can use Archipelago's release system. When a player releases their game, all items
in that game belonging to other players are sent out automatically. This allows other players to continue to play
uninterrupted. Here is a list of all of our [Server Commands](https://archipelago.gg/tutorial/Archipelago/commands/en).
## What happens if an item is placed somewhere it is impossible to get?
The randomizer has many strict sets of rules it must follow when generating a game. One of the functions of these rules
is to ensure items necessary to complete the game will be accessible to the player. Many games also have a subset of
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 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).
There, you will find examples of games in the `worlds` folder:
[/worlds Folder in Archipelago Code](https://github.com/ArchipelagoMW/Archipelago/tree/main/worlds).
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, feel free to ask in the **#ap-world-dev** channel on our Discord.

View File

@@ -0,0 +1,69 @@
# Frequently Asked Questions
## What is a randomizer?
A randomizer is a modification of a video game which reorganizes the items required to progress through the game. A
normal play-through of a game might require you to use item A to unlock item B, then C, and so forth. In a randomized
game, you might first find item C, then A, then B.
This transforms games from a linear experience into a puzzle, presenting players with a new challenge each time they
play a randomized game. Putting items in non-standard locations can require the player to think about the game world and
the items they encounter in new and interesting ways.
## What happens if an item is placed somewhere it is impossible to get?
The randomizer has many strict sets of rules it must follow when generating a game. One of the functions of these rules
is to ensure items necessary to complete the game will be accessible to the player. Many games also have a subset of
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.
## What is a multi-world?
While a randomizer shuffles a game, a multi-world randomizer shuffles that game for multiple players. For example, in a
two player multi-world, players A and B each get their own randomized version of a game, called a world. In each player's
game, they may find items which belong to the other player. If player A finds an item which belongs to player B, the
item will be sent to player B's world over the internet.
This creates a cooperative experience during multi-world games, requiring players to rely upon each other to complete
their game.
## What happens if a person has to leave early?
If a player must leave early, they can use Archipelago's release system. When a player releases their game, all the
items in that game which belong to other players are sent out automatically, so other players can continue to play.
## What does multi-game mean?
While a multi-world game traditionally requires all players to be playing the same game, a multi-game multi-world allows
players to randomize any of a number of supported games, and send items between them. This allows players of different
games to interact with one another in a single multiplayer environment.
## Can I generate a single-player game with Archipelago?
Yes. All our supported games can be generated as single-player experiences, and so long as you download the software,
the website is not required to generate them.
## How do I get started?
If you are ready to start randomizing games, or want to start playing your favorite randomizer with others, please join
our discord server at the [Archipelago Discord](https://discord.gg/8Z65BR2). There are always people ready to answer
any questions you might have.
## What are some common terms I should know?
As randomizers and multiworld randomizers have been around for a while now there are quite a lot of common terms
and jargon that is used in conjunction by the communities surrounding them. For a lot of the terms that are more common
to Archipelago and its specific systems please see the [Glossary](/glossary/en).
## I want to add a game to the Archipelago randomizer. How do I do that?
The best way to get started is to take a look at our code on GitHub
at [Archipelago GitHub Page](https://github.com/ArchipelagoMW/Archipelago).
There you will find examples of games in the worlds folder
at [/worlds Folder in Archipelago Code](https://github.com/ArchipelagoMW/Archipelago/tree/main/worlds).
You may also find developer documentation in the docs folder
at [/docs Folder in Archipelago Code](https://github.com/ArchipelagoMW/Archipelago/tree/main/docs).
If you have more questions, feel free to ask in the **#archipelago-dev** channel on our Discord.

View File

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

View File

@@ -0,0 +1,20 @@
window.addEventListener('load', () => {
const url = window.location;
setInterval(() => {
const ajax = new XMLHttpRequest();
ajax.onreadystatechange = () => {
if (ajax.readyState !== 4) { return; }
// Create a fake DOM using the returned HTML
const domParser = new DOMParser();
const fakeDOM = domParser.parseFromString(ajax.responseText, 'text/html');
// Update item and location trackers
document.getElementById('inventory-table').innerHTML = fakeDOM.getElementById('inventory-table').innerHTML;
document.getElementById('location-table').innerHTML = fakeDOM.getElementById('location-table').innerHTML;
};
ajax.open('GET', url);
ajax.send();
}, 15000)
});

View File

@@ -0,0 +1,398 @@
let gameName = null;
window.addEventListener('load', () => {
gameName = document.getElementById('player-settings').getAttribute('data-game');
// Update game name on page
document.getElementById('game-name').innerText = gameName;
fetchSettingData().then((results) => {
let settingHash = localStorage.getItem(`${gameName}-hash`);
if (!settingHash) {
// If no hash data has been set before, set it now
settingHash = md5(JSON.stringify(results));
localStorage.setItem(`${gameName}-hash`, settingHash);
localStorage.removeItem(gameName);
}
if (settingHash !== md5(JSON.stringify(results))) {
showUserMessage("Your settings are out of date! Click here to update them! Be aware this will reset " +
"them all to default.");
document.getElementById('user-message').addEventListener('click', resetSettings);
}
// Page setup
createDefaultSettings(results);
buildUI(results);
adjustHeaderWidth();
// Event listeners
document.getElementById('export-settings').addEventListener('click', () => exportSettings());
document.getElementById('generate-race').addEventListener('click', () => generateGame(true));
document.getElementById('generate-game').addEventListener('click', () => generateGame());
// Name input field
const playerSettings = JSON.parse(localStorage.getItem(gameName));
const nameInput = document.getElementById('player-name');
nameInput.addEventListener('keyup', (event) => updateBaseSetting(event));
nameInput.value = playerSettings.name;
}).catch((e) => {
console.error(e);
const url = new URL(window.location.href);
window.location.replace(`${url.protocol}//${url.hostname}/page-not-found`);
})
});
const resetSettings = () => {
localStorage.removeItem(gameName);
localStorage.removeItem(`${gameName}-hash`)
window.location.reload();
};
const fetchSettingData = () => new Promise((resolve, reject) => {
const ajax = new XMLHttpRequest();
ajax.onreadystatechange = () => {
if (ajax.readyState !== 4) { return; }
if (ajax.status !== 200) {
reject(ajax.responseText);
return;
}
try{ resolve(JSON.parse(ajax.responseText)); }
catch(error){ reject(error); }
};
ajax.open('GET', `${window.location.origin}/static/generated/player-settings/${gameName}.json`, true);
ajax.send();
});
const createDefaultSettings = (settingData) => {
if (!localStorage.getItem(gameName)) {
const newSettings = {
[gameName]: {},
};
for (let baseOption of Object.keys(settingData.baseOptions)){
newSettings[baseOption] = settingData.baseOptions[baseOption];
}
for (let gameOption of Object.keys(settingData.gameOptions)){
newSettings[gameName][gameOption] = settingData.gameOptions[gameOption].defaultValue;
}
localStorage.setItem(gameName, JSON.stringify(newSettings));
}
};
const buildUI = (settingData) => {
// Game Options
const leftGameOpts = {};
const rightGameOpts = {};
Object.keys(settingData.gameOptions).forEach((key, index) => {
if (index < Object.keys(settingData.gameOptions).length / 2) { leftGameOpts[key] = settingData.gameOptions[key]; }
else { rightGameOpts[key] = settingData.gameOptions[key]; }
});
document.getElementById('game-options-left').appendChild(buildOptionsTable(leftGameOpts));
document.getElementById('game-options-right').appendChild(buildOptionsTable(rightGameOpts));
};
const buildOptionsTable = (settings, romOpts = false) => {
const currentSettings = JSON.parse(localStorage.getItem(gameName));
const table = document.createElement('table');
const tbody = document.createElement('tbody');
Object.keys(settings).forEach((setting) => {
const tr = document.createElement('tr');
// td Left
const tdl = document.createElement('td');
const label = document.createElement('label');
label.textContent = `${settings[setting].displayName}: `;
label.setAttribute('for', setting);
const questionSpan = document.createElement('span');
questionSpan.classList.add('interactive');
questionSpan.setAttribute('data-tooltip', settings[setting].description);
questionSpan.innerText = '(?)';
label.appendChild(questionSpan);
tdl.appendChild(label);
tr.appendChild(tdl);
// td Right
const tdr = document.createElement('td');
let element = null;
const randomButton = document.createElement('button');
switch(settings[setting].type){
case 'select':
element = document.createElement('div');
element.classList.add('select-container');
let select = document.createElement('select');
select.setAttribute('id', setting);
select.setAttribute('data-key', setting);
if (romOpts) { select.setAttribute('data-romOpt', '1'); }
settings[setting].options.forEach((opt) => {
const option = document.createElement('option');
option.setAttribute('value', opt.value);
option.innerText = opt.name;
if ((isNaN(currentSettings[gameName][setting]) &&
(parseInt(opt.value, 10) === parseInt(currentSettings[gameName][setting]))) ||
(opt.value === currentSettings[gameName][setting]))
{
option.selected = true;
}
select.appendChild(option);
});
select.addEventListener('change', (event) => updateGameSetting(event.target));
element.appendChild(select);
// Randomize button
randomButton.innerText = '🎲';
randomButton.classList.add('randomize-button');
randomButton.setAttribute('data-key', setting);
randomButton.setAttribute('data-tooltip', 'Toggle randomization for this option!');
randomButton.addEventListener('click', (event) => toggleRandomize(event, select));
if (currentSettings[gameName][setting] === 'random') {
randomButton.classList.add('active');
select.disabled = true;
}
element.appendChild(randomButton);
break;
case 'range':
element = document.createElement('div');
element.classList.add('range-container');
let range = document.createElement('input');
range.setAttribute('type', 'range');
range.setAttribute('data-key', setting);
range.setAttribute('min', settings[setting].min);
range.setAttribute('max', settings[setting].max);
range.value = currentSettings[gameName][setting];
range.addEventListener('change', (event) => {
document.getElementById(`${setting}-value`).innerText = event.target.value;
updateGameSetting(event.target);
});
element.appendChild(range);
let rangeVal = document.createElement('span');
rangeVal.classList.add('range-value');
rangeVal.setAttribute('id', `${setting}-value`);
rangeVal.innerText = currentSettings[gameName][setting] !== 'random' ?
currentSettings[gameName][setting] : settings[setting].defaultValue;
element.appendChild(rangeVal);
// Randomize button
randomButton.innerText = '🎲';
randomButton.classList.add('randomize-button');
randomButton.setAttribute('data-key', setting);
randomButton.setAttribute('data-tooltip', 'Toggle randomization for this option!');
randomButton.addEventListener('click', (event) => toggleRandomize(event, range));
if (currentSettings[gameName][setting] === 'random') {
randomButton.classList.add('active');
range.disabled = true;
}
element.appendChild(randomButton);
break;
case 'special_range':
element = document.createElement('div');
element.classList.add('special-range-container');
// Build the select element
let specialRangeSelect = document.createElement('select');
specialRangeSelect.setAttribute('data-key', setting);
Object.keys(settings[setting].value_names).forEach((presetName) => {
let presetOption = document.createElement('option');
presetOption.innerText = presetName;
presetOption.value = settings[setting].value_names[presetName];
const words = presetOption.innerText.split("_");
for (let i = 0; i < words.length; i++) {
words[i] = words[i][0].toUpperCase() + words[i].substring(1);
}
presetOption.innerText = words.join(" ");
specialRangeSelect.appendChild(presetOption);
});
let customOption = document.createElement('option');
customOption.innerText = 'Custom';
customOption.value = 'custom';
customOption.selected = true;
specialRangeSelect.appendChild(customOption);
if (Object.values(settings[setting].value_names).includes(Number(currentSettings[gameName][setting]))) {
specialRangeSelect.value = Number(currentSettings[gameName][setting]);
}
// Build range element
let specialRangeWrapper = document.createElement('div');
specialRangeWrapper.classList.add('special-range-wrapper');
let specialRange = document.createElement('input');
specialRange.setAttribute('type', 'range');
specialRange.setAttribute('data-key', setting);
specialRange.setAttribute('min', settings[setting].min);
specialRange.setAttribute('max', settings[setting].max);
specialRange.value = currentSettings[gameName][setting];
// Build rage value element
let specialRangeVal = document.createElement('span');
specialRangeVal.classList.add('range-value');
specialRangeVal.setAttribute('id', `${setting}-value`);
specialRangeVal.innerText = currentSettings[gameName][setting] !== 'random' ?
currentSettings[gameName][setting] : settings[setting].defaultValue;
// Configure select event listener
specialRangeSelect.addEventListener('change', (event) => {
if (event.target.value === 'custom') { return; }
// Update range slider
specialRange.value = event.target.value;
document.getElementById(`${setting}-value`).innerText = event.target.value;
updateGameSetting(event.target);
});
// Configure range event handler
specialRange.addEventListener('change', (event) => {
// Update select element
specialRangeSelect.value =
(Object.values(settings[setting].value_names).includes(parseInt(event.target.value))) ?
parseInt(event.target.value) : 'custom';
document.getElementById(`${setting}-value`).innerText = event.target.value;
updateGameSetting(event.target);
});
element.appendChild(specialRangeSelect);
specialRangeWrapper.appendChild(specialRange);
specialRangeWrapper.appendChild(specialRangeVal);
element.appendChild(specialRangeWrapper);
// Randomize button
randomButton.innerText = '🎲';
randomButton.classList.add('randomize-button');
randomButton.setAttribute('data-key', setting);
randomButton.setAttribute('data-tooltip', 'Toggle randomization for this option!');
randomButton.addEventListener('click', (event) => toggleRandomize(
event, specialRange, specialRangeSelect)
);
if (currentSettings[gameName][setting] === 'random') {
randomButton.classList.add('active');
specialRange.disabled = true;
specialRangeSelect.disabled = true;
}
specialRangeWrapper.appendChild(randomButton);
break;
default:
console.error(`Ignoring unknown setting type: ${settings[setting].type} with name ${setting}`);
return;
}
tdr.appendChild(element);
tr.appendChild(tdr);
tbody.appendChild(tr);
});
table.appendChild(tbody);
return table;
};
const toggleRandomize = (event, inputElement, optionalSelectElement = null) => {
const active = event.target.classList.contains('active');
const randomButton = event.target;
if (active) {
randomButton.classList.remove('active');
inputElement.disabled = undefined;
if (optionalSelectElement) {
optionalSelectElement.disabled = undefined;
}
} else {
randomButton.classList.add('active');
inputElement.disabled = true;
if (optionalSelectElement) {
optionalSelectElement.disabled = true;
}
}
updateGameSetting(randomButton);
};
const updateBaseSetting = (event) => {
const options = JSON.parse(localStorage.getItem(gameName));
options[event.target.getAttribute('data-key')] = isNaN(event.target.value) ?
event.target.value : parseInt(event.target.value);
localStorage.setItem(gameName, JSON.stringify(options));
};
const updateGameSetting = (settingElement) => {
const options = JSON.parse(localStorage.getItem(gameName));
if (settingElement.classList.contains('randomize-button')) {
// If the event passed in is the randomize button, then we know what we must do.
options[gameName][settingElement.getAttribute('data-key')] = 'random';
} else {
options[gameName][settingElement.getAttribute('data-key')] = isNaN(settingElement.value) ?
settingElement.value : parseInt(settingElement.value, 10);
}
localStorage.setItem(gameName, JSON.stringify(options));
};
const exportSettings = () => {
const settings = JSON.parse(localStorage.getItem(gameName));
if (!settings.name || settings.name.toLowerCase() === 'player' || settings.name.trim().length === 0) {
return showUserMessage('You must enter a player name!');
}
const yamlText = jsyaml.safeDump(settings, { noCompatMode: true }).replaceAll(/'(\d+)':/g, (x, y) => `${y}:`);
download(`${document.getElementById('player-name').value}.yaml`, yamlText);
};
/** Create an anchor and trigger a download of a text file. */
const download = (filename, text) => {
const downloadLink = document.createElement('a');
downloadLink.setAttribute('href','data:text/yaml;charset=utf-8,'+ encodeURIComponent(text))
downloadLink.setAttribute('download', filename);
downloadLink.style.display = 'none';
document.body.appendChild(downloadLink);
downloadLink.click();
document.body.removeChild(downloadLink);
};
const generateGame = (raceMode = false) => {
const settings = JSON.parse(localStorage.getItem(gameName));
if (!settings.name || settings.name.toLowerCase() === 'player' || settings.name.trim().length === 0) {
return showUserMessage('You must enter a player name!');
}
axios.post('/api/generate', {
weights: { player: settings },
presetData: { player: settings },
playerCount: 1,
spoiler: 3,
race: raceMode ? '1' : '0',
}).then((response) => {
window.location.href = response.data.url;
}).catch((error) => {
let userMessage = 'Something went wrong and your game could not be generated.';
if (error.response.data.text) {
userMessage += ' ' + error.response.data.text;
}
showUserMessage(userMessage);
console.error(error);
});
};
const showUserMessage = (message) => {
const userMessage = document.getElementById('user-message');
userMessage.innerText = message;
userMessage.classList.add('visible');
window.scrollTo(0, 0);
userMessage.addEventListener('click', () => {
userMessage.classList.remove('visible');
userMessage.addEventListener('click', hideUserMessage);
});
};
const hideUserMessage = () => {
const userMessage = document.getElementById('user-message');
userMessage.classList.remove('visible');
userMessage.removeEventListener('click', hideUserMessage);
};

View File

@@ -1,340 +0,0 @@
let presets = {};
window.addEventListener('load', async () => {
// Load settings from localStorage, if available
loadSettings();
// Fetch presets if available
await fetchPresets();
// Handle changes to range inputs
document.querySelectorAll('input[type=range]').forEach((range) => {
const optionName = range.getAttribute('id');
range.addEventListener('change', () => {
document.getElementById(`${optionName}-value`).innerText = range.value;
// Handle updating named range selects to "custom" if appropriate
const select = document.querySelector(`select[data-option-name=${optionName}]`);
if (select) {
let updated = false;
select?.childNodes.forEach((option) => {
if (option.value === range.value) {
select.value = range.value;
updated = true;
}
});
if (!updated) {
select.value = 'custom';
}
}
});
});
// Handle changes to named range selects
document.querySelectorAll('.named-range-container select').forEach((select) => {
const optionName = select.getAttribute('data-option-name');
select.addEventListener('change', (evt) => {
document.getElementById(optionName).value = evt.target.value;
document.getElementById(`${optionName}-value`).innerText = evt.target.value;
});
});
// Handle changes to randomize checkboxes
document.querySelectorAll('.randomize-checkbox').forEach((checkbox) => {
const optionName = checkbox.getAttribute('data-option-name');
checkbox.addEventListener('change', () => {
const optionInput = document.getElementById(optionName);
const namedRangeSelect = document.querySelector(`select[data-option-name=${optionName}]`);
const customInput = document.getElementById(`${optionName}-custom`);
if (checkbox.checked) {
optionInput.setAttribute('disabled', '1');
namedRangeSelect?.setAttribute('disabled', '1');
if (customInput) {
customInput.setAttribute('disabled', '1');
}
} else {
optionInput.removeAttribute('disabled');
namedRangeSelect?.removeAttribute('disabled');
if (customInput) {
customInput.removeAttribute('disabled');
}
}
});
});
// Handle changes to TextChoice input[type=text]
document.querySelectorAll('.text-choice-container input[type=text]').forEach((input) => {
const optionName = input.getAttribute('data-option-name');
input.addEventListener('input', () => {
const select = document.getElementById(optionName);
const optionValues = [];
select.childNodes.forEach((option) => optionValues.push(option.value));
select.value = (optionValues.includes(input.value)) ? input.value : 'custom';
});
});
// Handle changes to TextChoice select
document.querySelectorAll('.text-choice-container select').forEach((select) => {
const optionName = select.getAttribute('id');
select.addEventListener('change', () => {
document.getElementById(`${optionName}-custom`).value = '';
});
});
// Update the "Option Preset" select to read "custom" when changes are made to relevant inputs
const presetSelect = document.getElementById('game-options-preset');
document.querySelectorAll('input, select').forEach((input) => {
if ( // Ignore inputs which have no effect on yaml generation
(input.id === 'player-name') ||
(input.id === 'game-options-preset') ||
(input.classList.contains('group-toggle')) ||
(input.type === 'submit')
) {
return;
}
input.addEventListener('change', () => {
presetSelect.value = 'custom';
});
});
// Handle changes to presets select
document.getElementById('game-options-preset').addEventListener('change', choosePreset);
// Save settings to localStorage when form is submitted
document.getElementById('options-form').addEventListener('submit', (evt) => {
const playerName = document.getElementById('player-name');
if (!playerName.value.trim()) {
evt.preventDefault();
window.scrollTo(0, 0);
showUserMessage('You must enter a player name!');
}
saveSettings();
});
});
// Save all settings to localStorage
const saveSettings = () => {
const options = {
inputs: {},
checkboxes: {},
};
document.querySelectorAll('input, select').forEach((input) => {
if (input.type === 'submit') {
// Ignore submit inputs
}
else if (input.type === 'checkbox') {
options.checkboxes[input.id] = input.checked;
}
else {
options.inputs[input.id] = input.value
}
});
const game = document.getElementById('player-options').getAttribute('data-game');
localStorage.setItem(game, JSON.stringify(options));
};
// Load all options from localStorage
const loadSettings = () => {
const game = document.getElementById('player-options').getAttribute('data-game');
const options = JSON.parse(localStorage.getItem(game));
if (options) {
if (!options.inputs || !options.checkboxes) {
localStorage.removeItem(game);
return;
}
// Restore value-based inputs and selects
Object.keys(options.inputs).forEach((key) => {
try{
document.getElementById(key).value = options.inputs[key];
const rangeValue = document.getElementById(`${key}-value`);
if (rangeValue) {
rangeValue.innerText = options.inputs[key];
}
} catch (err) {
console.error(`Unable to restore value to input with id ${key}`);
}
});
// Restore checkboxes
Object.keys(options.checkboxes).forEach((key) => {
try{
if (options.checkboxes[key]) {
document.getElementById(key).setAttribute('checked', '1');
}
} catch (err) {
console.error(`Unable to restore value to input with id ${key}`);
}
});
}
// Ensure any input for which the randomize checkbox is checked by default, the relevant inputs are disabled
document.querySelectorAll('.randomize-checkbox').forEach((checkbox) => {
const optionName = checkbox.getAttribute('data-option-name');
if (checkbox.checked) {
const input = document.getElementById(optionName);
if (input) {
input.setAttribute('disabled', '1');
}
const customInput = document.getElementById(`${optionName}-custom`);
if (customInput) {
customInput.setAttribute('disabled', '1');
}
}
});
};
/**
* Fetch the preset data for this game and apply the presets if localStorage indicates one was previously chosen
* @returns {Promise<void>}
*/
const fetchPresets = async () => {
const response = await fetch('option-presets');
presets = await response.json();
const presetSelect = document.getElementById('game-options-preset');
presetSelect.removeAttribute('disabled');
const game = document.getElementById('player-options').getAttribute('data-game');
const presetToApply = localStorage.getItem(`${game}-preset`);
const playerName = localStorage.getItem(`${game}-player`);
if (presetToApply) {
localStorage.removeItem(`${game}-preset`);
presetSelect.value = presetToApply;
applyPresets(presetToApply);
}
if (playerName) {
document.getElementById('player-name').value = playerName;
localStorage.removeItem(`${game}-player`);
}
};
/**
* Clear the localStorage for this game and set a preset to be loaded upon page reload
* @param evt
*/
const choosePreset = (evt) => {
if (evt.target.value === 'custom') { return; }
const game = document.getElementById('player-options').getAttribute('data-game');
localStorage.removeItem(game);
localStorage.setItem(`${game}-player`, document.getElementById('player-name').value);
if (evt.target.value !== 'default') {
localStorage.setItem(`${game}-preset`, evt.target.value);
}
document.querySelectorAll('#options-form input, #options-form select').forEach((input) => {
if (input.id === 'player-name') { return; }
input.removeAttribute('value');
});
window.location.replace(window.location.href);
};
const applyPresets = (presetName) => {
// Ignore the "default" preset, because it gets set automatically by Jinja
if (presetName === 'default') {
saveSettings();
return;
}
if (!presets[presetName]) {
console.error(`Unknown preset ${presetName} chosen`);
return;
}
const preset = presets[presetName];
Object.keys(preset).forEach((optionName) => {
const optionValue = preset[optionName];
// Handle List and Set options
if (Array.isArray(optionValue)) {
document.querySelectorAll(`input[type=checkbox][name=${optionName}]`).forEach((checkbox) => {
if (optionValue.includes(checkbox.value)) {
checkbox.setAttribute('checked', '1');
} else {
checkbox.removeAttribute('checked');
}
});
return;
}
// Handle Dict options
if (typeof(optionValue) === 'object' && optionValue !== null) {
const itemNames = Object.keys(optionValue);
document.querySelectorAll(`input[type=number][data-option-name=${optionName}]`).forEach((input) => {
const itemName = input.getAttribute('data-item-name');
input.value = (itemNames.includes(itemName)) ? optionValue[itemName] : 0
});
return;
}
// Identify all possible elements
const normalInput = document.getElementById(optionName);
const customInput = document.getElementById(`${optionName}-custom`);
const rangeValue = document.getElementById(`${optionName}-value`);
const randomizeInput = document.getElementById(`random-${optionName}`);
const namedRangeSelect = document.getElementById(`${optionName}-select`);
// It is possible for named ranges to use name of a value rather than the value itself. This is accounted for here
let trueValue = optionValue;
if (namedRangeSelect) {
namedRangeSelect.querySelectorAll('option').forEach((opt) => {
if (opt.innerText.startsWith(optionValue)) {
trueValue = opt.value;
}
});
namedRangeSelect.value = trueValue;
// It is also possible for a preset to use an unnamed value. If this happens, set the dropdown to "Custom"
if (namedRangeSelect.selectedIndex == -1)
{
namedRangeSelect.value = "custom";
}
}
// Handle options whose presets are "random"
if (optionValue === 'random') {
normalInput.setAttribute('disabled', '1');
randomizeInput.setAttribute('checked', '1');
if (customInput) {
customInput.setAttribute('disabled', '1');
}
if (rangeValue) {
rangeValue.innerText = normalInput.value;
}
if (namedRangeSelect) {
namedRangeSelect.setAttribute('disabled', '1');
}
return;
}
// Handle normal (text, number, select, etc.) and custom inputs (custom inputs exist with TextChoice only)
normalInput.value = trueValue;
normalInput.removeAttribute('disabled');
randomizeInput.removeAttribute('checked');
if (customInput) {
document.getElementById(`${optionName}-custom`).removeAttribute('disabled');
}
if (rangeValue) {
rangeValue.innerText = trueValue;
}
});
saveSettings();
};
const showUserMessage = (text) => {
const userMessage = document.getElementById('user-message');
userMessage.innerText = text;
userMessage.addEventListener('click', hideUserMessage);
userMessage.style.display = 'block';
};
const hideUserMessage = () => {
const userMessage = document.getElementById('user-message');
userMessage.removeEventListener('click', hideUserMessage);
userMessage.style.display = 'none';
};

View File

@@ -25,16 +25,16 @@ window.addEventListener('load', () => {
// 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') {
for (let i = 0; i < categories.length; i++) {
let hide_id = categories[i].id.split('-')[0];
if (hide_id == 'Total') {
continue;
}
category.addEventListener('click', function() {
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 tab_header = document.getElementById(hide_id+'-header').children[0];
const orig_text = tab_header.innerHTML;
let new_text;
if (orig_text.includes("▼")) {

View File

@@ -1,28 +1,51 @@
window.addEventListener('load', () => {
// Add toggle listener to all elements with .collapse-toggle
const toggleButtons = document.querySelectorAll('details');
const gameHeaders = document.getElementsByClassName('collapse-toggle');
Array.from(gameHeaders).forEach((header) => {
const gameName = header.getAttribute('data-game');
header.addEventListener('click', () => {
const gameArrow = document.getElementById(`${gameName}-arrow`);
const gameInfo = document.getElementById(gameName);
if (gameInfo.classList.contains('collapsed')) {
gameArrow.innerText = '▼';
gameInfo.classList.remove('collapsed');
} else {
gameArrow.innerText = '▶';
gameInfo.classList.add('collapsed');
}
});
});
// Handle game filter input
const gameSearch = document.getElementById('game-search');
gameSearch.value = '';
gameSearch.addEventListener('input', (evt) => {
if (!evt.target.value.trim()) {
// If input is empty, display all games as collapsed
return toggleButtons.forEach((header) => {
// If input is empty, display all collapsed games
return Array.from(gameHeaders).forEach((header) => {
header.style.display = null;
header.removeAttribute('open');
const gameName = header.getAttribute('data-game');
document.getElementById(`${gameName}-arrow`).innerText = '▶';
document.getElementById(gameName).classList.add('collapsed');
});
}
// Loop over all the games
toggleButtons.forEach((header) => {
Array.from(gameHeaders).forEach((header) => {
const gameName = header.getAttribute('data-game');
const gameArrow = document.getElementById(`${gameName}-arrow`);
const gameInfo = document.getElementById(gameName);
// If the game name includes the search string, display the game. If not, hide it
if (header.getAttribute('data-game').toLowerCase().includes(evt.target.value.toLowerCase())) {
if (gameName.toLowerCase().includes(evt.target.value.toLowerCase())) {
header.style.display = null;
header.setAttribute('open', '1');
gameArrow.innerText = '▼';
gameInfo.classList.remove('collapsed');
} else {
console.log(header);
header.style.display = 'none';
header.removeAttribute('open');
gameArrow.innerText = '▶';
gameInfo.classList.add('collapsed');
}
});
});
@@ -32,13 +55,29 @@ window.addEventListener('load', () => {
});
const expandAll = () => {
document.querySelectorAll('details').forEach((detail) => {
detail.setAttribute('open', '1');
});
const gameHeaders = document.getElementsByClassName('collapse-toggle');
// Loop over all the games
Array.from(gameHeaders).forEach((header) => {
const gameName = header.getAttribute('data-game');
const gameArrow = document.getElementById(`${gameName}-arrow`);
const gameInfo = document.getElementById(gameName);
if (header.style.display === 'none') { return; }
gameArrow.innerText = '▼';
gameInfo.classList.remove('collapsed');
});
};
const collapseAll = () => {
document.querySelectorAll('details').forEach((detail) => {
detail.removeAttribute('open');
});
const gameHeaders = document.getElementsByClassName('collapse-toggle');
// Loop over all the games
Array.from(gameHeaders).forEach((header) => {
const gameName = header.getAttribute('data-game');
const gameArrow = document.getElementById(`${gameName}-arrow`);
const gameInfo = document.getElementById(gameName);
if (header.style.display === 'none') { return; }
gameArrow.innerText = '▶';
gameInfo.classList.add('collapsed');
});
};

View File

@@ -4,20 +4,13 @@ const adjustTableHeight = () => {
return;
const upperDistance = tablesContainer.getBoundingClientRect().top;
const containerHeight = window.innerHeight - upperDistance;
tablesContainer.style.maxHeight = `calc(${containerHeight}px - 1rem)`;
const tableWrappers = document.getElementsByClassName('table-wrapper');
for (let i = 0; i < tableWrappers.length; i++) {
// Ensure we are starting from maximum size prior to calculation.
tableWrappers[i].style.height = null;
tableWrappers[i].style.maxHeight = null;
// Set as a reasonable height, but still allows the user to resize element if they desire.
const currentHeight = tableWrappers[i].offsetHeight;
const maxHeight = (window.innerHeight - upperDistance) / Math.min(tableWrappers.length, 4);
if (currentHeight > maxHeight) {
tableWrappers[i].style.height = `calc(${maxHeight}px - 1rem)`;
}
tableWrappers[i].style.maxHeight = `${currentHeight}px`;
for(let i=0; i < tableWrappers.length; i++){
const maxHeight = (window.innerHeight - upperDistance) / 2;
tableWrappers[i].style.maxHeight = `calc(${maxHeight}px - 1rem)`;
}
};
@@ -27,7 +20,7 @@ const adjustTableHeight = () => {
* @returns {string}
*/
const secondsToHours = (seconds) => {
let hours = Math.floor(seconds / 3600);
let hours = Math.floor(seconds / 3600);
let minutes = Math.floor((seconds - (hours * 3600)) / 60).toString().padStart(2, '0');
return `${hours}:${minutes}`;
};
@@ -38,18 +31,18 @@ window.addEventListener('load', () => {
info: false,
dom: "t",
stateSave: true,
stateSaveCallback: function (settings, data) {
stateSaveCallback: function(settings, data) {
delete data.search;
localStorage.setItem(`DataTables_${settings.sInstance}_/tracker`, JSON.stringify(data));
},
stateLoadCallback: function (settings) {
stateLoadCallback: function(settings) {
return JSON.parse(localStorage.getItem(`DataTables_${settings.sInstance}_/tracker`));
},
footerCallback: function (tfoot, data, start, end, display) {
footerCallback: function(tfoot, data, start, end, display) {
if (tfoot) {
const activityData = this.api().column('lastActivity:name').data().toArray().filter(x => !isNaN(x));
Array.from(tfoot?.children).find(td => td.classList.contains('last-activity')).innerText =
(activityData.length) ? secondsToHours(Math.min(...activityData)) : 'None';
(activityData.length) ? secondsToHours(Math.min(...activityData)) : 'None';
}
},
columnDefs: [
@@ -62,7 +55,7 @@ window.addEventListener('load', () => {
render: function (data, type, row) {
if (type === "sort" || type === 'type') {
if (data === "None")
return Number.MAX_VALUE;
return -1;
return parseInt(data);
}
@@ -123,64 +116,49 @@ window.addEventListener('load', () => {
event.preventDefault();
}
});
const target_second = parseInt(document.getElementById('tracker-wrapper').getAttribute('data-second')) + 3;
console.log("Target second of refresh: " + target_second);
const tracker = document.getElementById('tracker-wrapper').getAttribute('data-tracker');
const target_second = document.getElementById('tracker-wrapper').getAttribute('data-second') + 3;
function getSleepTimeSeconds() {
function getSleepTimeSeconds(){
// -40 % 60 is -40, which is absolutely wrong and should burn
var sleepSeconds = (((target_second - new Date().getSeconds()) % 60) + 60) % 60;
return sleepSeconds || 60;
}
let update_on_view = false;
const update = () => {
if (document.hidden) {
console.log("Document reporting as not visible, not updating Tracker...");
update_on_view = true;
} else {
update_on_view = false;
const target = $("<div></div>");
console.log("Updating Tracker...");
target.load(location.href, function (response, status) {
if (status === "success") {
target.find(".table").each(function (i, new_table) {
const new_trs = $(new_table).find("tbody>tr");
const footer_tr = $(new_table).find("tfoot>tr");
const old_table = tables.eq(i);
const topscroll = $(old_table.settings()[0].nScrollBody).scrollTop();
const leftscroll = $(old_table.settings()[0].nScrollBody).scrollLeft();
old_table.clear();
if (footer_tr.length) {
$(old_table.table).find("tfoot").html(footer_tr);
}
old_table.rows.add(new_trs);
old_table.draw();
$(old_table.settings()[0].nScrollBody).scrollTop(topscroll);
$(old_table.settings()[0].nScrollBody).scrollLeft(leftscroll);
});
$("#multi-stream-link").replaceWith(target.find("#multi-stream-link"));
} else {
console.log("Failed to connect to Server, in order to update Table Data.");
console.log(response);
}
})
}
updater = setTimeout(update, getSleepTimeSeconds() * 1000);
const target = $("<div></div>");
console.log("Updating Tracker...");
target.load(location.href, function (response, status) {
if (status === "success") {
target.find(".table").each(function (i, new_table) {
const new_trs = $(new_table).find("tbody>tr");
const footer_tr = $(new_table).find("tfoot>tr");
const old_table = tables.eq(i);
const topscroll = $(old_table.settings()[0].nScrollBody).scrollTop();
const leftscroll = $(old_table.settings()[0].nScrollBody).scrollLeft();
old_table.clear();
if (footer_tr.length) {
$(old_table.table).find("tfoot").html(footer_tr);
}
old_table.rows.add(new_trs);
old_table.draw();
$(old_table.settings()[0].nScrollBody).scrollTop(topscroll);
$(old_table.settings()[0].nScrollBody).scrollLeft(leftscroll);
});
$("#multi-stream-link").replaceWith(target.find("#multi-stream-link"));
} else {
console.log("Failed to connect to Server, in order to update Table Data.");
console.log(response);
}
})
setTimeout(update, getSleepTimeSeconds()*1000);
}
let updater = setTimeout(update, getSleepTimeSeconds() * 1000);
setTimeout(update, getSleepTimeSeconds()*1000);
window.addEventListener('resize', () => {
adjustTableHeight();
tables.draw();
});
window.addEventListener('visibilitychange', () => {
if (!document.hidden && update_on_view) {
console.log("Page became visible, tracker should be refreshed.");
clearTimeout(updater);
update();
}
});
adjustTableHeight();
});

File diff suppressed because it is too large Load Diff

View File

@@ -1,223 +0,0 @@
let deletedOptions = {};
window.addEventListener('load', () => {
const worldName = document.querySelector('#weighted-options').getAttribute('data-game');
// Generic change listener. Detecting unique qualities and acting on them here reduces initial JS initialisation time
// and handles dynamically created elements
document.addEventListener('change', (evt) => {
// Handle updates to range inputs
if (evt.target.type === 'range') {
// Update span containing range value. All ranges have a corresponding `{rangeId}-value` span
document.getElementById(`${evt.target.id}-value`).innerText = evt.target.value;
// If the changed option was the name of a game, determine whether to show or hide that game's div
if (evt.target.id.startsWith('game||')) {
const gameName = evt.target.id.split('||')[1];
const gameDiv = document.getElementById(`${gameName}-container`);
if (evt.target.value > 0) {
gameDiv.classList.remove('hidden');
} else {
gameDiv.classList.add('hidden');
}
}
}
});
// Generic click listener
document.addEventListener('click', (evt) => {
// Handle creating new rows for Range options
if (evt.target.classList.contains('add-range-option-button')) {
const optionName = evt.target.getAttribute('data-option');
addRangeRow(optionName);
}
// Handle deleting range rows
if (evt.target.classList.contains('range-option-delete')) {
const targetRow = document.querySelector(`tr[data-row="${evt.target.getAttribute('data-target')}"]`);
setDeletedOption(
targetRow.getAttribute('data-option-name'),
targetRow.getAttribute('data-value'),
);
targetRow.parentElement.removeChild(targetRow);
}
});
// Listen for enter presses on inputs intended to add range rows
document.addEventListener('keydown', (evt) => {
if (evt.key === 'Enter') {
evt.preventDefault();
}
if (evt.key === 'Enter' && evt.target.classList.contains('range-option-value')) {
const optionName = evt.target.getAttribute('data-option');
addRangeRow(optionName);
}
});
// Detect form submission
document.getElementById('weighted-options-form').addEventListener('submit', (evt) => {
// Save data to localStorage
const weightedOptions = {};
document.querySelectorAll('input[name]').forEach((input) => {
const keys = input.getAttribute('name').split('||');
// Determine keys
const optionName = keys[0] ?? null;
const subOption = keys[1] ?? null;
// Ensure keys exist
if (!weightedOptions[optionName]) { weightedOptions[optionName] = {}; }
if (subOption && !weightedOptions[optionName][subOption]) {
weightedOptions[optionName][subOption] = null;
}
if (subOption) { return weightedOptions[optionName][subOption] = determineValue(input); }
if (optionName) { return weightedOptions[optionName] = determineValue(input); }
});
localStorage.setItem(`${worldName}-weights`, JSON.stringify(weightedOptions));
localStorage.setItem(`${worldName}-deletedOptions`, JSON.stringify(deletedOptions));
});
// Remove all deleted values as specified by localStorage
deletedOptions = JSON.parse(localStorage.getItem(`${worldName}-deletedOptions`) || '{}');
Object.keys(deletedOptions).forEach((optionName) => {
deletedOptions[optionName].forEach((value) => {
const targetRow = document.querySelector(`tr[data-row="${value}-row"]`);
targetRow.parentElement.removeChild(targetRow);
});
});
// Populate all settings from localStorage on page initialisation
const previousSettingsJson = localStorage.getItem(`${worldName}-weights`);
if (previousSettingsJson) {
const previousSettings = JSON.parse(previousSettingsJson);
Object.keys(previousSettings).forEach((option) => {
if (typeof previousSettings[option] === 'string') {
return document.querySelector(`input[name="${option}"]`).value = previousSettings[option];
}
Object.keys(previousSettings[option]).forEach((value) => {
const input = document.querySelector(`input[name="${option}||${value}"]`);
if (!input?.type) {
return console.error(`Unable to populate option with name ${option}||${value}.`);
}
switch (input.type) {
case 'checkbox':
input.checked = (parseInt(previousSettings[option][value], 10) === 1);
break;
case 'range':
input.value = parseInt(previousSettings[option][value], 10);
break;
case 'number':
input.value = previousSettings[option][value].toString();
break;
default:
console.error(`Found unsupported input type: ${input.type}`);
}
});
});
}
});
const addRangeRow = (optionName) => {
const inputQuery = `input[type=number][data-option="${optionName}"].range-option-value`;
const inputTarget = document.querySelector(inputQuery);
const newValue = inputTarget.value;
if (!/^-?\d+$/.test(newValue)) {
alert('Range values must be a positive or negative integer!');
return;
}
inputTarget.value = '';
const tBody = document.querySelector(`table[data-option="${optionName}"].range-rows tbody`);
const tr = document.createElement('tr');
tr.setAttribute('data-row', `${optionName}-${newValue}-row`);
tr.setAttribute('data-option-name', optionName);
tr.setAttribute('data-value', newValue);
const tdLeft = document.createElement('td');
tdLeft.classList.add('td-left');
const label = document.createElement('label');
label.setAttribute('for', `${optionName}||${newValue}`);
label.innerText = newValue.toString();
tdLeft.appendChild(label);
tr.appendChild(tdLeft);
const tdMiddle = document.createElement('td');
tdMiddle.classList.add('td-middle');
const range = document.createElement('input');
range.setAttribute('type', 'range');
range.setAttribute('min', '0');
range.setAttribute('max', '50');
range.setAttribute('value', '0');
range.setAttribute('id', `${optionName}||${newValue}`);
range.setAttribute('name', `${optionName}||${newValue}`);
tdMiddle.appendChild(range);
tr.appendChild(tdMiddle);
const tdRight = document.createElement('td');
tdRight.classList.add('td-right');
const valueSpan = document.createElement('span');
valueSpan.setAttribute('id', `${optionName}||${newValue}-value`);
valueSpan.innerText = '0';
tdRight.appendChild(valueSpan);
tr.appendChild(tdRight);
const tdDelete = document.createElement('td');
const deleteSpan = document.createElement('span');
deleteSpan.classList.add('range-option-delete');
deleteSpan.classList.add('js-required');
deleteSpan.setAttribute('data-target', `${optionName}-${newValue}-row`);
deleteSpan.innerText = '❌';
tdDelete.appendChild(deleteSpan);
tr.appendChild(tdDelete);
tBody.appendChild(tr);
// Remove this option from the set of deleted options if it exists
unsetDeletedOption(optionName, newValue);
};
/**
* Determines the value of an input element, or returns a 1 or 0 if the element is a checkbox
*
* @param {object} input - The input element.
* @returns {number} The value of the input element.
*/
const determineValue = (input) => {
switch (input.type) {
case 'checkbox':
return (input.checked ? 1 : 0);
case 'range':
return parseInt(input.value, 10);
default:
return input.value;
}
};
/**
* Sets the deleted option value for a given world and option name.
* If the world or option does not exist, it creates the necessary entries.
*
* @param {string} optionName - The name of the option.
* @param {*} value - The value to be set for the deleted option.
* @returns {void}
*/
const setDeletedOption = (optionName, value) => {
deletedOptions[optionName] = deletedOptions[optionName] || [];
deletedOptions[optionName].push(`${optionName}-${value}`);
};
/**
* Removes a specific value from the deletedOptions object.
*
* @param {string} optionName - The name of the option.
* @param {*} value - The value to be removed
* @returns {void}
*/
const unsetDeletedOption = (optionName, value) => {
if (!deletedOptions.hasOwnProperty(optionName)) { return; }
if (deletedOptions[optionName].includes(`${optionName}-${value}`)) {
deletedOptions[optionName].splice(deletedOptions[optionName].indexOf(`${optionName}-${value}`), 1);
}
if (deletedOptions[optionName].length === 0) {
delete deletedOptions[optionName];
}
};

View File

@@ -1,20 +0,0 @@
User-agent: Googlebot
Disallow: /
User-agent: APIs-Google
Disallow: /
User-agent: AdsBot-Google-Mobile
Disallow: /
User-agent: AdsBot-Google-Mobile
Disallow: /
User-agent: Mediapartners-Google
Disallow: /
User-agent: Google-Safety
Disallow: /
User-agent: *
Disallow: /

Binary file not shown.

Before

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.8 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.9 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

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