diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 000000000000..17a60ad125f7 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,5 @@ +[report] +exclude_lines = + pragma: no cover + if TYPE_CHECKING: + if typing.TYPE_CHECKING: diff --git a/.github/labeler.yml b/.github/labeler.yml new file mode 100644 index 000000000000..2743104f410e --- /dev/null +++ b/.github/labeler.yml @@ -0,0 +1,31 @@ +'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' diff --git a/.github/pyright-config.json b/.github/pyright-config.json new file mode 100644 index 000000000000..6ad7fa5f19b5 --- /dev/null +++ b/.github/pyright-config.json @@ -0,0 +1,27 @@ +{ + "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": ".." + } + ] +} diff --git a/.github/type_check.py b/.github/type_check.py new file mode 100644 index 000000000000..90d41722c9a5 --- /dev/null +++ b/.github/type_check.py @@ -0,0 +1,15 @@ +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) diff --git a/.github/workflows/analyze-modified-files.yml b/.github/workflows/analyze-modified-files.yml index ba2660809aaa..c9995fa2d043 100644 --- a/.github/workflows/analyze-modified-files.yml +++ b/.github/workflows/analyze-modified-files.yml @@ -25,7 +25,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - 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@v4 + - uses: actions/setup-python@v5 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=10 --max-doc-length=120 --max-line-length=120 --statistics ${{ env.diff }} + flake8 --count --max-complexity=14 --max-doc-length=120 --max-line-length=120 --statistics ${{ env.diff }} - name: "mypy: Type check modified files" continue-on-error: true diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index a40084b9ab72..23c463fb947a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -8,11 +8,13 @@ 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: @@ -25,19 +27,24 @@ jobs: build-win-py38: # RCs will still be built and signed by hand runs-on: windows-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Install python - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 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" @@ -46,25 +53,63 @@ 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@v3 + uses: actions/upload-artifact@v4 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@v3 + - uses: actions/checkout@v4 - 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@v4 + uses: actions/setup-python@v5 with: python-version: '3.11' - name: Install build-time dependencies @@ -91,7 +136,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 -czvf ../dist/$TAR_NAME Archipelago && mv Archipelago "$DIR_NAME") + (cd build && DIR_NAME="`ls | grep exe`" && mv "$DIR_NAME" Archipelago && tar -cv Archipelago | gzip -8 > ../dist/$TAR_NAME && mv Archipelago "$DIR_NAME") echo "APPIMAGE_NAME=$APPIMAGE_NAME" >> $GITHUB_ENV echo "TAR_NAME=$TAR_NAME" >> $GITHUB_ENV # - copy code above to release.yml - @@ -99,15 +144,36 @@ 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@v3 + uses: actions/upload-artifact@v4 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@v3 + uses: actions/upload-artifact@v4 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 diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 6aeb477a22d7..b0cfe35d2bc5 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -43,7 +43,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v3 + uses: actions/checkout@v4 # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL diff --git a/.github/workflows/ctest.yml b/.github/workflows/ctest.yml new file mode 100644 index 000000000000..9492c83c9e53 --- /dev/null +++ b/.github/workflows/ctest.yml @@ -0,0 +1,54 @@ +# 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 diff --git a/.github/workflows/label-pull-requests.yml b/.github/workflows/label-pull-requests.yml new file mode 100644 index 000000000000..bc0f6999b6a8 --- /dev/null +++ b/.github/workflows/label-pull-requests.yml @@ -0,0 +1,46 @@ +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 }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index cc68a88b7651..3f8651d408e7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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@b7e450da2a4b4cb4bfbae528f788167786cfcedf + uses: softprops/action-gh-release@975c1b265e11dd76618af1c374e7981f9a6ff44a 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@v3 + - uses: actions/checkout@v4 - 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@v4 + uses: actions/setup-python@v5 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 -czvf ../dist/$TAR_NAME Archipelago && mv Archipelago "$DIR_NAME") + (cd build && DIR_NAME="`ls | grep exe`" && mv "$DIR_NAME" Archipelago && tar -cv Archipelago | gzip -8 > ../dist/$TAR_NAME && 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@b7e450da2a4b4cb4bfbae528f788167786cfcedf + uses: softprops/action-gh-release@975c1b265e11dd76618af1c374e7981f9a6ff44a with: draft: true # see above prerelease: false diff --git a/.github/workflows/scan-build.yml b/.github/workflows/scan-build.yml new file mode 100644 index 000000000000..5234d862b4d3 --- /dev/null +++ b/.github/workflows/scan-build.yml @@ -0,0 +1,65 @@ +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 diff --git a/.github/workflows/strict-type-check.yml b/.github/workflows/strict-type-check.yml new file mode 100644 index 000000000000..bafd572a26ae --- /dev/null +++ b/.github/workflows/strict-type-check.yml @@ -0,0 +1,33 @@ +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 diff --git a/.github/workflows/unittests.yml b/.github/workflows/unittests.yml index 1a76a7f47160..3ad29b007772 100644 --- a/.github/workflows/unittests.yml +++ b/.github/workflows/unittests.yml @@ -24,7 +24,7 @@ on: - '.github/workflows/unittests.yml' jobs: - build: + unit: runs-on: ${{ matrix.os }} name: Test Python ${{ matrix.python.version }} ${{ matrix.os }} @@ -46,9 +46,9 @@ jobs: os: macos-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python.version }} - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python.version }} - name: Install dependencies @@ -60,3 +60,32 @@ 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.11'} # 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) + python test/hosting/__main__.py diff --git a/.gitignore b/.gitignore index 022abe38fe40..791f7b1bb7fe 100644 --- a/.gitignore +++ b/.gitignore @@ -62,6 +62,7 @@ Output Logs/ /installdelete.iss /data/user.kv /datapackage +/custom_worlds # Byte-compiled / optimized / DLL files __pycache__/ @@ -149,7 +150,7 @@ venv/ ENV/ env.bak/ venv.bak/ -.code-workspace +*.code-workspace shell.nix # Spyder project settings @@ -177,6 +178,7 @@ dmypy.json cython_debug/ # Cython intermediates +_speedups.c _speedups.cpp _speedups.html diff --git a/.run/Archipelago Unittests.run.xml b/.run/Archipelago Unittests.run.xml new file mode 100644 index 000000000000..24fea0f73fec --- /dev/null +++ b/.run/Archipelago Unittests.run.xml @@ -0,0 +1,18 @@ + + + + + \ No newline at end of file diff --git a/AHITClient.py b/AHITClient.py new file mode 100644 index 000000000000..6ed7d7b49d48 --- /dev/null +++ b/AHITClient.py @@ -0,0 +1,8 @@ +from worlds.ahit.Client import launch +import Utils +import ModuleUpdate +ModuleUpdate.update() + +if __name__ == "__main__": + Utils.init_logging("AHITClient", exception_logger="Client") + launch() diff --git a/AdventureClient.py b/AdventureClient.py index d2f4e734ac2c..24c6a4c4fc58 100644 --- a/AdventureClient.py +++ b/AdventureClient.py @@ -80,7 +80,7 @@ def __init__(self, server_address, password): self.local_item_locations = {} self.dragon_speed_info = {} - options = Utils.get_options() + options = Utils.get_settings() self.display_msgs = options["adventure_options"]["display_msgs"] async def server_auth(self, password_requested: bool = False): @@ -102,7 +102,7 @@ def _set_message(self, msg: str, msg_id: int): def on_package(self, cmd: str, args: dict): if cmd == 'Connected': self.locations_array = None - if Utils.get_options()["adventure_options"].get("death_link", False): + if Utils.get_settings()["adventure_options"].get("death_link", False): self.set_deathlink = True async_start(self.get_freeincarnates_used()) elif cmd == "RoomInfo": @@ -112,14 +112,15 @@ def on_package(self, cmd: str, args: dict): 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']])}" + msg = f"Received {', '.join([self.item_names.lookup_in_game(item.item) for item in args['items']])}" self._set_message(msg, SYSTEM_MESSAGE_ID) elif cmd == "Retrieved": - 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() + if f"adventure_{self.auth}_freeincarnates_used" in args["keys"]: + self.freeincarnates_used = args["keys"][f"adventure_{self.auth}_freeincarnates_used"] + if self.freeincarnates_used is None: + self.freeincarnates_used = 0 + self.freeincarnates_used += self.freeincarnate_pending + self.send_pending_freeincarnates() elif cmd == "SetReply": if args["key"] == f"adventure_{self.auth}_freeincarnates_used": self.freeincarnates_used = args["value"] @@ -414,8 +415,8 @@ async def atari_sync_task(ctx: AdventureContext): async def run_game(romfile): - auto_start = Utils.get_options()["adventure_options"].get("rom_start", True) - rom_args = Utils.get_options()["adventure_options"].get("rom_args") + auto_start = Utils.get_settings()["adventure_options"].get("rom_start", True) + rom_args = Utils.get_settings()["adventure_options"].get("rom_args") if auto_start is True: import webbrowser webbrowser.open(romfile) diff --git a/BaseClasses.py b/BaseClasses.py index 7965eb8b0d0d..81601506d084 100644 --- a/BaseClasses.py +++ b/BaseClasses.py @@ -1,5 +1,6 @@ from __future__ import annotations +import collections import copy import itertools import functools @@ -11,18 +12,21 @@ from collections import Counter, deque from collections.abc import Collection, MutableSequence from enum import IntEnum, IntFlag -from typing import Any, Callable, Dict, Iterable, Iterator, List, NamedTuple, Optional, Set, Tuple, TypedDict, Union, \ - Type, ClassVar +from typing import Any, Callable, Dict, Iterable, Iterator, List, Mapping, NamedTuple, Optional, Set, Tuple, \ + TypedDict, Union, Type, ClassVar import NetUtils import Options import Utils +if typing.TYPE_CHECKING: + from worlds import AutoWorld + class Group(TypedDict, total=False): name: str game: str - world: auto_world + world: "AutoWorld.World" players: Set[int] item_pool: Set[str] replacement_items: Dict[int, Optional[str]] @@ -48,14 +52,10 @@ def __getattr__(self, name: str) -> Any: class MultiWorld(): debug_types = False player_name: Dict[int, str] - difficulty_requirements: dict - required_medallions: dict - dark_room_logic: Dict[int, str] - restrict_dungeon_item_on_boss: Dict[int, bool] plando_texts: List[Dict[str, str]] plando_items: List[List[Dict[str, Any]]] plando_connections: List - worlds: Dict[int, auto_world] + worlds: Dict[int, "AutoWorld.World"] groups: Dict[int, Group] regions: RegionManager itempool: List[Item] @@ -64,7 +64,6 @@ class MultiWorld(): state: CollectionState plando_options: PlandoOptions - accessibility: Dict[int, Options.Accessibility] early_items: Dict[int, Dict[str, int]] local_early_items: Dict[int, Dict[str, int]] local_items: Dict[int, Options.LocalItems] @@ -82,7 +81,7 @@ class MultiWorld(): game: Dict[int, str] random: random.Random - per_slot_randoms: Dict[int, random.Random] + per_slot_randoms: Utils.DeprecateDict[int, random.Random] """Deprecated. Please use `self.random` instead.""" class AttributeProxy(): @@ -107,10 +106,14 @@ def __iadd__(self, other: Iterable[Region]): return self def append(self, region: Region): + assert region.name not in self.region_cache[region.player], \ + f"{region.name} already exists in region cache." self.region_cache[region.player][region.name] = region def extend(self, regions: Iterable[Region]): for region in regions: + assert region.name not in self.region_cache[region.player], \ + f"{region.name} already exists in region cache." self.region_cache[region.player][region.name] = region def add_group(self, new_id: int): @@ -130,7 +133,6 @@ def __init__(self, players: int): self.random = ThreadBarrierProxy(random.Random()) self.players = players self.player_types = {player: NetUtils.SlotType.player for player in self.player_ids} - self.glitch_triforce = False self.algorithm = 'balanced' self.groups = {} self.regions = self.RegionManager(players) @@ -153,64 +155,18 @@ def __init__(self, players: int): self.local_early_items = {player: {} for player in self.player_ids} self.indirect_connections = {} self.start_inventory_from_pool: Dict[int, Options.StartInventoryPool] = {} - self.fix_trock_doors = self.AttributeProxy( - lambda player: self.shuffle[player] != 'vanilla' or self.mode[player] == 'inverted') - self.fix_skullwoods_exit = self.AttributeProxy( - lambda player: self.shuffle[player] not in ['vanilla', 'simple', 'restricted', 'dungeonssimple']) - self.fix_palaceofdarkness_exit = self.AttributeProxy( - lambda player: self.shuffle[player] not in ['vanilla', 'simple', 'restricted', 'dungeonssimple']) - self.fix_trock_exit = self.AttributeProxy( - lambda player: self.shuffle[player] not in ['vanilla', 'simple', 'restricted', 'dungeonssimple']) for player in range(1, players + 1): def set_player_attr(attr, val): self.__dict__.setdefault(attr, {})[player] = val - - set_player_attr('shuffle', "vanilla") - set_player_attr('logic', "noglitches") - set_player_attr('mode', 'open') - set_player_attr('difficulty', 'normal') - set_player_attr('item_functionality', 'normal') - set_player_attr('timer', False) - set_player_attr('goal', 'ganon') - set_player_attr('required_medallions', ['Ether', 'Quake']) - set_player_attr('swamp_patch_required', False) - set_player_attr('powder_patch_required', False) - set_player_attr('ganon_at_pyramid', True) - set_player_attr('ganonstower_vanilla', True) - set_player_attr('can_access_trock_eyebridge', None) - set_player_attr('can_access_trock_front', None) - set_player_attr('can_access_trock_big_chest', None) - set_player_attr('can_access_trock_middle', None) - set_player_attr('fix_fake_world', True) - set_player_attr('difficulty_requirements', None) - set_player_attr('boss_shuffle', 'none') - set_player_attr('enemy_health', 'default') - set_player_attr('enemy_damage', 'default') - set_player_attr('beemizer_total_chance', 0) - set_player_attr('beemizer_trap_chance', 0) - set_player_attr('escape_assist', []) - set_player_attr('treasure_hunt_icon', 'Triforce Piece') - set_player_attr('treasure_hunt_count', 0) - set_player_attr('clock_mode', False) - set_player_attr('countdown_start_time', 10) - set_player_attr('red_clock_time', -2) - set_player_attr('blue_clock_time', 2) - set_player_attr('green_clock_time', 4) - set_player_attr('can_take_damage', True) - set_player_attr('triforce_pieces_available', 30) - set_player_attr('triforce_pieces_required', 20) - set_player_attr('shop_shuffle', 'off') - set_player_attr('shuffle_prizes', "g") - set_player_attr('sprite_pool', []) - set_player_attr('dark_room_logic', "lamp") set_player_attr('plando_items', []) set_player_attr('plando_texts', {}) set_player_attr('plando_connections', []) - set_player_attr('game', "A Link to the Past") + set_player_attr('game', "Archipelago") set_player_attr('completion_condition', lambda state: True) self.worlds = {} - self.per_slot_randoms = {} + self.per_slot_randoms = Utils.DeprecateDict("Using per_slot_randoms is now deprecated. Please use the " + "world's random object instead (usually self.random)") self.plando_options = PlandoOptions.none def get_all_ids(self) -> Tuple[int, ...]: @@ -219,6 +175,8 @@ def get_all_ids(self) -> Tuple[int, ...]: def add_group(self, name: str, game: str, players: Set[int] = frozenset()) -> Tuple[int, Group]: """Create a group with name and return the assigned player ID and group. If a group of this name already exists, the set of players is extended instead of creating a new one.""" + from worlds import AutoWorld + for group_id, group in self.groups.items(): if group["name"] == name: group["players"] |= players @@ -242,29 +200,36 @@ def get_player_groups(self, player) -> Set[int]: return {group_id for group_id, group in self.groups.items() if player in group["players"]} def set_seed(self, seed: Optional[int] = None, secure: bool = False, name: Optional[str] = None): + assert not self.worlds, "seed needs to be initialized before Worlds" self.seed = get_seed(seed) if secure: self.secure() else: self.random.seed(self.seed) self.seed_name = name if name else str(self.seed) - self.per_slot_randoms = {player: random.Random(self.random.getrandbits(64)) for player in - range(1, self.players + 1)} def set_options(self, args: Namespace) -> None: + # TODO - remove this section once all worlds use options dataclasses + from worlds import AutoWorld + + all_keys: Set[str] = {key for player in self.player_ids for key in + AutoWorld.AutoWorldRegister.world_types[self.game[player]].options_dataclass.type_hints} + for option_key in all_keys: + option = Utils.DeprecateDict(f"Getting options from multiworld is now deprecated. " + f"Please use `self.options.{option_key}` instead.") + option.update(getattr(args, option_key, {})) + setattr(self, option_key, option) + for player in self.player_ids: world_type = AutoWorld.AutoWorldRegister.world_types[self.game[player]] self.worlds[player] = world_type(self, player) - self.worlds[player].random = self.per_slot_randoms[player] - for option_key in world_type.options_dataclass.type_hints: - option_values = getattr(args, option_key, {}) - setattr(self, option_key, option_values) - # TODO - remove this loop once all worlds use options dataclasses - options_dataclass: typing.Type[Options.PerGameCommonOptions] = self.worlds[player].options_dataclass + options_dataclass: typing.Type[Options.PerGameCommonOptions] = world_type.options_dataclass self.worlds[player].options = options_dataclass(**{option_key: getattr(args, option_key)[player] for option_key in options_dataclass.type_hints}) def set_item_links(self): + from worlds import AutoWorld + item_links = {} replacement_prio = [False, True, None] for player in self.player_ids: @@ -323,6 +288,86 @@ def set_item_links(self): group["non_local_items"] = item_link["non_local_items"] group["link_replacement"] = replacement_prio[item_link["link_replacement"]] + def link_items(self) -> None: + """Called to link together items in the itempool related to the registered item link groups.""" + from worlds import AutoWorld + + for group_id, group in self.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 self.itempool: + if item.player in counters and item.name in shared_pool: + counters[item.player][item.name] += 1 + classifications[item.name] |= item.classification + + for player in players.copy(): + if all([counters[player][item] == 0 for item in shared_pool]): + players.remove(player) + del (counters[player]) + + if not players: + return None, None + + 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, self, "ItemLink") + self.regions.append(region) + locations = region.locations + for item in self.itempool: + count = common_item_count.get(item.player, {}).get(item.name, 0) + if count: + loc = Location(group_id, f"Item Link: {item.name} -> {self.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(self.itempool) + self.itempool = new_itempool + + while itemcount > len(self.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(self, "create_item", item_player, + group["replacement_items"][player])) + else: + items_to_add.append(AutoWorld.call_single(self, "create_filler", item_player)) + self.random.shuffle(items_to_add) + self.itempool.extend(items_to_add[:itemcount - len(self.itempool)]) + def secure(self): self.random = ThreadBarrierProxy(secrets.SystemRandom()) self.is_race = True @@ -428,7 +473,7 @@ def push_item(self, location: Location, item: Item, collect: bool = True): location.item = item item.location = location if collect: - self.state.collect(item, location.event, location) + self.state.collect(item, location.advancement, location) logging.debug('Placed %s at %s', item, location) @@ -491,7 +536,7 @@ def has_beaten_game(self, state: CollectionState, player: Optional[int] = None) else: return all((self.has_beaten_game(state, p) for p in range(1, self.players + 1))) - def can_beat_game(self, starting_state: Optional[CollectionState] = None): + def can_beat_game(self, starting_state: Optional[CollectionState] = None) -> bool: if starting_state: if self.has_beaten_game(starting_state): return True @@ -504,7 +549,7 @@ def can_beat_game(self, starting_state: Optional[CollectionState] = None): and location.item.advancement and location not in state.locations_checked} while prog_locations: - sphere = set() + sphere: Set[Location] = set() # build up spheres of collection radius. # Everything in each sphere is independent from each other in dependencies and only depends on lower spheres for location in prog_locations: @@ -524,12 +569,19 @@ def can_beat_game(self, starting_state: Optional[CollectionState] = None): return False - def get_spheres(self): + def get_spheres(self) -> Iterator[Set[Location]]: + """ + yields a set of locations for each logical sphere + + If there are unreachable locations, the last sphere of reachable + locations is followed by an empty set, and then a set of all of the + unreachable locations. + """ state = CollectionState(self) locations = set(self.get_filled_locations()) while locations: - sphere = set() + sphere: Set[Location] = set() for location in locations: if location.can_reach(state): @@ -551,26 +603,22 @@ def fulfills_accessibility(self, state: Optional[CollectionState] = None): players: Dict[str, Set[int]] = { "minimal": set(), "items": set(), - "locations": set() + "full": set() } - for player, access in self.accessibility.items(): - players[access.current_key].add(player) + for player, world in self.worlds.items(): + players[world.options.accessibility.current_key].add(player) beatable_fulfilled = False - def location_condition(location: Location): + def location_condition(location: Location) -> bool: """Determine if this location has to be accessible, location is already filtered by location_relevant""" - if location.player in players["minimal"]: - return False - return True + return location.player in players["full"] or \ + (location.item and location.item.player not in players["minimal"]) - def location_relevant(location: Location): + def location_relevant(location: Location) -> bool: """Determine if this location is relevant to sweep.""" - if location.progress_type != LocationProgressType.EXCLUDED \ - and (location.player in players["locations"] or location.event - or (location.item and location.item.advancement)): - return True - return False + return location.progress_type != LocationProgressType.EXCLUDED \ + and (location.player in players["full"] or location.advancement) def all_done() -> bool: """Check if all access rules are fulfilled""" @@ -639,34 +687,34 @@ def __init__(self, parent: MultiWorld): def update_reachable_regions(self, player: int): self.stale[player] = False - rrp = self.reachable_regions[player] - bc = self.blocked_connections[player] + reachable_regions = self.reachable_regions[player] + blocked_connections = self.blocked_connections[player] queue = deque(self.blocked_connections[player]) - start = self.multiworld.get_region('Menu', player) + start = self.multiworld.get_region("Menu", player) # init on first call - this can't be done on construction since the regions don't exist yet - if start not in rrp: - rrp.add(start) - bc.update(start.exits) + if start not in reachable_regions: + reachable_regions.add(start) + blocked_connections.update(start.exits) queue.extend(start.exits) # run BFS on all connections, and keep track of those blocked by missing items while queue: connection = queue.popleft() new_region = connection.connected_region - if new_region in rrp: - bc.remove(connection) + if new_region in reachable_regions: + blocked_connections.remove(connection) elif connection.can_reach(self): assert new_region, f"tried to search through an Entrance \"{connection}\" with no Region" - rrp.add(new_region) - bc.remove(connection) - bc.update(new_region.exits) + reachable_regions.add(new_region) + blocked_connections.remove(connection) + blocked_connections.update(new_region.exits) queue.extend(new_region.exits) self.path[new_region] = (new_region.name, self.path.get(connection, None)) # Retry connections if the new region can unblock them for new_entrance in self.multiworld.indirect_connections.get(new_region, set()): - if new_entrance in bc and new_entrance not in queue: + if new_entrance in blocked_connections and new_entrance not in queue: queue.append(new_entrance) def copy(self) -> CollectionState: @@ -691,21 +739,30 @@ def can_reach(self, assert isinstance(player, int), "can_reach: player is required if spot is str" # try to resolve a name if resolution_hint == 'Location': - spot = self.multiworld.get_location(spot, player) + return self.can_reach_location(spot, player) elif resolution_hint == 'Entrance': - spot = self.multiworld.get_entrance(spot, player) + return self.can_reach_entrance(spot, player) else: # default to Region - spot = self.multiworld.get_region(spot, player) + return self.can_reach_region(spot, player) return spot.can_reach(self) - def sweep_for_events(self, key_only: bool = False, locations: Optional[Iterable[Location]] = None) -> None: + def can_reach_location(self, spot: str, player: int) -> bool: + return self.multiworld.get_location(spot, player).can_reach(self) + + def can_reach_entrance(self, spot: str, player: int) -> bool: + return self.multiworld.get_entrance(spot, player).can_reach(self) + + def can_reach_region(self, spot: str, player: int) -> bool: + return self.multiworld.get_region(spot, player).can_reach(self) + + def sweep_for_events(self, locations: Optional[Iterable[Location]] = None) -> None: if locations is None: locations = self.multiworld.get_filled_locations() reachable_events = True # since the loop has a good chance to run more than once, only filter the events once - locations = {location for location in locations if location.event and location not in self.events and - not key_only or getattr(location.item, "locked_dungeon_item", False)} + locations = {location for location in locations if location.advancement and location not in self.events} + while reachable_events: reachable_events = {location for location in locations if location.can_reach(self)} locations -= reachable_events @@ -726,15 +783,49 @@ def has_any(self, items: Iterable[str], player: int) -> bool: """Returns True if at least one item name of items is in state at least once.""" return any(self.prog_items[player][item] for item in items) + def has_all_counts(self, item_counts: Mapping[str, int], player: int) -> bool: + """Returns True if each item name is in the state at least as many times as specified.""" + return all(self.prog_items[player][item] >= count for item, count in item_counts.items()) + + def has_any_count(self, item_counts: Mapping[str, int], player: int) -> bool: + """Returns True if at least one item name is in the state at least as many times as specified.""" + return any(self.prog_items[player][item] >= count for item, count in item_counts.items()) + def count(self, item: str, player: int) -> int: return self.prog_items[player][item] - def item_count(self, item: str, player: int) -> int: - Utils.deprecate("Use count instead.") - return self.count(item, player) + def has_from_list(self, items: Iterable[str], player: int, count: int) -> bool: + """Returns True if the state contains at least `count` items matching any of the item names from a list.""" + found: int = 0 + player_prog_items = self.prog_items[player] + for item_name in items: + found += player_prog_items[item_name] + if found >= count: + return True + return False + + def has_from_list_unique(self, items: Iterable[str], player: int, count: int) -> bool: + """Returns True if the state contains at least `count` items matching any of the item names from a list. + Ignores duplicates of the same item.""" + found: int = 0 + player_prog_items = self.prog_items[player] + for item_name in items: + found += player_prog_items[item_name] > 0 + if found >= count: + return True + return False + + def count_from_list(self, items: Iterable[str], player: int) -> int: + """Returns the cumulative count of items from a list present in state.""" + return sum(self.prog_items[player][item_name] for item_name in items) + + def count_from_list_unique(self, items: Iterable[str], player: int) -> int: + """Returns the cumulative count of items from a list present in state. Ignores duplicates of the same item.""" + return sum(self.prog_items[player][item_name] > 0 for item_name in items) # item name group related def has_group(self, item_name_group: str, player: int, count: int = 1) -> bool: + """Returns True if the state contains at least `count` items present in a specified item group.""" found: int = 0 player_prog_items = self.prog_items[player] for item_name in self.multiworld.worlds[player].item_name_groups[item_name_group]: @@ -743,12 +834,34 @@ def has_group(self, item_name_group: str, player: int, count: int = 1) -> bool: return True return False - def count_group(self, item_name_group: str, player: int) -> int: + def has_group_unique(self, item_name_group: str, player: int, count: int = 1) -> bool: + """Returns True if the state contains at least `count` items present in a specified item group. + Ignores duplicates of the same item. + """ found: int = 0 player_prog_items = self.prog_items[player] for item_name in self.multiworld.worlds[player].item_name_groups[item_name_group]: - found += player_prog_items[item_name] - return found + found += player_prog_items[item_name] > 0 + if found >= count: + return True + return False + + def count_group(self, item_name_group: str, player: int) -> int: + """Returns the cumulative count of items from an item group present in state.""" + player_prog_items = self.prog_items[player] + return sum( + player_prog_items[item_name] + for item_name in self.multiworld.worlds[player].item_name_groups[item_name_group] + ) + + def count_group_unique(self, item_name_group: str, player: int) -> int: + """Returns the cumulative count of items from an item group present in state. + Ignores duplicates of the same item.""" + player_prog_items = self.prog_items[player] + return sum( + player_prog_items[item_name] > 0 + for item_name in self.multiworld.worlds[player].item_name_groups[item_name_group] + ) # Item related def collect(self, item: Item, event: bool = False, location: Optional[Location] = None) -> bool: @@ -811,8 +924,8 @@ def __repr__(self): return self.__str__() def __str__(self): - world = self.parent_region.multiworld if self.parent_region else None - return world.get_name_string_for_object(self) if world else f'{self.name} (Player {self.player})' + multiworld = self.parent_region.multiworld if self.parent_region else None + return multiworld.get_name_string_for_object(self) if multiworld else f'{self.name} (Player {self.player})' class Region: @@ -855,6 +968,8 @@ def __delitem__(self, index: int) -> None: del(self.region_manager.location_cache[location.player][location.name]) def insert(self, index: int, value: Location) -> None: + assert value.name not in self.region_manager.location_cache[value.player], \ + f"{value.name} already exists in the location cache." self._list.insert(index, value) self.region_manager.location_cache[value.player][value.name] = value @@ -865,6 +980,8 @@ def __delitem__(self, index: int) -> None: del(self.region_manager.entrance_cache[entrance.player][entrance.name]) def insert(self, index: int, value: Entrance) -> None: + assert value.name not in self.region_manager.entrance_cache[value.player], \ + f"{value.name} already exists in the entrance cache." self._list.insert(index, value) self.region_manager.entrance_cache[value.player][value.name] = value @@ -990,11 +1107,10 @@ class Location: name: str address: Optional[int] parent_region: Optional[Region] - event: bool = False locked: bool = False show_in_spoiler: bool = True progress_type: LocationProgressType = LocationProgressType.DEFAULT - always_allow = staticmethod(lambda item, state: False) + always_allow = staticmethod(lambda state, item: False) access_rule: Callable[[CollectionState], bool] = staticmethod(lambda state: True) item_rule = staticmethod(lambda item: True) item: Optional[Item] = None @@ -1006,7 +1122,7 @@ def __init__(self, player: int, name: str = '', address: Optional[int] = None, p self.parent_region = parent def can_fill(self, state: CollectionState, item: Item, check_access=True) -> bool: - return ((self.always_allow(state, item) and item.name not in state.multiworld.non_local_items[item.player]) + return ((self.always_allow(state, item) and item.name not in state.multiworld.worlds[item.player].options.non_local_items) or ((self.progress_type != LocationProgressType.EXCLUDED or not (item.advancement or item.useful)) and self.item_rule(item) and (not check_access or self.can_reach(state)))) @@ -1021,15 +1137,14 @@ def place_locked_item(self, item: Item): raise Exception(f"Location {self} already filled.") self.item = item item.location = self - self.event = item.advancement self.locked = True def __repr__(self): return self.__str__() def __str__(self): - world = self.parent_region.multiworld if self.parent_region and self.parent_region.multiworld else None - return world.get_name_string_for_object(self) if world else f'{self.name} (Player {self.player})' + multiworld = self.parent_region.multiworld if self.parent_region and self.parent_region.multiworld else None + return multiworld.get_name_string_for_object(self) if multiworld else f'{self.name} (Player {self.player})' def __hash__(self): return hash((self.name, self.player)) @@ -1037,6 +1152,15 @@ def __hash__(self): def __lt__(self, other: Location): return (self.player, self.name) < (other.player, other.name) + @property + def advancement(self) -> bool: + return self.item is not None and self.item.advancement + + @property + def is_event(self) -> bool: + """Returns True if the address of this location is None, denoting it is an Event Location.""" + return self.address is None + @property def native_item(self) -> bool: """Returns True if the item in this location matches game.""" @@ -1044,9 +1168,6 @@ def native_item(self) -> bool: @property def hint_text(self) -> str: - hint_text = getattr(self, "_hint_text", None) - if hint_text: - return hint_text return "at " + self.name.replace("_", " ").replace("-", " ") @@ -1166,7 +1287,7 @@ def set_entrance(self, entrance: str, exit_: str, direction: str, player: int) - {"player": player, "entrance": entrance, "exit": exit_, "direction": direction} def create_playthrough(self, create_paths: bool = True) -> None: - """Destructive to the world while it is run, damage gets repaired afterwards.""" + """Destructive to the multiworld while it is run, damage gets repaired afterwards.""" from itertools import chain # get locations containing progress items multiworld = self.multiworld @@ -1197,7 +1318,7 @@ def create_playthrough(self, create_paths: bool = True) -> None: logging.debug('The following items could not be reached: %s', ['%s (Player %d) at %s (Player %d)' % ( location.item.name, location.item.player, location.name, location.player) for location in sphere_candidates]) - if any([multiworld.accessibility[location.item.player] != 'minimal' for location in sphere_candidates]): + if any([multiworld.worlds[location.item.player].options.accessibility != 'minimal' for location in sphere_candidates]): raise RuntimeError(f'Not all progression items reachable ({sphere_candidates}). ' f'Something went terribly wrong here.') else: @@ -1246,19 +1367,17 @@ def create_playthrough(self, create_paths: bool = True) -> None: state = CollectionState(multiworld) collection_spheres = [] while required_locations: - state.sweep_for_events(key_only=True) - sphere = set(filter(state.can_reach, required_locations)) for location in sphere: state.collect(location.item, True, location) - required_locations -= sphere - collection_spheres.append(sphere) logging.debug('Calculated final sphere %i, containing %i of %i progress items.', len(collection_spheres), len(sphere), len(required_locations)) + + required_locations -= sphere if not sphere: raise RuntimeError(f'Not all required items reachable. Unreachable locations: {required_locations}') @@ -1317,10 +1436,15 @@ def get_path(state: CollectionState, region: Region) -> List[Union[Tuple[str, st get_path(state, multiworld.get_region('Inverted Big Bomb Shop', player)) def to_file(self, filename: str) -> None: + from itertools import chain + from worlds import AutoWorld + from Options import Visibility + def write_option(option_key: str, option_obj: Options.AssembleOptions) -> None: res = getattr(self.multiworld.worlds[player].options, option_key) - display_name = getattr(option_obj, "display_name", option_key) - outfile.write(f"{display_name + ':':33}{res.current_option_name}\n") + if res.visibility & Visibility.spoiler: + display_name = getattr(option_obj, "display_name", option_key) + outfile.write(f"{display_name + ':':33}{res.current_option_name}\n") with open(filename, 'w', encoding="utf-8-sig") as outfile: outfile.write( @@ -1351,6 +1475,14 @@ def write_option(option_key: str, option_obj: Options.AssembleOptions) -> None: AutoWorld.call_all(self.multiworld, "write_spoiler", outfile) + precollected_items = [f"{item.name} ({self.multiworld.get_player_name(item.player)})" + if self.multiworld.players > 1 + else item.name + for item in chain.from_iterable(self.multiworld.precollected_items.values())] + if precollected_items: + outfile.write("\n\nStarting Items:\n\n") + outfile.write("\n".join([item for item in precollected_items])) + locations = [(str(location), str(location.item) if location.item is not None else "Nothing") for location in self.multiworld.get_locations() if location.show_in_spoiler] outfile.write('\n\nLocations:\n\n') @@ -1440,8 +1572,3 @@ def get_seed(seed: Optional[int] = None) -> int: random.seed(None) return random.randint(0, pow(10, seeddigits) - 1) return seed - - -from worlds import AutoWorld - -auto_world = AutoWorld.World diff --git a/CommonClient.py b/CommonClient.py index c4d80f341611..09937e4b9ab8 100644 --- a/CommonClient.py +++ b/CommonClient.py @@ -1,5 +1,6 @@ from __future__ import annotations +import collections import copy import logging import asyncio @@ -8,6 +9,7 @@ import typing import time import functools +import warnings import ModuleUpdate ModuleUpdate.update() @@ -20,8 +22,8 @@ Utils.init_logging("TextClient", exception_logger="Client") from MultiServer import CommandProcessor -from NetUtils import Endpoint, decode, NetworkItem, encode, JSONtoTextParser, \ - ClientStatus, Permission, NetworkSlot, RawJSONtoTextParser +from NetUtils import (Endpoint, decode, NetworkItem, encode, JSONtoTextParser, ClientStatus, Permission, NetworkSlot, + RawJSONtoTextParser, add_json_text, add_json_location, add_json_item, JSONTypes, SlotType) from Utils import Version, stream_input, async_start from worlds import network_data_package, AutoWorldRegister import os @@ -59,6 +61,7 @@ def _cmd_connect(self, address: str = "") -> bool: 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 @@ -72,9 +75,16 @@ def _cmd_disconnect(self) -> bool: def _cmd_received(self) -> bool: """List all received items""" - self.output(f'{len(self.ctx.items_received)} received items:') + item: NetworkItem + self.output(f'{len(self.ctx.items_received)} received items, sorted by time:') for index, item in enumerate(self.ctx.items_received, 1): - self.output(f"{self.ctx.item_names[item.item]} from {self.ctx.player_names[item.player]}") + 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"}) return True def _cmd_missing(self, filter_text = "") -> bool: @@ -115,6 +125,15 @@ def _cmd_items(self): 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: @@ -124,6 +143,15 @@ def _cmd_locations(self): 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 @@ -148,10 +176,77 @@ class CommonContext: items_handling: typing.Optional[int] = None want_slot_data: bool = True # should slot_data be retrieved via Connect - # 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})') + 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) # defaults starting_reconnect_delay: int = 5 @@ -168,6 +263,7 @@ 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 @@ -181,6 +277,8 @@ class CommonContext: finished_game: bool ready: bool + team: typing.Optional[int] + slot: typing.Optional[int] auth: typing.Optional[str] seed_name: typing.Optional[str] @@ -203,7 +301,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], password: typing.Optional[str]) -> None: + def __init__(self, server_address: typing.Optional[str] = None, password: typing.Optional[str] = None) -> None: # server state self.server_address = server_address self.username = None @@ -243,6 +341,9 @@ def __init__(self, server_address: typing.Optional[str], password: typing.Option 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.jsontotextparser = JSONtoTextParser(self) self.rawjsontotextparser = RawJSONtoTextParser(self) self.update_data_package(network_data_package) @@ -396,6 +497,11 @@ def on_user_say(self, text: str) -> typing.Optional[str]: """Gets called before sending a Say to the server from the user. 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]): for permission_name, permission_flag in permissions.items(): @@ -409,6 +515,7 @@ def update_permissions(self, permissions: typing.Dict[str, int]): 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() @@ -458,25 +565,24 @@ async def prepare_data_package(self, relevant_games: typing.Set[str], or remote_checksum != cache_checksum: needed_updates.add(game) else: - self.update_game(cached_game) + self.update_game(cached_game, game) if needed_updates: - await self.send_msgs([{"cmd": "GetDataPackage", "games": list(needed_updates)}]) + await self.send_msgs([{"cmd": "GetDataPackage", "games": [game_name]} for game_name in needed_updates]) - 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_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"]) def update_data_package(self, data_package: dict): for game, game_data in data_package["games"].items(): - self.update_game(game_data) + self.update_game(game_data, game) 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) @@ -617,15 +723,16 @@ 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) + ssl=get_ssl_context() if address.startswith("wss://") else None, + max_size=ctx.max_size) if ctx.ui is not None: ctx.ui.update_address_bar(server_url.netloc) ctx.server = Endpoint(socket) @@ -727,14 +834,15 @@ 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. ' @@ -756,7 +864,8 @@ 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 = {int(pid): data for pid, data in args["slot_info"].items()} + 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.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}") @@ -941,4 +1050,5 @@ async def main(args): if __name__ == '__main__': + logging.getLogger().setLevel(logging.INFO) # force log-level to work around log level resetting to WARNING run_as_textclient() diff --git a/Fill.py b/Fill.py index 342c155079dd..5185bbb60ee4 100644 --- a/Fill.py +++ b/Fill.py @@ -19,23 +19,24 @@ 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()) -> CollectionState: +def sweep_from_pool(base_state: CollectionState, itempool: typing.Sequence[Item] = tuple(), + locations: typing.Optional[typing.List[Location]] = None) -> CollectionState: new_state = base_state.copy() for item in itempool: new_state.collect(item, True) - new_state.sweep_for_events() + new_state.sweep_for_events(locations=locations) return new_state -def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations: typing.List[Location], +def fill_restrictive(multiworld: 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: """ - :param world: Multiworld to be filled. + :param multiworld: Multiworld to be filled. :param base_state: State assumed before fill. - :param locations: Locations to be filled with item_pool - :param item_pool: Items to fill into the locations + :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 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 @@ -66,9 +67,10 @@ def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations: item_pool.pop(p) break maximum_exploration_state = sweep_from_pool( - base_state, item_pool + unplaced_items) + base_state, item_pool + unplaced_items, multiworld.get_filled_locations(item.player) + if single_player_placement else None) - has_beaten_game = world.has_beaten_game(maximum_exploration_state) + has_beaten_game = multiworld.has_beaten_game(maximum_exploration_state) while items_to_place: # if we have run out of locations to fill,break out of this loop @@ -80,8 +82,8 @@ def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations: spot_to_fill: typing.Optional[Location] = None # if minimal accessibility, only check whether location is reachable if game not beatable - if world.worlds[item_to_place.player].options.accessibility == Accessibility.option_minimal: - perform_access_check = not world.has_beaten_game(maximum_exploration_state, + if multiworld.worlds[item_to_place.player].options.accessibility == Accessibility.option_minimal: + perform_access_check = not multiworld.has_beaten_game(maximum_exploration_state, item_to_place.player) \ if single_player_placement else not has_beaten_game else: @@ -112,7 +114,9 @@ def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations: location.item = None placed_item.location = None - swap_state = sweep_from_pool(base_state, [placed_item, *item_pool] if unsafe else item_pool) + swap_state = sweep_from_pool(base_state, [placed_item, *item_pool] if unsafe else item_pool, + multiworld.get_filled_locations(item.player) + if single_player_placement else None) # unsafe means swap_state assumes we can somehow collect placed_item before item_to_place # by continuing to swap, which is not guaranteed. This is unsafe because there is no mechanic # to clean that up later, so there is a chance generation fails. @@ -122,11 +126,11 @@ def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations: # Verify placing this item won't reduce available locations, which would be a useless swap. prev_state = swap_state.copy() prev_loc_count = len( - world.get_reachable_locations(prev_state)) + multiworld.get_reachable_locations(prev_state)) swap_state.collect(item_to_place, True) new_loc_count = len( - world.get_reachable_locations(swap_state)) + multiworld.get_reachable_locations(swap_state)) if new_loc_count >= prev_loc_count: # Add this item to the existing placement, and @@ -156,10 +160,9 @@ def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations: else: unplaced_items.append(item_to_place) continue - world.push_item(spot_to_fill, item_to_place, False) + multiworld.push_item(spot_to_fill, item_to_place, False) spot_to_fill.locked = lock placements.append(spot_to_fill) - spot_to_fill.event = item_to_place.advancement placed += 1 if not placed % 1000: _log_fill_progress(name, placed, total) @@ -171,9 +174,11 @@ def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations: if cleanup_required: # validate all placements and remove invalid ones - state = sweep_from_pool(base_state, []) + state = sweep_from_pool( + base_state, [], multiworld.get_filled_locations(item.player) + if single_player_placement else None) for placement in placements: - if world.accessibility[placement.item.player] != "minimal" and not placement.can_reach(state): + if multiworld.worlds[placement.item.player].options.accessibility != "minimal" and not placement.can_reach(state): placement.item.location = None unplaced_items.append(placement.item) placement.item = None @@ -188,7 +193,7 @@ def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations: if excluded_locations: for location in excluded_locations: location.progress_type = location.progress_type.DEFAULT - fill_restrictive(world, base_state, excluded_locations, unplaced_items, single_player_placement, lock, + fill_restrictive(multiworld, 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: @@ -196,19 +201,27 @@ def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations: 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 world.can_beat_game(): + if multiworld.can_beat_game(): logging.warning( - f'Not all items placed. Game beatable anyway. (Could not place {unplaced_items})') + f"Not all items placed. Game beatable anyway.\nCould not place:\n" + f"{', '.join(str(item) for item in unplaced_items)}") else: - 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)}') + 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)}") item_pool.extend(unplaced_items) -def remaining_fill(world: MultiWorld, +def remaining_fill(multiworld: MultiWorld, locations: typing.List[Location], - itempool: typing.List[Item]) -> None: + itempool: typing.List[Item], + name: str = "Remaining", + move_unplaceable_to_start_inventory: bool = False) -> None: unplaced_items: typing.List[Item] = [] placements: typing.List[Location] = [] swapped_items: typing.Counter[typing.Tuple[int, str]] = Counter() @@ -261,36 +274,49 @@ def remaining_fill(world: MultiWorld, unplaced_items.append(item_to_place) continue - world.push_item(spot_to_fill, item_to_place, False) + multiworld.push_item(spot_to_fill, item_to_place, False) placements.append(spot_to_fill) placed += 1 if not placed % 1000: - _log_fill_progress("Remaining", placed, total) + _log_fill_progress(name, placed, total) if total > 1000: - _log_fill_progress("Remaining", placed, total) + _log_fill_progress(name, placed, total) if unplaced_items and locations: # There are leftover unplaceable items and locations that won't accept them - 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)}') + 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)}") itempool.extend(unplaced_items) -def fast_fill(world: MultiWorld, +def fast_fill(multiworld: 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): - world.push_item(location, item, False) + multiworld.push_item(location, item, False) return item_pool[placing:], fill_locations[placing:] -def accessibility_corrections(world: MultiWorld, state: CollectionState, locations, pool=[]): +def accessibility_corrections(multiworld: MultiWorld, state: CollectionState, locations, pool=[]): maximum_exploration_state = sweep_from_pool(state, pool) - minimal_players = {player for player in world.player_ids if world.worlds[player].options.accessibility == "minimal"} - unreachable_locations = [location for location in world.get_locations() if location.player in minimal_players and + minimal_players = {player for player in multiworld.player_ids if multiworld.worlds[player].options.accessibility == "minimal"} + unreachable_locations = [location for location in multiworld.get_locations() if location.player in minimal_players and not location.can_reach(maximum_exploration_state)] for location in unreachable_locations: if (location.item is not None and location.item.advancement and location.address is not None and not @@ -298,42 +324,41 @@ def accessibility_corrections(world: MultiWorld, state: CollectionState, locatio pool.append(location.item) state.remove(location.item) location.item = None - 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(world, state, locations, pool, name="Accessibility Corrections") + fill_restrictive(multiworld, state, locations, pool, name="Accessibility Corrections") -def inaccessible_location_rules(world: MultiWorld, state: CollectionState, locations): +def inaccessible_location_rules(multiworld: 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 world.worlds[item.player].options.accessibility != 'minimal') + return not ((item.classification & 0b0011) and multiworld.worlds[item.player].options.accessibility != 'minimal') for location in unreachable_locations: add_item_rule(location, forbid_important_item_rule) -def distribute_early_items(world: MultiWorld, +def distribute_early_items(multiworld: 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 world.player_ids: - items = itertools.chain(world.early_items[player], world.local_early_items[player]) + for player in multiworld.player_ids: + items = itertools.chain(multiworld.early_items[player], multiworld.local_early_items[player]) for item in items: - early_items_count[item, player] = [world.early_items[player].get(item, 0), - world.local_early_items[player].get(item, 0)] + early_items_count[item, player] = [multiworld.early_items[player].get(item, 0), + multiworld.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 = world.state.copy() - base_state.sweep_for_events(locations=(loc for loc in world.get_filled_locations() if loc.address is None)) + base_state = multiworld.state.copy() + base_state.sweep_for_events(locations=(loc for loc in multiworld.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: @@ -345,8 +370,8 @@ def distribute_early_items(world: 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 world.player_ids} - early_local_rest_items: typing.Dict[int, typing.List[Item]] = {player: [] for player in world.player_ids} + 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} item_indexes_to_remove: typing.Set[int] = set() for i, item in enumerate(itempool): if (item.name, item.player) in early_items_count: @@ -370,28 +395,28 @@ def distribute_early_items(world: 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 world.player_ids: + for player in multiworld.player_ids: player_local = early_local_rest_items[player] - fill_restrictive(world, base_state, + fill_restrictive(multiworld, 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}") 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(world, base_state, early_locations, early_rest_items, lock=True, allow_partial=True, + fill_restrictive(multiworld, base_state, early_locations, early_rest_items, lock=True, allow_partial=True, name="Early Items") early_locations += early_priority_locations - for player in world.player_ids: + for player in multiworld.player_ids: player_local = early_local_prog_items[player] - fill_restrictive(world, base_state, + fill_restrictive(multiworld, 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}") 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(world, base_state, early_locations, early_prog_items, lock=True, allow_partial=True, + fill_restrictive(multiworld, base_state, early_locations, early_prog_items, lock=True, allow_partial=True, name="Early Progression") unplaced_early_items = early_rest_items + early_prog_items if unplaced_early_items: @@ -400,18 +425,19 @@ def distribute_early_items(world: MultiWorld, itempool += unplaced_early_items fill_locations.extend(early_locations) - world.random.shuffle(fill_locations) + multiworld.random.shuffle(fill_locations) return fill_locations, itempool -def distribute_items_restrictive(world: MultiWorld) -> None: - fill_locations = sorted(world.get_unfilled_locations()) - world.random.shuffle(fill_locations) +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) # get items to distribute - itempool = sorted(world.itempool) - world.random.shuffle(itempool) + itempool = sorted(multiworld.itempool) + multiworld.random.shuffle(itempool) - fill_locations, itempool = distribute_early_items(world, fill_locations, itempool) + fill_locations, itempool = distribute_early_items(multiworld, fill_locations, itempool) progitempool: typing.List[Item] = [] usefulitempool: typing.List[Item] = [] @@ -425,7 +451,7 @@ def distribute_items_restrictive(world: MultiWorld) -> None: else: filleritempool.append(item) - call_all(world, "fill_hook", progitempool, usefulitempool, filleritempool, fill_locations) + call_all(multiworld, "fill_hook", progitempool, usefulitempool, filleritempool, fill_locations) locations: typing.Dict[LocationProgressType, typing.List[Location]] = { loc_type: [] for loc_type in LocationProgressType} @@ -446,75 +472,104 @@ def mark_for_locking(location: Location): if prioritylocations: # "priority fill" - fill_restrictive(world, world.state, prioritylocations, progitempool, swap=False, on_place=mark_for_locking, + fill_restrictive(multiworld, multiworld.state, prioritylocations, progitempool, + single_player_placement=multiworld.players == 1, swap=False, on_place=mark_for_locking, name="Priority") - accessibility_corrections(world, world.state, prioritylocations, progitempool) + accessibility_corrections(multiworld, multiworld.state, prioritylocations, progitempool) defaultlocations = prioritylocations + defaultlocations if progitempool: # "advancement/progression fill" - fill_restrictive(world, world.state, defaultlocations, progitempool, name="Progression") + if panic_method == "swap": + fill_restrictive(multiworld, multiworld.state, defaultlocations, progitempool, + swap=True, + name="Progression", single_player_placement=multiworld.players == 1) + elif panic_method == "raise": + fill_restrictive(multiworld, multiworld.state, defaultlocations, progitempool, + swap=False, + name="Progression", single_player_placement=multiworld.players == 1) + elif panic_method == "start_inventory": + fill_restrictive(multiworld, multiworld.state, defaultlocations, progitempool, + swap=False, allow_partial=True, + name="Progression", single_player_placement=multiworld.players == 1) + 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.") if progitempool: raise FillError( - f'Not enough locations for progress items. There are {len(progitempool)} more items than locations') - accessibility_corrections(world, world.state, defaultlocations) + f"Not enough locations for progression items. " + f"There are {len(progitempool)} more progression items than there are available locations." + ) + accessibility_corrections(multiworld, multiworld.state, defaultlocations) for location in lock_later: if location.item: location.locked = True del mark_for_locking, lock_later - inaccessible_location_rules(world, world.state, defaultlocations) + inaccessible_location_rules(multiworld, multiworld.state, defaultlocations) + + remaining_fill(multiworld, excludedlocations, filleritempool, "Remaining Excluded", + move_unplaceable_to_start_inventory=panic_method=="start_inventory") - remaining_fill(world, excludedlocations, filleritempool) if excludedlocations: raise FillError( - f"Not enough filler items for excluded locations. There are {len(excludedlocations)} more locations than items") + f"Not enough filler items for excluded locations. " + f"There are {len(excludedlocations)} more excluded locations than filler or trap items." + ) restitempool = filleritempool + usefulitempool - remaining_fill(world, defaultlocations, restitempool) + remaining_fill(multiworld, defaultlocations, restitempool, + move_unplaceable_to_start_inventory=panic_method=="start_inventory") 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 world.get_locations() if location.item) - locations_counter = Counter(location.player for location in world.get_locations()) + 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()) 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(world: MultiWorld) -> None: +def flood_items(multiworld: MultiWorld) -> None: # get items to distribute - world.random.shuffle(world.itempool) - itempool = world.itempool + multiworld.random.shuffle(multiworld.itempool) + itempool = multiworld.itempool progress_done = False # sweep once to pick up preplaced items - world.state.sweep_for_events() + multiworld.state.sweep_for_events() - # fill world from top of itempool while we can + # fill multiworld from top of itempool while we can while not progress_done: - location_list = world.get_unfilled_locations() - world.random.shuffle(location_list) + location_list = multiworld.get_unfilled_locations() + multiworld.random.shuffle(location_list) spot_to_fill = None for location in location_list: - if location.can_fill(world.state, itempool[0]): + if location.can_fill(multiworld.state, itempool[0]): spot_to_fill = location break if spot_to_fill: item = itempool.pop(0) - world.push_item(spot_to_fill, item, True) + multiworld.push_item(spot_to_fill, item, True) continue # ran out of spots, check if we need to step in and correct things - if len(world.get_reachable_locations()) == len(world.get_locations()): + if len(multiworld.get_reachable_locations()) == len(multiworld.get_locations()): progress_done = True continue @@ -524,7 +579,7 @@ def flood_items(world: MultiWorld) -> None: for item in itempool: if item.advancement: candidate_item_to_place = item - if world.unlocks_new_location(item): + if multiworld.unlocks_new_location(item): item_to_place = item break @@ -537,20 +592,20 @@ def flood_items(world: MultiWorld) -> None: raise FillError('No more progress items left to place.') # find item to replace with progress item - location_list = world.get_reachable_locations() - world.random.shuffle(location_list) + location_list = multiworld.get_reachable_locations() + multiworld.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) - world.push_item(location, item_to_place, True) + multiworld.push_item(location, item_to_place, True) itempool.remove(item_to_place) break -def balance_multiworld_progression(world: MultiWorld) -> None: +def balance_multiworld_progression(multiworld: 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. @@ -558,28 +613,28 @@ def balance_multiworld_progression(world: 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: world.worlds[player].options.progression_balancing / 100 - for player in world.player_ids - if world.worlds[player].options.progression_balancing > 0 + player: multiworld.worlds[player].options.progression_balancing / 100 + for player in multiworld.player_ids + if multiworld.worlds[player].options.progression_balancing > 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(world) + state: CollectionState = CollectionState(multiworld) checked_locations: typing.Set[Location] = set() - unchecked_locations: typing.Set[Location] = set(world.get_locations()) + unchecked_locations: typing.Set[Location] = set(multiworld.get_locations()) total_locations_count: typing.Counter[int] = Counter( location.player - for location in world.get_locations() + for location in multiworld.get_locations() if not location.locked ) reachable_locations_count: typing.Dict[int, int] = { player: 0 - for player in world.player_ids - if total_locations_count[player] and len(world.get_filled_locations(player)) != 0 + for player in multiworld.player_ids + if total_locations_count[player] and len(multiworld.get_filled_locations(player)) != 0 } balanceable_players = { player: balanceable_players[player] @@ -591,7 +646,6 @@ def balance_multiworld_progression(world: 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: @@ -643,7 +697,7 @@ def item_percentage(player: int, num: int) -> float: while True: # Check locations in the current sphere and gather progression items to swap earlier for location in balancing_sphere: - if location.event: + if location.advancement: balancing_state.collect(location.item, True, location) player = location.item.player # only replace items that end up in another player's world @@ -658,7 +712,7 @@ def item_percentage(player: int, num: int) -> float: balancing_unchecked_locations.remove(location) if not location.locked: balancing_reachables[location.player] += 1 - if world.has_beaten_game(balancing_state) or all( + if multiworld.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): @@ -675,7 +729,7 @@ def item_percentage(player: int, num: int) -> float: locations_to_test = unlocked_locations[player] items_to_test = list(candidate_items[player]) items_to_test.sort() - world.random.shuffle(items_to_test) + multiworld.random.shuffle(items_to_test) while items_to_test: testing = items_to_test.pop() reducing_state = state.copy() @@ -687,8 +741,8 @@ def item_percentage(player: int, num: int) -> float: reducing_state.sweep_for_events(locations=locations_to_test) - if world.has_beaten_game(balancing_state): - if not world.has_beaten_game(reducing_state): + if multiworld.has_beaten_game(balancing_state): + if not multiworld.has_beaten_game(reducing_state): items_to_replace.append(testing) else: reduced_sphere = get_sphere_locations(reducing_state, locations_to_test) @@ -696,33 +750,32 @@ def item_percentage(player: int, num: int) -> float: if p < threshold_percentages[player]: items_to_replace.append(testing) - replaced_items = False + old_moved_item_count = moved_item_count # 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.event and not l.locked) - world.random.shuffle(replacement_locations) + replacement_locations = sorted(l for l in checked_locations if not l.advancement and not l.locked) + multiworld.random.shuffle(replacement_locations) items_to_replace.sort() - world.random.shuffle(items_to_replace) + multiworld.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 new_location in replacement_locations: + for i, new_location in enumerate(replacement_locations): if new_location.can_fill(state, old_location.item, False) and \ old_location.can_fill(state, new_location.item, False): - replacement_locations.remove(new_location) + replacement_locations.pop(i) 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 replaced_items: + if old_moved_item_count < moved_item_count: 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): @@ -732,11 +785,11 @@ def item_percentage(player: int, num: int) -> float: sphere_locations.add(location) for location in sphere_locations: - if location.event: + if location.advancement: state.collect(location.item, True, location) checked_locations |= sphere_locations - if world.has_beaten_game(state): + if multiworld.has_beaten_game(state): break elif not sphere_locations: logging.warning("Progression Balancing ran out of paths.") @@ -753,10 +806,9 @@ 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(world: MultiWorld) -> None: +def distribute_planned(multiworld: MultiWorld) -> None: def warn(warning: str, force: typing.Union[bool, str]) -> None: if force in [True, 'fail', 'failure', 'none', False, 'warn', 'warning']: logging.warning(f'{warning}') @@ -769,24 +821,24 @@ def failed(warning: str, force: typing.Union[bool, str]) -> None: else: warn(warning, force) - swept_state = world.state.copy() + swept_state = multiworld.state.copy() swept_state.sweep_for_events() - reachable = frozenset(world.get_reachable_locations(swept_state)) + reachable = frozenset(multiworld.get_reachable_locations(swept_state)) early_locations: typing.Dict[int, typing.List[str]] = collections.defaultdict(list) non_early_locations: typing.Dict[int, typing.List[str]] = collections.defaultdict(list) - for loc in world.get_unfilled_locations(): + for loc in multiworld.get_unfilled_locations(): if loc in reachable: early_locations[loc.player].append(loc.name) else: # not reachable with swept state non_early_locations[loc.player].append(loc.name) - world_name_lookup = world.world_name_lookup + world_name_lookup = multiworld.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(world.player_ids) + player_ids = set(multiworld.player_ids) for player in player_ids: - for block in world.plando_items[player]: + for block in multiworld.plando_items[player]: block['player'] = player if 'force' not in block: block['force'] = 'silent' @@ -800,12 +852,12 @@ def failed(warning: str, force: typing.Union[bool, str]) -> None: else: target_world = block['world'] - if target_world is False or world.players == 1: # target own world + if target_world is False or multiworld.players == 1: # target own world worlds: typing.Set[int] = {player} elif target_world is True: # target any worlds besides own - worlds = set(world.player_ids) - {player} + worlds = set(multiworld.player_ids) - {player} elif target_world is None: # target all worlds - worlds = set(world.player_ids) + worlds = set(multiworld.player_ids) elif type(target_world) == list: # list of target worlds worlds = set() for listed_world in target_world: @@ -815,9 +867,9 @@ def failed(warning: str, force: typing.Union[bool, str]) -> 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, world.players + 1): + if target_world not in range(1, multiworld.players + 1): failed( - f"Cannot place item in world {target_world} as it is not in range of (1, {world.players})", + f"Cannot place item in world {target_world} as it is not in range of (1, {multiworld.players})", block['force']) continue worlds = {target_world} @@ -845,7 +897,7 @@ def failed(warning: str, force: typing.Union[bool, str]) -> None: item_list: typing.List[str] = [] for key, value in items.items(): if value is True: - value = world.itempool.count(world.worlds[player].create_item(key)) + value = multiworld.itempool.count(multiworld.worlds[player].create_item(key)) item_list += [key] * value items = item_list if isinstance(items, str): @@ -895,17 +947,17 @@ def failed(warning: str, force: typing.Union[bool, str]) -> None: count = block['count'] failed(f"Plando count {count} greater than locations specified", block['force']) block['count'] = len(block['locations']) - block['count']['target'] = world.random.randint(block['count']['min'], block['count']['max']) + block['count']['target'] = multiworld.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 - world.random.shuffle(plando_blocks) + multiworld.random.shuffle(plando_blocks) plando_blocks.sort(key=lambda block: (len(block['locations']) - block['count']['target'] if len(block['locations']) > 0 - else len(world.get_unfilled_locations(player)) - block['count']['target'])) + else len(multiworld.get_unfilled_locations(player)) - block['count']['target'])) for placement in plando_blocks: player = placement['player'] @@ -916,19 +968,19 @@ def failed(warning: str, force: typing.Union[bool, str]) -> None: maxcount = placement['count']['target'] from_pool = placement['from_pool'] - candidates = list(world.get_unfilled_locations_for_players(locations, sorted(worlds))) - world.random.shuffle(candidates) - world.random.shuffle(items) + candidates = list(multiworld.get_unfilled_locations_for_players(locations, sorted(worlds))) + multiworld.random.shuffle(candidates) + multiworld.random.shuffle(items) count = 0 err: typing.List[str] = [] successful_pairs: typing.List[typing.Tuple[Item, Location]] = [] for item_name in items: - item = world.worlds[player].create_item(item_name) + item = multiworld.worlds[player].create_item(item_name) for location in reversed(candidates): if (location.address is None) == (item.code is None): # either both None or both not None if not location.item: if location.item_rule(item): - if location.can_fill(world.state, item, False): + if location.can_fill(multiworld.state, item, False): successful_pairs.append((item, location)) candidates.remove(location) count = count + 1 @@ -946,21 +998,20 @@ def failed(warning: str, force: typing.Union[bool, str]) -> None: if count < placement['count']['min']: m = placement['count']['min'] failed( - f"Plando block failed to place {m - count} of {m} item(s) for {world.player_name[player]}, error(s): {' '.join(err)}", + f"Plando block failed to place {m - count} of {m} item(s) for {multiworld.player_name[player]}, error(s): {' '.join(err)}", placement['force']) for (item, location) in successful_pairs: - world.push_item(location, item, collect=False) - location.event = True # flag location to be checked during fill + multiworld.push_item(location, item, collect=False) location.locked = True logging.debug(f"Plando placed {item} at {location}") if from_pool: try: - world.itempool.remove(item) + multiworld.itempool.remove(item) except ValueError: warn( - f"Could not remove {item} from pool for {world.player_name[player]} as it's already missing from it.", + f"Could not remove {item} from pool for {multiworld.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} ({world.player_name[player]})") from e + f"Error running plando for player {player} ({multiworld.player_name[player]})") from e diff --git a/Generate.py b/Generate.py index e19a7a973f23..d7dd6523e7f1 100644 --- a/Generate.py +++ b/Generate.py @@ -1,48 +1,44 @@ 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 import ModuleUpdate ModuleUpdate.update() -import copy import Utils import Options from BaseClasses import seeddigits, get_seed, PlandoOptions -from Main import main as ERmain -from settings import get_settings from Utils import parse_yamls, version_tuple, __version__, tuplize_version -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(): - options = get_settings() - defaults = options.generator + from settings import get_settings + settings = get_settings() + defaults = settings.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 settings, urls are also valid') - parser.add_argument('--samesettings', help='Rolls settings per weights file rather than per player', + 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', 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=options.general_options.output_path, + parser.add_argument('--outputpath', default=settings.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) @@ -62,20 +58,23 @@ def mystery_argparse(): 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, options + return args def get_seed_name(random_source) -> str: return f"{random_source.randint(0, pow(10, seeddigits) - 1)}".zfill(seeddigits) -def main(args=None, callback=ERmain): +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.") + if not args: - args, options = mystery_argparse() - else: - options = get_settings() + args = mystery_argparse() seed = get_seed(args.seed) + Utils.init_logging(f"Generate_{seed}", loglevel=args.log_level) random.seed(seed) seed_name = get_seed_name(random) @@ -103,8 +102,8 @@ def main(args=None, callback=ERmain): 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.samesettings: - raise Exception("Cannot mix --samesettings with --meta") + if args.sameoptions: + raise Exception("Cannot mix --sameoptions with --meta") else: meta_weights = None player_id = 1 @@ -120,7 +119,7 @@ def main(args=None, callback=ERmain): 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())} + weights_cache = {key: value for key, value in sorted(weights_cache.items(), key=lambda k: k[0].casefold())} 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: @@ -144,10 +143,12 @@ def main(args=None, callback=ERmain): 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 @@ -156,7 +157,7 @@ def main(args=None, callback=ERmain): erargs.skip_output = args.skip_output settings_cache: Dict[str, Tuple[argparse.Namespace, ...]] = \ - {fname: (tuple(roll_settings(yaml, args.plando) for yaml in yamls) if args.samesettings else None) + {fname: (tuple(roll_settings(yaml, args.plando) for yaml in yamls) if args.sameoptions else None) for fname, yamls in weights_cache.items()} if meta_weights: @@ -236,7 +237,7 @@ def main(args=None, callback=ERmain): with open(os.path.join(args.outputpath if args.outputpath else ".", f"generate_{seed_name}.yaml"), "wt") as f: yaml.dump(important, f) - return callback(erargs, seed) + return erargs, seed def read_weights_yamls(path) -> Tuple[Any, ...]: @@ -302,52 +303,67 @@ def handle_name(name: str, player: int, name_counter: Counter): NUMBER=(number if number > 1 else ''), player=player, PLAYER=(player if player > 1 else ''))) - new_name = new_name.strip()[:16] + # Run .strip twice for edge case where after the initial .slice new_name has a leading whitespace. + # Could cause issues for some clients that cannot handle the additional whitespace. + new_name = new_name.strip()[:16].strip() if new_name == "Archipelago": raise Exception(f"You cannot name yourself \"{new_name}\"") return new_name -def 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, type: str, name: str) -> dict: +def update_weights(weights: dict, new_weights: dict, update_type: str, name: str) -> dict: logging.debug(f'Applying {new_weights}') - new_options = set(new_weights) - set(weights) - weights.update(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) if new_options: for new_option in new_options: - logging.warning(f'{type} Suboption "{new_option}" of "{name}" did not ' + logging.warning(f'{update_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: @@ -357,16 +373,7 @@ def roll_meta_option(option_key, game: str, category_dict: Dict) -> Any: if options[option_key].supports_weighting: return get_choice(option_key, category_dict) return category_dict[option_key] - if 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}.") + raise Options.OptionError(f"Error generating meta option {option_key} for {game}.") def roll_linked_options(weights: dict) -> dict: @@ -391,7 +398,7 @@ def roll_linked_options(weights: dict) -> dict: return weights -def roll_triggers(weights: dict, triggers: list) -> dict: +def roll_triggers(weights: dict, triggers: list, valid_keys: set) -> 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): @@ -414,7 +421,7 @@ def roll_triggers(weights: dict, triggers: list) -> 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 @@ -422,27 +429,31 @@ def roll_triggers(weights: dict, triggers: list) -> dict: def handle_option(ret: argparse.Namespace, game_weights: dict, option_key: str, option: type(Options.Option), plando_options: PlandoOptions): - if option_key in game_weights: - try: + try: + if option_key in game_weights: 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.verify(AutoWorldRegister.world_types[ret.game], ret.name, plando_options) + 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 else: - setattr(ret, option_key, option.from_any(option.default)) # call the from_any here to support default "random" + from worlds import AutoWorldRegister + player_option.verify(AutoWorldRegister.world_types[ret.game], ret.name, plando_options) 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"]) + weights = roll_triggers(weights, weights["triggers"], valid_keys) requirements = weights.get("requires", {}) if requirements: @@ -463,7 +474,12 @@ def roll_settings(weights: dict, plando_options: PlandoOptions = PlandoOptions.b ret.game = get_choice("game", weights) if ret.game not in AutoWorldRegister.world_types: - picks = Utils.get_fuzzy_results(ret.game, AutoWorldRegister.world_types, limit=1)[0] + 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.") 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,8 +489,14 @@ 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"]) + weights = roll_triggers(weights, game_weights["triggers"], valid_keys) game_weights = weights[ret.game] ret.name = get_choice('name', weights) @@ -483,146 +505,20 @@ def roll_settings(weights: dict, plando_options: PlandoOptions = PlandoOptions.b for option_key, option in world_type.options_dataclass.type_hints.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.") if PlandoOptions.items in plando_options: 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) + if ret.game == "A Link to the Past": + roll_alttp_settings(ret, game_weights) return ret -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") - )) - +def roll_alttp_settings(ret: argparse.Namespace, weights): ret.sprite_pool = weights.get('sprite_pool', []) ret.sprite = get_choice_legacy('sprite', weights, "Link") if 'random_sprite_on_event' in weights: @@ -650,7 +546,9 @@ def roll_alttp_settings(ret: argparse.Namespace, weights, plando_options): if __name__ == '__main__': import atexit confirmation = atexit.register(input, "Press enter to close.") - multiworld = main() + erargs, seed = main() + from Main import main as ERmain + multiworld = ERmain(erargs, seed) if __debug__: import gc import sys diff --git a/Launcher.py b/Launcher.py index 9e184bf1088d..e4b65be93a68 100644 --- a/Launcher.py +++ b/Launcher.py @@ -19,7 +19,7 @@ import webbrowser from os.path import isfile from shutil import which -from typing import Sequence, Union, Optional +from typing import Callable, Sequence, Union, Optional import Utils import settings @@ -100,9 +100,9 @@ def update_settings(): # Functions Component("Open host.yaml", func=open_host_yaml), Component("Open Patch", func=open_patch), - Component("Generate Template Settings", func=generate_yamls), + Component("Generate Template Options", func=generate_yamls), Component("Discord Server", icon="discord", func=lambda: webbrowser.open("https://discord.gg/8Z65BR2")), - Component("18+ Discord Server", icon="discord", func=lambda: webbrowser.open("https://discord.gg/fqvNCCRsu4")), + Component("Unrated/18+ Discord Server", icon="discord", func=lambda: webbrowser.open("https://discord.gg/fqvNCCRsu4")), Component("Browse Files", func=browse_files), ]) @@ -160,8 +160,12 @@ 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 + from kvui import App, ContainerLayout, GridLayout, Button, Label, ScrollBox, Widget + from kivy.core.window import Window from kivy.uix.image import AsyncImage from kivy.uix.relativelayout import RelativeLayout @@ -169,11 +173,8 @@ class Launcher(App): base_title: str = "Archipelago Launcher" container: ContainerLayout grid: GridLayout - - _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} + _tool_layout: Optional[ScrollBox] = None + _client_layout: Optional[ScrollBox] = None def __init__(self, ctx=None): self.title = self.base_title @@ -181,15 +182,9 @@ def __init__(self, ctx=None): self.icon = r"data/icon.png" super().__init__() - 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 _refresh_components(self) -> None: - def build_button(component: Component): + def build_button(component: Component) -> Widget: """ Builds a button widget for a given component. @@ -200,31 +195,61 @@ def build_button(component: Component): None. The button is added to the parent grid layout. """ - button = Button(text=component.display_name) + button = Button(text=component.display_name, size_hint_y=None, height=40) button.component = component button.bind(on_release=self.component_action) if component.icon != "icon": image = AsyncImage(source=icon_paths[component.icon], size=(38, 38), size_hint=(None, 1), pos=(5, 0)) - box_layout = RelativeLayout() + box_layout = RelativeLayout(size_hint_y=None, height=40) box_layout.add_widget(button) box_layout.add_widget(image) - button_layout.add_widget(box_layout) - else: - button_layout.add_widget(button) + 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} for (tool, client) in itertools.zip_longest(itertools.chain( - self._tools.items(), self._miscs.items(), self._adjusters.items()), self._clients.items()): + _tools.items(), _miscs.items(), _adjusters.items() + ), _clients.items()): # column 1 if tool: - build_button(tool[1]) - else: - button_layout.add_widget(Label()) + self._tool_layout.layout.add_widget(build_button(tool[1])) # column 2 if client: - build_button(client[1]) - else: - button_layout.add_widget(Label()) + 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) return self.container @@ -235,6 +260,14 @@ def component_action(button): 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 {filename}") + 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. @@ -243,10 +276,17 @@ def _stop(self, *largs): 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: @@ -259,7 +299,7 @@ def main(args: Optional[Union[argparse.Namespace, dict]] = None): elif not args: args = {} - if "Patch|Game|Component" in args: + if args.get("Patch|Game|Component", None) is not None: file, component = identify(args["Patch|Game|Component"]) if file: args['file'] = file diff --git a/LinksAwakeningClient.py b/LinksAwakeningClient.py index f3fc9d2cdb72..a51645feac92 100644 --- a/LinksAwakeningClient.py +++ b/LinksAwakeningClient.py @@ -348,7 +348,8 @@ async def wait_for_retroarch_connection(self): await asyncio.sleep(1.0) continue self.stop_bizhawk_spam = False - logger.info(f"Connected to Retroarch {version.decode('ascii')} running {rom_name.decode('ascii')}") + logger.info(f"Connected to Retroarch {version.decode('ascii', errors='replace')} " + f"running {rom_name.decode('ascii', errors='replace')}") return except (BlockingIOError, TimeoutError, ConnectionResetError): await asyncio.sleep(1.0) diff --git a/Main.py b/Main.py index b64650478bfe..ce054dcd393f 100644 --- a/Main.py +++ b/Main.py @@ -13,7 +13,7 @@ from BaseClasses import CollectionState, Item, Location, LocationProgressType, MultiWorld, Region 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 +from Utils import __version__, output_path, version_tuple, get_settings from settings import get_settings from worlds import AutoWorld from worlds.generic.Rules import exclusion_rules, locality_rules @@ -30,49 +30,24 @@ 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 world - world = MultiWorld(args.multi) + # initialize the multiworld + multiworld = MultiWorld(args.multi) logger = logging.getLogger() - world.set_seed(seed, args.race, str(args.outputname) if args.outputname else None) - world.plando_options = args.plando_options - - 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. - - world.set_options(args) - world.set_item_links() - world.state = CollectionState(world) - logger.info('Archipelago Version %s - Seed: %s\n', __version__, world.seed) + 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() + + multiworld.set_options(args) + multiworld.set_item_links() + multiworld.state = CollectionState(multiworld) + logger.info('Archipelago Version %s - Seed: %s\n', __version__, multiworld.seed) logger.info(f"Found {len(AutoWorld.AutoWorldRegister.world_types)} World Types:") longest_name = max(len(text) for text in AutoWorld.AutoWorldRegister.world_types) @@ -103,76 +78,98 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No # This assertion method should not be necessary to run if we are not outputting any multidata. if not args.skip_output: - AutoWorld.call_stage(world, "assert_generate") + AutoWorld.call_stage(multiworld, "assert_generate") - AutoWorld.call_all(world, "generate_early") + AutoWorld.call_all(multiworld, "generate_early") logger.info('') - for player in world.player_ids: - for item_name, count in world.worlds[player].options.start_inventory.value.items(): + for player in multiworld.player_ids: + for item_name, count in multiworld.worlds[player].options.start_inventory.value.items(): for _ in range(count): - world.push_precollected(world.create_item(item_name, player)) + multiworld.push_precollected(multiworld.create_item(item_name, player)) - for item_name, count in world.start_inventory_from_pool.setdefault(player, StartInventoryPool({})).value.items(): + for item_name, count in getattr(multiworld.worlds[player].options, + "start_inventory_from_pool", + StartInventoryPool({})).value.items(): for _ in range(count): - world.push_precollected(world.create_item(item_name, player)) - - logger.info('Creating World.') - AutoWorld.call_all(world, "create_regions") + 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.early_local_items[player].get(item_name, 0) + if local_early: + multiworld.early_items[player][item_name] = max(0, local_early - remaining_count) + del local_early + del early + + logger.info('Creating MultiWorld.') + AutoWorld.call_all(multiworld, "create_regions") logger.info('Creating Items.') - AutoWorld.call_all(world, "create_items") + AutoWorld.call_all(multiworld, "create_items") logger.info('Calculating Access Rules.') - for player in world.player_ids: + for player in multiworld.player_ids: # items can't be both local and non-local, prefer local - world.worlds[player].options.non_local_items.value -= world.worlds[player].options.local_items.value - world.worlds[player].options.non_local_items.value -= set(world.local_early_items[player]) + multiworld.worlds[player].options.non_local_items.value -= multiworld.worlds[player].options.local_items.value + multiworld.worlds[player].options.non_local_items.value -= set(multiworld.local_early_items[player]) - AutoWorld.call_all(world, "set_rules") + AutoWorld.call_all(multiworld, "set_rules") - for player in world.player_ids: - exclusion_rules(world, player, world.worlds[player].options.exclude_locations.value) - world.worlds[player].options.priority_locations.value -= world.worlds[player].options.exclude_locations.value - for location_name in world.worlds[player].options.priority_locations.value: + 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: try: - 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: + location = multiworld.get_location(location_name, player) + except KeyError: + continue + + if location.progress_type != LocationProgressType.EXCLUDED: location.progress_type = LocationProgressType.PRIORITY + 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 # Set local and non-local item rules. - if world.players > 1: - locality_rules(world) + if multiworld.players > 1: + locality_rules(multiworld) else: - world.worlds[1].options.non_local_items.value = set() - world.worlds[1].options.local_items.value = set() + multiworld.worlds[1].options.non_local_items.value = set() + multiworld.worlds[1].options.local_items.value = set() - AutoWorld.call_all(world, "generate_basic") + AutoWorld.call_all(multiworld, "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(world.start_inventory_from_pool[player].value for player in world.player_ids): + if any(getattr(multiworld.worlds[player].options, "start_inventory_from_pool", None) for player in multiworld.player_ids): new_items: List[Item] = [] depletion_pool: Dict[int, Dict[str, int]] = { - player: world.start_inventory_from_pool[player].value.copy() for player in world.player_ids} + player: getattr(multiworld.worlds[player].options, + "start_inventory_from_pool", + StartInventoryPool({})).value.copy() + for player in multiworld.player_ids + } for player, items in depletion_pool.items(): - player_world: AutoWorld.World = world.worlds[player] + player_world: AutoWorld.World = multiworld.worlds[player] for count in items.values(): for _ in range(count): new_items.append(player_world.create_filler()) target: int = sum(sum(items.values()) for items in depletion_pool.values()) - for i, item in enumerate(world.itempool): + for i, item in enumerate(multiworld.itempool): if depletion_pool[item.player].get(item.name, 0): target -= 1 depletion_pool[item.player][item.name] -= 1 # quick abort if we have found all items if not target: - new_items.extend(world.itempool[i+1:]) + new_items.extend(multiworld.itempool[i+1:]) break else: new_items.append(item) @@ -182,139 +179,64 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No for player, remaining_items in depletion_pool.items(): remaining_items = {name: count for name, count in remaining_items.items() if count} if remaining_items: - raise Exception(f"{world.get_player_name(player)}" + raise Exception(f"{multiworld.get_player_name(player)}" f" is trying to remove items from their pool that don't exist: {remaining_items}") - assert len(world.itempool) == len(new_items), "Item Pool amounts should not change." - world.itempool[:] = new_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 - - for player in players.copy(): - if all([counters[player][item] == 0 for item in shared_pool]): - players.remove(player) - del (counters[player]) - - if not players: - return None, None - - 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) + assert len(multiworld.itempool) == len(new_items), "Item Pool amounts should not change." + multiworld.itempool[:] = new_items - itemcount = len(world.itempool) - world.itempool = new_itempool + multiworld.link_items() - 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._all_state = None + if any(multiworld.item_links.values()): + multiworld._all_state = None logger.info("Running Item Plando.") - distribute_planned(world) + distribute_planned(multiworld) logger.info('Running Pre Main Fill.') - AutoWorld.call_all(world, "pre_fill") + AutoWorld.call_all(multiworld, "pre_fill") - logger.info(f'Filling the world with {len(world.itempool)} items.') + logger.info(f'Filling the multiworld with {len(multiworld.itempool)} items.') - if world.algorithm == 'flood': - flood_items(world) # different algo, biased towards early game progress items - elif world.algorithm == 'balanced': - distribute_items_restrictive(world) + 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) - AutoWorld.call_all(world, 'post_fill') + AutoWorld.call_all(multiworld, 'post_fill') - if world.players > 1 and not args.skip_prog_balancing: - balance_multiworld_progression(world) + if multiworld.players > 1 and not args.skip_prog_balancing: + balance_multiworld_progression(multiworld) 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 - world.random.passthrough = False + multiworld.random.passthrough = False if args.skip_output: logger.info('Done. Skipped output/spoiler generation. Total Time: %s', time.perf_counter() - start) - return world + return multiworld logger.info(f'Beginning output...') - outfilebase = 'AP_' + world.seed_name + outfilebase = 'AP_' + multiworld.seed_name output = tempfile.TemporaryDirectory() with output as temp_dir: - output_players = [player for player in world.player_ids if AutoWorld.World.generate_output.__code__ - is not world.worlds[player].generate_output.__code__] + 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(world.fulfills_accessibility) + check_accessibility_task = pool.submit(multiworld.fulfills_accessibility) - output_file_futures = [pool.submit(AutoWorld.call_stage, world, "generate_output", temp_dir)] + output_file_futures = [pool.submit(AutoWorld.call_stage, multiworld, "generate_output", temp_dir)] for player in output_players: # skip starting a thread for methods that say "pass". output_file_futures.append( - pool.submit(AutoWorld.call_single, world, "generate_output", player, temp_dir)) + pool.submit(AutoWorld.call_single, multiworld, "generate_output", player, temp_dir)) # collect ER hint info er_hint_data: Dict[int, Dict[int, str]] = {} - AutoWorld.call_all(world, 'extend_hint_information', er_hint_data) + AutoWorld.call_all(multiworld, 'extend_hint_information', er_hint_data) def write_multidata(): import NetUtils @@ -323,38 +245,38 @@ def write_multidata(): games = {} minimum_versions = {"server": AutoWorld.World.required_server_version, "clients": client_versions} slot_info = {} - names = [[name for player, name in sorted(world.player_name.items())]] - for slot in world.player_ids: - player_world: AutoWorld.World = world.worlds[slot] + names = [[name for player, name in sorted(multiworld.player_name.items())]] + for slot in multiworld.player_ids: + player_world: AutoWorld.World = multiworld.worlds[slot] minimum_versions["server"] = max(minimum_versions["server"], player_world.required_server_version) client_versions[slot] = player_world.required_client_version - 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], + 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], 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 world.precollected_items.items()} - precollected_hints = {player: set() for player in range(1, world.players + 1 + len(world.groups))} + 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 slot in world.player_ids: - slot_data[slot] = world.worlds[slot].fill_slot_data() + for slot in multiworld.player_ids: + slot_data[slot] = multiworld.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 world.groups: + if location.item.player not in multiworld.groups: precollected_hints[location.item.player].add(hint) else: - for player in world.groups[location.item.player]["players"]: + for player in multiworld.groups[location.item.player]["players"]: precollected_hints[player].add(hint) - locations_data: Dict[int, Dict[int, Tuple[int, int, int]]] = {player: {} for player in world.player_ids} - for location in world.get_filled_locations(): + locations_data: Dict[int, Dict[int, Tuple[int, int, int]]] = {player: {} for player in multiworld.player_ids} + for location in multiworld.get_filled_locations(): if type(location.address) == int: assert location.item.code is not None, "item code None should be event, " \ "location.address should then also be None. Location: " \ @@ -364,26 +286,37 @@ def precollect_hint(location): 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 world.worlds[location.player].options.start_location_hints: + if location.name in multiworld.worlds[location.player].options.start_location_hints: precollect_hint(location) - elif location.item.name in world.worlds[location.item.player].options.start_hints: + elif location.item.name in multiworld.worlds[location.item.player].options.start_hints: precollect_hint(location) - elif any([location.item.name in world.worlds[player].options.start_hints - for player in world.groups.get(location.item.player, {}).get("players", [])]): + elif any([location.item.name in multiworld.worlds[player].options.start_hints + for player in multiworld.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 world.worlds.values() + for game_world in multiworld.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 world.player_name.items()}, + "connect_names": {name: (0, player) for player, name in multiworld.player_name.items()}, "locations": locations_data, "checks_in_area": checks_in_area, "server_options": baked_server_options, @@ -393,10 +326,11 @@ def precollect_hint(location): "version": tuple(version_tuple), "tags": ["AP"], "minimum_versions": minimum_versions, - "seed_name": world.seed_name, + "seed_name": multiworld.seed_name, + "spheres": spheres, "datapackage": data_package, } - AutoWorld.call_all(world, "modify_multidata", multidata) + AutoWorld.call_all(multiworld, "modify_multidata", multidata) multidata = zlib.compress(pickle.dumps(multidata), 9) @@ -406,7 +340,7 @@ def precollect_hint(location): output_file_futures.append(pool.submit(write_multidata)) if not check_accessibility_task.result(): - if not world.can_beat_game(): + if not multiworld.can_beat_game(): raise Exception("Game appears as unbeatable. Aborting.") else: logger.warning("Location Accessibility requirements not fulfilled.") @@ -419,12 +353,12 @@ def precollect_hint(location): if args.spoiler > 1: logger.info('Calculating playthrough.') - world.spoiler.create_playthrough(create_paths=args.spoiler > 2) + multiworld.spoiler.create_playthrough(create_paths=args.spoiler > 2) if args.spoiler: - world.spoiler.to_file(os.path.join(temp_dir, '%s_Spoiler.txt' % outfilebase)) + multiworld.spoiler.to_file(os.path.join(temp_dir, '%s_Spoiler.txt' % outfilebase)) - zipfilename = output_path(f"AP_{world.seed_name}.zip") + zipfilename = output_path(f"AP_{multiworld.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: @@ -432,4 +366,4 @@ def precollect_hint(location): zf.write(file.path, arcname=file.name) logger.info('Done. Enjoy. Total Time: %s', time.perf_counter() - start) - return world + return multiworld diff --git a/ModuleUpdate.py b/ModuleUpdate.py index c33e894e8b5f..ed041bef4604 100644 --- a/ModuleUpdate.py +++ b/ModuleUpdate.py @@ -4,14 +4,29 @@ 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) -update_ran = getattr(sys, "frozen", False) or multiprocessing.parent_process() +_skip_update = bool(getattr(sys, "frozen", False) or multiprocessing.parent_process()) +update_ran = _skip_update + + +class RequirementsSet(set): + def add(self, e): + global update_ran + update_ran &= _skip_update + super().add(e) + + def update(self, *s): + global update_ran + update_ran &= _skip_update + super().update(*s) + + +local_dir = os.path.dirname(__file__) +requirements_files = RequirementsSet((os.path.join(local_dir, 'requirements.txt'),)) if not update_ran: for entry in os.scandir(os.path.join(local_dir, "worlds")): @@ -55,7 +70,7 @@ def install_pkg_resources(yes=False): subprocess.call([sys.executable, "-m", "pip", "install", "--upgrade", "setuptools"]) -def update(yes=False, force=False): +def update(yes: bool = False, force: bool = False) -> None: global update_ran if not update_ran: update_ran = True diff --git a/MultiServer.py b/MultiServer.py index 9d2e9b564e75..f59855fca6a4 100644 --- a/MultiServer.py +++ b/MultiServer.py @@ -3,6 +3,7 @@ import argparse import asyncio import collections +import contextlib import copy import datetime import functools @@ -37,7 +38,7 @@ import NetUtils import Utils -from Utils import version_tuple, restricted_loads, Version, async_start +from Utils import version_tuple, restricted_loads, Version, async_start, get_intended_text from NetUtils import Endpoint, ClientStatus, NetworkItem, decode, encode, NetworkPlayer, Permission, NetworkSlot, \ SlotType, LocationStore @@ -168,18 +169,25 @@ class Context: slot_info: typing.Dict[int, NetworkSlot] generator_version = Version(0, 0, 0) checksums: typing.Dict[str, str] - item_names: typing.Dict[int, str] = Utils.KeyedDefaultDict(lambda code: f'Unknown item (ID:{code})') + item_names: typing.Dict[str, typing.Dict[int, str]] = ( + collections.defaultdict(lambda: 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[int, str] = Utils.KeyedDefaultDict(lambda code: f'Unknown location (ID:{code})') + location_names: typing.Dict[str, typing.Dict[int, str]] = ( + collections.defaultdict(lambda: 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.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 + 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): + log_network: bool = False, logger: logging.Logger = logging.getLogger()): + self.logger = logger super(Context, self).__init__() self.slot_info = {} self.log_network = log_network @@ -224,7 +232,7 @@ def __init__(self, host: str, port: int, server_password: str, password: str, lo 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 = None + self.auto_saver_thread: typing.Optional[threading.Thread] = None self.save_dirty = False self.tags = ['AP'] self.games: typing.Dict[int, str] = {} @@ -236,6 +244,7 @@ def __init__(self, host: str, port: int, server_password: str, password: str, lo 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 = {} @@ -260,19 +269,31 @@ def _load_game_data(self): 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[item_id] = item_name + self.item_names[game_name][item_id] = item_name for location_name, location_id in game_package["location_name_to_id"].items(): - self.location_names[location_id] = location_name + self.location_names[game_name][location_id] = location_name self.all_item_and_group_names[game_name] = \ set(game_package["item_name_to_id"]) | set(self.item_name_groups[game_name]) self.all_location_and_group_names[game_name] = \ set(game_package["location_name_to_id"]) | set(self.location_name_groups.get(game_name, [])) + archipelago_item_names = self.item_names["Archipelago"] + archipelago_location_names = self.location_names["Archipelago"] + for game in [game_name for game_name in self.gamespackage if game_name != "Archipelago"]: + # Add Archipelago items and locations to each data package. + self.item_names[game].update(archipelago_item_names) + self.location_names[game].update(archipelago_location_names) + def item_names_for_game(self, game: str) -> typing.Optional[typing.Dict[str, int]]: return self.gamespackage[game]["item_name_to_id"] if game in self.gamespackage else None @@ -287,12 +308,12 @@ async def send_msgs(self, endpoint: Endpoint, msgs: typing.Iterable[dict]) -> bo try: await endpoint.socket.send(msg) except websockets.ConnectionClosed: - logging.exception(f"Exception during send_msgs, could not send {msg}") + self.logger.exception(f"Exception during send_msgs, could not send {msg}") await self.disconnect(endpoint) return False else: if self.log_network: - logging.info(f"Outgoing message: {msg}") + self.logger.info(f"Outgoing message: {msg}") return True async def send_encoded_msgs(self, endpoint: Endpoint, msg: str) -> bool: @@ -301,12 +322,12 @@ async def send_encoded_msgs(self, endpoint: Endpoint, msg: str) -> bool: try: await endpoint.socket.send(msg) except websockets.ConnectionClosed: - logging.exception("Exception during send_encoded_msgs") + self.logger.exception("Exception during send_encoded_msgs") await self.disconnect(endpoint) return False else: if self.log_network: - logging.info(f"Outgoing message: {msg}") + self.logger.info(f"Outgoing message: {msg}") return True async def broadcast_send_encoded_msgs(self, endpoints: typing.Iterable[Endpoint], msg: str) -> bool: @@ -317,11 +338,11 @@ async def broadcast_send_encoded_msgs(self, endpoints: typing.Iterable[Endpoint] try: websockets.broadcast(sockets, msg) except RuntimeError: - logging.exception("Exception during broadcast_send_encoded_msgs") + self.logger.exception("Exception during broadcast_send_encoded_msgs") return False else: if self.log_network: - logging.info(f"Outgoing broadcast: {msg}") + self.logger.info(f"Outgoing broadcast: {msg}") return True def broadcast_all(self, msgs: typing.List[dict]): @@ -330,7 +351,7 @@ def broadcast_all(self, msgs: typing.List[dict]): async_start(self.broadcast_send_encoded_msgs(endpoints, msgs)) def broadcast_text_all(self, text: str, additional_arguments: dict = {}): - logging.info("Notice (all): %s" % text) + self.logger.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]): @@ -352,7 +373,7 @@ async def disconnect(self, endpoint: Client): def notify_client(self, client: Client, text: str, additional_arguments: dict = {}): if not client.auth: return - logging.info("Notice (Player %s in team %d): %s" % (client.name, client.team + 1, text)) + self.logger.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 = {}): @@ -451,7 +472,7 @@ def _load(self, decoded_obj: dict, game_data_packages: typing.Dict[str, typing.A for game_name, data in decoded_obj.get("datapackage", {}).items(): if game_name in game_data_packages: data = game_data_packages[game_name] - logging.info(f"Loading embedded data package for game {game_name}") + self.logger.info(f"Loading embedded data package for game {game_name}") self.gamespackage[game_name] = data self.item_name_groups[game_name] = data["item_name_groups"] if "location_name_groups" in data: @@ -464,6 +485,9 @@ def _load(self, decoded_obj: dict, game_data_packages: typing.Dict[str, typing.A 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: @@ -483,7 +507,7 @@ def _save(self, exit_save: bool = False) -> bool: with open(self.save_filename, "wb") as f: f.write(zlib.compress(encoded_save)) except Exception as e: - logging.exception(e) + self.logger.exception(e) return False else: return True @@ -501,12 +525,12 @@ def init_save(self, enabled: bool = True): save_data = restricted_loads(zlib.decompress(f.read())) self.set_save(save_data) except FileNotFoundError: - logging.error('No save data found, starting a new game') + self.logger.error('No save data found, starting a new game') except Exception as e: - logging.exception(e) + self.logger.exception(e) self._start_async_saving() - def _start_async_saving(self): + def _start_async_saving(self, atexit_save: bool = True): if not self.auto_saver_thread: def save_regularly(): # time.time() is platform dependent, so using the expensive datetime method instead @@ -520,18 +544,19 @@ def get_datetime_second(): next_wakeup = (second - get_datetime_second()) % self.auto_save_interval time.sleep(max(1.0, next_wakeup)) if self.save_dirty: - logging.debug("Saving via thread.") + self.logger.debug("Saving via thread.") self._save() except OperationalError as e: - logging.exception(e) - logging.info(f"Saving failed. Retry in {self.auto_save_interval} seconds.") + self.logger.exception(e) + self.logger.info(f"Saving failed. Retry in {self.auto_save_interval} seconds.") else: self.save_dirty = False self.auto_saver_thread = threading.Thread(target=save_regularly, daemon=True) self.auto_saver_thread.start() - import atexit - atexit.register(self._save, True) # make sure we save on exit too + if atexit_save: + import atexit + atexit.register(self._save, True) # make sure we save on exit too def get_save(self) -> dict: self.recheck_hints() @@ -586,7 +611,7 @@ def set_save(self, savedata: dict): 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"].get("release_mode", savedata["game_options"].get("forfeit_mode", "goal")) + self.release_mode = savedata["game_options"]["release_mode"] self.remaining_mode = savedata["game_options"]["remaining_mode"] self.collect_mode = savedata["game_options"]["collect_mode"] self.item_cheat = savedata["game_options"]["item_cheat"] @@ -598,7 +623,7 @@ def set_save(self, savedata: dict): if "stored_data" in savedata: self.stored_data = savedata["stored_data"] # count items and slots from lists for items_handling = remote - logging.info( + self.logger.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') @@ -621,6 +646,16 @@ def get_rechecked_hints(self, team: int, slot: int): 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()] @@ -631,8 +666,6 @@ def slot_set(self, slot) -> typing.Set[int]: 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 @@ -642,13 +675,13 @@ def _set_options(self, server_options: dict): try: raise Exception(f"Could not set server option {key}, skipping.") from e except Exception as e: - logging.exception(e) - logging.debug(f"Setting server option {key} to {value} from supplied multidata") + self.logger.exception(e) + self.logger.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: - logging.debug(f"Unrecognized server option {key}") + self.logger.debug(f"Unrecognized server option {key}") def get_aliased_name(self, team: int, slot: int): if (team, slot) in self.name_aliases: @@ -656,7 +689,8 @@ def get_aliased_name(self, team: int, slot: int): else: return self.player_names[team, slot] - def notify_hints(self, team: int, hints: typing.List[NetUtils.Hint], only_new: bool = False): + def notify_hints(self, team: int, hints: typing.List[NetUtils.Hint], only_new: bool = False, + recipients: typing.Sequence[int] = None): """Send and remember hints.""" if only_new: hints = [hint for hint in hints if hint not in self.hints[team, hint.finding_player]] @@ -681,16 +715,17 @@ def notify_hints(self, team: int, hints: typing.List[NetUtils.Hint], only_new: b self.hints[team, player].add(hint) new_hint_events.add(player) - logging.info("Notice (Team #%d): %s" % (team + 1, format_hint(self, team, hint))) + self.logger.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(): - 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)) + 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)) # "events" @@ -705,15 +740,18 @@ def on_goal_achieved(self, client: Client): self.save() # save goal completion flag def on_new_hint(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]}]) + 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]) @@ -736,21 +774,21 @@ async def server(websocket, path: str = "/", ctx: Context = None): try: if ctx.log_network: - logging.info("Incoming connection") + ctx.logger.info("Incoming connection") await on_client_connected(ctx, client) if ctx.log_network: - logging.info("Sent Room Info") + ctx.logger.info("Sent Room Info") async for data in websocket: if ctx.log_network: - logging.info(f"Incoming message: {data}") + ctx.logger.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): - logging.exception(e) + ctx.logger.exception(e) finally: if ctx.log_network: - logging.info("Disconnected") + ctx.logger.info("Disconnected") await ctx.disconnect(client) @@ -760,10 +798,7 @@ 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, [{ @@ -778,8 +813,6 @@ 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, @@ -800,14 +833,25 @@ 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) - verb = "tracking" if "Tracker" in client.tags else "playing" + + for tag, verb in _non_game_messages.items(): + if tag in client.tags: + final_verb = verb + break + else: + final_verb = "playing" + ctx.broadcast_text_all( f"{ctx.get_aliased_name(client.team, client.slot)} (Team #{client.team + 1}) " - f"{verb} {ctx.games[client.slot]} has joined. " + f"{final_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, " @@ -822,8 +866,19 @@ 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( - "%s (Team #%d) has left the game" % (ctx.get_aliased_name(client.team, client.slot), client.team + 1), + f"{ctx.get_aliased_name(client.team, client.slot)} (Team #{client.team + 1}) has {final_verb} the game. " + f"Client({version_str}), {client.tags}.", {"type": "Part", "team": client.team, "slot": client.slot}) @@ -960,9 +1015,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) - 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])) + 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])) info_text = json_format_send_event(new_item, target_player) ctx.broadcast_team(team, [info_text]) @@ -973,7 +1028,10 @@ 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() @@ -1013,8 +1071,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[hint.item]} is " \ - f"at {ctx.location_names[hint.location]} " \ + 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"in {ctx.player_names[team, hint.finding_player]}'s World" if hint.entrance: @@ -1043,26 +1101,6 @@ 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"] = {} @@ -1314,7 +1352,7 @@ def _cmd_remaining(self) -> bool: if self.ctx.remaining_mode == "enabled": 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] + self.output("Remaining items: " + ", ".join(self.ctx.item_names[self.ctx.games[self.client.slot]][item_id] for item_id in remaining_item_ids)) else: self.output("No remaining items found.") @@ -1327,7 +1365,7 @@ def _cmd_remaining(self) -> bool: if self.ctx.client_game_state[self.client.team, self.client.slot] == ClientStatus.CLIENT_GOAL: 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] + self.output("Remaining items: " + ", ".join(self.ctx.item_names[self.ctx.games[self.client.slot]][item_id] for item_id in remaining_item_ids)) else: self.output("No remaining items found.") @@ -1337,6 +1375,7 @@ def _cmd_remaining(self) -> bool: "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.""" @@ -1344,9 +1383,14 @@ def _cmd_missing(self, filter_text="") -> bool: locations = get_missing_checks(self.ctx, self.client.team, self.client.slot) if locations: - names = [self.ctx.location_names[location] for location in locations] + game = self.ctx.slot_info[self.client.slot].game + names = [self.ctx.location_names[game][location] for location in locations] if filter_text: - names = [name for name in names if filter_text in name] + 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] 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.") @@ -1357,6 +1401,7 @@ def _cmd_missing(self, filter_text="") -> bool: 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.""" @@ -1364,9 +1409,14 @@ def _cmd_checked(self, filter_text="") -> bool: locations = get_checked_checks(self.ctx, self.client.team, self.client.slot) if locations: - names = [self.ctx.location_names[location] for location in locations] + game = self.ctx.slot_info[self.client.slot].game + names = [self.ctx.location_names[game][location] for location in locations] if filter_text: - names = [name for name in names if filter_text in name] + 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] 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.") @@ -1429,18 +1479,22 @@ def get_hints(self, input_text: str, for_location: bool = False) -> bool: 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)) + self.ctx.notify_hints(self.client.team, list(hints), recipients=(self.client.slot,)) 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[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 \ + 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] \ else None if hint_name in self.ctx.non_hintable_names[game]: self.output(f"Sorry, \"{hint_name}\" is marked as non-hintable.") @@ -1485,15 +1539,13 @@ def get_hints(self, input_text: str, for_location: bool = False) -> bool: if hints: new_hints = set(hints) - self.ctx.hints[self.client.team, self.client.slot] - 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.") + 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.") 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: @@ -1504,8 +1556,11 @@ def get_hints(self, input_text: str, for_location: bool = False) -> bool: 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 + hints = found_hints + old_hints while can_pay > 0: if not not_found_hints: break @@ -1513,9 +1568,10 @@ def get_hints(self, input_text: str, for_location: bool = False) -> bool: hints.append(hint) can_pay -= 1 self.ctx.hints_used[self.client.team, self.client.slot] += 1 - points_available = get_client_points(self.ctx, self.client) + self.ctx.notify_hints(self.client.team, hints) if not_found_hints: + points_available = get_client_points(self.ctx, self.client) 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. " @@ -1528,7 +1584,6 @@ def get_hints(self, input_text: str, for_location: bool = False) -> bool: 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 @@ -1583,7 +1638,7 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict): try: cmd: str = args["cmd"] except: - logging.exception(f"Could not get command from {args}") + ctx.logger.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 @@ -1609,7 +1664,9 @@ 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 = ("TextOnly" in args["tags"] or "Tracker" in args["tags"]) and not args.get("game") + + ignore_game = not args.get("game") and any(tag in _non_game_messages for tag in args["tags"]) + if not ignore_game and args['game'] != game: errors.add('InvalidGame') minver = min_client_version if ignore_game else ctx.minimum_client_versions[slot] @@ -1624,7 +1681,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: - logging.info(f"A client connection was refused due to: {errors}, the sent connect information was {args}.") + ctx.logger.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']] @@ -1825,6 +1882,11 @@ 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) @@ -1870,15 +1932,13 @@ def _cmd_status(self, tag: str = "") -> bool: def _cmd_exit(self) -> bool: """Shutdown the server""" 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.split(" ", 1) + player_name, _, alias_name = player_name_then_alias_name.partition(" ") 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(): @@ -1958,7 +2018,7 @@ def _cmd_allow_release(self, player_name: str) -> bool: @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 @@ -2078,8 +2138,8 @@ def _cmd_hint_location(self, player_name: str, *location_name: str) -> bool: if full_name.isnumeric(): location, usable, response = int(full_name), True, None - 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)) + 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]) else: self.output("Can't look up location for unknown game. Hint for ID instead.") return False @@ -2087,6 +2147,11 @@ def _cmd_hint_location(self, player_name: str, *location_name: str) -> bool: 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: @@ -2102,32 +2167,47 @@ def _cmd_hint_location(self, player_name: str, *location_name: str) -> bool: self.output(response) return False - 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)}") + 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)}") 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 @@ -2151,7 +2231,7 @@ async def console(ctx: Context): def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser() - defaults = Utils.get_options()["server_options"].as_dict() + defaults = Utils.get_settings()["server_options"].as_dict() parser.add_argument('multidata', nargs="?", default=defaults["multidata"]) parser.add_argument('--host', default=defaults["host"]) parser.add_argument('--port', default=defaults["port"], type=int) @@ -2209,28 +2289,29 @@ def parse_args() -> argparse.Namespace: async def auto_shutdown(ctx, to_cancel=None): - await asyncio.sleep(ctx.auto_shutdown) + 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.") + while not ctx.exit_event.is_set(): if not ctx.client_activity_timers.values(): - 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.") + inactivity_shutdown() 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: - 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.") + inactivity_shutdown() else: - await asyncio.sleep(seconds) + with contextlib.suppress(asyncio.TimeoutError): + await asyncio.wait_for(ctx.exit_event.wait(), seconds) def load_server_cert(path: str, cert_key: typing.Optional[str]) -> "ssl.SSLContext": diff --git a/NetUtils.py b/NetUtils.py index a2db6a2ac5c4..f8d698c74fcc 100644 --- a/NetUtils.py +++ b/NetUtils.py @@ -198,7 +198,8 @@ class JSONtoTextParser(metaclass=HandlerMeta): "slateblue": "6D8BE8", "plum": "AF99EF", "salmon": "FA8072", - "white": "FFFFFF" + "white": "FFFFFF", + "orange": "FF7700", } def __init__(self, ctx): @@ -247,7 +248,7 @@ def _handle_item_name(self, node: JSONMessagePart): def _handle_item_id(self, node: JSONMessagePart): item_id = int(node["text"]) - node["text"] = self.ctx.item_names[item_id] + node["text"] = self.ctx.item_names.lookup_in_slot(item_id, node["player"]) return self._handle_item_name(node) def _handle_location_name(self, node: JSONMessagePart): @@ -255,8 +256,8 @@ def _handle_location_name(self, node: JSONMessagePart): return self._handle_color(node) def _handle_location_id(self, node: JSONMessagePart): - item_id = int(node["text"]) - node["text"] = self.ctx.location_names[item_id] + location_id = int(node["text"]) + node["text"] = self.ctx.location_names.lookup_in_slot(location_id, node["player"]) return self._handle_location_name(node) def _handle_entrance_name(self, node: JSONMessagePart): @@ -290,8 +291,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, item_id: int, player: int = 0, **kwargs) -> None: - parts.append({"text": str(item_id), "player": player, "type": JSONTypes.location_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}) class Hint(typing.NamedTuple): diff --git a/OoTAdjuster.py b/OoTAdjuster.py index 38ebe62e2ae1..9519b191e704 100644 --- a/OoTAdjuster.py +++ b/OoTAdjuster.py @@ -195,10 +195,10 @@ def set_icon(window): window.tk.call('wm', 'iconphoto', window._w, logo) def adjust(args): - # Create a fake world and OOTWorld to use as a base - world = MultiWorld(1) - world.per_slot_randoms = {1: random} - ootworld = OOTWorld(world, 1) + # Create a fake multiworld and OOTWorld to use as a base + multiworld = MultiWorld(1) + multiworld.per_slot_randoms = {1: random} + ootworld = OOTWorld(multiworld, 1) # Set options in the fake OOTWorld for name, option in chain(cosmetic_options.items(), sfx_options.items()): result = getattr(args, name, None) diff --git a/Options.py b/Options.py index 2e3927aae3f3..d040828509d1 100644 --- a/Options.py +++ b/Options.py @@ -1,19 +1,20 @@ from __future__ import annotations import abc -import logging -from copy import deepcopy -from dataclasses import dataclass import functools +import logging import math import numbers import random import typing +import enum from copy import deepcopy +from dataclasses import dataclass from schema import And, Optional, Or, Schema +from typing_extensions import Self -from Utils import get_fuzzy_results +from Utils import get_fuzzy_results, is_iterable_except_str if typing.TYPE_CHECKING: from BaseClasses import PlandoOptions @@ -21,6 +22,19 @@ 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"] = {} @@ -39,9 +53,14 @@ def __new__(mcs, name, bases, attrs): 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 = {name[6:].lower(): option_id for name, option_id in attrs.items() if - name.startswith("alias_")} - + aliases = attrs["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 @@ -59,6 +78,7 @@ def __new__(mcs, name, bases, attrs): 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" @@ -96,7 +116,8 @@ def meta__init__(self, *args, **kwargs): class Option(typing.Generic[T], metaclass=AssembleOptions): value: T - default = 0 + default: typing.ClassVar[typing.Any] # something that __init__ will be able to convert to the correct type + visibility = Visibility.all # convert option_name_long into Name Long as display_name, otherwise name_long is the result. # Handled in get_option_name() @@ -105,9 +126,28 @@ 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.Dict[T, str] - options: typing.Dict[str, int] + 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]] def __repr__(self) -> str: return f"{self.__class__.__name__}({self.current_option_name})" @@ -119,12 +159,6 @@ def __hash__(self) -> int: 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.""" @@ -160,6 +194,8 @@ 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 @@ -180,9 +216,18 @@ def from_any(cls, data: typing.Any) -> FreeText: 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) @@ -356,7 +401,8 @@ class Toggle(NumericOption): default = 0 def __init__(self, value: int): - assert value == 0 or value == 1, "value of Toggle can only be 0 or 1" + # if user puts in an invalid value, make it valid + value = int(bool(value)) self.value = value @classmethod @@ -598,7 +644,7 @@ def verify(self, world: typing.Type[World], player_name: str, plando_options: "P 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] @@ -707,6 +753,12 @@ def __init__(self, value: int) -> None: 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 @@ -717,39 +769,9 @@ def from_text(cls, text: str) -> Range: return super().from_text(text) -class SpecialRange(NamedRange): - special_range_cutoff = 0 - - # TODO: remove class SpecialRange, earliest 3 releases after 0.4.3 - def __new__(cls, value: int) -> SpecialRange: - from Utils import deprecate - deprecate(f"Option type {cls.__name__} is a subclass of SpecialRange, which is deprecated and pending removal. " - "Consider switching to NamedRange, which supports all use-cases of SpecialRange, and more. In " - "NamedRange, range_start specifies the lower end of the regular range, while special values can be " - "placed anywhere (below, inside, or above the regular range).") - return super().__new__(cls, value) - - @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--, random-range-middle--, " - f"random-range-high--, or random-range--.") - - 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) @@ -764,17 +786,22 @@ class VerifyKeys(metaclass=FreezeValidKeys): verify_location_name: bool = False value: typing.Any - @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 + 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 if extra: - raise Exception(f"Found unexpected key {', '.join(extra)} in {cls}. " - f"Allowed keys: {cls._valid_keys}.") + raise OptionError( + f"Found unexpected key {', '.join(extra)} in {getattr(self, 'display_name', self)}. " + f"Allowed keys: {self._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: @@ -802,7 +829,7 @@ def verify(self, world: typing.Type[World], player_name: str, plando_options: "P class OptionDict(Option[typing.Dict[str, typing.Any]], VerifyKeys, typing.Mapping[str, typing.Any]): - default: typing.Dict[str, typing.Any] = {} + default = {} supports_weighting = False def __init__(self, value: typing.Dict[str, typing.Any]): @@ -811,7 +838,6 @@ def __init__(self, value: typing.Dict[str, typing.Any]): @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)}") @@ -843,11 +869,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: typing.List[typing.Any] = [] + default = () supports_weighting = False - def __init__(self, value: typing.List[typing.Any]): - self.value = deepcopy(value) + def __init__(self, value: typing.Iterable[typing.Any]): + self.value = list(deepcopy(value)) super(OptionList, self).__init__() @classmethod @@ -856,8 +882,7 @@ def from_text(cls, text: str): @classmethod def from_any(cls, data: typing.Any): - if type(data) == list: - cls.verify_keys(data) + if is_iterable_except_str(data): return cls(data) return cls.from_text(str(data)) @@ -869,7 +894,7 @@ def __contains__(self, item): class OptionSet(Option[typing.Set[str]], VerifyKeys): - default: typing.Union[typing.Set[str], typing.FrozenSet[str]] = frozenset() + default = frozenset() supports_weighting = False def __init__(self, value: typing.Iterable[str]): @@ -882,8 +907,7 @@ def from_text(cls, text: str): @classmethod def from_any(cls, data: typing.Any): - if isinstance(data, (list, set, frozenset)): - cls.verify_keys(data) + if is_iterable_except_str(data): return cls(data) return cls.from_text(str(data)) @@ -899,26 +923,283 @@ 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: + given_text = text.get("text", []) + if isinstance(given_text, str): + given_text = [given_text] + texts.append(PlandoText( + at, + given_text, + text.get("percentage", 100) + )) + 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. - 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.""" + """ + 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. + """ display_name = "Accessibility" - option_locations = 0 - option_items = 1 + rich_text_doc = True + option_full = 0 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): """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, @@ -932,7 +1213,7 @@ def __new__(mcs, bases: typing.Tuple[type, ...], attrs: typing.Dict[str, typing.Any]) -> "OptionsMetaProperty": for attr_type in attrs.values(): - assert not isinstance(attr_type, AssembleOptions),\ + 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) @@ -951,7 +1232,7 @@ class CommonOptions(metaclass=OptionsMetaProperty): 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` """ @@ -983,29 +1264,36 @@ def as_dict(self, *option_names: str, casing: str = "snake") -> typing.Dict[str, 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 = "Not Local Items" + display_name = "Non-local Items" + rich_text_doc = True 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): @@ -1014,28 +1302,33 @@ 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 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([ { @@ -1050,7 +1343,8 @@ 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): @@ -1096,6 +1390,19 @@ def verify(self, world: typing.Type[World], player_name: str, plando_options: "P 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) @dataclass @@ -1110,7 +1417,52 @@ class PerGameCommonOptions(CommonOptions): item_links: ItemLinks -def generate_yaml_templates(target_folder: typing.Union[str, "pathlib.Path"], generate_hidden: bool = True): +@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: import os import yaml @@ -1146,15 +1498,18 @@ def dictify_range(option: Range): 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: - all_options: typing.Dict[str, AssembleOptions] = world.options_dataclass.type_hints - + option_groups = get_option_groups(world) with open(local_path("data", "options.yaml")) as f: file_data = f.read() res = Template(file_data).render( - options=all_options, - __version__=__version__, game=game_name, yaml_dump=yaml.dump, + option_groups=option_groups, + __version__=__version__, game=game_name, yaml_dump=yaml_dump_scalar, dictify_range=dictify_range, ) diff --git a/Patch.py b/Patch.py index 113d0658c6b7..9b49876bb72d 100644 --- a/Patch.py +++ b/Patch.py @@ -8,7 +8,7 @@ import ModuleUpdate ModuleUpdate.update() -from worlds.Files import AutoPatchRegister, APDeltaPatch +from worlds.Files import AutoPatchRegister, APAutoPatchInterface 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: APDeltaPatch = auto_handler(patch_file) + handler: APAutoPatchInterface = auto_handler(patch_file) target = os.path.splitext(patch_file)[0]+handler.result_file_ending handler.patch(target) return {"server": handler.server, diff --git a/README.md b/README.md index a1e03293d587..cebd4f7e7529 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,10 @@ # [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 @@ -25,7 +27,7 @@ Currently, the following games are supported: * Hollow Knight * The Witness * Sonic Adventure 2: Battle -* Starcraft 2: Wings of Liberty +* Starcraft 2 * Donkey Kong Country 3 * Dark Souls 3 * Super Mario World @@ -58,6 +60,18 @@ Currently, the following games are supported: * 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 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 @@ -65,36 +79,57 @@ 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 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 up 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. -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). +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). ## 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 -For contribution guidelines, please see our [Contributing doc.](/docs/contributing.md) + +To contribute to Archipelago, including the WebHost, core program, or by adding a new game, see our +[Contributing guidelines](/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). diff --git a/SNIClient.py b/SNIClient.py index 062d7a7cbea1..222ed54f5cc5 100644 --- a/SNIClient.py +++ b/SNIClient.py @@ -85,6 +85,7 @@ def _cmd_snes_close(self) -> bool: """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 @@ -281,7 +282,7 @@ class SNESState(enum.IntEnum): def launch_sni() -> None: - sni_path = Utils.get_options()["sni_options"]["sni_path"] + sni_path = Utils.get_settings()["sni_options"]["sni_path"] if not os.path.isdir(sni_path): sni_path = Utils.local_path(sni_path) @@ -564,16 +565,12 @@ 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: - 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}") + 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}") except ConnectionClosed: return False @@ -657,7 +654,7 @@ async def game_watcher(ctx: SNIContext) -> None: async def run_game(romfile: str) -> None: auto_start = typing.cast(typing.Union[bool, str], - Utils.get_options()["sni_options"].get("snes_rom_start", True)) + Utils.get_settings()["sni_options"].get("snes_rom_start", True)) if auto_start is True: import webbrowser webbrowser.open(romfile) diff --git a/Starcraft2Client.py b/Starcraft2Client.py index 87b50d35063e..fb219a690460 100644 --- a/Starcraft2Client.py +++ b/Starcraft2Client.py @@ -3,7 +3,7 @@ import ModuleUpdate ModuleUpdate.update() -from worlds.sc2wol.Client import launch +from worlds.sc2.Client import launch import Utils if __name__ == "__main__": diff --git a/UndertaleClient.py b/UndertaleClient.py index e1538ce81d2e..dfacee148abc 100644 --- a/UndertaleClient.py +++ b/UndertaleClient.py @@ -29,7 +29,7 @@ def _cmd_resync(self): def _cmd_patch(self): """Patch the game. Only use this command if /auto_patch fails.""" if isinstance(self.ctx, UndertaleContext): - os.makedirs(name=os.path.join(os.getcwd(), "Undertale"), exist_ok=True) + os.makedirs(name=Utils.user_path("Undertale"), exist_ok=True) self.ctx.patch_game() self.output("Patched.") @@ -43,7 +43,7 @@ def _cmd_savepath(self, directory: str): def _cmd_auto_patch(self, steaminstall: typing.Optional[str] = None): """Patch the game automatically.""" if isinstance(self.ctx, UndertaleContext): - os.makedirs(name=os.path.join(os.getcwd(), "Undertale"), exist_ok=True) + os.makedirs(name=Utils.user_path("Undertale"), exist_ok=True) tempInstall = steaminstall if not os.path.isfile(os.path.join(tempInstall, "data.win")): tempInstall = None @@ -62,7 +62,7 @@ def _cmd_auto_patch(self, steaminstall: typing.Optional[str] = None): for file_name in os.listdir(tempInstall): if file_name != "steam_api.dll": shutil.copy(os.path.join(tempInstall, file_name), - os.path.join(os.getcwd(), "Undertale", file_name)) + Utils.user_path("Undertale", file_name)) self.ctx.patch_game() self.output("Patching successful!") @@ -111,12 +111,12 @@ def __init__(self, server_address, password): self.save_game_folder = os.path.expandvars(r"%localappdata%/UNDERTALE") def patch_game(self): - with open(os.path.join(os.getcwd(), "Undertale", "data.win"), "rb") as f: + with open(Utils.user_path("Undertale", "data.win"), "rb") as f: patchedFile = bsdiff4.patch(f.read(), undertale.data_path("patch.bsdiff")) - with open(os.path.join(os.getcwd(), "Undertale", "data.win"), "wb") as f: + with open(Utils.user_path("Undertale", "data.win"), "wb") as f: f.write(patchedFile) - os.makedirs(name=os.path.join(os.getcwd(), "Undertale", "Custom Sprites"), exist_ok=True) - with open(os.path.expandvars(os.path.join(os.getcwd(), "Undertale", "Custom Sprites", + os.makedirs(name=Utils.user_path("Undertale", "Custom Sprites"), exist_ok=True) + with open(os.path.expandvars(Utils.user_path("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[l.item])): - toDraw += str(ctx.item_names[l.item])[i] + if i < len(str(ctx.item_names.lookup_in_game(l.item))): + toDraw += str(ctx.item_names.lookup_in_game(l.item))[i] else: break f.write(toDraw) diff --git a/Utils.py b/Utils.py index 5955e924322f..f89330cf7c65 100644 --- a/Utils.py +++ b/Utils.py @@ -19,14 +19,13 @@ from argparse import Namespace from settings import Settings, get_settings from typing import BinaryIO, Coroutine, Optional, Set, Dict, Any, Union -from yaml import load, load_all, dump, SafeLoader +from typing_extensions import TypeGuard +from yaml import load, load_all, dump try: - from yaml import CLoader as UnsafeLoader - from yaml import CDumper as Dumper + from yaml import CLoader as UnsafeLoader, CSafeLoader as SafeLoader, CDumper as Dumper except ImportError: - from yaml import Loader as UnsafeLoader - from yaml import Dumper + from yaml import Loader as UnsafeLoader, SafeLoader, Dumper if typing.TYPE_CHECKING: import tkinter @@ -47,7 +46,7 @@ def as_simple_string(self) -> str: return ".".join(str(item) for item in self) -__version__ = "0.4.4" +__version__ = "0.5.0" version_tuple = tuplize_version(__version__) is_linux = sys.platform.startswith("linux") @@ -102,8 +101,7 @@ def cache_self1(function: typing.Callable[[S, T], RetType]) -> typing.Callable[[ @functools.wraps(function) def wrap(self: S, arg: T) -> RetType: - cache: Optional[Dict[T, RetType]] = typing.cast(Optional[Dict[T, RetType]], - getattr(self, cache_name, None)) + cache: Optional[Dict[T, RetType]] = getattr(self, cache_name, None) if cache is None: res = function(self, arg) setattr(self, cache_name, {arg: res}) @@ -202,7 +200,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_options()["general_options"]["output_path"]) + output_path.cached_path = user_path(get_settings()["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,10 +208,11 @@ def output_path(*path: str) -> str: def open_file(filename: typing.Union[str, "pathlib.Path"]) -> None: if is_windows: - os.startfile(filename) + os.startfile(filename) # type: ignore 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]) @@ -226,6 +225,9 @@ def construct_mapping(self, node, deep=False): 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) @@ -298,21 +300,21 @@ def get_options() -> Settings: return get_settings() -def persistent_store(category: str, key: typing.Any, value: typing.Any): +def persistent_store(category: str, key: str, value: typing.Any): path = user_path("_persistent_storage.yaml") - storage: dict = persistent_load() - category = storage.setdefault(category, {}) - category[key] = value + storage = persistent_load() + category_dict = storage.setdefault(category, {}) + category_dict[key] = value with open(path, "wt") as f: f.write(dump(storage, Dumper=Dumper)) -def persistent_load() -> typing.Dict[str, dict]: - storage = getattr(persistent_load, "storage", None) +def persistent_load() -> Dict[str, Dict[str, Any]]: + storage: Union[Dict[str, Dict[str, Any]], None] = getattr(persistent_load, "storage", None) if storage: return storage path = user_path("_persistent_storage.yaml") - storage: dict = {} + storage = {} if os.path.exists(path): try: with open(path, "r") as f: @@ -321,7 +323,7 @@ def persistent_load() -> typing.Dict[str, dict]: logging.debug(f"Could not read store: {e}") if storage is None: storage = {} - persistent_load.storage = storage + setattr(persistent_load, "storage", storage) return storage @@ -363,6 +365,7 @@ 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() @@ -381,7 +384,9 @@ 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 @@ -405,13 +410,13 @@ def get_unique_identifier(): class RestrictedUnpickler(pickle.Unpickler): generic_properties_module: Optional[object] - def __init__(self, *args, **kwargs): + def __init__(self, *args: Any, **kwargs: Any) -> None: 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, name): + def find_class(self, module: str, name: str) -> type: if module == "builtins" and name in safe_builtins: return getattr(builtins, name) # used by MultiServer -> savegame/multidata @@ -435,7 +440,7 @@ def find_class(self, module, name): raise pickle.UnpicklingError(f"global '{module}.{name}' is forbidden") -def restricted_loads(s): +def restricted_loads(s: bytes) -> Any: """Helper function analogous to pickle.loads().""" return RestrictedUnpickler(io.BytesIO(s)).load() @@ -453,6 +458,15 @@ 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 @@ -491,7 +505,7 @@ def init_logging(name: str, loglevel: typing.Union[str, int] = logging.INFO, wri file_handler.setFormatter(logging.Formatter(log_format)) class Filter(logging.Filter): - def __init__(self, filter_name, condition): + def __init__(self, filter_name: str, condition: typing.Callable[[logging.LogRecord], bool]) -> None: super().__init__(filter_name) self.condition = condition @@ -539,10 +553,11 @@ def _cleanup(): 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, queue): +def stream_input(stream: typing.TextIO, queue: "asyncio.Queue[str]"): def queuer(): while 1: try: @@ -570,7 +585,7 @@ class VersionException(Exception): pass -def chaining_prefix(index: int, labels: typing.Tuple[str]) -> str: +def chaining_prefix(index: int, labels: typing.Sequence[str]) -> str: text = "" max_label = len(labels) - 1 while index > max_label: @@ -593,7 +608,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, wordlist: typing.Sequence[str], limit: typing.Optional[int] = None) \ +def get_fuzzy_results(input_word: str, word_list: typing.Collection[str], limit: typing.Optional[int] = None) \ -> typing.List[typing.Tuple[str, int]]: import jellyfish @@ -601,22 +616,58 @@ def get_fuzzy_ratio(word1: str, word2: str) -> float: return (1 - jellyfish.damerau_levenshtein_distance(word1.lower(), word2.lower()) / max(len(word1), len(word2))) - limit: int = limit if limit else len(wordlist) + limit = limit if limit else len(word_list) 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)), - wordlist), + map(lambda candidate: (candidate, get_fuzzy_ratio(input_word, candidate)), word_list), key=lambda element: element[1], - reverse=True)[0:limit] + reverse=True + )[0:limit] ) ) -def open_filename(title: str, filetypes: typing.Sequence[typing.Tuple[str, typing.Sequence[str]]], suggest: str = "") \ +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 open_filename(title: str, filetypes: typing.Iterable[typing.Tuple[str, typing.Iterable[str]]], suggest: str = "") \ -> typing.Optional[str]: + logging.info(f"Opening file input dialog for {title}.") + def run(*args: str): return subprocess.run(args, capture_output=True, text=True).stdout.split("\n", 1)[0] or None @@ -714,7 +765,7 @@ def is_kivy_running(): import ctypes style = 0x10 if error else 0x0 return ctypes.windll.user32.MessageBoxW(0, text, title, style) - + # fall back to tk try: import tkinter @@ -730,7 +781,7 @@ def is_kivy_running(): root.update() -def title_sorted(data: typing.Sequence, key=None, ignore: typing.Set = frozenset(("a", "the"))): +def title_sorted(data: typing.Iterable, key=None, ignore: typing.AbstractSet[str] = frozenset(("a", "the"))): """Sorts a sequence of text ignoring typical articles like "a" or "the" in the beginning.""" def sorter(element: Union[str, Dict[str, Any]]) -> str: if (not isinstance(element, str)): @@ -779,6 +830,25 @@ 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 @@ -852,8 +922,8 @@ def visualize_regions(root_region: Region, file_name: str, *, Example usage in Main code: from Utils import visualize_regions - for player in world.player_ids: - visualize_regions(world.get_region("Menu", player), f"{world.get_out_file_name_base(player)}.puml") + for player in multiworld.player_ids: + visualize_regions(multiworld.get_region("Menu", player), f"{multiworld.get_out_file_name_base(player)}.puml") """ assert root_region.multiworld, "The multiworld attribute of root_region has to be filled" from BaseClasses import Entrance, Item, Location, LocationProgressType, MultiWorld, Region @@ -949,3 +1019,10 @@ def __bool__(self): 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) diff --git a/WargrooveClient.py b/WargrooveClient.py index 77180502cefc..39da044d659c 100644 --- a/WargrooveClient.py +++ b/WargrooveClient.py @@ -176,7 +176,7 @@ def on_package(self, cmd: str, args: dict): if not os.path.isfile(path): open(path, 'w').close() # Announcing commander unlocks - item_name = self.item_names[network_item.item] + item_name = self.item_names.lookup_in_game(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 +197,7 @@ def on_package(self, cmd: str, args: dict): open(print_path, 'w').close() with open(print_path, 'w') as f: f.write("Received " + - self.item_names[network_item.item] + + self.item_names.lookup_in_game(network_item.item) + " from " + self.player_names[network_item.player]) f.close() @@ -342,7 +342,7 @@ def update_commander_data(self): faction_items = 0 faction_item_names = [faction + ' Commanders' for faction in faction_table.keys()] for network_item in self.items_received: - if self.item_names[network_item.item] in faction_item_names: + if self.item_names.lookup_in_game(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 diff --git a/WebHost.py b/WebHost.py index 8595fa7a27a4..08ef3c430795 100644 --- a/WebHost.py +++ b/WebHost.py @@ -12,6 +12,9 @@ import Utils import settings +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 configpath = os.path.abspath("config.yaml") @@ -19,11 +22,10 @@ configpath = os.path.abspath(Utils.user_path('config.yaml')) -def get_app(): +def get_app() -> "Flask": 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 @@ -34,6 +36,7 @@ def get_app(): 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) @@ -55,6 +58,7 @@ 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, game) @@ -117,7 +121,7 @@ def create_ordered_tutorials_file() -> typing.List[typing.Dict[str, typing.Any]] logging.basicConfig(format='[%(asctime)s] %(message)s', level=logging.INFO) from WebHostLib.lttpsprites import update_sprites_lttp - from WebHostLib.autolauncher import autohost, autogen + from WebHostLib.autolauncher import autohost, autogen, stop from WebHostLib.options import create as create_options_files try: @@ -138,3 +142,11 @@ def create_ordered_tutorials_file() -> typing.List[typing.Dict[str, typing.Any]] 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 diff --git a/WebHostLib/__init__.py b/WebHostLib/__init__.py index 43ca89f0b3f3..fdf3037fe015 100644 --- a/WebHostLib/__init__.py +++ b/WebHostLib/__init__.py @@ -23,6 +23,7 @@ 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 @@ -51,6 +52,7 @@ app.config["MAX_ROLL"] = 20 app.config["CACHE_TYPE"] = "SimpleCache" app.config["HOST_ADDRESS"] = "" +app.config["ASSET_RIGHTS"] = False cache = Cache() Compress(app) @@ -82,6 +84,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 + from . import tracker, upload, landing, check, generate, downloads, api, stats, misc, robots, options app.register_blueprint(api.api_endpoints) diff --git a/WebHostLib/api/__init__.py b/WebHostLib/api/__init__.py index 102c3a49f6aa..4003243a281d 100644 --- a/WebHostLib/api/__init__.py +++ b/WebHostLib/api/__init__.py @@ -2,9 +2,9 @@ from typing import List, Tuple from uuid import UUID -from flask import Blueprint, abort +from flask import Blueprint, abort, url_for -from .. import cache +import worlds.Files from ..models import Room, Seed api_endpoints = Blueprint('api', __name__, url_prefix="/api") @@ -21,39 +21,31 @@ def room_info(room: UUID): room = Room.get(id=room) if room is None: return abort(404) + + def supports_apdeltapatch(game: str): + return game in worlds.Files.AutoPatchRegister.patch_types + downloads = [] + for slot in sorted(room.seed.slots): + if slot.data and not supports_apdeltapatch(slot.game): + slot_download = { + "slot": slot.player_id, + "download": url_for("download_slot_file", room_id=room.id, player_id=slot.player_id) + } + downloads.append(slot_download) + elif slot.data: + slot_download = { + "slot": slot.player_id, + "download": url_for("download_patch", patch_id=slot.id, room_id=room.id) + } + downloads.append(slot_download) return { "tracker": room.tracker, "players": get_players(room.seed), "last_port": room.last_port, "last_activity": room.last_activity, - "timeout": room.timeout + "timeout": room.timeout, + "downloads": downloads, } -@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 +from . import generate, user, datapackage # trigger registration diff --git a/WebHostLib/api/datapackage.py b/WebHostLib/api/datapackage.py new file mode 100644 index 000000000000..3fb472d95dfd --- /dev/null +++ b/WebHostLib/api/datapackage.py @@ -0,0 +1,32 @@ +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/') +@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 diff --git a/WebHostLib/api/generate.py b/WebHostLib/api/generate.py index 61e9164e2652..5a66d1e69331 100644 --- a/WebHostLib/api/generate.py +++ b/WebHostLib/api/generate.py @@ -20,8 +20,8 @@ def generate_api(): race = False meta_options_source = {} if 'file' in request.files: - file = request.files['file'] - options = get_yaml_data(file) + files = request.files.getlist('file') + options = get_yaml_data(files) if isinstance(options, Markup): return {"text": options.striptags()}, 400 if isinstance(options, str): diff --git a/WebHostLib/autolauncher.py b/WebHostLib/autolauncher.py index 90838671200c..08a1309ebc73 100644 --- a/WebHostLib/autolauncher.py +++ b/WebHostLib/autolauncher.py @@ -3,25 +3,25 @@ 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) - multiworld.start() +def stop(): + """Stops previously launched threads""" + global _stop_event + stop_event = _stop_event + _stop_event = Event() # new event for new threads + stop_event.set() def handle_generation_success(seed_id): @@ -58,29 +58,50 @@ 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"): - run_guardian() - while 1: - time.sleep(0.1) + cleanup() + hosters = [] + for x in range(config["HOSTERS"]): + hoster = MultiworldInstance(config, x) + hosters.append(hoster) + hoster.start() + + while not stop_event.wait(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: - launch_room(room, config) + # 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) except AlreadyRunningException: logging.info("Autohost reports as already running, not starting another.") - import threading - threading.Thread(target=keep_running, name="AP_Autohost").start() + Thread(target=keep_running, name="AP_Autohost").start() def autogen(config: dict): def keep_running(): + stop_event = _stop_event try: with Locker("autogen"): @@ -101,8 +122,7 @@ def keep_running(): commit() select(generation for generation in Generation if generation.state == STATE_ERROR).delete() - while 1: - time.sleep(0.1) + while not stop_event.wait(0.1): with db_session: # for update locks the database row(s) during transaction, preventing writes from elsewhere to_start = select( @@ -113,37 +133,45 @@ def keep_running(): except AlreadyRunningException: logging.info("Autogen reports as already running, not starting another.") - import threading - threading.Thread(target=keep_running, name="AP_Autogen").start() + Thread(target=keep_running, name="AP_Autogen").start() multiworlds: typing.Dict[type(Room.id), MultiworldInstance] = {} class MultiworldInstance(): - def __init__(self, room: Room, config: dict): - self.room_id = room.id + def __init__(self, config: dict, id: int): + self.room_ids = set() 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.room_id, self.ponyconfig, get_static_server_data(), - self.cert, self.key, self.host), - name="MultiHost") + 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) 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() @@ -157,40 +185,6 @@ def collect(self): self.process = None -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 .models import Room, Generation, STATE_QUEUED, STATE_STARTED, STATE_ERROR, db, Seed, Slot from .customserver import run_server_process, get_static_server_data from .generate import gen_game diff --git a/WebHostLib/check.py b/WebHostLib/check.py index 4db2ec2ce35e..97cb797f7a56 100644 --- a/WebHostLib/check.py +++ b/WebHostLib/check.py @@ -1,3 +1,4 @@ +import os import zipfile import base64 from typing import Union, Dict, Set, Tuple @@ -6,13 +7,7 @@ from markupsafe import Markup from WebHostLib import app - -banned_zip_contents = (".sfc",) - - -def allowed_file(filename): - return filename.endswith(('.txt', ".yaml", ".zip")) - +from WebHostLib.upload import allowed_options, allowed_options_extensions, banned_file from Generate import roll_settings, PlandoOptions from Utils import parse_yamls @@ -33,7 +28,7 @@ def check(): results, _ = roll_options(options) if len(options) > 1: # offer combined file back - combined_yaml = "---\n".join(f"# original filename: {file_name}\n{file_content.decode('utf-8-sig')}" + 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: @@ -51,33 +46,41 @@ def mysterycheck(): def get_yaml_data(files) -> Union[Dict[str, str], str, Markup]: options = {} for uploaded_file in files: - # if user does not select file, browser also - # submit an empty part without filename - if uploaded_file.filename == '': - return 'No selected file' + 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_file(uploaded_file.filename): + return f"Conflicting files named {uploaded_file.filename} submitted." + elif uploaded_file and allowed_options(uploaded_file.filename): if uploaded_file.filename.endswith(".zip"): - - with zipfile.ZipFile(uploaded_file, 'r') as zfile: - infolist = zfile.infolist() - - if any(file.filename.endswith(".archipelago") for file in infolist): - return Markup("Error: Your .zip file contains an .archipelago file. " - 'Did you mean to host a game?') - - 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")): + if not zipfile.is_zipfile(uploaded_file): + return f"Uploaded file {uploaded_file.filename} is not a valid .zip file and cannot be opened." + + uploaded_file.seek(0) # offset from is_zipfile check + with zipfile.ZipFile(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) + + if base_filename.endswith(".archipelago"): + return Markup("Error: Your .zip file contains an .archipelago file. " + 'Did you mean to host a game?') + elif base_filename.endswith(".zip"): + return "Nested .zip files inside a .zip are not supported." + elif banned_file(base_filename): + return ("Uploaded data contained a rom file, which is likely to contain copyrighted " + "material. Your file was deleted.") + # Ignore dot-files. + elif not base_filename.startswith(".") and allowed_options(base_filename): options[file.filename] = zfile.open(file, "r").read() else: options[uploaded_file.filename] = uploaded_file.read() + if not options: - return "Did not find a .yaml file to process." + return f"Did not find any valid files to process. Accepted formats: {allowed_options_extensions}" return options @@ -105,7 +108,10 @@ 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: - results[filename] = f"Failed to generate options in {filename}: {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}" else: results[filename] = True return results, rolled_results diff --git a/WebHostLib/customserver.py b/WebHostLib/customserver.py index 998fec5e738d..ccffc40b384d 100644 --- a/WebHostLib/customserver.py +++ b/WebHostLib/customserver.py @@ -5,6 +5,7 @@ import datetime import functools import logging +import multiprocessing import pickle import random import socket @@ -53,17 +54,19 @@ def _cmd_video(self, platform: str, user: str): class DBCommandProcessor(ServerCommandProcessor): def output(self, text: str): - logging.info(text) + self.ctx.logger.info(text) class WebHostContext(Context): room_id: int - def __init__(self, static_server_data: dict): + def __init__(self, static_server_data: dict, logger: logging.Logger): # static server data is used during _load_game_data to load required data, # 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) + super(WebHostContext, self).__init__("", 0, "", "", 1, + 40, True, "enabled", "enabled", + "enabled", 0, 2, logger=logger) del self.static_server_data self.main_loop = asyncio.get_running_loop() self.video = {} @@ -71,6 +74,7 @@ def __init__(self, static_server_data: dict): 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) @@ -98,18 +102,37 @@ def load(self, room_id: int): 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 self.gamespackage.get(game, {}).get("checksum") == game_data["checksum"]: - # non-custom. remove from multidata + if static_gamespackage.get(game, {}).get("checksum") == game_data["checksum"]: + # non-custom. remove from multidata and use static data # games package could be dropped from static data once all rooms embed data package del multidata["datapackage"][game] else: row = GameDataPackage.get(checksum=game_data["checksum"]) if row: # None if rolled on >= 0.3.9 but uploaded to <= 0.3.8. multidata should be complete game_data_packages[game] = Utils.restricted_loads(row.data) - + continue + else: + self.logger.warning(f"Did not find game_data_package for {game}: {game_data['checksum']}") + 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 @@ -119,7 +142,7 @@ def init_save(self, enabled: bool = True): 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() + self._start_async_saving(atexit_save=False) threading.Thread(target=self.listen_to_db_commands, daemon=True).start() @db_session @@ -145,80 +168,178 @@ def get_random_port(): def get_static_server_data() -> dict: import worlds data = { - "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()}, + "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() + }, } - for world_name, world in worlds.AutoWorldRegister.world_types.items(): - data["non_hintable_names"][world_name] = world.hint_blacklist - return data -def run_server_process(room_id, ponyconfig: dict, static_server_data: dict, +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, cert_file: typing.Optional[str], cert_key_file: typing.Optional[str], - host: str): + host: str, rooms_to_run: multiprocessing.Queue, rooms_shutting_down: multiprocessing.Queue): + Utils.init_logging(name) + try: + import resource + except ModuleNotFoundError: + pass # unix only module + else: + # Each Server is another file handle, so request as many as we can from the system + file_limit = resource.getrlimit(resource.RLIMIT_NOFILE)[1] + # set soft limit to hard limit + resource.setrlimit(resource.RLIMIT_NOFILE, (file_limit, file_limit)) + del resource, file_limit + # establish DB connection for multidata and multisave db.bind(**ponyconfig) db.generate_mapping(check_tables=False) - async def main(): - if "worlds" in sys.modules: - raise Exception("Worlds system should not be loaded in the custom server.") - - 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) - - 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: - 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") - - 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 + 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 + + loop = asyncio.get_event_loop() + + 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() + try: + ctx.server = websockets.serve( + functools.partial(server, ctx=ctx), ctx.host, ctx.port, ssl=ssl_context) + + await ctx.server + except OSError: # likely port in use + ctx.server = websockets.serve( + functools.partial(server, ctx=ctx), ctx.host, 0, ssl=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)) + 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(0) + 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() diff --git a/WebHostLib/generate.py b/WebHostLib/generate.py index ee1ce591ee84..a12dc0f4ae14 100644 --- a/WebHostLib/generate.py +++ b/WebHostLib/generate.py @@ -6,7 +6,7 @@ import tempfile import zipfile from collections import Counter -from typing import Any, Dict, List, Optional, Union +from typing import Any, Dict, List, Optional, Union, Set from flask import flash, redirect, render_template, request, session, url_for from pony.orm import commit, db_session @@ -16,6 +16,7 @@ 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 @@ -23,25 +24,22 @@ def get_meta(options_source: dict, race: bool = False) -> Dict[str, Union[List[str], Dict[str, Any]]]: - plando_options = { - options_source.get("plando_bosses", ""), - options_source.get("plando_items", ""), - options_source.get("plando_connections", ""), - options_source.get("plando_texts", "") - } - plando_options -= {""} + 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) server_options = { - "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))), + "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))), "server_password": options_source.get("server_password", None), } generator_options = { - "spoiler": int(options_source.get("spoiler", 0)), - "race": race + "spoiler": int(options_source.get("spoiler", GeneratorOptions.spoiler)), + "race": race, } if race: @@ -70,35 +68,39 @@ def generate(race=False): flash(options) else: meta = get_meta(request.form, race) - 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 start_generation(options, meta) + + return render_template("generate.html", race=race, version=__version__) - 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 start_generation(options: Dict[str, Union[dict, str]], meta: Dict[str, Any]): + results, gen_options = roll_options(options, set(meta["plando_options"])) + + if any(type(result) == str for result in results.values()): + return render_template("checkResult.html", results=results) + elif len(gen_options) > app.config["MAX_ROLL"]: + flash(f"Sorry, generating of multiworlds is limited to {app.config['MAX_ROLL']} players. " + f"If you have a larger group, please generate it yourself and upload it.") + elif len(gen_options) >= app.config["JOB_THRESHOLD"]: + gen = Generation( + options=pickle.dumps({name: vars(options) for name, options in gen_options.items()}), + # convert to json compatible + meta=json.dumps(meta), + state=STATE_QUEUED, + owner=session["_id"]) + commit() + + return redirect(url_for("wait_seed", seed=gen.id)) + else: + try: + seed_id = gen_game({name: vars(options) for name, options in gen_options.items()}, + meta=meta, owner=session["_id"].int) + except BaseException as e: + from .autolauncher import handle_generation_failure + handle_generation_failure(e) + return render_template("seedError.html", seed_error=(e.__class__.__name__ + ": " + str(e))) - return render_template("generate.html", race=race, version=__version__) + 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): diff --git a/WebHostLib/misc.py b/WebHostLib/misc.py index ee04e56fd768..01c1ad84a707 100644 --- a/WebHostLib/misc.py +++ b/WebHostLib/misc.py @@ -1,6 +1,6 @@ import datetime import os -from typing import List, Dict, Union +from typing import Any, IO, Dict, Iterator, List, Tuple, Union import jinja2.exceptions from flask import request, redirect, url_for, render_template, Response, session, abort, send_from_directory @@ -37,31 +37,6 @@ def start_playing(): return render_template(f"startPlaying.html") -# TODO for back compat. remove around 0.4.5 -@app.route("/weighted-settings") -def weighted_settings(): - return redirect("weighted-options", 301) - - -@app.route("/weighted-options") -@cache.cached() -def weighted_options(): - return render_template("weighted-options.html") - - -# TODO for back compat. remove around 0.4.5 -@app.route("/games//player-settings") -def player_settings(game: str): - return redirect(url_for("player_options", game=game), 301) - - -# Player options pages -@app.route("/games//player-options") -@cache.cached() -def player_options(game: str): - return render_template("player-options.html", game=game, theme=get_world_theme(game)) - - # Game Info Pages @app.route('/games//info/') @cache.cached() @@ -122,25 +97,37 @@ def new_room(seed: UUID): return redirect(url_for("host_room", room=room.id)) -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." +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 @app.route('/log/') -def display_log(room: UUID): +def display_log(room: UUID) -> Union[str, Response, Tuple[str, int]]: 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") - if os.path.exists(file_path): - return Response(_read_log(file_path), mimetype="text/plain;charset=UTF-8") - return "Log File does not exist." + 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") return "Access Denied", 403 @@ -156,6 +143,7 @@ def host_room(room: UUID): if cmd: Command(room=room, commandtext=cmd) commit() + return redirect(url_for("host_room", room=room.id)) now = datetime.datetime.utcnow() # indicate that the page should reload to get the assigned port @@ -163,7 +151,22 @@ def host_room(room: UUID): with db_session: room.last_activity = now # will trigger a spinup, if it's not already running - return render_template("hostRoom.html", room=room, should_refresh=should_refresh) + def get_log(max_size: int = 1024000) -> str: + 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) @app.route('/favicon.ico') diff --git a/WebHostLib/options.py b/WebHostLib/options.py index 0158de7e241f..15b7bd61ceee 100644 --- a/WebHostLib/options.py +++ b/WebHostLib/options.py @@ -1,188 +1,281 @@ +import collections.abc import json -import logging import os -import typing +from textwrap import dedent +from typing import Dict, Union +from docutils.core import publish_parts + +import yaml +from flask import redirect, render_template, request, Response import Options from Utils import local_path from worlds.AutoWorld import AutoWorldRegister - -handled_in_js = {"start_inventory", "local_items", "non_local_items", "start_hints", "start_location_hints", - "exclude_locations", "priority_locations"} +from . import app, cache +from .generate import get_meta -def create(): +def create() -> None: 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() - - weighted_options = { - "baseOptions": { - "description": "Generated by https://archipelago.gg/", - "name": "", - "game": {}, - }, - "games": {}, - } - - for game_name, world in AutoWorldRegister.world_types.items(): - - all_options: typing.Dict[str, Options.AssembleOptions] = world.options_dataclass.type_hints - - # Generate JSON files for player-options pages - player_options = { - "baseOptions": { - "description": f"Generated by https://archipelago.gg/ for {game_name}", - "game": game_name, - "name": "", - }, - } - game_options = {} - for option_name, option in all_options.items(): - if option_name in handled_in_js: - pass - - 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.NamedRange): - game_options[option_name]["type"] = 'named_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 [] - } +def get_world_theme(game_name: str) -> str: + if game_name in AutoWorldRegister.world_types: + return AutoWorldRegister.world_types[game_name].web.theme + return 'grass' + + +def render_options_page(template: str, world_name: str, is_complex: bool = False) -> Union[Response, str]: + world = AutoWorldRegister.world_types[world_name] + if world.hidden or world.web.options_page is False: + 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//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//weighted-options") +@cache.cached() +def weighted_options(game: str): + return render_options_page("weightedOptions/weightedOptions.html", game, is_complex=True) + + +@app.route("/games//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: - logging.debug(f"{option} not exported to Web Options.") - - player_options["gameOptions"] = game_options - - player_options["presetOptions"] = {} - for preset_name, preset in world.web.options_presets.items(): - player_options["presetOptions"][preset_name] = {} - for option_name, option_value in preset.items(): - # Random range type settings are not valid. - assert (not str(option_value).startswith("random-")), \ - f"Invalid preset value '{option_value}' for '{option_name}' in '{preset_name}'. Special random " \ - f"values are not supported for presets." - - # Normal random is supported, but needs to be handled explicitly. - if option_value == "random": - player_options["presetOptions"][preset_name][option_name] = option_value + if int(val) == 0: continue - option = world.options_dataclass.type_hints[option_name].from_any(option_value) - if isinstance(option, Options.NamedRange) and isinstance(option_value, str): - assert option_value in option.special_range_names, \ - f"Invalid preset value '{option_value}' for '{option_name}' in '{preset_name}'. " \ - f"Expected {option.special_range_names.keys()} or {option.range_start}-{option.range_end}." - - # Still use the true value for the option, not the name. - player_options["presetOptions"][preset_name][option_name] = option.value - elif isinstance(option, Options.Range): - player_options["presetOptions"][preset_name][option_name] = option.value - elif isinstance(option_value, str): - # For Choice and Toggle options, the value should be the name of the option. This is to prevent - # setting a preset for an option with an overridden from_text method that would normally be okay, - # but would not be okay for the webhost's current implementation of player options UI. - assert option.name_lookup[option.value] == option_value, \ - f"Invalid option value '{option_value}' for '{option_name}' in preset '{preset_name}'. " \ - f"Values must not be resolved to a different option via option.from_text (or an alias)." - player_options["presetOptions"][preset_name][option_name] = option.current_key - else: - # int and bool values are fine, just resolve them to the current key for webhost. - player_options["presetOptions"][preset_name][option_name] = option.current_key - - os.makedirs(os.path.join(target_folder, 'player-options'), exist_ok=True) - - with open(os.path.join(target_folder, 'player-options', game_name + ".json"), "w") as f: - json.dump(player_options, f, indent=2, separators=(',', ': ')) - - if not world.hidden and world.web.options_page is True: - # Add the random option to Choice, TextChoice, and Toggle options - 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_options["baseOptions"]["game"][game_name] = 0 - weighted_options["games"][game_name] = { - "gameSettings": game_options, - "gameItems": tuple(world.item_names), - "gameItemGroups": [ - group for group in world.item_name_groups.keys() if group != "Everything" - ], - "gameItemDescriptions": world.item_descriptions, - "gameLocations": tuple(world.location_names), - "gameLocationGroups": [ - group for group in world.location_name_groups.keys() if group != "Everywhere" - ], - "gameLocationDescriptions": world.location_descriptions, - } - - with open(os.path.join(target_folder, 'weighted-options.json'), "w") as f: - json.dump(weighted_options, f, indent=2, separators=(',', ': ')) + [option, setting] = key.split("||") + options.setdefault(option, {})[setting] = int(val) + + # Error checking + if "name" not in options: + return "Player name is required." + + # Remove POST data irrelevant to YAML + if "intent-generate" in options: + intent_generate = True + del options["intent-generate"] + if "intent-export" in options: + del options["intent-export"] + + # Properly format YAML output + player_name = options["name"] + del options["name"] + + formatted_options = { + "name": player_name, + "game": game, + "description": f"Generated by https://archipelago.gg/ for {game}", + game: options, + } + + if intent_generate: + return generate_game({player_name: formatted_options}) + + else: + return send_yaml(player_name, formatted_options) + + +# Player options pages +@app.route("/games//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//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, + } + + if intent_generate: + return generate_game({player_name: formatted_options}) + else: + return send_yaml(player_name, formatted_options) diff --git a/WebHostLib/requirements.txt b/WebHostLib/requirements.txt index 654104252cec..3452c9d416db 100644 --- a/WebHostLib/requirements.txt +++ b/WebHostLib/requirements.txt @@ -1,9 +1,10 @@ -flask>=3.0.0 +flask>=3.0.3 +werkzeug>=3.0.3 pony>=0.7.17 -waitress>=2.1.2 -Flask-Caching>=2.1.0 -Flask-Compress>=1.14 -Flask-Limiter>=3.5.0 +waitress>=3.0.0 +Flask-Caching>=2.3.0 +Flask-Compress>=1.15 +Flask-Limiter>=3.7.0 bokeh>=3.1.1; python_version <= '3.8' -bokeh>=3.2.2; python_version >= '3.9' -markupsafe>=2.1.3 +bokeh>=3.4.1; python_version >= '3.9' +markupsafe>=2.1.5 diff --git a/WebHostLib/robots.py b/WebHostLib/robots.py new file mode 100644 index 000000000000..93c735c71015 --- /dev/null +++ b/WebHostLib/robots.py @@ -0,0 +1,15 @@ +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) diff --git a/WebHostLib/static/assets/lttp-tracker.js b/WebHostLib/static/assets/lttp-tracker.js deleted file mode 100644 index 3f01f93cd38c..000000000000 --- a/WebHostLib/static/assets/lttp-tracker.js +++ /dev/null @@ -1,20 +0,0 @@ -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) -}); diff --git a/WebHostLib/static/assets/player-options.js b/WebHostLib/static/assets/player-options.js deleted file mode 100644 index 92cd6c43f3cc..000000000000 --- a/WebHostLib/static/assets/player-options.js +++ /dev/null @@ -1,523 +0,0 @@ -let gameName = null; - -window.addEventListener('load', () => { - gameName = document.getElementById('player-options').getAttribute('data-game'); - - // Update game name on page - document.getElementById('game-name').innerText = gameName; - - fetchOptionData().then((results) => { - let optionHash = localStorage.getItem(`${gameName}-hash`); - if (!optionHash) { - // If no hash data has been set before, set it now - optionHash = md5(JSON.stringify(results)); - localStorage.setItem(`${gameName}-hash`, optionHash); - localStorage.removeItem(gameName); - } - - if (optionHash !== md5(JSON.stringify(results))) { - showUserMessage( - 'Your options are out of date! Click here to update them! Be aware this will reset them all to default.' - ); - document.getElementById('user-message').addEventListener('click', resetOptions); - } - - // Page setup - createDefaultOptions(results); - buildUI(results); - adjustHeaderWidth(); - - // Event listeners - document.getElementById('export-options').addEventListener('click', () => exportOptions()); - document.getElementById('generate-race').addEventListener('click', () => generateGame(true)); - document.getElementById('generate-game').addEventListener('click', () => generateGame()); - - // Name input field - const playerOptions = JSON.parse(localStorage.getItem(gameName)); - const nameInput = document.getElementById('player-name'); - nameInput.addEventListener('keyup', (event) => updateBaseOption(event)); - nameInput.value = playerOptions.name; - - // Presets - const presetSelect = document.getElementById('game-options-preset'); - presetSelect.addEventListener('change', (event) => setPresets(results, event.target.value)); - for (const preset in results['presetOptions']) { - const presetOption = document.createElement('option'); - presetOption.innerText = preset; - presetSelect.appendChild(presetOption); - } - presetSelect.value = localStorage.getItem(`${gameName}-preset`); - results['presetOptions']['__default'] = {}; - }).catch((e) => { - console.error(e); - const url = new URL(window.location.href); - window.location.replace(`${url.protocol}//${url.hostname}/page-not-found`); - }) -}); - -const resetOptions = () => { - localStorage.removeItem(gameName); - localStorage.removeItem(`${gameName}-hash`); - localStorage.removeItem(`${gameName}-preset`); - window.location.reload(); -}; - -const fetchOptionData = () => 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-options/${gameName}.json`, true); - ajax.send(); -}); - -const createDefaultOptions = (optionData) => { - if (!localStorage.getItem(gameName)) { - const newOptions = { - [gameName]: {}, - }; - for (let baseOption of Object.keys(optionData.baseOptions)){ - newOptions[baseOption] = optionData.baseOptions[baseOption]; - } - for (let gameOption of Object.keys(optionData.gameOptions)){ - newOptions[gameName][gameOption] = optionData.gameOptions[gameOption].defaultValue; - } - localStorage.setItem(gameName, JSON.stringify(newOptions)); - } - - if (!localStorage.getItem(`${gameName}-preset`)) { - localStorage.setItem(`${gameName}-preset`, '__default'); - } -}; - -const buildUI = (optionData) => { - // Game Options - const leftGameOpts = {}; - const rightGameOpts = {}; - Object.keys(optionData.gameOptions).forEach((key, index) => { - if (index < Object.keys(optionData.gameOptions).length / 2) { - leftGameOpts[key] = optionData.gameOptions[key]; - } else { - rightGameOpts[key] = optionData.gameOptions[key]; - } - }); - document.getElementById('game-options-left').appendChild(buildOptionsTable(leftGameOpts)); - document.getElementById('game-options-right').appendChild(buildOptionsTable(rightGameOpts)); -}; - -const buildOptionsTable = (options, romOpts = false) => { - const currentOptions = JSON.parse(localStorage.getItem(gameName)); - const table = document.createElement('table'); - const tbody = document.createElement('tbody'); - - Object.keys(options).forEach((option) => { - const tr = document.createElement('tr'); - - // td Left - const tdl = document.createElement('td'); - const label = document.createElement('label'); - label.textContent = `${options[option].displayName}: `; - label.setAttribute('for', option); - - const questionSpan = document.createElement('span'); - questionSpan.classList.add('interactive'); - questionSpan.setAttribute('data-tooltip', options[option].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(options[option].type) { - case 'select': - element = document.createElement('div'); - element.classList.add('select-container'); - let select = document.createElement('select'); - select.setAttribute('id', option); - select.setAttribute('data-key', option); - if (romOpts) { select.setAttribute('data-romOpt', '1'); } - options[option].options.forEach((opt) => { - const optionElement = document.createElement('option'); - optionElement.setAttribute('value', opt.value); - optionElement.innerText = opt.name; - - if ((isNaN(currentOptions[gameName][option]) && - (parseInt(opt.value, 10) === parseInt(currentOptions[gameName][option]))) || - (opt.value === currentOptions[gameName][option])) - { - optionElement.selected = true; - } - select.appendChild(optionElement); - }); - select.addEventListener('change', (event) => updateGameOption(event.target)); - element.appendChild(select); - - // Randomize button - randomButton.innerText = '🎲'; - randomButton.classList.add('randomize-button'); - randomButton.setAttribute('data-key', option); - randomButton.setAttribute('data-tooltip', 'Toggle randomization for this option!'); - randomButton.addEventListener('click', (event) => toggleRandomize(event, select)); - if (currentOptions[gameName][option] === '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('id', option); - range.setAttribute('type', 'range'); - range.setAttribute('data-key', option); - range.setAttribute('min', options[option].min); - range.setAttribute('max', options[option].max); - range.value = currentOptions[gameName][option]; - range.addEventListener('change', (event) => { - document.getElementById(`${option}-value`).innerText = event.target.value; - updateGameOption(event.target); - }); - element.appendChild(range); - - let rangeVal = document.createElement('span'); - rangeVal.classList.add('range-value'); - rangeVal.setAttribute('id', `${option}-value`); - rangeVal.innerText = currentOptions[gameName][option] !== 'random' ? - currentOptions[gameName][option] : options[option].defaultValue; - element.appendChild(rangeVal); - - // Randomize button - randomButton.innerText = '🎲'; - randomButton.classList.add('randomize-button'); - randomButton.setAttribute('data-key', option); - randomButton.setAttribute('data-tooltip', 'Toggle randomization for this option!'); - randomButton.addEventListener('click', (event) => toggleRandomize(event, range)); - if (currentOptions[gameName][option] === 'random') { - randomButton.classList.add('active'); - range.disabled = true; - } - - element.appendChild(randomButton); - break; - - case 'named_range': - element = document.createElement('div'); - element.classList.add('named-range-container'); - - // Build the select element - let namedRangeSelect = document.createElement('select'); - namedRangeSelect.setAttribute('data-key', option); - Object.keys(options[option].value_names).forEach((presetName) => { - let presetOption = document.createElement('option'); - presetOption.innerText = presetName; - presetOption.value = options[option].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(' '); - namedRangeSelect.appendChild(presetOption); - }); - let customOption = document.createElement('option'); - customOption.innerText = 'Custom'; - customOption.value = 'custom'; - customOption.selected = true; - namedRangeSelect.appendChild(customOption); - if (Object.values(options[option].value_names).includes(Number(currentOptions[gameName][option]))) { - namedRangeSelect.value = Number(currentOptions[gameName][option]); - } - - // Build range element - let namedRangeWrapper = document.createElement('div'); - namedRangeWrapper.classList.add('named-range-wrapper'); - let namedRange = document.createElement('input'); - namedRange.setAttribute('type', 'range'); - namedRange.setAttribute('data-key', option); - namedRange.setAttribute('min', options[option].min); - namedRange.setAttribute('max', options[option].max); - namedRange.value = currentOptions[gameName][option]; - - // Build rage value element - let namedRangeVal = document.createElement('span'); - namedRangeVal.classList.add('range-value'); - namedRangeVal.setAttribute('id', `${option}-value`); - namedRangeVal.innerText = currentOptions[gameName][option] !== 'random' ? - currentOptions[gameName][option] : options[option].defaultValue; - - // Configure select event listener - namedRangeSelect.addEventListener('change', (event) => { - if (event.target.value === 'custom') { return; } - - // Update range slider - namedRange.value = event.target.value; - document.getElementById(`${option}-value`).innerText = event.target.value; - updateGameOption(event.target); - }); - - // Configure range event handler - namedRange.addEventListener('change', (event) => { - // Update select element - namedRangeSelect.value = - (Object.values(options[option].value_names).includes(parseInt(event.target.value))) ? - parseInt(event.target.value) : 'custom'; - document.getElementById(`${option}-value`).innerText = event.target.value; - updateGameOption(event.target); - }); - - element.appendChild(namedRangeSelect); - namedRangeWrapper.appendChild(namedRange); - namedRangeWrapper.appendChild(namedRangeVal); - element.appendChild(namedRangeWrapper); - - // Randomize button - randomButton.innerText = '🎲'; - randomButton.classList.add('randomize-button'); - randomButton.setAttribute('data-key', option); - randomButton.setAttribute('data-tooltip', 'Toggle randomization for this option!'); - randomButton.addEventListener('click', (event) => toggleRandomize( - event, namedRange, namedRangeSelect) - ); - if (currentOptions[gameName][option] === 'random') { - randomButton.classList.add('active'); - namedRange.disabled = true; - namedRangeSelect.disabled = true; - } - - namedRangeWrapper.appendChild(randomButton); - break; - - default: - console.error(`Ignoring unknown option type: ${options[option].type} with name ${option}`); - return; - } - - tdr.appendChild(element); - tr.appendChild(tdr); - tbody.appendChild(tr); - }); - - table.appendChild(tbody); - return table; -}; - -const setPresets = (optionsData, presetName) => { - const defaults = optionsData['gameOptions']; - const preset = optionsData['presetOptions'][presetName]; - - localStorage.setItem(`${gameName}-preset`, presetName); - - if (!preset) { - console.error(`No presets defined for preset name: '${presetName}'`); - return; - } - - const updateOptionElement = (option, presetValue) => { - const optionElement = document.querySelector(`#${option}[data-key='${option}']`); - const randomElement = document.querySelector(`.randomize-button[data-key='${option}']`); - - if (presetValue === 'random') { - randomElement.classList.add('active'); - optionElement.disabled = true; - updateGameOption(randomElement, false); - } else { - optionElement.value = presetValue; - randomElement.classList.remove('active'); - optionElement.disabled = undefined; - updateGameOption(optionElement, false); - } - }; - - for (const option in defaults) { - let presetValue = preset[option]; - if (presetValue === undefined) { - // Using the default value if not set in presets. - presetValue = defaults[option]['defaultValue']; - } - - switch (defaults[option].type) { - case 'range': - const numberElement = document.querySelector(`#${option}-value`); - if (presetValue === 'random') { - numberElement.innerText = defaults[option]['defaultValue'] === 'random' - ? defaults[option]['min'] // A fallback so we don't print 'random' in the UI. - : defaults[option]['defaultValue']; - } else { - numberElement.innerText = presetValue; - } - - updateOptionElement(option, presetValue); - break; - - case 'select': { - updateOptionElement(option, presetValue); - break; - } - - case 'named_range': { - const selectElement = document.querySelector(`select[data-key='${option}']`); - const rangeElement = document.querySelector(`input[data-key='${option}']`); - const randomElement = document.querySelector(`.randomize-button[data-key='${option}']`); - - if (presetValue === 'random') { - randomElement.classList.add('active'); - selectElement.disabled = true; - rangeElement.disabled = true; - updateGameOption(randomElement, false); - } else { - rangeElement.value = presetValue; - selectElement.value = Object.values(defaults[option]['value_names']).includes(parseInt(presetValue)) ? - parseInt(presetValue) : 'custom'; - document.getElementById(`${option}-value`).innerText = presetValue; - - randomElement.classList.remove('active'); - selectElement.disabled = undefined; - rangeElement.disabled = undefined; - updateGameOption(rangeElement, false); - } - break; - } - - default: - console.warn(`Ignoring preset value for unknown option type: ${defaults[option].type} with name ${option}`); - break; - } - } -}; - -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; - } - } - updateGameOption(active ? inputElement : randomButton); -}; - -const updateBaseOption = (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 updateGameOption = (optionElement, toggleCustomPreset = true) => { - const options = JSON.parse(localStorage.getItem(gameName)); - - if (toggleCustomPreset) { - localStorage.setItem(`${gameName}-preset`, '__custom'); - const presetElement = document.getElementById('game-options-preset'); - presetElement.value = '__custom'; - } - - if (optionElement.classList.contains('randomize-button')) { - // If the event passed in is the randomize button, then we know what we must do. - options[gameName][optionElement.getAttribute('data-key')] = 'random'; - } else { - options[gameName][optionElement.getAttribute('data-key')] = isNaN(optionElement.value) ? - optionElement.value : parseInt(optionElement.value, 10); - } - - localStorage.setItem(gameName, JSON.stringify(options)); -}; - -const exportOptions = () => { - const options = JSON.parse(localStorage.getItem(gameName)); - const preset = localStorage.getItem(`${gameName}-preset`); - switch (preset) { - case '__default': - options['description'] = `Generated by https://archipelago.gg with the default preset.`; - break; - - case '__custom': - options['description'] = `Generated by https://archipelago.gg.`; - break; - - default: - options['description'] = `Generated by https://archipelago.gg with the ${preset} preset.`; - } - - if (!options.name || options.name.toString().trim().length === 0) { - return showUserMessage('You must enter a player name!'); - } - const yamlText = jsyaml.safeDump(options, { 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 options = JSON.parse(localStorage.getItem(gameName)); - if (!options.name || options.name.toLowerCase() === 'player' || options.name.trim().length === 0) { - return showUserMessage('You must enter a player name!'); - } - - axios.post('/api/generate', { - weights: { player: options }, - presetData: { player: options }, - 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); -}; diff --git a/WebHostLib/static/assets/playerOptions.js b/WebHostLib/static/assets/playerOptions.js new file mode 100644 index 000000000000..d0f2e388c2a6 --- /dev/null +++ b/WebHostLib/static/assets/playerOptions.js @@ -0,0 +1,335 @@ +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} + */ +const fetchPresets = async () => { + const response = await fetch('option-presets'); + presets = await response.json(); + const presetSelect = document.getElementById('game-options-preset'); + presetSelect.removeAttribute('disabled'); + + const game = document.getElementById('player-options').getAttribute('data-game'); + const presetToApply = localStorage.getItem(`${game}-preset`); + const playerName = localStorage.getItem(`${game}-player`); + if (presetToApply) { + localStorage.removeItem(`${game}-preset`); + presetSelect.value = presetToApply; + applyPresets(presetToApply); + } + + if (playerName) { + document.getElementById('player-name').value = playerName; + localStorage.removeItem(`${game}-player`); + } +}; + +/** + * Clear the localStorage for this game and set a preset to be loaded upon page reload + * @param evt + */ +const choosePreset = (evt) => { + if (evt.target.value === 'custom') { return; } + + const game = document.getElementById('player-options').getAttribute('data-game'); + localStorage.removeItem(game); + + localStorage.setItem(`${game}-player`, document.getElementById('player-name').value); + if (evt.target.value !== 'default') { + localStorage.setItem(`${game}-preset`, evt.target.value); + } + + document.querySelectorAll('#options-form input, #options-form select').forEach((input) => { + if (input.id === 'player-name') { return; } + input.removeAttribute('value'); + }); + + window.location.replace(window.location.href); +}; + +const applyPresets = (presetName) => { + // Ignore the "default" preset, because it gets set automatically by Jinja + if (presetName === 'default') { + saveSettings(); + return; + } + + if (!presets[presetName]) { + console.error(`Unknown preset ${presetName} chosen`); + return; + } + + const preset = presets[presetName]; + Object.keys(preset).forEach((optionName) => { + const optionValue = preset[optionName]; + + // Handle List and Set options + if (Array.isArray(optionValue)) { + document.querySelectorAll(`input[type=checkbox][name=${optionName}]`).forEach((checkbox) => { + if (optionValue.includes(checkbox.value)) { + checkbox.setAttribute('checked', '1'); + } else { + checkbox.removeAttribute('checked'); + } + }); + return; + } + + // Handle Dict options + if (typeof(optionValue) === 'object' && optionValue !== null) { + const itemNames = Object.keys(optionValue); + document.querySelectorAll(`input[type=number][data-option-name=${optionName}]`).forEach((input) => { + const itemName = input.getAttribute('data-item-name'); + input.value = (itemNames.includes(itemName)) ? optionValue[itemName] : 0 + }); + return; + } + + // Identify all possible elements + const normalInput = document.getElementById(optionName); + const customInput = document.getElementById(`${optionName}-custom`); + const rangeValue = document.getElementById(`${optionName}-value`); + const randomizeInput = document.getElementById(`random-${optionName}`); + const namedRangeSelect = document.getElementById(`${optionName}-select`); + + // It is possible for named ranges to use name of a value rather than the value itself. This is accounted for here + let trueValue = optionValue; + if (namedRangeSelect) { + namedRangeSelect.querySelectorAll('option').forEach((opt) => { + if (opt.innerText.startsWith(optionValue)) { + trueValue = opt.value; + } + }); + namedRangeSelect.value = trueValue; + } + + // Handle options whose presets are "random" + if (optionValue === 'random') { + normalInput.setAttribute('disabled', '1'); + randomizeInput.setAttribute('checked', '1'); + if (customInput) { + customInput.setAttribute('disabled', '1'); + } + if (rangeValue) { + rangeValue.innerText = normalInput.value; + } + if (namedRangeSelect) { + namedRangeSelect.setAttribute('disabled', '1'); + } + return; + } + + // Handle normal (text, number, select, etc.) and custom inputs (custom inputs exist with TextChoice only) + normalInput.value = trueValue; + normalInput.removeAttribute('disabled'); + randomizeInput.removeAttribute('checked'); + if (customInput) { + document.getElementById(`${optionName}-custom`).removeAttribute('disabled'); + } + if (rangeValue) { + rangeValue.innerText = trueValue; + } + }); + + saveSettings(); +}; + +const showUserMessage = (text) => { + const userMessage = document.getElementById('user-message'); + userMessage.innerText = text; + userMessage.addEventListener('click', hideUserMessage); + userMessage.style.display = 'block'; +}; + +const hideUserMessage = () => { + const userMessage = document.getElementById('user-message'); + userMessage.removeEventListener('click', hideUserMessage); + userMessage.style.display = 'none'; +}; diff --git a/WebHostLib/static/assets/sc2wolTracker.js b/WebHostLib/static/assets/sc2Tracker.js similarity index 85% rename from WebHostLib/static/assets/sc2wolTracker.js rename to WebHostLib/static/assets/sc2Tracker.js index a698214b8dd6..30d4acd60b7e 100644 --- a/WebHostLib/static/assets/sc2wolTracker.js +++ b/WebHostLib/static/assets/sc2Tracker.js @@ -25,16 +25,16 @@ window.addEventListener('load', () => { // Collapsible advancement sections const categories = document.getElementsByClassName("location-category"); - for (let i = 0; i < categories.length; i++) { - let hide_id = categories[i].id.split('-')[0]; - if (hide_id == 'Total') { + for (let category of categories) { + let hide_id = category.id.split('_')[0]; + if (hide_id === 'Total') { continue; } - categories[i].addEventListener('click', function() { + category.addEventListener('click', function() { // Toggle the advancement list document.getElementById(hide_id).classList.toggle("hide"); // Change text of the header - const tab_header = document.getElementById(hide_id+'-header').children[0]; + const tab_header = document.getElementById(hide_id+'_header').children[0]; const orig_text = tab_header.innerHTML; let new_text; if (orig_text.includes("▼")) { diff --git a/WebHostLib/static/assets/supportedGames.js b/WebHostLib/static/assets/supportedGames.js index 56eb15b5e580..b692db9283d2 100644 --- a/WebHostLib/static/assets/supportedGames.js +++ b/WebHostLib/static/assets/supportedGames.js @@ -1,18 +1,16 @@ window.addEventListener('load', () => { // Add toggle listener to all elements with .collapse-toggle - const toggleButtons = document.querySelectorAll('.collapse-toggle'); - toggleButtons.forEach((e) => e.addEventListener('click', toggleCollapse)); + const toggleButtons = document.querySelectorAll('details'); // Handle game filter input const gameSearch = document.getElementById('game-search'); gameSearch.value = ''; gameSearch.addEventListener('input', (evt) => { if (!evt.target.value.trim()) { - // If input is empty, display all collapsed games + // If input is empty, display all games as collapsed return toggleButtons.forEach((header) => { header.style.display = null; - header.firstElementChild.innerText = '▶'; - header.nextElementSibling.classList.add('collapsed'); + header.removeAttribute('open'); }); } @@ -21,12 +19,10 @@ window.addEventListener('load', () => { // If the game name includes the search string, display the game. If not, hide it if (header.getAttribute('data-game').toLowerCase().includes(evt.target.value.toLowerCase())) { header.style.display = null; - header.firstElementChild.innerText = '▼'; - header.nextElementSibling.classList.remove('collapsed'); + header.setAttribute('open', '1'); } else { header.style.display = 'none'; - header.firstElementChild.innerText = '▶'; - header.nextElementSibling.classList.add('collapsed'); + header.removeAttribute('open'); } }); }); @@ -35,30 +31,14 @@ window.addEventListener('load', () => { document.getElementById('collapse-all').addEventListener('click', collapseAll); }); -const toggleCollapse = (evt) => { - const gameArrow = evt.target.firstElementChild; - const gameInfo = evt.target.nextElementSibling; - if (gameInfo.classList.contains('collapsed')) { - gameArrow.innerText = '▼'; - gameInfo.classList.remove('collapsed'); - } else { - gameArrow.innerText = '▶'; - gameInfo.classList.add('collapsed'); - } -}; - const expandAll = () => { - document.querySelectorAll('.collapse-toggle').forEach((header) => { - if (header.style.display === 'none') { return; } - header.firstElementChild.innerText = '▼'; - header.nextElementSibling.classList.remove('collapsed'); + document.querySelectorAll('details').forEach((detail) => { + detail.setAttribute('open', '1'); }); }; const collapseAll = () => { - document.querySelectorAll('.collapse-toggle').forEach((header) => { - if (header.style.display === 'none') { return; } - header.firstElementChild.innerText = '▶'; - header.nextElementSibling.classList.add('collapsed'); + document.querySelectorAll('details').forEach((detail) => { + detail.removeAttribute('open'); }); }; diff --git a/WebHostLib/static/assets/trackerCommon.js b/WebHostLib/static/assets/trackerCommon.js index b8e089ece5d3..6324837b2816 100644 --- a/WebHostLib/static/assets/trackerCommon.js +++ b/WebHostLib/static/assets/trackerCommon.js @@ -27,7 +27,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 +38,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: [ @@ -123,49 +123,64 @@ window.addEventListener('load', () => { event.preventDefault(); } }); - const tracker = document.getElementById('tracker-wrapper').getAttribute('data-tracker'); - const target_second = document.getElementById('tracker-wrapper').getAttribute('data-second') + 3; + const target_second = parseInt(document.getElementById('tracker-wrapper').getAttribute('data-second')) + 3; + console.log("Target second of refresh: " + target_second); - 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 = () => { - const target = $("
"); - 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); + if (document.hidden) { + console.log("Document reporting as not visible, not updating Tracker..."); + update_on_view = true; + } else { + update_on_view = false; + const target = $("
"); + 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); } - setTimeout(update, getSleepTimeSeconds()*1000); + let updater = 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(); }); diff --git a/WebHostLib/static/assets/weighted-options.js b/WebHostLib/static/assets/weighted-options.js deleted file mode 100644 index a2fedb5383b7..000000000000 --- a/WebHostLib/static/assets/weighted-options.js +++ /dev/null @@ -1,1190 +0,0 @@ -window.addEventListener('load', () => { - fetchSettingData().then((data) => { - let settingHash = localStorage.getItem('weighted-settings-hash'); - if (!settingHash) { - // If no hash data has been set before, set it now - settingHash = md5(JSON.stringify(data)); - localStorage.setItem('weighted-settings-hash', settingHash); - localStorage.removeItem('weighted-settings'); - } - - if (settingHash !== md5(JSON.stringify(data))) { - const userMessage = document.getElementById('user-message'); - userMessage.innerText = "Your settings are out of date! Click here to update them! Be aware this will reset " + - "them all to default."; - userMessage.classList.add('visible'); - userMessage.addEventListener('click', resetSettings); - } - - // Page setup - const settings = new WeightedSettings(data); - settings.buildUI(); - settings.updateVisibleGames(); - adjustHeaderWidth(); - - // Event listeners - document.getElementById('export-options').addEventListener('click', () => settings.export()); - document.getElementById('generate-race').addEventListener('click', () => settings.generateGame(true)); - document.getElementById('generate-game').addEventListener('click', () => settings.generateGame()); - - // Name input field - const nameInput = document.getElementById('player-name'); - nameInput.setAttribute('data-type', 'data'); - nameInput.setAttribute('data-setting', 'name'); - nameInput.addEventListener('keyup', (evt) => settings.updateBaseSetting(evt)); - nameInput.value = settings.current.name; - }); -}); - -const resetSettings = () => { - localStorage.removeItem('weighted-settings'); - localStorage.removeItem('weighted-settings-hash') - window.location.reload(); -}; - -const fetchSettingData = () => new Promise((resolve, reject) => { - fetch(new Request(`${window.location.origin}/static/generated/weighted-options.json`)).then((response) => { - try{ response.json().then((jsonObj) => resolve(jsonObj)); } - catch(error){ reject(error); } - }); -}); - -/// The weighted settings across all games. -class WeightedSettings { - // The data from the server describing the types of settings available for - // each game, as a JSON-safe blob. - data; - - // The settings chosen by the user as they'd appear in the YAML file, stored - // to and retrieved from local storage. - current; - - // A record mapping game names to the associated GameSettings. - games; - - constructor(data) { - this.data = data; - this.current = JSON.parse(localStorage.getItem('weighted-settings')); - this.games = Object.keys(this.data.games).map((game) => new GameSettings(this, game)); - if (this.current) { return; } - - this.current = {}; - - // Transfer base options directly - for (let baseOption of Object.keys(this.data.baseOptions)){ - this.current[baseOption] = this.data.baseOptions[baseOption]; - } - - // Set options per game - for (let game of Object.keys(this.data.games)) { - // Initialize game object - this.current[game] = {}; - - // Transfer game settings - for (let gameSetting of Object.keys(this.data.games[game].gameSettings)){ - this.current[game][gameSetting] = {}; - - const setting = this.data.games[game].gameSettings[gameSetting]; - switch(setting.type){ - case 'select': - setting.options.forEach((option) => { - this.current[game][gameSetting][option.value] = - (setting.hasOwnProperty('defaultValue') && setting.defaultValue === option.value) ? 25 : 0; - }); - break; - case 'range': - case 'named_range': - this.current[game][gameSetting]['random'] = 0; - this.current[game][gameSetting]['random-low'] = 0; - this.current[game][gameSetting]['random-middle'] = 0; - this.current[game][gameSetting]['random-high'] = 0; - if (setting.hasOwnProperty('defaultValue')) { - this.current[game][gameSetting][setting.defaultValue] = 25; - } else { - this.current[game][gameSetting][setting.min] = 25; - } - break; - - case 'items-list': - case 'locations-list': - case 'custom-list': - this.current[game][gameSetting] = setting.defaultValue; - break; - - default: - console.error(`Unknown setting type for ${game} setting ${gameSetting}: ${setting.type}`); - } - } - - this.current[game].start_inventory = {}; - this.current[game].exclude_locations = []; - this.current[game].priority_locations = []; - this.current[game].local_items = []; - this.current[game].non_local_items = []; - this.current[game].start_hints = []; - this.current[game].start_location_hints = []; - } - - this.save(); - } - - // Saves the current settings to local storage. - save() { - localStorage.setItem('weighted-settings', JSON.stringify(this.current)); - } - - buildUI() { - // Build the game-choice div - this.#buildGameChoice(); - - const gamesWrapper = document.getElementById('games-wrapper'); - this.games.forEach((game) => { - gamesWrapper.appendChild(game.buildUI()); - }); - } - - #buildGameChoice() { - const gameChoiceDiv = document.getElementById('game-choice'); - const h2 = document.createElement('h2'); - h2.innerText = 'Game Select'; - gameChoiceDiv.appendChild(h2); - - const gameSelectDescription = document.createElement('p'); - gameSelectDescription.classList.add('setting-description'); - gameSelectDescription.innerText = 'Choose which games you might be required to play.'; - gameChoiceDiv.appendChild(gameSelectDescription); - - const hintText = document.createElement('p'); - hintText.classList.add('hint-text'); - hintText.innerText = 'If a game\'s value is greater than zero, you can click it\'s name to jump ' + - 'to that section.' - gameChoiceDiv.appendChild(hintText); - - // Build the game choice table - const table = document.createElement('table'); - const tbody = document.createElement('tbody'); - - Object.keys(this.data.games).forEach((game) => { - const tr = document.createElement('tr'); - const tdLeft = document.createElement('td'); - tdLeft.classList.add('td-left'); - const span = document.createElement('span'); - span.innerText = game; - span.setAttribute('id', `${game}-game-option`) - tdLeft.appendChild(span); - 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('data-type', 'weight'); - range.setAttribute('data-setting', 'game'); - range.setAttribute('data-option', game); - range.value = this.current.game[game]; - range.addEventListener('change', (evt) => { - this.updateBaseSetting(evt); - this.updateVisibleGames(); // Show or hide games based on the new settings - }); - tdMiddle.appendChild(range); - tr.appendChild(tdMiddle); - - const tdRight = document.createElement('td'); - tdRight.setAttribute('id', `game-${game}`) - tdRight.classList.add('td-right'); - tdRight.innerText = range.value; - tr.appendChild(tdRight); - tbody.appendChild(tr); - }); - - table.appendChild(tbody); - gameChoiceDiv.appendChild(table); - } - - // Verifies that `this.settings` meets all the requirements for world - // generation, normalizes it for serialization, and returns the result. - #validateSettings() { - const settings = structuredClone(this.current); - const userMessage = document.getElementById('user-message'); - let errorMessage = null; - - // User must choose a name for their file - if ( - !settings.name || - settings.name.toString().trim().length === 0 || - settings.name.toString().toLowerCase().trim() === 'player' - ) { - userMessage.innerText = 'You forgot to set your player name at the top of the page!'; - userMessage.classList.add('visible'); - userMessage.scrollIntoView({ - behavior: 'smooth', - block: 'start', - }); - return; - } - - // Clean up the settings output - Object.keys(settings.game).forEach((game) => { - // Remove any disabled games - if (settings.game[game] === 0) { - delete settings.game[game]; - delete settings[game]; - return; - } - - Object.keys(settings[game]).forEach((setting) => { - // Remove any disabled options - Object.keys(settings[game][setting]).forEach((option) => { - if (settings[game][setting][option] === 0) { - delete settings[game][setting][option]; - } - }); - - if ( - Object.keys(settings[game][setting]).length === 0 && - !Array.isArray(settings[game][setting]) && - setting !== 'start_inventory' - ) { - errorMessage = `${game} // ${setting} has no values above zero!`; - } - - // Remove weights from options with only one possibility - if ( - Object.keys(settings[game][setting]).length === 1 && - !Array.isArray(settings[game][setting]) && - setting !== 'start_inventory' - ) { - settings[game][setting] = Object.keys(settings[game][setting])[0]; - } - - // Remove empty arrays - else if ( - ['exclude_locations', 'priority_locations', 'local_items', - 'non_local_items', 'start_hints', 'start_location_hints'].includes(setting) && - settings[game][setting].length === 0 - ) { - delete settings[game][setting]; - } - - // Remove empty start inventory - else if ( - setting === 'start_inventory' && - Object.keys(settings[game]['start_inventory']).length === 0 - ) { - delete settings[game]['start_inventory']; - } - }); - }); - - if (Object.keys(settings.game).length === 0) { - errorMessage = 'You have not chosen a game to play!'; - } - - // Remove weights if there is only one game - else if (Object.keys(settings.game).length === 1) { - settings.game = Object.keys(settings.game)[0]; - } - - // If an error occurred, alert the user and do not export the file - if (errorMessage) { - userMessage.innerText = errorMessage; - userMessage.classList.add('visible'); - userMessage.scrollIntoView({ - behavior: 'smooth', - block: 'start', - }); - return; - } - - // If no error occurred, hide the user message if it is visible - userMessage.classList.remove('visible'); - return settings; - } - - updateVisibleGames() { - Object.entries(this.current.game).forEach(([game, weight]) => { - const gameDiv = document.getElementById(`${game}-div`); - const gameOption = document.getElementById(`${game}-game-option`); - if (parseInt(weight, 10) > 0) { - gameDiv.classList.remove('invisible'); - gameOption.classList.add('jump-link'); - gameOption.addEventListener('click', () => { - const gameDiv = document.getElementById(`${game}-div`); - if (gameDiv.classList.contains('invisible')) { return; } - gameDiv.scrollIntoView({ - behavior: 'smooth', - block: 'start', - }); - }); - } else { - gameDiv.classList.add('invisible'); - gameOption.classList.remove('jump-link'); - } - }); - } - - updateBaseSetting(event) { - const setting = event.target.getAttribute('data-setting'); - const option = event.target.getAttribute('data-option'); - const type = event.target.getAttribute('data-type'); - - switch(type){ - case 'weight': - this.current[setting][option] = isNaN(event.target.value) ? event.target.value : parseInt(event.target.value, 10); - document.getElementById(`${setting}-${option}`).innerText = event.target.value; - break; - case 'data': - this.current[setting] = isNaN(event.target.value) ? event.target.value : parseInt(event.target.value, 10); - break; - } - - this.save(); - } - - export() { - const settings = this.#validateSettings(); - if (!settings) { return; } - - const yamlText = jsyaml.safeDump(settings, { noCompatMode: true }).replaceAll(/'(\d+)':/g, (x, y) => `${y}:`); - download(`${document.getElementById('player-name').value}.yaml`, yamlText); - } - - generateGame(raceMode = false) { - const settings = this.#validateSettings(); - if (!settings) { return; } - - axios.post('/api/generate', { - weights: { player: JSON.stringify(settings) }, - presetData: { player: JSON.stringify(settings) }, - playerCount: 1, - spoiler: 3, - race: raceMode ? '1' : '0', - }).then((response) => { - window.location.href = response.data.url; - }).catch((error) => { - const userMessage = document.getElementById('user-message'); - userMessage.innerText = 'Something went wrong and your game could not be generated.'; - if (error.response.data.text) { - userMessage.innerText += ' ' + error.response.data.text; - } - userMessage.classList.add('visible'); - userMessage.scrollIntoView({ - behavior: 'smooth', - block: 'start', - }); - console.error(error); - }); - } -} - -// Settings for an individual game. -class GameSettings { - // The WeightedSettings that contains this game's settings. Used to save - // settings after editing. - #allSettings; - - // The name of this game. - name; - - // The data from the server describing the types of settings available for - // this game, as a JSON-safe blob. - get data() { - return this.#allSettings.data.games[this.name]; - } - - // The settings chosen by the user as they'd appear in the YAML file, stored - // to and retrieved from local storage. - get current() { - return this.#allSettings.current[this.name]; - } - - constructor(allSettings, name) { - this.#allSettings = allSettings; - this.name = name; - } - - // Builds and returns the settings UI for this game. - buildUI() { - // Create game div, invisible by default - const gameDiv = document.createElement('div'); - gameDiv.setAttribute('id', `${this.name}-div`); - gameDiv.classList.add('game-div'); - gameDiv.classList.add('invisible'); - - const gameHeader = document.createElement('h2'); - gameHeader.innerText = this.name; - gameDiv.appendChild(gameHeader); - - const collapseButton = document.createElement('a'); - collapseButton.innerText = '(Collapse)'; - gameDiv.appendChild(collapseButton); - - const expandButton = document.createElement('a'); - expandButton.innerText = '(Expand)'; - expandButton.classList.add('invisible'); - gameDiv.appendChild(expandButton); - - // Sort items and locations alphabetically. - this.data.gameItems.sort(); - this.data.gameLocations.sort(); - - const weightedSettingsDiv = this.#buildWeightedSettingsDiv(); - gameDiv.appendChild(weightedSettingsDiv); - - const itemPoolDiv = this.#buildItemPoolDiv(); - gameDiv.appendChild(itemPoolDiv); - - const hintsDiv = this.#buildHintsDiv(); - gameDiv.appendChild(hintsDiv); - - const locationsDiv = this.#buildPriorityExclusionDiv(); - gameDiv.appendChild(locationsDiv); - - collapseButton.addEventListener('click', () => { - collapseButton.classList.add('invisible'); - weightedSettingsDiv.classList.add('invisible'); - itemPoolDiv.classList.add('invisible'); - hintsDiv.classList.add('invisible'); - locationsDiv.classList.add('invisible'); - expandButton.classList.remove('invisible'); - }); - - expandButton.addEventListener('click', () => { - collapseButton.classList.remove('invisible'); - weightedSettingsDiv.classList.remove('invisible'); - itemPoolDiv.classList.remove('invisible'); - hintsDiv.classList.remove('invisible'); - locationsDiv.classList.remove('invisible'); - expandButton.classList.add('invisible'); - }); - - return gameDiv; - } - - #buildWeightedSettingsDiv() { - const settingsWrapper = document.createElement('div'); - settingsWrapper.classList.add('settings-wrapper'); - - Object.keys(this.data.gameSettings).forEach((settingName) => { - const setting = this.data.gameSettings[settingName]; - const settingWrapper = document.createElement('div'); - settingWrapper.classList.add('setting-wrapper'); - - const settingNameHeader = document.createElement('h4'); - settingNameHeader.innerText = setting.displayName; - settingWrapper.appendChild(settingNameHeader); - - const settingDescription = document.createElement('p'); - settingDescription.classList.add('setting-description'); - settingDescription.innerText = setting.description.replace(/(\n)/g, ' '); - settingWrapper.appendChild(settingDescription); - - switch(setting.type){ - case 'select': - const optionTable = document.createElement('table'); - const tbody = document.createElement('tbody'); - - // Add a weight range for each option - setting.options.forEach((option) => { - const tr = document.createElement('tr'); - const tdLeft = document.createElement('td'); - tdLeft.classList.add('td-left'); - tdLeft.innerText = option.name; - tr.appendChild(tdLeft); - - const tdMiddle = document.createElement('td'); - tdMiddle.classList.add('td-middle'); - const range = document.createElement('input'); - range.setAttribute('type', 'range'); - range.setAttribute('data-game', this.name); - range.setAttribute('data-setting', settingName); - range.setAttribute('data-option', option.value); - range.setAttribute('data-type', setting.type); - range.setAttribute('min', 0); - range.setAttribute('max', 50); - range.addEventListener('change', (evt) => this.#updateRangeSetting(evt)); - range.value = this.current[settingName][option.value]; - tdMiddle.appendChild(range); - tr.appendChild(tdMiddle); - - const tdRight = document.createElement('td'); - tdRight.setAttribute('id', `${this.name}-${settingName}-${option.value}`); - tdRight.classList.add('td-right'); - tdRight.innerText = range.value; - tr.appendChild(tdRight); - - tbody.appendChild(tr); - }); - - optionTable.appendChild(tbody); - settingWrapper.appendChild(optionTable); - break; - - case 'range': - case 'named_range': - const rangeTable = document.createElement('table'); - const rangeTbody = document.createElement('tbody'); - - const hintText = document.createElement('p'); - hintText.classList.add('hint-text'); - hintText.innerHTML = 'This is a range option. You may enter a valid numerical value in the text box ' + - `below, then press the "Add" button to add a weight for it.

Accepted values:
` + - `Normal range: ${setting.min} - ${setting.max}`; - - const acceptedValuesOutsideRange = []; - if (setting.hasOwnProperty('value_names')) { - Object.keys(setting.value_names).forEach((specialName) => { - if ( - (setting.value_names[specialName] < setting.min) || - (setting.value_names[specialName] > setting.max) - ) { - hintText.innerHTML += `
${specialName}: ${setting.value_names[specialName]}`; - acceptedValuesOutsideRange.push(setting.value_names[specialName]); - } - }); - - hintText.innerHTML += '

Certain values have special meaning:'; - Object.keys(setting.value_names).forEach((specialName) => { - hintText.innerHTML += `
${specialName}: ${setting.value_names[specialName]}`; - }); - } - - settingWrapper.appendChild(hintText); - - const addOptionDiv = document.createElement('div'); - addOptionDiv.classList.add('add-option-div'); - const optionInput = document.createElement('input'); - optionInput.setAttribute('id', `${this.name}-${settingName}-option`); - let placeholderText = `${setting.min} - ${setting.max}`; - acceptedValuesOutsideRange.forEach((aVal) => placeholderText += `, ${aVal}`); - optionInput.setAttribute('placeholder', placeholderText); - addOptionDiv.appendChild(optionInput); - const addOptionButton = document.createElement('button'); - addOptionButton.innerText = 'Add'; - addOptionDiv.appendChild(addOptionButton); - settingWrapper.appendChild(addOptionDiv); - optionInput.addEventListener('keydown', (evt) => { - if (evt.key === 'Enter') { addOptionButton.dispatchEvent(new Event('click')); } - }); - - addOptionButton.addEventListener('click', () => { - const optionInput = document.getElementById(`${this.name}-${settingName}-option`); - let option = optionInput.value; - if (!option || !option.trim()) { return; } - option = parseInt(option, 10); - - let optionAcceptable = false; - if ((option > setting.min) && (option < setting.max)) { - optionAcceptable = true; - } - if (setting.hasOwnProperty('value_names') && Object.values(setting.value_names).includes(option)){ - optionAcceptable = true; - } - if (!optionAcceptable) { return; } - - optionInput.value = ''; - if (document.getElementById(`${this.name}-${settingName}-${option}-range`)) { return; } - - const tr = document.createElement('tr'); - const tdLeft = document.createElement('td'); - tdLeft.classList.add('td-left'); - tdLeft.innerText = option; - if ( - setting.hasOwnProperty('value_names') && - Object.values(setting.value_names).includes(parseInt(option, 10)) - ) { - const optionName = Object.keys(setting.value_names).find( - (key) => setting.value_names[key] === parseInt(option, 10) - ); - tdLeft.innerText += ` [${optionName}]`; - } - tr.appendChild(tdLeft); - - const tdMiddle = document.createElement('td'); - tdMiddle.classList.add('td-middle'); - const range = document.createElement('input'); - range.setAttribute('type', 'range'); - range.setAttribute('id', `${this.name}-${settingName}-${option}-range`); - range.setAttribute('data-game', this.name); - range.setAttribute('data-setting', settingName); - range.setAttribute('data-option', option); - range.setAttribute('min', 0); - range.setAttribute('max', 50); - range.addEventListener('change', (evt) => this.#updateRangeSetting(evt)); - range.value = this.current[settingName][parseInt(option, 10)]; - tdMiddle.appendChild(range); - tr.appendChild(tdMiddle); - - const tdRight = document.createElement('td'); - tdRight.setAttribute('id', `${this.name}-${settingName}-${option}`) - tdRight.classList.add('td-right'); - tdRight.innerText = range.value; - tr.appendChild(tdRight); - - const tdDelete = document.createElement('td'); - tdDelete.classList.add('td-delete'); - const deleteButton = document.createElement('span'); - deleteButton.classList.add('range-option-delete'); - deleteButton.innerText = 'âŒ'; - deleteButton.addEventListener('click', () => { - range.value = 0; - range.dispatchEvent(new Event('change')); - rangeTbody.removeChild(tr); - }); - tdDelete.appendChild(deleteButton); - tr.appendChild(tdDelete); - - rangeTbody.appendChild(tr); - - // Save new option to settings - range.dispatchEvent(new Event('change')); - }); - - Object.keys(this.current[settingName]).forEach((option) => { - // These options are statically generated below, and should always appear even if they are deleted - // from localStorage - if (['random', 'random-low', 'random-middle', 'random-high'].includes(option)) { return; } - - const tr = document.createElement('tr'); - const tdLeft = document.createElement('td'); - tdLeft.classList.add('td-left'); - tdLeft.innerText = option; - if ( - setting.hasOwnProperty('value_names') && - Object.values(setting.value_names).includes(parseInt(option, 10)) - ) { - const optionName = Object.keys(setting.value_names).find( - (key) => setting.value_names[key] === parseInt(option, 10) - ); - tdLeft.innerText += ` [${optionName}]`; - } - tr.appendChild(tdLeft); - - const tdMiddle = document.createElement('td'); - tdMiddle.classList.add('td-middle'); - const range = document.createElement('input'); - range.setAttribute('type', 'range'); - range.setAttribute('id', `${this.name}-${settingName}-${option}-range`); - range.setAttribute('data-game', this.name); - range.setAttribute('data-setting', settingName); - range.setAttribute('data-option', option); - range.setAttribute('min', 0); - range.setAttribute('max', 50); - range.addEventListener('change', (evt) => this.#updateRangeSetting(evt)); - range.value = this.current[settingName][parseInt(option, 10)]; - tdMiddle.appendChild(range); - tr.appendChild(tdMiddle); - - const tdRight = document.createElement('td'); - tdRight.setAttribute('id', `${this.name}-${settingName}-${option}`) - tdRight.classList.add('td-right'); - tdRight.innerText = range.value; - tr.appendChild(tdRight); - - const tdDelete = document.createElement('td'); - tdDelete.classList.add('td-delete'); - const deleteButton = document.createElement('span'); - deleteButton.classList.add('range-option-delete'); - deleteButton.innerText = 'âŒ'; - deleteButton.addEventListener('click', () => { - range.value = 0; - const changeEvent = new Event('change'); - changeEvent.action = 'rangeDelete'; - range.dispatchEvent(changeEvent); - rangeTbody.removeChild(tr); - }); - tdDelete.appendChild(deleteButton); - tr.appendChild(tdDelete); - - rangeTbody.appendChild(tr); - }); - - ['random', 'random-low', 'random-middle', 'random-high'].forEach((option) => { - const tr = document.createElement('tr'); - const tdLeft = document.createElement('td'); - tdLeft.classList.add('td-left'); - switch(option){ - case 'random': - tdLeft.innerText = 'Random'; - break; - case 'random-low': - tdLeft.innerText = "Random (Low)"; - break; - case 'random-middle': - tdLeft.innerText = 'Random (Middle)'; - break; - case 'random-high': - tdLeft.innerText = "Random (High)"; - break; - } - tr.appendChild(tdLeft); - - const tdMiddle = document.createElement('td'); - tdMiddle.classList.add('td-middle'); - const range = document.createElement('input'); - range.setAttribute('type', 'range'); - range.setAttribute('id', `${this.name}-${settingName}-${option}-range`); - range.setAttribute('data-game', this.name); - range.setAttribute('data-setting', settingName); - range.setAttribute('data-option', option); - range.setAttribute('min', 0); - range.setAttribute('max', 50); - range.addEventListener('change', (evt) => this.#updateRangeSetting(evt)); - range.value = this.current[settingName][option]; - tdMiddle.appendChild(range); - tr.appendChild(tdMiddle); - - const tdRight = document.createElement('td'); - tdRight.setAttribute('id', `${this.name}-${settingName}-${option}`) - tdRight.classList.add('td-right'); - tdRight.innerText = range.value; - tr.appendChild(tdRight); - rangeTbody.appendChild(tr); - }); - - rangeTable.appendChild(rangeTbody); - settingWrapper.appendChild(rangeTable); - break; - - case 'items-list': - const itemsList = this.#buildItemsDiv(settingName); - settingWrapper.appendChild(itemsList); - break; - - case 'locations-list': - const locationsList = this.#buildLocationsDiv(settingName); - settingWrapper.appendChild(locationsList); - break; - - case 'custom-list': - const customList = this.#buildListDiv(settingName, this.data.gameSettings[settingName].options); - settingWrapper.appendChild(customList); - break; - - default: - console.error(`Unknown setting type for ${this.name} setting ${settingName}: ${setting.type}`); - return; - } - - settingsWrapper.appendChild(settingWrapper); - }); - - return settingsWrapper; - } - - #buildItemPoolDiv() { - const itemsDiv = document.createElement('div'); - itemsDiv.classList.add('items-div'); - - const itemsDivHeader = document.createElement('h3'); - itemsDivHeader.innerText = 'Item Pool'; - itemsDiv.appendChild(itemsDivHeader); - - const itemsDescription = document.createElement('p'); - itemsDescription.classList.add('setting-description'); - itemsDescription.innerText = 'Choose if you would like to start with items, or control if they are placed in ' + - 'your seed or someone else\'s.'; - itemsDiv.appendChild(itemsDescription); - - const itemsHint = document.createElement('p'); - itemsHint.classList.add('hint-text'); - itemsHint.innerText = 'Drag and drop items from one box to another.'; - itemsDiv.appendChild(itemsHint); - - const itemsWrapper = document.createElement('div'); - itemsWrapper.classList.add('items-wrapper'); - - const itemDragoverHandler = (evt) => evt.preventDefault(); - const itemDropHandler = (evt) => this.#itemDropHandler(evt); - - // Create container divs for each category - const availableItemsWrapper = document.createElement('div'); - availableItemsWrapper.classList.add('item-set-wrapper'); - availableItemsWrapper.innerText = 'Available Items'; - const availableItems = document.createElement('div'); - availableItems.classList.add('item-container'); - availableItems.setAttribute('id', `${this.name}-available_items`); - availableItems.addEventListener('dragover', itemDragoverHandler); - availableItems.addEventListener('drop', itemDropHandler); - - const startInventoryWrapper = document.createElement('div'); - startInventoryWrapper.classList.add('item-set-wrapper'); - startInventoryWrapper.innerText = 'Start Inventory'; - const startInventory = document.createElement('div'); - startInventory.classList.add('item-container'); - startInventory.setAttribute('id', `${this.name}-start_inventory`); - startInventory.setAttribute('data-setting', 'start_inventory'); - startInventory.addEventListener('dragover', itemDragoverHandler); - startInventory.addEventListener('drop', itemDropHandler); - - const localItemsWrapper = document.createElement('div'); - localItemsWrapper.classList.add('item-set-wrapper'); - localItemsWrapper.innerText = 'Local Items'; - const localItems = document.createElement('div'); - localItems.classList.add('item-container'); - localItems.setAttribute('id', `${this.name}-local_items`); - localItems.setAttribute('data-setting', 'local_items') - localItems.addEventListener('dragover', itemDragoverHandler); - localItems.addEventListener('drop', itemDropHandler); - - const nonLocalItemsWrapper = document.createElement('div'); - nonLocalItemsWrapper.classList.add('item-set-wrapper'); - nonLocalItemsWrapper.innerText = 'Non-Local Items'; - const nonLocalItems = document.createElement('div'); - nonLocalItems.classList.add('item-container'); - nonLocalItems.setAttribute('id', `${this.name}-non_local_items`); - nonLocalItems.setAttribute('data-setting', 'non_local_items'); - nonLocalItems.addEventListener('dragover', itemDragoverHandler); - nonLocalItems.addEventListener('drop', itemDropHandler); - - // Populate the divs - this.data.gameItems.forEach((item) => { - if (Object.keys(this.current.start_inventory).includes(item)){ - const itemDiv = this.#buildItemQtyDiv(item); - itemDiv.setAttribute('data-setting', 'start_inventory'); - startInventory.appendChild(itemDiv); - } else if (this.current.local_items.includes(item)) { - const itemDiv = this.#buildItemDiv(item); - itemDiv.setAttribute('data-setting', 'local_items'); - localItems.appendChild(itemDiv); - } else if (this.current.non_local_items.includes(item)) { - const itemDiv = this.#buildItemDiv(item); - itemDiv.setAttribute('data-setting', 'non_local_items'); - nonLocalItems.appendChild(itemDiv); - } else { - const itemDiv = this.#buildItemDiv(item); - availableItems.appendChild(itemDiv); - } - }); - - availableItemsWrapper.appendChild(availableItems); - startInventoryWrapper.appendChild(startInventory); - localItemsWrapper.appendChild(localItems); - nonLocalItemsWrapper.appendChild(nonLocalItems); - itemsWrapper.appendChild(availableItemsWrapper); - itemsWrapper.appendChild(startInventoryWrapper); - itemsWrapper.appendChild(localItemsWrapper); - itemsWrapper.appendChild(nonLocalItemsWrapper); - itemsDiv.appendChild(itemsWrapper); - return itemsDiv; - } - - #buildItemDiv(item) { - const itemDiv = document.createElement('div'); - itemDiv.classList.add('item-div'); - itemDiv.setAttribute('id', `${this.name}-${item}`); - itemDiv.setAttribute('data-game', this.name); - itemDiv.setAttribute('data-item', item); - itemDiv.setAttribute('draggable', 'true'); - itemDiv.innerText = item; - itemDiv.addEventListener('dragstart', (evt) => { - evt.dataTransfer.setData('text/plain', itemDiv.getAttribute('id')); - }); - return itemDiv; - } - - #buildItemQtyDiv(item) { - const itemQtyDiv = document.createElement('div'); - itemQtyDiv.classList.add('item-qty-div'); - itemQtyDiv.setAttribute('id', `${this.name}-${item}`); - itemQtyDiv.setAttribute('data-game', this.name); - itemQtyDiv.setAttribute('data-item', item); - itemQtyDiv.setAttribute('draggable', 'true'); - itemQtyDiv.innerText = item; - - const inputWrapper = document.createElement('div'); - inputWrapper.classList.add('item-qty-input-wrapper') - - const itemQty = document.createElement('input'); - itemQty.setAttribute('value', this.current.start_inventory.hasOwnProperty(item) ? - this.current.start_inventory[item] : '1'); - itemQty.setAttribute('data-game', this.name); - itemQty.setAttribute('data-setting', 'start_inventory'); - itemQty.setAttribute('data-option', item); - itemQty.setAttribute('maxlength', '3'); - itemQty.addEventListener('keyup', (evt) => { - evt.target.value = isNaN(parseInt(evt.target.value)) ? 0 : parseInt(evt.target.value); - this.#updateItemSetting(evt); - }); - inputWrapper.appendChild(itemQty); - itemQtyDiv.appendChild(inputWrapper); - - itemQtyDiv.addEventListener('dragstart', (evt) => { - evt.dataTransfer.setData('text/plain', itemQtyDiv.getAttribute('id')); - }); - return itemQtyDiv; - } - - #itemDropHandler(evt) { - evt.preventDefault(); - const sourceId = evt.dataTransfer.getData('text/plain'); - const sourceDiv = document.getElementById(sourceId); - - const item = sourceDiv.getAttribute('data-item'); - - const oldSetting = sourceDiv.hasAttribute('data-setting') ? sourceDiv.getAttribute('data-setting') : null; - const newSetting = evt.target.hasAttribute('data-setting') ? evt.target.getAttribute('data-setting') : null; - - const itemDiv = newSetting === 'start_inventory' ? this.#buildItemQtyDiv(item) : this.#buildItemDiv(item); - - if (oldSetting) { - if (oldSetting === 'start_inventory') { - if (this.current[oldSetting].hasOwnProperty(item)) { - delete this.current[oldSetting][item]; - } - } else { - if (this.current[oldSetting].includes(item)) { - this.current[oldSetting].splice(this.current[oldSetting].indexOf(item), 1); - } - } - } - - if (newSetting) { - itemDiv.setAttribute('data-setting', newSetting); - document.getElementById(`${this.name}-${newSetting}`).appendChild(itemDiv); - if (newSetting === 'start_inventory') { - this.current[newSetting][item] = 1; - } else { - if (!this.current[newSetting].includes(item)){ - this.current[newSetting].push(item); - } - } - } else { - // No setting was assigned, this item has been removed from the settings - document.getElementById(`${this.name}-available_items`).appendChild(itemDiv); - } - - // Remove the source drag object - sourceDiv.parentElement.removeChild(sourceDiv); - - // Save the updated settings - this.save(); - } - - #buildHintsDiv() { - const hintsDiv = document.createElement('div'); - hintsDiv.classList.add('hints-div'); - const hintsHeader = document.createElement('h3'); - hintsHeader.innerText = 'Item & Location Hints'; - hintsDiv.appendChild(hintsHeader); - const hintsDescription = document.createElement('p'); - hintsDescription.classList.add('setting-description'); - hintsDescription.innerText = 'Choose any items or locations to begin the game with the knowledge of where those ' + - ' items are, or what those locations contain.'; - hintsDiv.appendChild(hintsDescription); - - const itemHintsContainer = document.createElement('div'); - itemHintsContainer.classList.add('hints-container'); - - // Item Hints - const itemHintsWrapper = document.createElement('div'); - itemHintsWrapper.classList.add('hints-wrapper'); - itemHintsWrapper.innerText = 'Starting Item Hints'; - - const itemHintsDiv = this.#buildItemsDiv('start_hints'); - itemHintsWrapper.appendChild(itemHintsDiv); - itemHintsContainer.appendChild(itemHintsWrapper); - - // Starting Location Hints - const locationHintsWrapper = document.createElement('div'); - locationHintsWrapper.classList.add('hints-wrapper'); - locationHintsWrapper.innerText = 'Starting Location Hints'; - - const locationHintsDiv = this.#buildLocationsDiv('start_location_hints'); - locationHintsWrapper.appendChild(locationHintsDiv); - itemHintsContainer.appendChild(locationHintsWrapper); - - hintsDiv.appendChild(itemHintsContainer); - return hintsDiv; - } - - #buildPriorityExclusionDiv() { - const locationsDiv = document.createElement('div'); - locationsDiv.classList.add('locations-div'); - const locationsHeader = document.createElement('h3'); - locationsHeader.innerText = 'Priority & Exclusion Locations'; - locationsDiv.appendChild(locationsHeader); - const locationsDescription = document.createElement('p'); - locationsDescription.classList.add('setting-description'); - locationsDescription.innerText = 'Priority locations guarantee a progression item will be placed there while ' + - 'excluded locations will not contain progression or useful items.'; - locationsDiv.appendChild(locationsDescription); - - const locationsContainer = document.createElement('div'); - locationsContainer.classList.add('locations-container'); - - // Priority Locations - const priorityLocationsWrapper = document.createElement('div'); - priorityLocationsWrapper.classList.add('locations-wrapper'); - priorityLocationsWrapper.innerText = 'Priority Locations'; - - const priorityLocationsDiv = this.#buildLocationsDiv('priority_locations'); - priorityLocationsWrapper.appendChild(priorityLocationsDiv); - locationsContainer.appendChild(priorityLocationsWrapper); - - // Exclude Locations - const excludeLocationsWrapper = document.createElement('div'); - excludeLocationsWrapper.classList.add('locations-wrapper'); - excludeLocationsWrapper.innerText = 'Exclude Locations'; - - const excludeLocationsDiv = this.#buildLocationsDiv('exclude_locations'); - excludeLocationsWrapper.appendChild(excludeLocationsDiv); - locationsContainer.appendChild(excludeLocationsWrapper); - - locationsDiv.appendChild(locationsContainer); - return locationsDiv; - } - - // Builds a div for a setting whose value is a list of locations. - #buildLocationsDiv(setting) { - return this.#buildListDiv(setting, this.data.gameLocations, { - groups: this.data.gameLocationGroups, - descriptions: this.data.gameLocationDescriptions, - }); - } - - // Builds a div for a setting whose value is a list of items. - #buildItemsDiv(setting) { - return this.#buildListDiv(setting, this.data.gameItems, { - groups: this.data.gameItemGroups, - descriptions: this.data.gameItemDescriptions - }); - } - - // Builds a div for a setting named `setting` with a list value that can - // contain `items`. - // - // The `groups` option can be a list of additional options for this list - // (usually `item_name_groups` or `location_name_groups`) that are displayed - // in a special section at the top of the list. - // - // The `descriptions` option can be a map from item names or group names to - // descriptions for the user's benefit. - #buildListDiv(setting, items, {groups = [], descriptions = {}} = {}) { - const div = document.createElement('div'); - div.classList.add('simple-list'); - - groups.forEach((group) => { - const row = this.#addListRow(setting, group, descriptions[group]); - div.appendChild(row); - }); - - if (groups.length > 0) { - div.appendChild(document.createElement('hr')); - } - - items.forEach((item) => { - const row = this.#addListRow(setting, item, descriptions[item]); - div.appendChild(row); - }); - - return div; - } - - // Builds and returns a row for a list of checkboxes. - // - // If `help` is passed, it's displayed as a help tooltip for this list item. - #addListRow(setting, item, help = undefined) { - const row = document.createElement('div'); - row.classList.add('list-row'); - - const label = document.createElement('label'); - label.setAttribute('for', `${this.name}-${setting}-${item}`); - - const checkbox = document.createElement('input'); - checkbox.setAttribute('type', 'checkbox'); - checkbox.setAttribute('id', `${this.name}-${setting}-${item}`); - checkbox.setAttribute('data-game', this.name); - checkbox.setAttribute('data-setting', setting); - checkbox.setAttribute('data-option', item); - if (this.current[setting].includes(item)) { - checkbox.setAttribute('checked', '1'); - } - checkbox.addEventListener('change', (evt) => this.#updateListSetting(evt)); - label.appendChild(checkbox); - - const name = document.createElement('span'); - name.innerText = item; - - if (help) { - const helpSpan = document.createElement('span'); - helpSpan.classList.add('interactive'); - helpSpan.setAttribute('data-tooltip', help); - helpSpan.innerText = '(?)'; - name.innerText += ' '; - name.appendChild(helpSpan); - - // Put the first 7 tooltips below their rows. CSS tooltips in scrolling - // containers can't be visible outside those containers, so this helps - // ensure they won't be pushed out the top. - if (helpSpan.parentNode.childNodes.length < 7) { - helpSpan.classList.add('tooltip-bottom'); - } - } - - label.appendChild(name); - - row.appendChild(label); - return row; - } - - #updateRangeSetting(evt) { - const setting = evt.target.getAttribute('data-setting'); - const option = evt.target.getAttribute('data-option'); - document.getElementById(`${this.name}-${setting}-${option}`).innerText = evt.target.value; - if (evt.action && evt.action === 'rangeDelete') { - delete this.current[setting][option]; - } else { - this.current[setting][option] = parseInt(evt.target.value, 10); - } - this.save(); - } - - #updateListSetting(evt) { - const setting = evt.target.getAttribute('data-setting'); - const option = evt.target.getAttribute('data-option'); - - if (evt.target.checked) { - // If the option is to be enabled and it is already enabled, do nothing - if (this.current[setting].includes(option)) { return; } - - this.current[setting].push(option); - } else { - // If the option is to be disabled and it is already disabled, do nothing - if (!this.current[setting].includes(option)) { return; } - - this.current[setting].splice(this.current[setting].indexOf(option), 1); - } - this.save(); - } - - #updateItemSetting(evt) { - const setting = evt.target.getAttribute('data-setting'); - const option = evt.target.getAttribute('data-option'); - if (setting === 'start_inventory') { - this.current[setting][option] = evt.target.value.trim() ? parseInt(evt.target.value) : 0; - } else { - this.current[setting][option] = isNaN(evt.target.value) ? - evt.target.value : parseInt(evt.target.value, 10); - } - this.save(); - } - - // Saves the current settings to local storage. - save() { - this.#allSettings.save(); - } -} - -/** 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); -}; diff --git a/WebHostLib/static/assets/weightedOptions.js b/WebHostLib/static/assets/weightedOptions.js new file mode 100644 index 000000000000..0417ab174b0e --- /dev/null +++ b/WebHostLib/static/assets/weightedOptions.js @@ -0,0 +1,223 @@ +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]; + } +}; diff --git a/WebHostLib/static/robots_file.txt b/WebHostLib/static/robots_file.txt new file mode 100644 index 000000000000..770ae26c1985 --- /dev/null +++ b/WebHostLib/static/robots_file.txt @@ -0,0 +1,20 @@ +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: / diff --git a/WebHostLib/static/static/icons/sc2/SC2_Lab_BioSteel_L1.png b/WebHostLib/static/static/icons/sc2/SC2_Lab_BioSteel_L1.png deleted file mode 100644 index 8fb366b93ff0..000000000000 Binary files a/WebHostLib/static/static/icons/sc2/SC2_Lab_BioSteel_L1.png and /dev/null differ diff --git a/WebHostLib/static/static/icons/sc2/SC2_Lab_BioSteel_L2.png b/WebHostLib/static/static/icons/sc2/SC2_Lab_BioSteel_L2.png deleted file mode 100644 index 336dc5f77af2..000000000000 Binary files a/WebHostLib/static/static/icons/sc2/SC2_Lab_BioSteel_L2.png and /dev/null differ diff --git a/WebHostLib/static/static/icons/sc2/advanceballistics.png b/WebHostLib/static/static/icons/sc2/advanceballistics.png deleted file mode 100644 index 1bf7df9fb74c..000000000000 Binary files a/WebHostLib/static/static/icons/sc2/advanceballistics.png and /dev/null differ diff --git a/WebHostLib/static/static/icons/sc2/autoturretblackops.png b/WebHostLib/static/static/icons/sc2/autoturretblackops.png deleted file mode 100644 index 552707831a00..000000000000 Binary files a/WebHostLib/static/static/icons/sc2/autoturretblackops.png and /dev/null differ diff --git a/WebHostLib/static/static/icons/sc2/biomechanicaldrone.png b/WebHostLib/static/static/icons/sc2/biomechanicaldrone.png deleted file mode 100644 index e7ebf4031619..000000000000 Binary files a/WebHostLib/static/static/icons/sc2/biomechanicaldrone.png and /dev/null differ diff --git a/WebHostLib/static/static/icons/sc2/burstcapacitors.png b/WebHostLib/static/static/icons/sc2/burstcapacitors.png deleted file mode 100644 index 3af9b20a1698..000000000000 Binary files a/WebHostLib/static/static/icons/sc2/burstcapacitors.png and /dev/null differ diff --git a/WebHostLib/static/static/icons/sc2/crossspectrumdampeners.png b/WebHostLib/static/static/icons/sc2/crossspectrumdampeners.png deleted file mode 100644 index d1c0c6c9a010..000000000000 Binary files a/WebHostLib/static/static/icons/sc2/crossspectrumdampeners.png and /dev/null differ diff --git a/WebHostLib/static/static/icons/sc2/cyclone.png b/WebHostLib/static/static/icons/sc2/cyclone.png deleted file mode 100644 index d2016116ea3b..000000000000 Binary files a/WebHostLib/static/static/icons/sc2/cyclone.png and /dev/null differ diff --git a/WebHostLib/static/static/icons/sc2/cyclonerangeupgrade.png b/WebHostLib/static/static/icons/sc2/cyclonerangeupgrade.png deleted file mode 100644 index 351be570d11b..000000000000 Binary files a/WebHostLib/static/static/icons/sc2/cyclonerangeupgrade.png and /dev/null differ diff --git a/WebHostLib/static/static/icons/sc2/drillingclaws.png b/WebHostLib/static/static/icons/sc2/drillingclaws.png deleted file mode 100644 index 2b067a6e44d4..000000000000 Binary files a/WebHostLib/static/static/icons/sc2/drillingclaws.png and /dev/null differ diff --git a/WebHostLib/static/static/icons/sc2/emergencythrusters.png b/WebHostLib/static/static/icons/sc2/emergencythrusters.png deleted file mode 100644 index 159fba37c903..000000000000 Binary files a/WebHostLib/static/static/icons/sc2/emergencythrusters.png and /dev/null differ diff --git a/WebHostLib/static/static/icons/sc2/hellionbattlemode.png b/WebHostLib/static/static/icons/sc2/hellionbattlemode.png deleted file mode 100644 index 56bfd98c924c..000000000000 Binary files a/WebHostLib/static/static/icons/sc2/hellionbattlemode.png and /dev/null differ diff --git a/WebHostLib/static/static/icons/sc2/high-explosive-spidermine.png b/WebHostLib/static/static/icons/sc2/high-explosive-spidermine.png deleted file mode 100644 index 40a5991ebb80..000000000000 Binary files a/WebHostLib/static/static/icons/sc2/high-explosive-spidermine.png and /dev/null differ diff --git a/WebHostLib/static/static/icons/sc2/hyperflightrotors.png b/WebHostLib/static/static/icons/sc2/hyperflightrotors.png deleted file mode 100644 index 375325845876..000000000000 Binary files a/WebHostLib/static/static/icons/sc2/hyperflightrotors.png and /dev/null differ diff --git a/WebHostLib/static/static/icons/sc2/hyperfluxor.png b/WebHostLib/static/static/icons/sc2/hyperfluxor.png deleted file mode 100644 index cdd95bb515be..000000000000 Binary files a/WebHostLib/static/static/icons/sc2/hyperfluxor.png and /dev/null differ diff --git a/WebHostLib/static/static/icons/sc2/impalerrounds.png b/WebHostLib/static/static/icons/sc2/impalerrounds.png deleted file mode 100644 index b00e0c475827..000000000000 Binary files a/WebHostLib/static/static/icons/sc2/impalerrounds.png and /dev/null differ diff --git a/WebHostLib/static/static/icons/sc2/improvedburstlaser.png b/WebHostLib/static/static/icons/sc2/improvedburstlaser.png deleted file mode 100644 index 8a48e38e874d..000000000000 Binary files a/WebHostLib/static/static/icons/sc2/improvedburstlaser.png and /dev/null differ diff --git a/WebHostLib/static/static/icons/sc2/improvedsiegemode.png b/WebHostLib/static/static/icons/sc2/improvedsiegemode.png deleted file mode 100644 index f19dad952bb5..000000000000 Binary files a/WebHostLib/static/static/icons/sc2/improvedsiegemode.png and /dev/null differ diff --git a/WebHostLib/static/static/icons/sc2/interferencematrix.png b/WebHostLib/static/static/icons/sc2/interferencematrix.png deleted file mode 100644 index ced928aa57a9..000000000000 Binary files a/WebHostLib/static/static/icons/sc2/interferencematrix.png and /dev/null differ diff --git a/WebHostLib/static/static/icons/sc2/internalizedtechmodule.png b/WebHostLib/static/static/icons/sc2/internalizedtechmodule.png deleted file mode 100644 index e97f3db0d29a..000000000000 Binary files a/WebHostLib/static/static/icons/sc2/internalizedtechmodule.png and /dev/null differ diff --git a/WebHostLib/static/static/icons/sc2/jotunboosters.png b/WebHostLib/static/static/icons/sc2/jotunboosters.png deleted file mode 100644 index 25720306e5c2..000000000000 Binary files a/WebHostLib/static/static/icons/sc2/jotunboosters.png and /dev/null differ diff --git a/WebHostLib/static/static/icons/sc2/jumpjets.png b/WebHostLib/static/static/icons/sc2/jumpjets.png deleted file mode 100644 index dfdfef4052ca..000000000000 Binary files a/WebHostLib/static/static/icons/sc2/jumpjets.png and /dev/null differ diff --git a/WebHostLib/static/static/icons/sc2/lasertargetingsystem.png b/WebHostLib/static/static/icons/sc2/lasertargetingsystem.png deleted file mode 100644 index c57899b270ff..000000000000 Binary files a/WebHostLib/static/static/icons/sc2/lasertargetingsystem.png and /dev/null differ diff --git a/WebHostLib/static/static/icons/sc2/liberator.png b/WebHostLib/static/static/icons/sc2/liberator.png deleted file mode 100644 index 31507be5fe68..000000000000 Binary files a/WebHostLib/static/static/icons/sc2/liberator.png and /dev/null differ diff --git a/WebHostLib/static/static/icons/sc2/lockdown.png b/WebHostLib/static/static/icons/sc2/lockdown.png deleted file mode 100644 index a2e7f5dc3e3f..000000000000 Binary files a/WebHostLib/static/static/icons/sc2/lockdown.png and /dev/null differ diff --git a/WebHostLib/static/static/icons/sc2/magfieldaccelerator.png b/WebHostLib/static/static/icons/sc2/magfieldaccelerator.png deleted file mode 100644 index 0272b4b73892..000000000000 Binary files a/WebHostLib/static/static/icons/sc2/magfieldaccelerator.png and /dev/null differ diff --git a/WebHostLib/static/static/icons/sc2/magrailmunitions.png b/WebHostLib/static/static/icons/sc2/magrailmunitions.png deleted file mode 100644 index ec303498ccdb..000000000000 Binary files a/WebHostLib/static/static/icons/sc2/magrailmunitions.png and /dev/null differ diff --git a/WebHostLib/static/static/icons/sc2/medivacemergencythrusters.png b/WebHostLib/static/static/icons/sc2/medivacemergencythrusters.png deleted file mode 100644 index 1c7ce9d6ab1a..000000000000 Binary files a/WebHostLib/static/static/icons/sc2/medivacemergencythrusters.png and /dev/null differ diff --git a/WebHostLib/static/static/icons/sc2/neosteelfortifiedarmor.png b/WebHostLib/static/static/icons/sc2/neosteelfortifiedarmor.png deleted file mode 100644 index 04d68d35dc46..000000000000 Binary files a/WebHostLib/static/static/icons/sc2/neosteelfortifiedarmor.png and /dev/null differ diff --git a/WebHostLib/static/static/icons/sc2/opticalflare.png b/WebHostLib/static/static/icons/sc2/opticalflare.png deleted file mode 100644 index f888fd518b99..000000000000 Binary files a/WebHostLib/static/static/icons/sc2/opticalflare.png and /dev/null differ diff --git a/WebHostLib/static/static/icons/sc2/optimizedlogistics.png b/WebHostLib/static/static/icons/sc2/optimizedlogistics.png deleted file mode 100644 index dcf5fd72da86..000000000000 Binary files a/WebHostLib/static/static/icons/sc2/optimizedlogistics.png and /dev/null differ diff --git a/WebHostLib/static/static/icons/sc2/reapercombatdrugs.png b/WebHostLib/static/static/icons/sc2/reapercombatdrugs.png deleted file mode 100644 index b9f2f055c265..000000000000 Binary files a/WebHostLib/static/static/icons/sc2/reapercombatdrugs.png and /dev/null differ diff --git a/WebHostLib/static/static/icons/sc2/restoration.png b/WebHostLib/static/static/icons/sc2/restoration.png deleted file mode 100644 index f5c94e1aeefd..000000000000 Binary files a/WebHostLib/static/static/icons/sc2/restoration.png and /dev/null differ diff --git a/WebHostLib/static/static/icons/sc2/ripwavemissiles.png b/WebHostLib/static/static/icons/sc2/ripwavemissiles.png deleted file mode 100644 index f68e82039765..000000000000 Binary files a/WebHostLib/static/static/icons/sc2/ripwavemissiles.png and /dev/null differ diff --git a/WebHostLib/static/static/icons/sc2/shreddermissile.png b/WebHostLib/static/static/icons/sc2/shreddermissile.png deleted file mode 100644 index 40899095fe3a..000000000000 Binary files a/WebHostLib/static/static/icons/sc2/shreddermissile.png and /dev/null differ diff --git a/WebHostLib/static/static/icons/sc2/siegetank-spidermines.png b/WebHostLib/static/static/icons/sc2/siegetank-spidermines.png deleted file mode 100644 index 1b9f8cf06097..000000000000 Binary files a/WebHostLib/static/static/icons/sc2/siegetank-spidermines.png and /dev/null differ diff --git a/WebHostLib/static/static/icons/sc2/siegetankrange.png b/WebHostLib/static/static/icons/sc2/siegetankrange.png deleted file mode 100644 index 5aef00a656c9..000000000000 Binary files a/WebHostLib/static/static/icons/sc2/siegetankrange.png and /dev/null differ diff --git a/WebHostLib/static/static/icons/sc2/specialordance.png b/WebHostLib/static/static/icons/sc2/specialordance.png deleted file mode 100644 index 4f7410d7ca9e..000000000000 Binary files a/WebHostLib/static/static/icons/sc2/specialordance.png and /dev/null differ diff --git a/WebHostLib/static/static/icons/sc2/spidermine.png b/WebHostLib/static/static/icons/sc2/spidermine.png deleted file mode 100644 index bb39cf0bf8ce..000000000000 Binary files a/WebHostLib/static/static/icons/sc2/spidermine.png and /dev/null differ diff --git a/WebHostLib/static/static/icons/sc2/staticempblast.png b/WebHostLib/static/static/icons/sc2/staticempblast.png deleted file mode 100644 index 38f361510775..000000000000 Binary files a/WebHostLib/static/static/icons/sc2/staticempblast.png and /dev/null differ diff --git a/WebHostLib/static/static/icons/sc2/superstimpack.png b/WebHostLib/static/static/icons/sc2/superstimpack.png deleted file mode 100644 index 0fba8ce5749a..000000000000 Binary files a/WebHostLib/static/static/icons/sc2/superstimpack.png and /dev/null differ diff --git a/WebHostLib/static/static/icons/sc2/targetingoptics.png b/WebHostLib/static/static/icons/sc2/targetingoptics.png deleted file mode 100644 index 057a40f08e30..000000000000 Binary files a/WebHostLib/static/static/icons/sc2/targetingoptics.png and /dev/null differ diff --git a/WebHostLib/static/static/icons/sc2/terran-cloak-color.png b/WebHostLib/static/static/icons/sc2/terran-cloak-color.png deleted file mode 100644 index 44d1bb9541fb..000000000000 Binary files a/WebHostLib/static/static/icons/sc2/terran-cloak-color.png and /dev/null differ diff --git a/WebHostLib/static/static/icons/sc2/terran-emp-color.png b/WebHostLib/static/static/icons/sc2/terran-emp-color.png deleted file mode 100644 index 972b828c75e2..000000000000 Binary files a/WebHostLib/static/static/icons/sc2/terran-emp-color.png and /dev/null differ diff --git a/WebHostLib/static/static/icons/sc2/terrandefendermodestructureattack.png b/WebHostLib/static/static/icons/sc2/terrandefendermodestructureattack.png deleted file mode 100644 index 9d5982655183..000000000000 Binary files a/WebHostLib/static/static/icons/sc2/terrandefendermodestructureattack.png and /dev/null differ diff --git a/WebHostLib/static/static/icons/sc2/thorsiegemode.png b/WebHostLib/static/static/icons/sc2/thorsiegemode.png deleted file mode 100644 index a298fb57de5a..000000000000 Binary files a/WebHostLib/static/static/icons/sc2/thorsiegemode.png and /dev/null differ diff --git a/WebHostLib/static/static/icons/sc2/transformationservos.png b/WebHostLib/static/static/icons/sc2/transformationservos.png deleted file mode 100644 index f7f0524ac15c..000000000000 Binary files a/WebHostLib/static/static/icons/sc2/transformationservos.png and /dev/null differ diff --git a/WebHostLib/static/static/icons/sc2/valkyrie.png b/WebHostLib/static/static/icons/sc2/valkyrie.png deleted file mode 100644 index 9cbf339b10db..000000000000 Binary files a/WebHostLib/static/static/icons/sc2/valkyrie.png and /dev/null differ diff --git a/WebHostLib/static/static/icons/sc2/warpjump.png b/WebHostLib/static/static/icons/sc2/warpjump.png deleted file mode 100644 index ff0a7b1af4aa..000000000000 Binary files a/WebHostLib/static/static/icons/sc2/warpjump.png and /dev/null differ diff --git a/WebHostLib/static/static/icons/sc2/widowmine-attackrange.png b/WebHostLib/static/static/icons/sc2/widowmine-attackrange.png deleted file mode 100644 index 8f5e09c6a593..000000000000 Binary files a/WebHostLib/static/static/icons/sc2/widowmine-attackrange.png and /dev/null differ diff --git a/WebHostLib/static/static/icons/sc2/widowmine-deathblossom.png b/WebHostLib/static/static/icons/sc2/widowmine-deathblossom.png deleted file mode 100644 index 7097db05e6c0..000000000000 Binary files a/WebHostLib/static/static/icons/sc2/widowmine-deathblossom.png and /dev/null differ diff --git a/WebHostLib/static/static/icons/sc2/widowmine.png b/WebHostLib/static/static/icons/sc2/widowmine.png deleted file mode 100644 index 802c49a83d88..000000000000 Binary files a/WebHostLib/static/static/icons/sc2/widowmine.png and /dev/null differ diff --git a/WebHostLib/static/static/icons/sc2/widowminehidden.png b/WebHostLib/static/static/icons/sc2/widowminehidden.png deleted file mode 100644 index e568742e8a50..000000000000 Binary files a/WebHostLib/static/static/icons/sc2/widowminehidden.png and /dev/null differ diff --git a/WebHostLib/static/styles/globalStyles.css b/WebHostLib/static/styles/globalStyles.css index a787b0c6570a..1a0144830e7c 100644 --- a/WebHostLib/static/styles/globalStyles.css +++ b/WebHostLib/static/styles/globalStyles.css @@ -44,7 +44,7 @@ a{ font-family: LexendDeca-Regular, sans-serif; } -button{ +button, input[type=submit]{ font-weight: 500; font-size: 0.9rem; padding: 10px 17px 11px 16px; /* top right bottom left */ @@ -57,7 +57,7 @@ button{ cursor: pointer; } -button:active{ +button:active, input[type=submit]:active{ border-right: 1px solid rgba(0, 0, 0, 0.5); border-bottom: 1px solid rgba(0, 0, 0, 0.5); padding-right: 16px; @@ -66,11 +66,11 @@ button:active{ margin-bottom: 2px; } -button.button-grass{ +button.button-grass, input[type=submit].button-grass{ border: 1px solid black; } -button.button-dirt{ +button.button-dirt, input[type=submit].button-dirt{ border: 1px solid black; } @@ -111,4 +111,4 @@ h5, h6{ .interactive{ color: #ffef00; -} \ No newline at end of file +} diff --git a/WebHostLib/static/styles/lttp-tracker.css b/WebHostLib/static/styles/lttp-tracker.css deleted file mode 100644 index 899a8f695925..000000000000 --- a/WebHostLib/static/styles/lttp-tracker.css +++ /dev/null @@ -1,75 +0,0 @@ -#player-tracker-wrapper{ - margin: 0; - font-family: LexendDeca-Light, sans-serif; - color: white; - font-size: 14px; -} - -#inventory-table{ - border-top: 2px solid #000000; - border-left: 2px solid #000000; - border-right: 2px solid #000000; - border-top-left-radius: 4px; - border-top-right-radius: 4px; - padding: 3px 3px 10px; - width: 284px; - background-color: #42b149; -} - -#inventory-table td{ - width: 40px; - height: 40px; - text-align: center; - vertical-align: middle; -} - -#inventory-table img{ - height: 100%; - max-width: 40px; - max-height: 40px; - filter: grayscale(100%) contrast(75%) brightness(75%); -} - -#inventory-table img.acquired{ - filter: none; -} - -#inventory-table img.powder-fix{ - width: 35px; - height: 35px; -} - -#location-table{ - width: 284px; - border-left: 2px solid #000000; - border-right: 2px solid #000000; - border-bottom: 2px solid #000000; - border-bottom-left-radius: 4px; - border-bottom-right-radius: 4px; - background-color: #42b149; - padding: 0 3px 3px; -} - -#location-table th{ - vertical-align: middle; - text-align: center; - padding-right: 10px; -} - -#location-table td{ - padding-top: 2px; - padding-bottom: 2px; - padding-right: 5px; - line-height: 20px; -} - -#location-table td.counter{ - padding-right: 8px; - text-align: right; -} - -#location-table img{ - height: 100%; - max-width: 30px; - max-height: 30px; -} diff --git a/WebHostLib/static/styles/markdown.css b/WebHostLib/static/styles/markdown.css index dce135588e5f..e0165b7489ef 100644 --- a/WebHostLib/static/styles/markdown.css +++ b/WebHostLib/static/styles/markdown.css @@ -23,7 +23,7 @@ .markdown a{} -.markdown h1{ +.markdown h1, .markdown details summary.h1{ font-size: 52px; font-weight: normal; font-family: LondrinaSolid-Regular, sans-serif; @@ -33,7 +33,7 @@ text-shadow: 1px 1px 4px #000000; } -.markdown h2{ +.markdown h2, .markdown details summary.h2{ font-size: 38px; font-weight: normal; font-family: LondrinaSolid-Light, sans-serif; @@ -45,7 +45,7 @@ text-shadow: 1px 1px 2px #000000; } -.markdown h3{ +.markdown h3, .markdown details summary.h3{ font-size: 26px; font-family: LexendDeca-Regular, sans-serif; text-transform: none; @@ -55,7 +55,7 @@ margin-bottom: 0.5rem; } -.markdown h4{ +.markdown h4, .markdown details summary.h4{ font-family: LexendDeca-Regular, sans-serif; text-transform: none; font-size: 24px; @@ -63,21 +63,21 @@ margin-bottom: 24px; } -.markdown h5{ +.markdown h5, .markdown details summary.h5{ font-family: LexendDeca-Regular, sans-serif; text-transform: none; font-size: 22px; cursor: pointer; } -.markdown h6{ +.markdown h6, .markdown details summary.h6{ font-family: LexendDeca-Regular, sans-serif; text-transform: none; font-size: 20px; cursor: pointer;; } -.markdown h4, .markdown h5,.markdown h6{ +.markdown h4, .markdown h5, .markdown h6{ margin-bottom: 0.5rem; } diff --git a/WebHostLib/static/styles/player-options.css b/WebHostLib/static/styles/player-options.css deleted file mode 100644 index cc2d5e2de5ce..000000000000 --- a/WebHostLib/static/styles/player-options.css +++ /dev/null @@ -1,244 +0,0 @@ -html{ - background-image: url('../static/backgrounds/grass.png'); - background-repeat: repeat; - background-size: 650px 650px; -} - -#player-options{ - box-sizing: border-box; - max-width: 1024px; - margin-left: auto; - margin-right: auto; - background-color: rgba(0, 0, 0, 0.15); - border-radius: 8px; - padding: 1rem; - color: #eeffeb; -} - -#player-options #player-options-button-row{ - display: flex; - flex-direction: row; - justify-content: space-between; - margin-top: 15px; -} - -#player-options code{ - background-color: #d9cd8e; - border-radius: 4px; - padding-left: 0.25rem; - padding-right: 0.25rem; - color: #000000; -} - -#player-options #user-message{ - display: none; - width: calc(100% - 8px); - background-color: #ffe86b; - border-radius: 4px; - color: #000000; - padding: 4px; - text-align: center; -} - -#player-options #user-message.visible{ - display: block; - cursor: pointer; -} - -#player-options h1{ - font-size: 2.5rem; - font-weight: normal; - width: 100%; - margin-bottom: 0.5rem; - text-shadow: 1px 1px 4px #000000; -} - -#player-options h2{ - font-size: 40px; - font-weight: normal; - width: 100%; - margin-bottom: 0.5rem; - text-transform: lowercase; - text-shadow: 1px 1px 2px #000000; -} - -#player-options h3, #player-options h4, #player-options h5, #player-options h6{ - text-shadow: 1px 1px 1px rgba(0, 0, 0, 0.5); -} - -#player-options input:not([type]){ - border: 1px solid #000000; - padding: 3px; - border-radius: 3px; - min-width: 150px; -} - -#player-options input:not([type]):focus{ - border: 1px solid #ffffff; -} - -#player-options select{ - border: 1px solid #000000; - padding: 3px; - border-radius: 3px; - min-width: 150px; - background-color: #ffffff; -} - -#player-options #game-options, #player-options #rom-options{ - display: flex; - flex-direction: row; -} - -#player-options #meta-options { - display: flex; - justify-content: space-between; - gap: 20px; - padding: 3px; -} - -#player-options div { - display: flex; - flex-grow: 1; -} - -#player-options #meta-options label { - display: inline-block; - min-width: 180px; - flex-grow: 1; -} - -#player-options #meta-options input, -#player-options #meta-options select { - box-sizing: border-box; - min-width: 150px; - width: 50%; -} - -#player-options .left, #player-options .right{ - flex-grow: 1; -} - -#player-options .left{ - margin-right: 10px; -} - -#player-options .right{ - margin-left: 10px; -} - -#player-options table{ - margin-bottom: 30px; - width: 100%; -} - -#player-options table .select-container{ - display: flex; - flex-direction: row; -} - -#player-options table .select-container select{ - min-width: 200px; - flex-grow: 1; -} - -#player-options table select:disabled{ - background-color: lightgray; -} - -#player-options table .range-container{ - display: flex; - flex-direction: row; -} - -#player-options table .range-container input[type=range]{ - flex-grow: 1; -} - -#player-options table .range-value{ - min-width: 20px; - margin-left: 0.25rem; -} - -#player-options table .named-range-container{ - display: flex; - flex-direction: column; -} - -#player-options table .named-range-wrapper{ - display: flex; - flex-direction: row; - margin-top: 0.25rem; -} - -#player-options table .named-range-wrapper input[type=range]{ - flex-grow: 1; -} - -#player-options table .randomize-button { - max-height: 24px; - line-height: 16px; - padding: 2px 8px; - margin: 0 0 0 0.25rem; - font-size: 12px; - border: 1px solid black; - border-radius: 3px; -} - -#player-options table .randomize-button.active { - background-color: #ffef00; /* Same as .interactive in globalStyles.css */ -} - -#player-options table .randomize-button[data-tooltip]::after { - left: unset; - right: 0; -} - -#player-options table label{ - display: block; - min-width: 200px; - margin-right: 4px; - cursor: default; -} - -#player-options th, #player-options td{ - border: none; - padding: 3px; - font-size: 17px; - vertical-align: top; -} - -@media all and (max-width: 1024px) { - #player-options { - border-radius: 0; - } - - #player-options #meta-options { - flex-direction: column; - justify-content: flex-start; - gap: 6px; - } - - #player-options #game-options{ - justify-content: flex-start; - flex-wrap: wrap; - } - - #player-options .left, - #player-options .right { - margin: 0; - } - - #game-options table { - margin-bottom: 0; - } - - #game-options table label{ - display: block; - min-width: 200px; - } - - #game-options table tr td { - width: 50%; - } -} diff --git a/WebHostLib/static/styles/playerOptions/playerOptions.css b/WebHostLib/static/styles/playerOptions/playerOptions.css new file mode 100644 index 000000000000..56c9263d3330 --- /dev/null +++ b/WebHostLib/static/styles/playerOptions/playerOptions.css @@ -0,0 +1,310 @@ +@import "../markdown.css"; +html { + background-image: url("../../static/backgrounds/grass.png"); + background-repeat: repeat; + background-size: 650px 650px; + overflow-x: hidden; +} + +#player-options { + box-sizing: border-box; + max-width: 1024px; + margin-left: auto; + margin-right: auto; + background-color: rgba(0, 0, 0, 0.15); + border-radius: 8px; + padding: 1rem; + color: #eeffeb; + word-break: break-word; +} +#player-options #player-options-header h1 { + margin-bottom: 0; + padding-bottom: 0; +} +#player-options #player-options-header h1:nth-child(2) { + font-size: 1.4rem; + margin-top: -8px; + margin-bottom: 0.5rem; +} +#player-options .js-warning-banner { + width: calc(100% - 1rem); + padding: 0.5rem; + border-radius: 4px; + background-color: #f3f309; + color: #000000; + margin-bottom: 0.5rem; + text-align: center; +} +#player-options .group-container { + padding: 0; + margin: 0; +} +#player-options .group-container h2 { + user-select: none; + cursor: unset; +} +#player-options .group-container h2 label { + cursor: pointer; +} +#player-options #player-options-button-row { + display: flex; + flex-direction: row; + justify-content: space-between; + margin-top: 15px; +} +#player-options #user-message { + display: none; + width: calc(100% - 8px); + background-color: #ffe86b; + border-radius: 4px; + color: #000000; + padding: 4px; + text-align: center; + cursor: pointer; +} +#player-options h1 { + font-size: 2.5rem; + font-weight: normal; + width: 100%; + margin-bottom: 0.5rem; + text-shadow: 1px 1px 4px #000000; +} +#player-options h2 { + font-size: 40px; + font-weight: normal; + width: 100%; + margin-bottom: 0.5rem; + text-transform: lowercase; + text-shadow: 1px 1px 2px #000000; +} +#player-options h3, #player-options h4, #player-options h5, #player-options h6 { + text-shadow: 1px 1px 1px rgba(0, 0, 0, 0.5); +} +#player-options input:not([type]) { + border: 1px solid #000000; + padding: 3px; + border-radius: 3px; + min-width: 150px; +} +#player-options input:not([type]):focus { + border: 1px solid #ffffff; +} +#player-options select { + border: 1px solid #000000; + padding: 3px; + border-radius: 3px; + min-width: 150px; + background-color: #ffffff; + text-overflow: ellipsis; +} +#player-options .game-options { + display: flex; + flex-direction: row; +} +#player-options .game-options .left, #player-options .game-options .right { + display: grid; + grid-template-columns: 12rem auto; + grid-row-gap: 0.5rem; + grid-auto-rows: min-content; + align-items: start; + min-width: 480px; + width: 50%; +} +#player-options #meta-options { + display: flex; + justify-content: space-between; + gap: 20px; + padding: 3px; +} +#player-options #meta-options input, #player-options #meta-options select { + box-sizing: border-box; + width: 200px; +} +#player-options .left, #player-options .right { + flex-grow: 1; + margin-bottom: 0.5rem; +} +#player-options .left { + margin-right: 20px; +} +#player-options .select-container { + display: flex; + flex-direction: row; + max-width: 270px; +} +#player-options .select-container select { + min-width: 200px; + flex-grow: 1; +} +#player-options .select-container select:disabled { + background-color: lightgray; +} +#player-options .range-container { + display: flex; + flex-direction: row; + max-width: 270px; +} +#player-options .range-container input[type=range] { + flex-grow: 1; +} +#player-options .range-container .range-value { + min-width: 20px; + margin-left: 0.25rem; +} +#player-options .named-range-container { + display: flex; + flex-direction: column; + max-width: 270px; +} +#player-options .named-range-container .named-range-wrapper { + display: flex; + flex-direction: row; + margin-top: 0.25rem; +} +#player-options .named-range-container .named-range-wrapper input[type=range] { + flex-grow: 1; +} +#player-options .free-text-container { + display: flex; + flex-direction: column; + max-width: 270px; +} +#player-options .free-text-container input[type=text] { + flex-grow: 1; +} +#player-options .text-choice-container { + display: flex; + flex-direction: column; + max-width: 270px; +} +#player-options .text-choice-container .text-choice-wrapper { + display: flex; + flex-direction: row; + margin-bottom: 0.25rem; +} +#player-options .text-choice-container .text-choice-wrapper select { + flex-grow: 1; +} +#player-options .option-container { + display: flex; + flex-direction: column; + background-color: rgba(0, 0, 0, 0.25); + border: 1px solid rgba(20, 20, 20, 0.25); + border-radius: 3px; + color: #ffffff; + max-height: 10rem; + min-width: 14.5rem; + overflow-y: auto; + padding-right: 0.25rem; + padding-left: 0.25rem; +} +#player-options .option-container .option-divider { + width: 100%; + height: 2px; + background-color: rgba(20, 20, 20, 0.25); + margin-top: 0.125rem; + margin-bottom: 0.125rem; +} +#player-options .option-container .option-entry { + display: flex; + flex-direction: row; + align-items: flex-start; + margin-bottom: 0.125rem; + margin-top: 0.125rem; + user-select: none; +} +#player-options .option-container .option-entry:hover { + background-color: rgba(20, 20, 20, 0.25); +} +#player-options .option-container .option-entry input[type=checkbox] { + margin-right: 0.25rem; +} +#player-options .option-container .option-entry input[type=number] { + max-width: 1.5rem; + max-height: 1rem; + margin-left: 0.125rem; + text-align: center; + /* Hide arrows on input[type=number] fields */ + -moz-appearance: textfield; +} +#player-options .option-container .option-entry input[type=number]::-webkit-outer-spin-button, #player-options .option-container .option-entry input[type=number]::-webkit-inner-spin-button { + -webkit-appearance: none; + margin: 0; +} +#player-options .option-container .option-entry label { + flex-grow: 1; + margin-right: 0; + min-width: unset; + display: unset; +} +#player-options .randomize-button { + display: flex; + flex-direction: column; + justify-content: center; + height: 22px; + max-width: 30px; + margin: 0 0 0 0.25rem; + font-size: 14px; + border: 1px solid black; + border-radius: 3px; + background-color: #d3d3d3; + user-select: none; +} +#player-options .randomize-button:hover { + background-color: #c0c0c0; + cursor: pointer; +} +#player-options .randomize-button label { + line-height: 22px; + padding-left: 5px; + padding-right: 2px; + margin-right: 4px; + width: 100%; + height: 100%; + min-width: unset; +} +#player-options .randomize-button label:hover { + cursor: pointer; +} +#player-options .randomize-button input[type=checkbox] { + display: none; +} +#player-options .randomize-button:has(input[type=checkbox]:checked) { + background-color: #ffef00; /* Same as .interactive in globalStyles.css */ +} +#player-options .randomize-button:has(input[type=checkbox]:checked):hover { + background-color: #eedd27; +} +#player-options .randomize-button[data-tooltip]::after { + left: unset; + right: 0; +} +#player-options label { + display: block; + margin-right: 4px; + cursor: default; + word-break: break-word; +} +#player-options th, #player-options td { + border: none; + padding: 3px; + font-size: 17px; + vertical-align: top; +} + +@media all and (max-width: 1024px) { + #player-options { + border-radius: 0; + } + #player-options #meta-options { + flex-direction: column; + justify-content: flex-start; + gap: 6px; + } + #player-options .game-options { + justify-content: flex-start; + flex-wrap: wrap; + } +} + +/*# sourceMappingURL=playerOptions.css.map */ diff --git a/WebHostLib/static/styles/playerOptions/playerOptions.css.map b/WebHostLib/static/styles/playerOptions/playerOptions.css.map new file mode 100644 index 000000000000..6797b88c7bfe --- /dev/null +++ b/WebHostLib/static/styles/playerOptions/playerOptions.css.map @@ -0,0 +1 @@ +{"version":3,"sourceRoot":"","sources":["playerOptions.scss"],"names":[],"mappings":"AAAQ;AAER;EACI;EACA;EACA;EACA;;;AAGJ;EACI;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;AAGI;EACI;EACA;;AAGJ;EACI;EACA;EACA;;AAIR;EACI;EACA;EACA;EACA;EACA;EACA;EACA;;AAGJ;EACI;EACA;;AAEA;EACI;EACA;;AAEA;EACI;;AAKZ;EACI;EACA;EACA;EACA;;AAGJ;EACI;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;AAGJ;EACI;EACA;EACA;EACA;EACA;;AAGJ;EACI;EACA;EACA;EACA;EACA;EACA;;AAGJ;EACI;;AAGJ;EACI;EACA;EACA;EACA;;AAEA;EACI;;AAIR;EACI;EACA;EACA;EACA;EACA;EACA;;AAGJ;EACI;EACA;;AAEA;EACI;EACA;EACA;EACA;EACA;EACA;EACA;;AAIR;EACI;EACA;EACA;EACA;;AAEA;EACI;EACA;;AAIR;EACI;EACA;;AAGJ;EACI;;AAGJ;EACI;EACA;EACA;;AAEA;EACI;EACA;;AAEA;EACI;;AAKZ;EACI;EACA;EACA;;AAEA;EACI;;AAGJ;EACI;EACA;;AAIR;EACI;EACA;EACA;;AAEA;EACI;EACA;EACA;;AAEA;EACI;;AAKZ;EACI;EACA;EACA;;AAEA;EACI;;AAIR;EACI;EACA;EACA;;AAEA;EACI;EACA;EACA;;AAEA;EACI;;AAKZ;EACI;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;AAEA;EACI;EACA;EACA;EACA;EACA;;AAGJ;EACI;EACA;EACA;EACA;EACA;EACA;;AAEA;EACI;;AAGJ;EACI;;AAGJ;EACI;EACA;EACA;EACA;AAEA;EACA;;AACA;EACI;EACA;;AAIR;EACI;EACA;EACA;EACA;;AAKZ;EACI;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;AAEA;EACI;EACA;;AAGJ;EACI;EACA;EACA;EACA;EACA;EACA;EACA;;AACA;EACI;;AAIR;EACI;;AAGJ;EACI;;AAEA;EACI;;AAIR;EACI;EACA;;AAIR;EACI;EACA;EACA;EACA;;AAGJ;EACI;EACA;EACA;EACA;;;AAIR;EACI;IACI;;EAEA;IACI;IACA;IACA;;EAGJ;IACI;IACA","file":"playerOptions.css"} \ No newline at end of file diff --git a/WebHostLib/static/styles/playerOptions/playerOptions.scss b/WebHostLib/static/styles/playerOptions/playerOptions.scss new file mode 100644 index 000000000000..06bde759d263 --- /dev/null +++ b/WebHostLib/static/styles/playerOptions/playerOptions.scss @@ -0,0 +1,364 @@ +@import "../markdown.css"; + +html{ + background-image: url('../../static/backgrounds/grass.png'); + background-repeat: repeat; + background-size: 650px 650px; + overflow-x: hidden; +} + +#player-options{ + box-sizing: border-box; + max-width: 1024px; + margin-left: auto; + margin-right: auto; + background-color: rgba(0, 0, 0, 0.15); + border-radius: 8px; + padding: 1rem; + color: #eeffeb; + word-break: break-word; + + #player-options-header{ + h1{ + margin-bottom: 0; + padding-bottom: 0; + } + + h1:nth-child(2){ + font-size: 1.4rem; + margin-top: -8px; + margin-bottom: 0.5rem; + } + } + + .js-warning-banner{ + width: calc(100% - 1rem); + padding: 0.5rem; + border-radius: 4px; + background-color: #f3f309; + color: #000000; + margin-bottom: 0.5rem; + text-align: center; + } + + .group-container{ + padding: 0; + margin: 0; + + h2{ + user-select: none; + cursor: unset; + + label{ + cursor: pointer; + } + } + } + + #player-options-button-row{ + display: flex; + flex-direction: row; + justify-content: space-between; + margin-top: 15px; + } + + #user-message{ + display: none; + width: calc(100% - 8px); + background-color: #ffe86b; + border-radius: 4px; + color: #000000; + padding: 4px; + text-align: center; + cursor: pointer; + } + + h1{ + font-size: 2.5rem; + font-weight: normal; + width: 100%; + margin-bottom: 0.5rem; + text-shadow: 1px 1px 4px #000000; + } + + h2{ + font-size: 40px; + font-weight: normal; + width: 100%; + margin-bottom: 0.5rem; + text-transform: lowercase; + text-shadow: 1px 1px 2px #000000; + } + + h3, h4, h5, h6{ + text-shadow: 1px 1px 1px rgba(0, 0, 0, 0.5); + } + + input:not([type]){ + border: 1px solid #000000; + padding: 3px; + border-radius: 3px; + min-width: 150px; + + &:focus{ + border: 1px solid #ffffff; + } + } + + select{ + border: 1px solid #000000; + padding: 3px; + border-radius: 3px; + min-width: 150px; + background-color: #ffffff; + text-overflow: ellipsis; + } + + .game-options{ + display: flex; + flex-direction: row; + + .left, .right{ + display: grid; + grid-template-columns: 12rem auto; + grid-row-gap: 0.5rem; + grid-auto-rows: min-content; + align-items: start; + min-width: 480px; + width: 50%; + } + } + + #meta-options{ + display: flex; + justify-content: space-between; + gap: 20px; + padding: 3px; + + input, select{ + box-sizing: border-box; + width: 200px; + } + } + + .left, .right{ + flex-grow: 1; + margin-bottom: 0.5rem; + } + + .left{ + margin-right: 20px; + } + + .select-container{ + display: flex; + flex-direction: row; + max-width: 270px; + + select{ + min-width: 200px; + flex-grow: 1; + + &:disabled{ + background-color: lightgray; + } + } + } + + .range-container{ + display: flex; + flex-direction: row; + max-width: 270px; + + input[type=range]{ + flex-grow: 1; + } + + .range-value{ + min-width: 20px; + margin-left: 0.25rem; + } + } + + .named-range-container{ + display: flex; + flex-direction: column; + max-width: 270px; + + .named-range-wrapper{ + display: flex; + flex-direction: row; + margin-top: 0.25rem; + + input[type=range]{ + flex-grow: 1; + } + } + } + + .free-text-container{ + display: flex; + flex-direction: column; + max-width: 270px; + + input[type=text]{ + flex-grow: 1; + } + } + + .text-choice-container{ + display: flex; + flex-direction: column; + max-width: 270px; + + .text-choice-wrapper{ + display: flex; + flex-direction: row; + margin-bottom: 0.25rem; + + select{ + flex-grow: 1; + } + } + } + + .option-container{ + display: flex; + flex-direction: column; + background-color: rgba(0, 0, 0, 0.25); + border: 1px solid rgba(20, 20, 20, 0.25); + border-radius: 3px; + color: #ffffff; + max-height: 10rem; + min-width: 14.5rem; + overflow-y: auto; + padding-right: 0.25rem; + padding-left: 0.25rem; + + .option-divider{ + width: 100%; + height: 2px; + background-color: rgba(20, 20, 20, 0.25); + margin-top: 0.125rem; + margin-bottom: 0.125rem; + } + + .option-entry{ + display: flex; + flex-direction: row; + align-items: flex-start; + margin-bottom: 0.125rem; + margin-top: 0.125rem; + user-select: none; + + &:hover{ + background-color: rgba(20, 20, 20, 0.25); + } + + input[type=checkbox]{ + margin-right: 0.25rem; + } + + input[type=number]{ + max-width: 1.5rem; + max-height: 1rem; + margin-left: 0.125rem; + text-align: center; + + /* Hide arrows on input[type=number] fields */ + -moz-appearance: textfield; + &::-webkit-outer-spin-button, &::-webkit-inner-spin-button{ + -webkit-appearance: none; + margin: 0; + } + } + + label{ + flex-grow: 1; + margin-right: 0; + min-width: unset; + display: unset; + } + } + } + + .randomize-button{ + display: flex; + flex-direction: column; + justify-content: center; + height: 22px; + max-width: 30px; + margin: 0 0 0 0.25rem; + font-size: 14px; + border: 1px solid black; + border-radius: 3px; + background-color: #d3d3d3; + user-select: none; + + &:hover{ + background-color: #c0c0c0; + cursor: pointer; + } + + label{ + line-height: 22px; + padding-left: 5px; + padding-right: 2px; + margin-right: 4px; + width: 100%; + height: 100%; + min-width: unset; + &:hover{ + cursor: pointer; + } + } + + input[type=checkbox]{ + display: none; + } + + &:has(input[type=checkbox]:checked){ + background-color: #ffef00; /* Same as .interactive in globalStyles.css */ + + &:hover{ + background-color: #eedd27; + } + } + + &[data-tooltip]::after{ + left: unset; + right: 0; + } + } + + label{ + display: block; + margin-right: 4px; + cursor: default; + word-break: break-word; + } + + th, td{ + border: none; + padding: 3px; + font-size: 17px; + vertical-align: top; + } +} + +@media all and (max-width: 1024px) { + #player-options { + border-radius: 0; + + #meta-options { + flex-direction: column; + justify-content: flex-start; + gap: 6px; + } + + .game-options{ + justify-content: flex-start; + flex-wrap: wrap; + } + } +} diff --git a/WebHostLib/static/styles/sc2Tracker.css b/WebHostLib/static/styles/sc2Tracker.css new file mode 100644 index 000000000000..29a719a110c8 --- /dev/null +++ b/WebHostLib/static/styles/sc2Tracker.css @@ -0,0 +1,160 @@ +#player-tracker-wrapper{ + margin: 0; +} + +#tracker-table td { + vertical-align: top; +} + +.inventory-table-area{ + border: 2px solid #000000; + border-radius: 4px; + padding: 3px 10px 3px 10px; +} + +.inventory-table-area:has(.inventory-table-terran) { + width: 690px; + background-color: #525494; +} + +.inventory-table-area:has(.inventory-table-zerg) { + width: 360px; + background-color: #9d60d2; +} + +.inventory-table-area:has(.inventory-table-protoss) { + width: 400px; + background-color: #d2b260; +} + +#tracker-table .inventory-table td{ + width: 40px; + height: 40px; + text-align: center; + vertical-align: middle; +} + +.inventory-table td.title{ + padding-top: 10px; + height: 20px; + font-family: "JuraBook", monospace; + font-size: 16px; + font-weight: bold; +} + +.inventory-table img{ + height: 100%; + max-width: 40px; + max-height: 40px; + border: 1px solid #000000; + filter: grayscale(100%) contrast(75%) brightness(20%); + background-color: black; +} + +.inventory-table img.acquired{ + filter: none; + background-color: black; +} + +.inventory-table .tint-terran img.acquired { + filter: sepia(100%) saturate(300%) brightness(130%) hue-rotate(120deg) +} + +.inventory-table .tint-protoss img.acquired { + filter: sepia(100%) saturate(1000%) brightness(110%) hue-rotate(180deg) +} + +.inventory-table .tint-level-1 img.acquired { + filter: sepia(100%) saturate(1000%) brightness(110%) hue-rotate(60deg) +} + +.inventory-table .tint-level-2 img.acquired { + filter: sepia(100%) saturate(1000%) brightness(110%) hue-rotate(60deg) hue-rotate(120deg) +} + +.inventory-table .tint-level-3 img.acquired { + filter: sepia(100%) saturate(1000%) brightness(110%) hue-rotate(60deg) hue-rotate(240deg) +} + +.inventory-table div.counted-item { + position: relative; +} + +.inventory-table div.item-count { + width: 160px; + text-align: left; + color: black; + font-family: "JuraBook", monospace; + font-weight: bold; +} + +#location-table{ + border: 2px solid #000000; + border-radius: 4px; + background-color: #87b678; + padding: 10px 3px 3px; + font-family: "JuraBook", monospace; + font-size: 16px; + font-weight: bold; + cursor: default; +} + +#location-table table{ + width: 100%; +} + +#location-table th{ + vertical-align: middle; + text-align: left; + padding-right: 10px; +} + +#location-table td{ + padding-top: 2px; + padding-bottom: 2px; + line-height: 20px; +} + +#location-table td.counter { + text-align: right; + font-size: 14px; +} + +#location-table td.toggle-arrow { + text-align: right; +} + +#location-table tr#Total-header { + font-weight: bold; +} + +#location-table img{ + height: 100%; + max-width: 30px; + max-height: 30px; +} + +#location-table tbody.locations { + font-size: 16px; +} + +#location-table td.location-name { + padding-left: 16px; +} + +#location-table td:has(.location-column) { + vertical-align: top; +} + +#location-table .location-column { + width: 100%; + height: 100%; +} + +#location-table .location-column .spacer { + min-height: 24px; +} + +.hide { + display: none; +} diff --git a/WebHostLib/static/styles/sc2wolTracker.css b/WebHostLib/static/styles/sc2wolTracker.css deleted file mode 100644 index a7d8bd28c4f8..000000000000 --- a/WebHostLib/static/styles/sc2wolTracker.css +++ /dev/null @@ -1,112 +0,0 @@ -#player-tracker-wrapper{ - margin: 0; -} - -#inventory-table{ - border-top: 2px solid #000000; - border-left: 2px solid #000000; - border-right: 2px solid #000000; - border-top-left-radius: 4px; - border-top-right-radius: 4px; - padding: 3px 3px 10px; - width: 710px; - background-color: #525494; -} - -#inventory-table td{ - width: 40px; - height: 40px; - text-align: center; - vertical-align: middle; -} - -#inventory-table td.title{ - padding-top: 10px; - height: 20px; - font-family: "JuraBook", monospace; - font-size: 16px; - font-weight: bold; -} - -#inventory-table img{ - height: 100%; - max-width: 40px; - max-height: 40px; - border: 1px solid #000000; - filter: grayscale(100%) contrast(75%) brightness(20%); - background-color: black; -} - -#inventory-table img.acquired{ - filter: none; - background-color: black; -} - -#inventory-table div.counted-item { - position: relative; -} - -#inventory-table div.item-count { - text-align: left; - color: black; - font-family: "JuraBook", monospace; - font-weight: bold; -} - -#location-table{ - width: 710px; - border-left: 2px solid #000000; - border-right: 2px solid #000000; - border-bottom: 2px solid #000000; - border-bottom-left-radius: 4px; - border-bottom-right-radius: 4px; - background-color: #525494; - padding: 10px 3px 3px; - font-family: "JuraBook", monospace; - font-size: 16px; - font-weight: bold; - cursor: default; -} - -#location-table th{ - vertical-align: middle; - text-align: left; - padding-right: 10px; -} - -#location-table td{ - padding-top: 2px; - padding-bottom: 2px; - line-height: 20px; -} - -#location-table td.counter { - text-align: right; - font-size: 14px; -} - -#location-table td.toggle-arrow { - text-align: right; -} - -#location-table tr#Total-header { - font-weight: bold; -} - -#location-table img{ - height: 100%; - max-width: 30px; - max-height: 30px; -} - -#location-table tbody.locations { - font-size: 16px; -} - -#location-table td.location-name { - padding-left: 16px; -} - -.hide { - display: none; -} diff --git a/WebHostLib/static/styles/supportedGames.css b/WebHostLib/static/styles/supportedGames.css index 7396daa95404..ab12f320716b 100644 --- a/WebHostLib/static/styles/supportedGames.css +++ b/WebHostLib/static/styles/supportedGames.css @@ -8,30 +8,15 @@ cursor: unset; } -#games h1{ +#games h1, #games details summary.h1{ font-size: 60px; cursor: unset; } -#games h2{ +#games h2, #games details summary.h2{ color: #93dcff; margin-bottom: 2px; -} - -#games .collapse-toggle{ - cursor: pointer; -} - -#games h2 .collapse-arrow{ - font-size: 20px; - display: inline-block; /* make vertical-align work */ - padding-bottom: 9px; - vertical-align: middle; - padding-right: 8px; -} - -#games p.collapsed{ - display: none; + text-transform: none; } #games a{ diff --git a/WebHostLib/static/styles/tooltip.css b/WebHostLib/static/styles/tooltip.css index 7cd8463f64a4..dc9026ce6c3d 100644 --- a/WebHostLib/static/styles/tooltip.css +++ b/WebHostLib/static/styles/tooltip.css @@ -12,12 +12,12 @@ give it one of the following classes: tooltip-left, tooltip-right, tooltip-top, */ /* Base styles for the element that has a tooltip */ -[data-tooltip], .tooltip { +[data-tooltip], .tooltip-container { position: relative; } /* Base styles for the entire tooltip */ -[data-tooltip]:before, [data-tooltip]:after, .tooltip:before, .tooltip:after { +[data-tooltip]:before, [data-tooltip]:after, .tooltip-container:before, .tooltip { position: absolute; visibility: hidden; opacity: 0; @@ -39,13 +39,15 @@ give it one of the following classes: tooltip-left, tooltip-right, tooltip-top, pointer-events: none; } -[data-tooltip]:hover:before, [data-tooltip]:hover:after, .tooltip:hover:before, .tooltip:hover:after{ +[data-tooltip]:hover:before, [data-tooltip]:hover:after, .tooltip-container:hover:before, +.tooltip-container:hover .tooltip { visibility: visible; opacity: 1; + word-break: break-word; } /** Directional arrow styles */ -.tooltip:before, [data-tooltip]:before { +[data-tooltip]:before, .tooltip-container:before { z-index: 10000; border: 6px solid transparent; background: transparent; @@ -53,7 +55,7 @@ give it one of the following classes: tooltip-left, tooltip-right, tooltip-top, } /** Content styles */ -.tooltip:after, [data-tooltip]:after { +[data-tooltip]:after, .tooltip { width: 260px; z-index: 10000; padding: 8px; @@ -62,24 +64,26 @@ give it one of the following classes: tooltip-left, tooltip-right, tooltip-top, background-color: hsla(0, 0%, 20%, 0.9); color: #fff; content: attr(data-tooltip); - white-space: pre-wrap; font-size: 14px; line-height: 1.2; } -[data-tooltip]:before, [data-tooltip]:after{ +[data-tooltip]:after { + white-space: pre-wrap; +} + +[data-tooltip]:before, [data-tooltip]:after, .tooltip-container:before, .tooltip { visibility: hidden; opacity: 0; pointer-events: none; } -[data-tooltip]:before, [data-tooltip]:after, .tooltip:before, .tooltip:after, -.tooltip-top:before, .tooltip-top:after { +[data-tooltip]:before, [data-tooltip]:after, .tooltip-container:before, .tooltip { bottom: 100%; left: 50%; } -[data-tooltip]:before, .tooltip:before, .tooltip-top:before { +[data-tooltip]:before, .tooltip-container:before { margin-left: -6px; margin-bottom: -12px; border-top-color: #000; @@ -87,19 +91,19 @@ give it one of the following classes: tooltip-left, tooltip-right, tooltip-top, } /** Horizontally align tooltips on the top and bottom */ -[data-tooltip]:after, .tooltip:after, .tooltip-top:after { +[data-tooltip]:after, .tooltip { margin-left: -80px; } -[data-tooltip]:hover:before, [data-tooltip]:hover:after, .tooltip:hover:before, .tooltip:hover:after, -.tooltip-top:hover:before, .tooltip-top:hover:after { +[data-tooltip]:hover:before, [data-tooltip]:hover:after, .tooltip-container:hover:before, +.tooltip-container:hover .tooltip { -webkit-transform: translateY(-12px); -moz-transform: translateY(-12px); transform: translateY(-12px); } /** Tooltips on the left */ -.tooltip-left:before, .tooltip-left:after { +.tooltip-left:before, [data-tooltip].tooltip-left:after, .tooltip-left .tooltip { right: 100%; bottom: 50%; left: auto; @@ -114,14 +118,14 @@ give it one of the following classes: tooltip-left, tooltip-right, tooltip-top, border-left-color: hsla(0, 0%, 20%, 0.9); } -.tooltip-left:hover:before, .tooltip-left:hover:after { +.tooltip-left:hover:before, [data-tooltip].tooltip-left:hover:after, .tooltip-left:hover .tooltip { -webkit-transform: translateX(-12px); -moz-transform: translateX(-12px); transform: translateX(-12px); } /** Tooltips on the bottom */ -.tooltip-bottom:before, .tooltip-bottom:after { +.tooltip-bottom:before, [data-tooltip].tooltip-bottom:after, .tooltip-bottom .tooltip { top: 100%; bottom: auto; left: 50%; @@ -135,14 +139,15 @@ give it one of the following classes: tooltip-left, tooltip-right, tooltip-top, border-bottom-color: hsla(0, 0%, 20%, 0.9); } -.tooltip-bottom:hover:before, .tooltip-bottom:hover:after { +.tooltip-bottom:hover:before, [data-tooltip].tooltip-bottom:hover:after, +.tooltip-bottom:hover .tooltip { -webkit-transform: translateY(12px); -moz-transform: translateY(12px); transform: translateY(12px); } /** Tooltips on the right */ -.tooltip-right:before, .tooltip-right:after { +.tooltip-right:before, [data-tooltip].tooltip-right:after, .tooltip-right .tooltip { bottom: 50%; left: 100%; } @@ -155,7 +160,8 @@ give it one of the following classes: tooltip-left, tooltip-right, tooltip-top, border-right-color: hsla(0, 0%, 20%, 0.9); } -.tooltip-right:hover:before, .tooltip-right:hover:after { +.tooltip-right:hover:before, [data-tooltip].tooltip-right:hover:after, +.tooltip-right:hover .tooltip { -webkit-transform: translateX(12px); -moz-transform: translateX(12px); transform: translateX(12px); @@ -167,7 +173,16 @@ give it one of the following classes: tooltip-left, tooltip-right, tooltip-top, } /** Center content vertically for tooltips ont he left and right */ -.tooltip-left:after, .tooltip-right:after { +[data-tooltip].tooltip-left:after, [data-tooltip].tooltip-right:after, +.tooltip-left .tooltip, .tooltip-right .tooltip { margin-left: 0; margin-bottom: -16px; } + +.tooltip ul, .tooltip ol { + padding-left: 1rem; +} + +.tooltip :last-child { + margin-bottom: 0; +} diff --git a/WebHostLib/static/styles/tracker__ALinkToThePast.css b/WebHostLib/static/styles/tracker__ALinkToThePast.css new file mode 100644 index 000000000000..db5dfcbdfed7 --- /dev/null +++ b/WebHostLib/static/styles/tracker__ALinkToThePast.css @@ -0,0 +1,142 @@ +@import url('https://fonts.googleapis.com/css2?family=Lexend+Deca:wght@100..900&display=swap'); + +.tracker-container { + width: 440px; + box-sizing: border-box; + font-family: "Lexend Deca", Arial, Helvetica, sans-serif; + border: 2px solid black; + border-radius: 4px; + resize: both; + + background-color: #42b149; + color: white; +} + +.hidden { + visibility: hidden; +} + +/** Inventory Grid ****************************************************************************************************/ +.inventory-grid { + display: grid; + grid-template-columns: repeat(6, minmax(0, 1fr)); + padding: 1rem; + gap: 1rem; +} + +.inventory-grid .item { + position: relative; + display: flex; + justify-content: center; + height: 48px; +} + +.inventory-grid .dual-item { + display: flex; + justify-content: center; +} + +.inventory-grid .missing { + /* Missing items will be in full grayscale to signify "uncollected". */ + filter: grayscale(100%) contrast(75%) brightness(75%); +} + +.inventory-grid .item img, +.inventory-grid .dual-item img { + display: flex; + align-items: center; + text-align: center; + font-size: 0.8rem; + text-shadow: 0 1px 2px black; + font-weight: bold; + image-rendering: crisp-edges; + background-size: contain; + background-repeat: no-repeat; +} + +.inventory-grid .dual-item img { + height: 48px; + margin: 0 -4px; +} + +.inventory-grid .dual-item img:first-child { + align-self: flex-end; +} + +.inventory-grid .item .quantity { + position: absolute; + bottom: 0; + right: 0; + text-align: right; + font-weight: 600; + font-size: 1.75rem; + line-height: 1.75rem; + text-shadow: + -1px -1px 0 #000, + 1px -1px 0 #000, + -1px 1px 0 #000, + 1px 1px 0 #000; + user-select: none; +} + +/** Regions List ******************************************************************************************************/ +.regions-list { + padding: 1rem; +} + +.regions-list summary { + list-style: none; + display: flex; + gap: 0.5rem; + cursor: pointer; +} + +.regions-list summary::before { + content: "⯈"; + width: 1em; + flex-shrink: 0; +} + +.regions-list details { + font-weight: 300; +} + +.regions-list details[open] > summary::before { + content: "⯆"; +} + +.regions-list .region { + width: 100%; + display: grid; + grid-template-columns: 20fr 8fr 2fr 2fr; + align-items: center; + gap: 4px; + text-align: center; + font-weight: 300; + box-sizing: border-box; +} + +.regions-list .region :first-child { + text-align: left; + font-weight: 500; +} + +.regions-list .region.region-header { + margin-left: 24px; + width: calc(100% - 24px); + padding: 2px; +} + +.regions-list .location-rows { + border-top: 1px solid white; + display: grid; + grid-template-columns: auto 32px; + font-weight: 300; + padding: 2px 8px; + margin-top: 4px; + font-size: 0.8rem; +} + +.regions-list .location-rows :nth-child(even) { + text-align: right; +} diff --git a/WebHostLib/static/styles/weighted-options.css b/WebHostLib/static/styles/weighted-options.css deleted file mode 100644 index 8a66ca237015..000000000000 --- a/WebHostLib/static/styles/weighted-options.css +++ /dev/null @@ -1,315 +0,0 @@ -html{ - background-image: url('../static/backgrounds/grass.png'); - background-repeat: repeat; - background-size: 650px 650px; - scroll-padding-top: 90px; -} - -#weighted-settings{ - max-width: 1000px; - margin-left: auto; - margin-right: auto; - background-color: rgba(0, 0, 0, 0.15); - border-radius: 8px; - padding: 1rem; - color: #eeffeb; -} - -#weighted-settings #games-wrapper{ - width: 100%; -} - -#weighted-settings .setting-wrapper{ - width: 100%; - margin-bottom: 2rem; -} - -#weighted-settings .setting-wrapper .add-option-div{ - display: flex; - flex-direction: row; - justify-content: flex-start; - margin-bottom: 1rem; -} - -#weighted-settings .setting-wrapper .add-option-div button{ - width: auto; - height: auto; - margin: 0 0 0 0.15rem; - padding: 0 0.25rem; - border-radius: 4px; - cursor: default; -} - -#weighted-settings .setting-wrapper .add-option-div button:active{ - margin-bottom: 1px; -} - -#weighted-settings p.setting-description{ - margin: 0 0 1rem; -} - -#weighted-settings p.hint-text{ - margin: 0 0 1rem; - font-style: italic; -} - -#weighted-settings .jump-link{ - color: #ffef00; - cursor: pointer; - text-decoration: underline; -} - -#weighted-settings table{ - width: 100%; -} - -#weighted-settings table th, #weighted-settings table td{ - border: none; -} - -#weighted-settings table td{ - padding: 5px; -} - -#weighted-settings table .td-left{ - font-family: LexendDeca-Regular, sans-serif; - padding-right: 1rem; - width: 200px; -} - -#weighted-settings table .td-middle{ - display: flex; - flex-direction: column; - justify-content: space-evenly; - padding-right: 1rem; -} - -#weighted-settings table .td-right{ - width: 4rem; - text-align: right; -} - -#weighted-settings table .td-delete{ - width: 50px; - text-align: right; -} - -#weighted-settings table .range-option-delete{ - cursor: pointer; -} - -#weighted-settings .items-wrapper{ - display: flex; - flex-direction: row; - justify-content: space-between; -} - -#weighted-settings .items-div h3{ - margin-bottom: 0.5rem; -} - -#weighted-settings .items-wrapper .item-set-wrapper{ - width: 24%; - font-weight: bold; -} - -#weighted-settings .item-container{ - border: 1px solid #ffffff; - border-radius: 2px; - width: 100%; - height: 300px; - overflow-y: auto; - overflow-x: hidden; - margin-top: 0.125rem; - font-weight: normal; -} - -#weighted-settings .item-container .item-div{ - padding: 0.125rem 0.5rem; - cursor: pointer; -} - -#weighted-settings .item-container .item-div:hover{ - background-color: rgba(0, 0, 0, 0.1); -} - -#weighted-settings .item-container .item-qty-div{ - display: flex; - flex-direction: row; - justify-content: space-between; - padding: 0.125rem 0.5rem; - cursor: pointer; -} - -#weighted-settings .item-container .item-qty-div .item-qty-input-wrapper{ - display: flex; - flex-direction: column; - justify-content: space-around; -} - -#weighted-settings .item-container .item-qty-div input{ - min-width: unset; - width: 1.5rem; - text-align: center; -} - -#weighted-settings .item-container .item-qty-div:hover{ - background-color: rgba(0, 0, 0, 0.1); -} - -#weighted-settings .hints-div, #weighted-settings .locations-div{ - margin-top: 2rem; -} - -#weighted-settings .hints-div h3, #weighted-settings .locations-div h3{ - margin-bottom: 0.5rem; -} - -#weighted-settings .hints-container, #weighted-settings .locations-container{ - display: flex; - flex-direction: row; - justify-content: space-between; -} - -#weighted-settings .hints-wrapper, #weighted-settings .locations-wrapper{ - width: calc(50% - 0.5rem); - font-weight: bold; -} - -#weighted-settings .hints-wrapper .simple-list, #weighted-settings .locations-wrapper .simple-list{ - margin-top: 0.25rem; - height: 300px; - font-weight: normal; -} - -#weighted-settings #weighted-settings-button-row{ - display: flex; - flex-direction: row; - justify-content: space-between; - margin-top: 15px; -} - -#weighted-settings code{ - background-color: #d9cd8e; - border-radius: 4px; - padding-left: 0.25rem; - padding-right: 0.25rem; - color: #000000; -} - -#weighted-settings #user-message{ - display: none; - width: calc(100% - 8px); - background-color: #ffe86b; - border-radius: 4px; - color: #000000; - padding: 4px; - text-align: center; -} - -#weighted-settings #user-message.visible{ - display: block; - cursor: pointer; -} - -#weighted-settings h1{ - font-size: 2.5rem; - font-weight: normal; - border-bottom: 1px solid #ffffff; - width: 100%; - margin-bottom: 0.5rem; - color: #ffffff; - text-shadow: 1px 1px 4px #000000; -} - -#weighted-settings h2{ - font-size: 2rem; - font-weight: normal; - border-bottom: 1px solid #ffffff; - width: 100%; - margin-bottom: 0.5rem; - color: #ffe993; - text-transform: none; - text-shadow: 1px 1px 2px #000000; -} - -#weighted-settings h3, #weighted-settings h4, #weighted-settings h5, #weighted-settings h6{ - color: #ffffff; - text-shadow: 1px 1px 1px rgba(0, 0, 0, 0.5); - text-transform: none; -} - -#weighted-settings a{ - color: #ffef00; - cursor: pointer; -} - -#weighted-settings input:not([type]){ - border: 1px solid #000000; - padding: 3px; - border-radius: 3px; - min-width: 150px; -} - -#weighted-settings input:not([type]):focus{ - border: 1px solid #ffffff; -} - -#weighted-settings select{ - border: 1px solid #000000; - padding: 3px; - border-radius: 3px; - min-width: 150px; - background-color: #ffffff; -} - -#weighted-settings .game-options, #weighted-settings .rom-options{ - display: flex; - flex-direction: column; -} - -#weighted-settings .simple-list{ - display: flex; - flex-direction: column; - - max-height: 300px; - overflow-y: auto; - border: 1px solid #ffffff; - border-radius: 4px; -} - -#weighted-settings .simple-list .list-row label{ - display: block; - width: calc(100% - 0.5rem); - padding: 0.0625rem 0.25rem; -} - -#weighted-settings .simple-list .list-row label:hover{ - background-color: rgba(0, 0, 0, 0.1); -} - -#weighted-settings .simple-list .list-row label input[type=checkbox]{ - margin-right: 0.5rem; -} - -#weighted-settings .simple-list hr{ - width: calc(100% - 2px); - margin: 2px auto; - border-bottom: 1px solid rgb(255 255 255 / 0.6); -} - -#weighted-settings .invisible{ - display: none; -} - -@media all and (max-width: 1000px), all and (orientation: portrait){ - #weighted-settings .game-options{ - justify-content: flex-start; - flex-wrap: wrap; - } - - #game-options table label{ - display: block; - min-width: 200px; - } -} diff --git a/WebHostLib/static/styles/weightedOptions/weightedOptions.css b/WebHostLib/static/styles/weightedOptions/weightedOptions.css new file mode 100644 index 000000000000..3cfc6d24992d --- /dev/null +++ b/WebHostLib/static/styles/weightedOptions/weightedOptions.css @@ -0,0 +1,232 @@ +html { + background-image: url("../../static/backgrounds/grass.png"); + background-repeat: repeat; + background-size: 650px 650px; + scroll-padding-top: 90px; +} + +#weighted-options { + max-width: 1000px; + margin-left: auto; + margin-right: auto; + background-color: rgba(0, 0, 0, 0.15); + border-radius: 8px; + padding: 1rem; + color: #eeffeb; +} +#weighted-options #weighted-options-header h1 { + margin-bottom: 0; + padding-bottom: 0; +} +#weighted-options #weighted-options-header h1:nth-child(2) { + font-size: 1.4rem; + margin-top: -8px; + margin-bottom: 0.5rem; +} +#weighted-options .js-warning-banner { + width: calc(100% - 1rem); + padding: 0.5rem; + border-radius: 4px; + background-color: #f3f309; + color: #000000; + margin-bottom: 0.5rem; + text-align: center; +} +#weighted-options .option-wrapper { + width: 100%; + margin-bottom: 2rem; +} +#weighted-options .option-wrapper .add-option-div { + display: flex; + flex-direction: row; + justify-content: flex-start; + margin-bottom: 1rem; +} +#weighted-options .option-wrapper .add-option-div button { + width: auto; + height: auto; + margin: 0 0 0 0.15rem; + padding: 0 0.25rem; + border-radius: 4px; + cursor: default; +} +#weighted-options .option-wrapper .add-option-div button:active { + margin-bottom: 1px; +} +#weighted-options p.option-description { + margin: 0 0 1rem; +} +#weighted-options p.hint-text { + margin: 0 0 1rem; + font-style: italic; +} +#weighted-options table { + width: 100%; + margin-top: 0.5rem; + margin-bottom: 1.5rem; +} +#weighted-options table th, #weighted-options table td { + border: none; +} +#weighted-options table td { + padding: 5px; +} +#weighted-options table .td-left { + font-family: LexendDeca-Regular, sans-serif; + padding-right: 1rem; + width: 200px; +} +#weighted-options table .td-middle { + display: flex; + flex-direction: column; + justify-content: space-evenly; + padding-right: 1rem; +} +#weighted-options table .td-right { + width: 4rem; + text-align: right; +} +#weighted-options table .td-delete { + width: 50px; + text-align: right; +} +#weighted-options table .range-option-delete { + cursor: pointer; +} +#weighted-options #weighted-options-button-row { + display: flex; + flex-direction: row; + justify-content: space-between; + margin-top: 15px; +} +#weighted-options #user-message { + display: none; + width: calc(100% - 8px); + background-color: #ffe86b; + border-radius: 4px; + color: #000000; + padding: 4px; + text-align: center; +} +#weighted-options #user-message.visible { + display: block; + cursor: pointer; +} +#weighted-options h1 { + font-size: 2.5rem; + font-weight: normal; + width: 100%; + margin-bottom: 0.5rem; + color: #ffffff; + text-shadow: 1px 1px 4px #000000; +} +#weighted-options h2, #weighted-options details summary.h2 { + font-size: 2rem; + font-weight: normal; + border-bottom: 1px solid #ffffff; + width: 100%; + margin-bottom: 0.5rem; + color: #ffe993; + text-transform: none; + text-shadow: 1px 1px 2px #000000; +} +#weighted-options h3, #weighted-options h4, #weighted-options h5, #weighted-options h6 { + color: #ffffff; + text-shadow: 1px 1px 1px rgba(0, 0, 0, 0.5); + text-transform: none; + cursor: unset; +} +#weighted-options h3.option-group-header { + margin-top: 0.75rem; + font-weight: bold; +} +#weighted-options a { + color: #ffef00; + cursor: pointer; +} +#weighted-options input:not([type]) { + border: 1px solid #000000; + padding: 3px; + border-radius: 3px; + min-width: 150px; +} +#weighted-options input:not([type]):focus { + border: 1px solid #ffffff; +} +#weighted-options .invisible { + display: none; +} +#weighted-options .unsupported-option { + margin-top: 0.5rem; +} +#weighted-options .set-container, #weighted-options .dict-container, #weighted-options .list-container { + display: flex; + flex-direction: column; + background-color: rgba(0, 0, 0, 0.25); + border: 1px solid rgba(20, 20, 20, 0.25); + border-radius: 3px; + color: #ffffff; + max-height: 15rem; + min-width: 14.5rem; + overflow-y: auto; + padding-right: 0.25rem; + padding-left: 0.25rem; + margin-top: 0.5rem; +} +#weighted-options .set-container .divider, #weighted-options .dict-container .divider, #weighted-options .list-container .divider { + width: 100%; + height: 2px; + background-color: rgba(20, 20, 20, 0.25); + margin-top: 0.125rem; + margin-bottom: 0.125rem; +} +#weighted-options .set-container .set-entry, #weighted-options .set-container .dict-entry, #weighted-options .set-container .list-entry, #weighted-options .dict-container .set-entry, #weighted-options .dict-container .dict-entry, #weighted-options .dict-container .list-entry, #weighted-options .list-container .set-entry, #weighted-options .list-container .dict-entry, #weighted-options .list-container .list-entry { + display: flex; + flex-direction: row; + align-items: flex-start; + padding-bottom: 0.25rem; + padding-top: 0.25rem; + user-select: none; + line-height: 1rem; +} +#weighted-options .set-container .set-entry:hover, #weighted-options .set-container .dict-entry:hover, #weighted-options .set-container .list-entry:hover, #weighted-options .dict-container .set-entry:hover, #weighted-options .dict-container .dict-entry:hover, #weighted-options .dict-container .list-entry:hover, #weighted-options .list-container .set-entry:hover, #weighted-options .list-container .dict-entry:hover, #weighted-options .list-container .list-entry:hover { + background-color: rgba(20, 20, 20, 0.25); +} +#weighted-options .set-container .set-entry input[type=checkbox], #weighted-options .set-container .dict-entry input[type=checkbox], #weighted-options .set-container .list-entry input[type=checkbox], #weighted-options .dict-container .set-entry input[type=checkbox], #weighted-options .dict-container .dict-entry input[type=checkbox], #weighted-options .dict-container .list-entry input[type=checkbox], #weighted-options .list-container .set-entry input[type=checkbox], #weighted-options .list-container .dict-entry input[type=checkbox], #weighted-options .list-container .list-entry input[type=checkbox] { + margin-right: 0.25rem; +} +#weighted-options .set-container .set-entry input[type=number], #weighted-options .set-container .dict-entry input[type=number], #weighted-options .set-container .list-entry input[type=number], #weighted-options .dict-container .set-entry input[type=number], #weighted-options .dict-container .dict-entry input[type=number], #weighted-options .dict-container .list-entry input[type=number], #weighted-options .list-container .set-entry input[type=number], #weighted-options .list-container .dict-entry input[type=number], #weighted-options .list-container .list-entry input[type=number] { + max-width: 1.5rem; + max-height: 1rem; + margin-left: 0.125rem; + text-align: center; + /* Hide arrows on input[type=number] fields */ + -moz-appearance: textfield; +} +#weighted-options .set-container .set-entry input[type=number]::-webkit-outer-spin-button, #weighted-options .set-container .set-entry input[type=number]::-webkit-inner-spin-button, #weighted-options .set-container .dict-entry input[type=number]::-webkit-outer-spin-button, #weighted-options .set-container .dict-entry input[type=number]::-webkit-inner-spin-button, #weighted-options .set-container .list-entry input[type=number]::-webkit-outer-spin-button, #weighted-options .set-container .list-entry input[type=number]::-webkit-inner-spin-button, #weighted-options .dict-container .set-entry input[type=number]::-webkit-outer-spin-button, #weighted-options .dict-container .set-entry input[type=number]::-webkit-inner-spin-button, #weighted-options .dict-container .dict-entry input[type=number]::-webkit-outer-spin-button, #weighted-options .dict-container .dict-entry input[type=number]::-webkit-inner-spin-button, #weighted-options .dict-container .list-entry input[type=number]::-webkit-outer-spin-button, #weighted-options .dict-container .list-entry input[type=number]::-webkit-inner-spin-button, #weighted-options .list-container .set-entry input[type=number]::-webkit-outer-spin-button, #weighted-options .list-container .set-entry input[type=number]::-webkit-inner-spin-button, #weighted-options .list-container .dict-entry input[type=number]::-webkit-outer-spin-button, #weighted-options .list-container .dict-entry input[type=number]::-webkit-inner-spin-button, #weighted-options .list-container .list-entry input[type=number]::-webkit-outer-spin-button, #weighted-options .list-container .list-entry input[type=number]::-webkit-inner-spin-button { + -webkit-appearance: none; + margin: 0; +} +#weighted-options .set-container .set-entry label, #weighted-options .set-container .dict-entry label, #weighted-options .set-container .list-entry label, #weighted-options .dict-container .set-entry label, #weighted-options .dict-container .dict-entry label, #weighted-options .dict-container .list-entry label, #weighted-options .list-container .set-entry label, #weighted-options .list-container .dict-entry label, #weighted-options .list-container .list-entry label { + flex-grow: 1; + margin-right: 0; + min-width: unset; + display: unset; +} + +.hidden { + display: none; +} + +@media all and (max-width: 1000px), all and (orientation: portrait) { + #weighted-options .game-options { + justify-content: flex-start; + flex-wrap: wrap; + } + #game-options table label { + display: block; + min-width: 200px; + } +} + +/*# sourceMappingURL=weightedOptions.css.map */ diff --git a/WebHostLib/static/styles/weightedOptions/weightedOptions.css.map b/WebHostLib/static/styles/weightedOptions/weightedOptions.css.map new file mode 100644 index 000000000000..7c57cde01506 --- /dev/null +++ b/WebHostLib/static/styles/weightedOptions/weightedOptions.css.map @@ -0,0 +1 @@ +{"version":3,"sourceRoot":"","sources":["weightedOptions.scss"],"names":[],"mappings":"AAAA;EACI;EACA;EACA;EACA;;;AAGJ;EACI;EACA;EACA;EACA;EACA;EACA;EACA;;AAGI;EACI;EACA;;AAGJ;EACI;EACA;EACA;;AAIR;EACI;EACA;EACA;EACA;EACA;EACA;EACA;;AAGJ;EACI;EACA;;AAEA;EACI;EACA;EACA;EACA;;AAEA;EACI;EACA;EACA;EACA;EACA;EACA;;AAEA;EACI;;AAOZ;EACI;;AAGJ;EACI;EACA;;AAIR;EACI;EACA;EACA;;AAEA;EACI;;AAGJ;EACI;;AAGJ;EACI;EACA;EACA;;AAGJ;EACI;EACA;EACA;EACA;;AAGJ;EACI;EACA;;AAGJ;EACI;EACA;;AAGJ;EACI;;AAIR;EACI;EACA;EACA;EACA;;AAGJ;EACI;EACA;EACA;EACA;EACA;EACA;EACA;;AAEA;EACI;EACA;;AAIR;EACI;EACA;EACA;EACA;EACA;EACA;;AAGJ;EACI;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;AAGJ;EACI;EACA;EACA;EACA;;AAIA;EACI;EACA;;AAIR;EACI;EACA;;AAGJ;EACI;EACA;EACA;EACA;;AAEA;EACI;;AAIR;EACI;;AAGJ;EACI;;AAGJ;EACI;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;AAEA;EACI;EACA;EACA;EACA;EACA;;AAGJ;EACI;EACA;EACA;EACA;EACA;EACA;EACA;;AAEA;EACI;;AAGJ;EACI;;AAGJ;EACI;EACA;EACA;EACA;AAEA;EACA;;AACA;EACI;EACA;;AAIR;EACI;EACA;EACA;EACA;;;AAMhB;EACI;;;AAGJ;EACI;IACI;IACA;;EAGJ;IACI;IACA","file":"weightedOptions.css"} \ No newline at end of file diff --git a/WebHostLib/static/styles/weightedOptions/weightedOptions.scss b/WebHostLib/static/styles/weightedOptions/weightedOptions.scss new file mode 100644 index 000000000000..7ff3a2c3722e --- /dev/null +++ b/WebHostLib/static/styles/weightedOptions/weightedOptions.scss @@ -0,0 +1,274 @@ +html{ + background-image: url('../../static/backgrounds/grass.png'); + background-repeat: repeat; + background-size: 650px 650px; + scroll-padding-top: 90px; +} + +#weighted-options{ + max-width: 1000px; + margin-left: auto; + margin-right: auto; + background-color: rgba(0, 0, 0, 0.15); + border-radius: 8px; + padding: 1rem; + color: #eeffeb; + + #weighted-options-header{ + h1{ + margin-bottom: 0; + padding-bottom: 0; + } + + h1:nth-child(2){ + font-size: 1.4rem; + margin-top: -8px; + margin-bottom: 0.5rem; + } + } + + .js-warning-banner{ + width: calc(100% - 1rem); + padding: 0.5rem; + border-radius: 4px; + background-color: #f3f309; + color: #000000; + margin-bottom: 0.5rem; + text-align: center; + } + + .option-wrapper{ + width: 100%; + margin-bottom: 2rem; + + .add-option-div{ + display: flex; + flex-direction: row; + justify-content: flex-start; + margin-bottom: 1rem; + + button{ + width: auto; + height: auto; + margin: 0 0 0 0.15rem; + padding: 0 0.25rem; + border-radius: 4px; + cursor: default; + + &:active{ + margin-bottom: 1px; + } + } + } + } + + p{ + &.option-description{ + margin: 0 0 1rem; + } + + &.hint-text{ + margin: 0 0 1rem; + font-style: italic; + }; + } + + table{ + width: 100%; + margin-top: 0.5rem; + margin-bottom: 1.5rem; + + th, td{ + border: none; + } + + td{ + padding: 5px; + } + + .td-left{ + font-family: LexendDeca-Regular, sans-serif; + padding-right: 1rem; + width: 200px; + } + + .td-middle{ + display: flex; + flex-direction: column; + justify-content: space-evenly; + padding-right: 1rem; + } + + .td-right{ + width: 4rem; + text-align: right; + } + + .td-delete{ + width: 50px; + text-align: right; + } + + .range-option-delete{ + cursor: pointer; + } + } + + #weighted-options-button-row{ + display: flex; + flex-direction: row; + justify-content: space-between; + margin-top: 15px; + } + + #user-message{ + display: none; + width: calc(100% - 8px); + background-color: #ffe86b; + border-radius: 4px; + color: #000000; + padding: 4px; + text-align: center; + + &.visible{ + display: block; + cursor: pointer; + } + } + + h1{ + font-size: 2.5rem; + font-weight: normal; + width: 100%; + margin-bottom: 0.5rem; + color: #ffffff; + text-shadow: 1px 1px 4px #000000; + } + + h2, details summary.h2{ + font-size: 2rem; + font-weight: normal; + border-bottom: 1px solid #ffffff; + width: 100%; + margin-bottom: 0.5rem; + color: #ffe993; + text-transform: none; + text-shadow: 1px 1px 2px #000000; + } + + h3, h4, h5, h6{ + color: #ffffff; + text-shadow: 1px 1px 1px rgba(0, 0, 0, 0.5); + text-transform: none; + cursor: unset; + } + + h3{ + &.option-group-header{ + margin-top: 0.75rem; + font-weight: bold; + } + } + + a{ + color: #ffef00; + cursor: pointer; + } + + input:not([type]){ + border: 1px solid #000000; + padding: 3px; + border-radius: 3px; + min-width: 150px; + + &:focus{ + border: 1px solid #ffffff; + } + } + + .invisible{ + display: none; + } + + .unsupported-option{ + margin-top: 0.5rem; + } + + .set-container, .dict-container, .list-container{ + display: flex; + flex-direction: column; + background-color: rgba(0, 0, 0, 0.25); + border: 1px solid rgba(20, 20, 20, 0.25); + border-radius: 3px; + color: #ffffff; + max-height: 15rem; + min-width: 14.5rem; + overflow-y: auto; + padding-right: 0.25rem; + padding-left: 0.25rem; + margin-top: 0.5rem; + + .divider{ + width: 100%; + height: 2px; + background-color: rgba(20, 20, 20, 0.25); + margin-top: 0.125rem; + margin-bottom: 0.125rem; + } + + .set-entry, .dict-entry, .list-entry{ + display: flex; + flex-direction: row; + align-items: flex-start; + padding-bottom: 0.25rem; + padding-top: 0.25rem; + user-select: none; + line-height: 1rem; + + &:hover{ + background-color: rgba(20, 20, 20, 0.25); + } + + input[type=checkbox]{ + margin-right: 0.25rem; + } + + input[type=number]{ + max-width: 1.5rem; + max-height: 1rem; + margin-left: 0.125rem; + text-align: center; + + /* Hide arrows on input[type=number] fields */ + -moz-appearance: textfield; + &::-webkit-outer-spin-button, &::-webkit-inner-spin-button{ + -webkit-appearance: none; + margin: 0; + } + } + + label{ + flex-grow: 1; + margin-right: 0; + min-width: unset; + display: unset; + } + } + } +} + +.hidden{ + display: none; +} + +@media all and (max-width: 1000px), all and (orientation: portrait){ + #weighted-options .game-options{ + justify-content: flex-start; + flex-wrap: wrap; + } + + #game-options table label{ + display: block; + min-width: 200px; + } +} diff --git a/WebHostLib/templates/generate.html b/WebHostLib/templates/generate.html index 33f8dbc09e6c..53d98dfae6ba 100644 --- a/WebHostLib/templates/generate.html +++ b/WebHostLib/templates/generate.html @@ -69,8 +69,8 @@

Generate Game{% if race %} (Race Mode){% endif %}

@@ -185,12 +185,12 @@

Generate Game{% if race %} (Race Mode){% endif %}

+ +
+
- -
-
diff --git a/WebHostLib/templates/hostRoom.html b/WebHostLib/templates/hostRoom.html index ba15d64acac1..fa8e26c2cbf8 100644 --- a/WebHostLib/templates/hostRoom.html +++ b/WebHostLib/templates/hostRoom.html @@ -3,6 +3,16 @@ {% block head %} Multiworld {{ room.id|suuid }} {% if should_refresh %}{% endif %} + + + + {% if room.seed.slots|length < 2 %} + + {% else %} + + {% endif %} {% endblock %} @@ -14,7 +24,8 @@
{% endif %} {% if room.tracker %} - This room has a Multiworld Tracker enabled. + This room has a Multiworld Tracker + and a Sphere Tracker enabled.
{% endif %} The server for this room will be paused after {{ room.timeout//60//60 }} hours of inactivity. @@ -33,7 +44,7 @@ {{ macros.list_patches_room(room) }} {% if room.owner == session["_id"] %}
-
+
-
- {% endif %}
diff --git a/WebHostLib/templates/islandFooter.html b/WebHostLib/templates/islandFooter.html index 7b89c4a9e079..08cf227990b8 100644 --- a/WebHostLib/templates/islandFooter.html +++ b/WebHostLib/templates/islandFooter.html @@ -1,6 +1,6 @@ {% block footer %}