diff --git a/.gitattributes b/.gitattributes index 537a05f68b..5ab5379334 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1 +1,2 @@ worlds/blasphemous/region_data.py linguist-generated=true +worlds/yachtdice/YachtWeights.py linguist-generated=true diff --git a/.github/pyright-config.json b/.github/pyright-config.json index 6ad7fa5f19..b6561afa46 100644 --- a/.github/pyright-config.json +++ b/.github/pyright-config.json @@ -1,8 +1,21 @@ { "include": [ - "type_check.py", + "../BizHawkClient.py", + "../Patch.py", + "../test/param.py", + "../test/general/test_groups.py", + "../test/general/test_helpers.py", + "../test/general/test_memory.py", + "../test/general/test_names.py", + "../test/multiworld/__init__.py", + "../test/multiworld/test_multiworlds.py", + "../test/netutils/__init__.py", + "../test/programs/__init__.py", + "../test/programs/test_multi_server.py", + "../test/utils/__init__.py", + "../test/webhost/test_descriptions.py", "../worlds/AutoSNIClient.py", - "../Patch.py" + "type_check.py" ], "exclude": [ @@ -16,7 +29,7 @@ "reportMissingImports": true, "reportMissingTypeStubs": true, - "pythonVersion": "3.8", + "pythonVersion": "3.10", "pythonPlatform": "Windows", "executionEnvironments": [ diff --git a/.github/workflows/analyze-modified-files.yml b/.github/workflows/analyze-modified-files.yml index c9995fa2d0..6788abd30a 100644 --- a/.github/workflows/analyze-modified-files.yml +++ b/.github/workflows/analyze-modified-files.yml @@ -53,7 +53,7 @@ jobs: - uses: actions/setup-python@v5 if: env.diff != '' with: - python-version: 3.8 + python-version: '3.10' - name: "Install dependencies" if: env.diff != '' @@ -65,7 +65,7 @@ jobs: continue-on-error: false if: env.diff != '' && matrix.task == 'flake8' run: | - flake8 --count --select=E9,F63,F7,F82 --show-source --statistics ${{ env.diff }} + flake8 --count --select=E9,F63,F7,F82 --ignore F824 --show-source --statistics ${{ env.diff }} - name: "flake8: Lint modified files" continue-on-error: true diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 23c463fb94..d6b80965f0 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -21,17 +21,23 @@ env: ENEMIZER_VERSION: 7.1 APPIMAGETOOL_VERSION: 13 +permissions: # permissions required for attestation + id-token: 'write' + attestations: 'write' + jobs: # build-release-macos: # LF volunteer - build-win-py38: # RCs will still be built and signed by hand + build-win: # RCs and releases may still be built and signed by hand runs-on: windows-latest steps: + # - copy code below to release.yml - - uses: actions/checkout@v4 - name: Install python uses: actions/setup-python@v5 with: - python-version: '3.8' + python-version: '~3.12.7' + check-latest: true - name: Download run-time dependencies run: | Invoke-WebRequest -Uri https://github.com/Ijwu/Enemizer/releases/download/${Env:ENEMIZER_VERSION}/win-x64.zip -OutFile enemizer.zip @@ -64,6 +70,18 @@ jobs: $contents = Get-ChildItem -Path setups/*.exe -Force -Recurse $SETUP_NAME=$contents[0].Name echo "SETUP_NAME=$SETUP_NAME" >> $Env:GITHUB_ENV + # - copy code above to release.yml - + - name: Attest Build + if: ${{ github.event_name == 'workflow_dispatch' }} + uses: actions/attest-build-provenance@v2 + with: + subject-path: | + build/exe.*/ArchipelagoLauncher.exe + build/exe.*/ArchipelagoLauncherDebug.exe + build/exe.*/ArchipelagoGenerate.exe + build/exe.*/ArchipelagoServer.exe + dist/${{ env.ZIP_NAME }} + setups/${{ env.SETUP_NAME }} - name: Check build loads expected worlds shell: bash run: | @@ -98,8 +116,8 @@ jobs: if-no-files-found: error retention-days: 7 # keep for 7 days, should be enough - build-ubuntu2004: - runs-on: ubuntu-20.04 + build-ubuntu2204: + runs-on: ubuntu-22.04 steps: # - copy code below to release.yml - - uses: actions/checkout@v4 @@ -111,10 +129,11 @@ jobs: - name: Get a recent python uses: actions/setup-python@v5 with: - python-version: '3.11' + python-version: '~3.12.7' + check-latest: true - name: Install build-time dependencies run: | - echo "PYTHON=python3.11" >> $GITHUB_ENV + echo "PYTHON=python3.12" >> $GITHUB_ENV wget -nv https://github.com/AppImage/AppImageKit/releases/download/$APPIMAGETOOL_VERSION/appimagetool-x86_64.AppImage chmod a+rx appimagetool-x86_64.AppImage ./appimagetool-x86_64.AppImage --appimage-extract @@ -130,7 +149,7 @@ jobs: # charset-normalizer was somehow incomplete in the github runner "${{ env.PYTHON }}" -m venv venv source venv/bin/activate - "${{ env.PYTHON }}" -m pip install --upgrade pip PyGObject charset-normalizer + "${{ env.PYTHON }}" -m pip install --upgrade pip "PyGObject<3.51.0" charset-normalizer python setup.py build_exe --yes bdist_appimage --yes echo -e "setup.py build output:\n `ls build`" echo -e "setup.py dist output:\n `ls dist`" @@ -140,6 +159,16 @@ jobs: echo "APPIMAGE_NAME=$APPIMAGE_NAME" >> $GITHUB_ENV echo "TAR_NAME=$TAR_NAME" >> $GITHUB_ENV # - copy code above to release.yml - + - name: Attest Build + if: ${{ github.event_name == 'workflow_dispatch' }} + uses: actions/attest-build-provenance@v2 + with: + subject-path: | + build/exe.*/ArchipelagoLauncher + build/exe.*/ArchipelagoGenerate + build/exe.*/ArchipelagoServer + dist/${{ env.APPIMAGE_NAME }}* + dist/${{ env.TAR_NAME }} - name: Build Again run: | source venv/bin/activate diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index b0cfe35d2b..3abbb5f644 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -47,7 +47,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@v2 + uses: github/codeql-action/init@v3 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. @@ -58,7 +58,7 @@ jobs: # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). # If this step fails, then you should remove it and run the build manually (see below) - name: Autobuild - uses: github/codeql-action/autobuild@v2 + uses: github/codeql-action/autobuild@v3 # â„šī¸ Command-line programs to run using the OS shell. # 📚 https://git.io/JvXDl @@ -72,4 +72,4 @@ jobs: # make release - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v2 + uses: github/codeql-action/analyze@v3 diff --git a/.github/workflows/ctest.yml b/.github/workflows/ctest.yml index 9492c83c9e..610f6d7477 100644 --- a/.github/workflows/ctest.yml +++ b/.github/workflows/ctest.yml @@ -11,7 +11,7 @@ on: - '**.hh?' - '**.hpp' - '**.hxx' - - '**.CMakeLists' + - '**/CMakeLists.txt' - '.github/workflows/ctest.yml' pull_request: paths: @@ -21,7 +21,7 @@ on: - '**.hh?' - '**.hpp' - '**.hxx' - - '**.CMakeLists' + - '**/CMakeLists.txt' - '.github/workflows/ctest.yml' jobs: @@ -36,9 +36,9 @@ jobs: steps: - uses: actions/checkout@v4 - - uses: ilammy/msvc-dev-cmd@v1 + - uses: ilammy/msvc-dev-cmd@0b201ec74fa43914dc39ae48a89fd1d8cb592756 if: startsWith(matrix.os,'windows') - - uses: Bacondish2023/setup-googletest@v1 + - uses: Bacondish2023/setup-googletest@49065d1f7a6d21f6134864dd65980fe5dbe06c73 with: build-type: 'Release' - name: Build tests diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3f8651d408..a500f9a23b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -11,6 +11,11 @@ env: ENEMIZER_VERSION: 7.1 APPIMAGETOOL_VERSION: 13 +permissions: # permissions required for attestation + id-token: 'write' + attestations: 'write' + contents: 'write' # additionally required for release + jobs: create-release: runs-on: ubuntu-latest @@ -26,11 +31,79 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - # build-release-windows: # this is done by hand because of signing # build-release-macos: # LF volunteer - build-release-ubuntu2004: - runs-on: ubuntu-20.04 + build-release-win: + runs-on: windows-latest + if: ${{ true }} # change to false to skip if release is built by hand + needs: create-release + steps: + - name: Set env + shell: bash + run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV + # - code below copied from build.yml - + - uses: actions/checkout@v4 + - name: Install python + uses: actions/setup-python@v5 + with: + python-version: '~3.12.7' + check-latest: true + - name: Download run-time dependencies + run: | + Invoke-WebRequest -Uri https://github.com/Ijwu/Enemizer/releases/download/${Env:ENEMIZER_VERSION}/win-x64.zip -OutFile enemizer.zip + Expand-Archive -Path enemizer.zip -DestinationPath EnemizerCLI -Force + choco install innosetup --version=6.2.2 --allow-downgrade + - name: Build + run: | + python -m pip install --upgrade pip + python setup.py build_exe --yes + if ( $? -eq $false ) { + Write-Error "setup.py failed!" + exit 1 + } + $NAME="$(ls build | Select-String -Pattern 'exe')".Split('.',2)[1] + $ZIP_NAME="Archipelago_$NAME.7z" + echo "$NAME -> $ZIP_NAME" + echo "ZIP_NAME=$ZIP_NAME" >> $Env:GITHUB_ENV + New-Item -Path dist -ItemType Directory -Force + cd build + Rename-Item "exe.$NAME" Archipelago + 7z a -mx=9 -mhe=on -ms "../dist/$ZIP_NAME" Archipelago + Rename-Item Archipelago "exe.$NAME" # inno_setup.iss expects the original name + - name: Build Setup + run: | + & "${env:ProgramFiles(x86)}\Inno Setup 6\iscc.exe" inno_setup.iss /DNO_SIGNTOOL + if ( $? -eq $false ) { + Write-Error "Building setup failed!" + exit 1 + } + $contents = Get-ChildItem -Path setups/*.exe -Force -Recurse + $SETUP_NAME=$contents[0].Name + echo "SETUP_NAME=$SETUP_NAME" >> $Env:GITHUB_ENV + # - code above copied from build.yml - + - name: Attest Build + uses: actions/attest-build-provenance@v2 + with: + subject-path: | + build/exe.*/ArchipelagoLauncher.exe + build/exe.*/ArchipelagoLauncherDebug.exe + build/exe.*/ArchipelagoGenerate.exe + build/exe.*/ArchipelagoServer.exe + setups/* + - name: Add to Release + uses: softprops/action-gh-release@975c1b265e11dd76618af1c374e7981f9a6ff44a + with: + draft: true # see above + prerelease: false + name: Archipelago ${{ env.RELEASE_VERSION }} + files: | + setups/* + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + build-release-ubuntu2204: + runs-on: ubuntu-22.04 + needs: create-release steps: - name: Set env run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV @@ -44,10 +117,11 @@ jobs: - name: Get a recent python uses: actions/setup-python@v5 with: - python-version: '3.11' + python-version: '~3.12.7' + check-latest: true - name: Install build-time dependencies run: | - echo "PYTHON=python3.11" >> $GITHUB_ENV + echo "PYTHON=python3.12" >> $GITHUB_ENV wget -nv https://github.com/AppImage/AppImageKit/releases/download/$APPIMAGETOOL_VERSION/appimagetool-x86_64.AppImage chmod a+rx appimagetool-x86_64.AppImage ./appimagetool-x86_64.AppImage --appimage-extract @@ -63,7 +137,7 @@ jobs: # charset-normalizer was somehow incomplete in the github runner "${{ env.PYTHON }}" -m venv venv source venv/bin/activate - "${{ env.PYTHON }}" -m pip install --upgrade pip PyGObject charset-normalizer + "${{ env.PYTHON }}" -m pip install --upgrade pip "PyGObject<3.51.0" charset-normalizer python setup.py build_exe --yes bdist_appimage --yes echo -e "setup.py build output:\n `ls build`" echo -e "setup.py dist output:\n `ls dist`" @@ -73,6 +147,14 @@ jobs: echo "APPIMAGE_NAME=$APPIMAGE_NAME" >> $GITHUB_ENV echo "TAR_NAME=$TAR_NAME" >> $GITHUB_ENV # - code above copied from build.yml - + - name: Attest Build + uses: actions/attest-build-provenance@v2 + with: + subject-path: | + build/exe.*/ArchipelagoLauncher + build/exe.*/ArchipelagoGenerate + build/exe.*/ArchipelagoServer + dist/* - name: Add to Release uses: softprops/action-gh-release@975c1b265e11dd76618af1c374e7981f9a6ff44a with: diff --git a/.github/workflows/scan-build.yml b/.github/workflows/scan-build.yml index 5234d862b4..ac84207062 100644 --- a/.github/workflows/scan-build.yml +++ b/.github/workflows/scan-build.yml @@ -40,10 +40,10 @@ jobs: run: | wget https://apt.llvm.org/llvm.sh chmod +x ./llvm.sh - sudo ./llvm.sh 17 + sudo ./llvm.sh 19 - name: Install scan-build command run: | - sudo apt install clang-tools-17 + sudo apt install clang-tools-19 - name: Get a recent python uses: actions/setup-python@v5 with: @@ -56,7 +56,7 @@ jobs: - 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 + scan-build-19 --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 diff --git a/.github/workflows/strict-type-check.yml b/.github/workflows/strict-type-check.yml index bafd572a26..2ccdad8d11 100644 --- a/.github/workflows/strict-type-check.yml +++ b/.github/workflows/strict-type-check.yml @@ -26,7 +26,7 @@ jobs: - name: "Install dependencies" run: | - python -m pip install --upgrade pip pyright==1.1.358 + python -m pip install --upgrade pip pyright==1.1.392.post0 python ModuleUpdate.py --append "WebHostLib/requirements.txt" --force --yes - name: "pyright: strict check on specific files" diff --git a/.github/workflows/unittests.yml b/.github/workflows/unittests.yml index 9a3a6d1121..88b5d12987 100644 --- a/.github/workflows/unittests.yml +++ b/.github/workflows/unittests.yml @@ -33,13 +33,11 @@ jobs: matrix: os: [ubuntu-latest] python: - - {version: '3.8'} - - {version: '3.9'} - {version: '3.10'} - {version: '3.11'} - {version: '3.12'} include: - - python: {version: '3.8'} # win7 compat + - python: {version: '3.10'} # old compat os: windows-latest - python: {version: '3.12'} # current os: windows-latest @@ -89,4 +87,4 @@ jobs: run: | source venv/bin/activate export PYTHONPATH=$(pwd) - python test/hosting/__main__.py + timeout 600 python test/hosting/__main__.py diff --git a/.gitignore b/.gitignore index 791f7b1bb7..f50fc17e23 100644 --- a/.gitignore +++ b/.gitignore @@ -4,11 +4,13 @@ *_Spoiler.txt *.bmbp *.apbp +*.apcivvi *.apl2ac *.apm3 *.apmc *.apz5 *.aptloz +*.aptww *.apemerald *.pyc *.pyd diff --git a/AHITClient.py b/AHITClient.py index 6ed7d7b49d..edcbbd842e 100644 --- a/AHITClient.py +++ b/AHITClient.py @@ -1,3 +1,4 @@ +import sys from worlds.ahit.Client import launch import Utils import ModuleUpdate @@ -5,4 +6,4 @@ ModuleUpdate.update() if __name__ == "__main__": Utils.init_logging("AHITClient", exception_logger="Client") - launch() + launch(*sys.argv[1:]) diff --git a/AdventureClient.py b/AdventureClient.py index 24c6a4c4fc..91567fc0a0 100644 --- a/AdventureClient.py +++ b/AdventureClient.py @@ -511,7 +511,7 @@ if __name__ == '__main__': import colorama - colorama.init() + colorama.just_fix_windows_console() asyncio.run(main()) colorama.deinit() diff --git a/BaseClasses.py b/BaseClasses.py index 0d4f34e514..f480cbbda3 100644 --- a/BaseClasses.py +++ b/BaseClasses.py @@ -1,18 +1,17 @@ from __future__ import annotations import collections -import itertools import functools import logging import random import secrets -import typing # this can go away when Python 3.8 support is dropped from argparse import Namespace from collections import Counter, deque from collections.abc import Collection, MutableSequence from enum import IntEnum, IntFlag -from typing import (AbstractSet, Any, Callable, ClassVar, Dict, Iterable, Iterator, List, Mapping, NamedTuple, - Optional, Protocol, Set, Tuple, Union, Type) +from typing import (AbstractSet, Any, Callable, ClassVar, Dict, Iterable, Iterator, List, Literal, Mapping, NamedTuple, + Optional, Protocol, Set, Tuple, Union, TYPE_CHECKING) +import dataclasses from typing_extensions import NotRequired, TypedDict @@ -20,7 +19,8 @@ import NetUtils import Options import Utils -if typing.TYPE_CHECKING: +if TYPE_CHECKING: + from entrance_rando import ERPlacementState from worlds import AutoWorld @@ -55,12 +55,21 @@ class HasNameAndPlayer(Protocol): player: int +@dataclasses.dataclass +class PlandoItemBlock: + player: int + from_pool: bool + force: bool | Literal["silent"] + worlds: set[int] = dataclasses.field(default_factory=set) + items: list[str] = dataclasses.field(default_factory=list) + locations: list[str] = dataclasses.field(default_factory=list) + resolved_locations: list[Location] = dataclasses.field(default_factory=list) + count: dict[str, int] = dataclasses.field(default_factory=dict) + + class MultiWorld(): debug_types = False player_name: Dict[int, str] - plando_texts: List[Dict[str, str]] - plando_items: List[List[Dict[str, Any]]] - plando_connections: List worlds: Dict[int, "AutoWorld.World"] groups: Dict[int, Group] regions: RegionManager @@ -84,6 +93,8 @@ class MultiWorld(): start_location_hints: Dict[int, Options.StartLocationHints] item_links: Dict[int, Options.ItemLinks] + plando_item_blocks: Dict[int, List[PlandoItemBlock]] + game: Dict[int, str] random: random.Random @@ -161,13 +172,12 @@ class MultiWorld(): self.local_early_items = {player: {} for player in self.player_ids} self.indirect_connections = {} self.start_inventory_from_pool: Dict[int, Options.StartInventoryPool] = {} + self.plando_item_blocks = {} for player in range(1, players + 1): def set_player_attr(attr: str, val) -> None: self.__dict__.setdefault(attr, {})[player] = val - set_player_attr('plando_items', []) - set_player_attr('plando_texts', {}) - set_player_attr('plando_connections', []) + set_player_attr('plando_item_blocks', []) set_player_attr('game', "Archipelago") set_player_attr('completion_condition', lambda state: True) self.worlds = {} @@ -224,14 +234,14 @@ class MultiWorld(): 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.") + f"Please use `self.options.{option_key}` instead.", True) 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) - options_dataclass: typing.Type[Options.PerGameCommonOptions] = world_type.options_dataclass + options_dataclass: 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}) @@ -341,7 +351,7 @@ class MultiWorld(): new_item.classification |= classifications[item_name] new_itempool.append(new_item) - region = Region("Menu", group_id, self, "ItemLink") + region = Region(group["world"].origin_region_name, group_id, self, "ItemLink") self.regions.append(region) locations = region.locations # ensure that progression items are linked first, then non-progression @@ -428,19 +438,21 @@ class MultiWorld(): def get_location(self, location_name: str, player: int) -> Location: return self.regions.location_cache[player][location_name] - def get_all_state(self, use_cache: bool) -> CollectionState: + def get_all_state(self, use_cache: bool, allow_partial_entrances: bool = False, + collect_pre_fill_items: bool = True) -> CollectionState: cached = getattr(self, "_all_state", None) if use_cache and cached: return cached.copy() - ret = CollectionState(self) + ret = CollectionState(self, allow_partial_entrances) for item in self.itempool: self.worlds[item.player].collect(ret, item) - for player in self.player_ids: - subworld = self.worlds[player] - for item in subworld.get_pre_fill_items(): - subworld.collect(ret, item) + if collect_pre_fill_items: + for player in self.player_ids: + subworld = self.worlds[player] + for item in subworld.get_pre_fill_items(): + subworld.collect(ret, item) ret.sweep_for_advancements() if use_cache: @@ -606,6 +618,49 @@ class MultiWorld(): state.collect(location.item, True, location) locations -= sphere + def get_sendable_spheres(self) -> Iterator[Set[Location]]: + """ + yields a set of multiserver sendable locations (location.item.code: int) 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[Location] = set() + events: Set[Location] = set() + for location in self.get_filled_locations(): + if type(location.item.code) is int and type(location.address) is int: + locations.add(location) + else: + events.add(location) + + while locations: + sphere: Set[Location] = set() + + # cull events out + done_events: Set[Union[Location, None]] = {None} + while done_events: + done_events = set() + for event in events: + if event.can_reach(state): + state.collect(event.item, True, event) + done_events.add(event) + events -= done_events + + for location in locations: + if location.can_reach(state): + sphere.add(location) + + yield sphere + if not sphere: + if locations: + yield locations # unreachable locations + break + + for location in sphere: + state.collect(location.item, True, location) + locations -= sphere + def fulfills_accessibility(self, state: Optional[CollectionState] = None): """Check if accessibility rules are fulfilled with current or supplied state.""" if not state: @@ -676,10 +731,11 @@ class CollectionState(): path: Dict[Union[Region, Entrance], PathValue] locations_checked: Set[Location] stale: Dict[int, bool] + allow_partial_entrances: bool additional_init_functions: List[Callable[[CollectionState, MultiWorld], None]] = [] additional_copy_functions: List[Callable[[CollectionState, CollectionState], CollectionState]] = [] - def __init__(self, parent: MultiWorld): + def __init__(self, parent: MultiWorld, allow_partial_entrances: bool = False): self.prog_items = {player: Counter() for player in parent.get_all_ids()} self.multiworld = parent self.reachable_regions = {player: set() for player in parent.get_all_ids()} @@ -688,6 +744,7 @@ class CollectionState(): self.path = {} self.locations_checked = set() self.stale = {player: True for player in parent.get_all_ids()} + self.allow_partial_entrances = allow_partial_entrances for function in self.additional_init_functions: function(self, parent) for items in parent.precollected_items.values(): @@ -722,6 +779,8 @@ class CollectionState(): if new_region in reachable_regions: blocked_connections.remove(connection) elif connection.can_reach(self): + if self.allow_partial_entrances and not new_region: + continue assert new_region, f"tried to search through an Entrance \"{connection}\" with no connected Region" reachable_regions.add(new_region) blocked_connections.remove(connection) @@ -747,7 +806,9 @@ class CollectionState(): 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" + if self.allow_partial_entrances and not new_region: + continue + assert new_region, f"tried to search through an Entrance \"{connection}\" with no connected Region" reachable_regions.add(new_region) blocked_connections.remove(connection) blocked_connections.update(new_region.exits) @@ -767,6 +828,7 @@ class CollectionState(): ret.advancements = self.advancements.copy() ret.path = self.path.copy() ret.locations_checked = self.locations_checked.copy() + ret.allow_partial_entrances = self.allow_partial_entrances for function in self.additional_copy_functions: ret = function(self, ret) return ret @@ -820,21 +882,40 @@ class CollectionState(): def has(self, item: str, player: int, count: int = 1) -> bool: return self.prog_items[player][item] >= count + # for loops are specifically used in all/any/count methods, instead of all()/any()/sum(), to avoid the overhead of + # creating and iterating generator instances. In `return all(player_prog_items[item] for item in items)`, the + # argument to all() would be a new generator instance, for example. def has_all(self, items: Iterable[str], player: int) -> bool: """Returns True if each item name of items is in state at least once.""" - return all(self.prog_items[player][item] for item in items) + player_prog_items = self.prog_items[player] + for item in items: + if not player_prog_items[item]: + return False + return True 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) + player_prog_items = self.prog_items[player] + for item in items: + if player_prog_items[item]: + return True + return False 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()) + player_prog_items = self.prog_items[player] + for item, count in item_counts.items(): + if player_prog_items[item] < count: + return False + return True 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()) + player_prog_items = self.prog_items[player] + for item, count in item_counts.items(): + if player_prog_items[item] >= count: + return True + return False def count(self, item: str, player: int) -> int: return self.prog_items[player][item] @@ -862,11 +943,20 @@ class CollectionState(): 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) + player_prog_items = self.prog_items[player] + total = 0 + for item_name in items: + total += player_prog_items[item_name] + return total 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) + player_prog_items = self.prog_items[player] + total = 0 + for item_name in items: + if player_prog_items[item_name] > 0: + total += 1 + return total # item name group related def has_group(self, item_name_group: str, player: int, count: int = 1) -> bool: @@ -931,6 +1021,11 @@ class CollectionState(): self.stale[item.player] = True +class EntranceType(IntEnum): + ONE_WAY = 1 + TWO_WAY = 2 + + class Entrance: access_rule: Callable[[CollectionState], bool] = staticmethod(lambda state: True) hide_path: bool = False @@ -938,30 +1033,56 @@ class Entrance: name: str parent_region: Optional[Region] connected_region: Optional[Region] = None - # LttP specific, TODO: should make a LttPEntrance - addresses = None - target = None + randomization_group: int + randomization_type: EntranceType - def __init__(self, player: int, name: str = "", parent: Optional[Region] = None) -> None: + def __init__(self, player: int, name: str = "", parent: Optional[Region] = None, + randomization_group: int = 0, randomization_type: EntranceType = EntranceType.ONE_WAY) -> None: self.name = name self.parent_region = parent self.player = player + self.randomization_group = randomization_group + self.randomization_type = randomization_type def can_reach(self, state: CollectionState) -> bool: assert self.parent_region, f"called can_reach on an Entrance \"{self}\" with no parent_region" if self.parent_region.can_reach(state) and self.access_rule(state): - if not self.hide_path and not self in state.path: + if not self.hide_path and self not in state.path: state.path[self] = (self.name, state.path.get(self.parent_region, (self.parent_region.name, None))) return True return False - def connect(self, region: Region, addresses: Any = None, target: Any = None) -> None: + def connect(self, region: Region) -> None: self.connected_region = region - self.target = target - self.addresses = addresses region.entrances.append(self) + def is_valid_source_transition(self, er_state: "ERPlacementState") -> bool: + """ + Determines whether this is a valid source transition, that is, whether the entrance + randomizer is allowed to pair it to place any other regions. By default, this is the + same as a reachability check, but can be modified by Entrance implementations to add + other restrictions based on the placement state. + + :param er_state: The current (partial) state of the ongoing entrance randomization + """ + return self.can_reach(er_state.collection_state) + + def can_connect_to(self, other: Entrance, dead_end: bool, er_state: "ERPlacementState") -> bool: + """ + Determines whether a given Entrance is a valid target transition, that is, whether + the entrance randomizer is allowed to pair this Entrance to that Entrance. By default, + only allows connection between entrances of the same type (one ways only go to one ways, + two ways always go to two ways) and prevents connecting an exit to itself in coupled mode. + + :param other: The proposed Entrance to connect to + :param dead_end: Whether the other entrance considered a dead end by Entrance randomization + :param er_state: The current (partial) state of the ongoing entrance randomization + """ + # the implementation of coupled causes issues for self-loops since the reverse entrance will be the + # same as the forward entrance. In uncoupled they are ok. + return self.randomization_type == other.randomization_type and (not er_state.coupled or self.name != other.name) + def __repr__(self): 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})' @@ -975,7 +1096,7 @@ class Region: entrances: List[Entrance] exits: List[Entrance] locations: List[Location] - entrance_type: ClassVar[Type[Entrance]] = Entrance + entrance_type: ClassVar[type[Entrance]] = Entrance class Register(MutableSequence): region_manager: MultiWorld.RegionManager @@ -993,6 +1114,9 @@ class Region: def __len__(self) -> int: return self._list.__len__() + def __iter__(self): + return iter(self._list) + # This seems to not be needed, but that's a bit suspicious. # def __del__(self): # self.clear() @@ -1075,7 +1199,7 @@ class Region: return entrance.parent_region.get_connecting_entrance(is_main_entrance) def add_locations(self, locations: Dict[str, Optional[int]], - location_type: Optional[Type[Location]] = None) -> None: + location_type: Optional[type[Location]] = None) -> None: """ Adds locations to the Region object, where location_type is your Location class and locations is a dict of location names to address. @@ -1087,6 +1211,48 @@ class Region: for location, address in locations.items(): self.locations.append(location_type(self.player, location, address, self)) + def add_event( + self, + location_name: str, + item_name: str | None = None, + rule: Callable[[CollectionState], bool] | None = None, + location_type: type[Location] | None = None, + item_type: type[Item] | None = None, + show_in_spoiler: bool = True, + ) -> Item: + """ + Adds an event location/item pair to the region. + + :param location_name: Name for the event location. + :param item_name: Name for the event item. If not provided, defaults to location_name. + :param rule: Callable to determine access for this event location within its region. + :param location_type: Location class to create the event location with. Defaults to BaseClasses.Location. + :param item_type: Item class to create the event item with. Defaults to BaseClasses.Item. + :param show_in_spoiler: Will be passed along to the created event Location's show_in_spoiler attribute. + :return: The created Event Item + """ + if location_type is None: + location_type = Location + + if item_name is None: + item_name = location_name + + if item_type is None: + item_type = Item + + event_location = location_type(self.player, location_name, None, self) + event_location.show_in_spoiler = show_in_spoiler + if rule is not None: + event_location.access_rule = rule + + event_item = item_type(item_name, ItemClassification.progression, None, self.player) + + event_location.place_locked_item(event_item) + + self.locations.append(event_location) + + return event_item + def connect(self, connecting_region: Region, name: Optional[str] = None, rule: Optional[Callable[[CollectionState], bool]] = None) -> Entrance: """ @@ -1111,8 +1277,18 @@ class Region: self.exits.append(exit_) return exit_ + def create_er_target(self, name: str) -> Entrance: + """ + Creates and returns an Entrance object as an entrance to this region + + :param name: name of the Entrance being created + """ + entrance = self.entrance_type(self.player, name) + entrance.connect(self) + return entrance + def add_exits(self, exits: Union[Iterable[str], Dict[str, Optional[str]]], - rules: Dict[str, Callable[[CollectionState], bool]] = None) -> None: + rules: Dict[str, Callable[[CollectionState], bool]] = None) -> List[Entrance]: """ Connects current region to regions in exit dictionary. Passed region names must exist first. @@ -1122,10 +1298,14 @@ class Region: """ if not isinstance(exits, Dict): exits = dict.fromkeys(exits) - for connecting_region, name in exits.items(): - self.connect(self.multiworld.get_region(connecting_region, self.player), - name, - rules[connecting_region] if rules and connecting_region in rules else None) + return [ + self.connect( + self.multiworld.get_region(connecting_region, self.player), + name, + rules[connecting_region] if rules and connecting_region in rules else None, + ) + for connecting_region, name in exits.items() + ] def __repr__(self): return self.multiworld.get_name_string_for_object(self) if self.multiworld else f'{self.name} (Player {self.player})' @@ -1183,9 +1363,6 @@ class Location: multiworld = self.parent_region.multiworld if self.parent_region and self.parent_region.multiworld else None return multiworld.get_name_string_for_object(self) if multiworld else f'{self.name} (Player {self.player})' - def __hash__(self): - return hash((self.name, self.player)) - def __lt__(self, other: Location): return (self.player, self.name) < (other.player, other.name) @@ -1209,13 +1386,26 @@ class Location: class ItemClassification(IntFlag): - filler = 0b0000 # aka trash, as in filler items like ammo, currency etc, - progression = 0b0001 # Item that is logically relevant - useful = 0b0010 # Item that is generally quite useful, but not required for anything logical - trap = 0b0100 # detrimental item - skip_balancing = 0b1000 # should technically never occur on its own - # Item that is logically relevant, but progression balancing should not touch. - # Typically currency or other counted items. + filler = 0b0000 + """ aka trash, as in filler items like ammo, currency etc """ + + progression = 0b0001 + """ Item that is logically relevant. + Protects this item from being placed on excluded or unreachable locations. """ + + useful = 0b0010 + """ Item that is especially useful. + Protects this item from being placed on excluded or unreachable locations. + When combined with another flag like "progression", it means "an especially useful progression item". """ + + trap = 0b0100 + """ Item that is detrimental in some way. """ + + skip_balancing = 0b1000 + """ should technically never occur on its own + Item that is logically relevant, but progression balancing should not touch. + Typically currency or other counted items. """ + progression_skip_balancing = 0b1001 # only progression gets balanced def as_flag(self) -> int: @@ -1264,10 +1454,22 @@ class Item: def trap(self) -> bool: return ItemClassification.trap in self.classification + @property + def filler(self) -> bool: + return not (self.advancement or self.useful or self.trap) + + @property + def excludable(self) -> bool: + return not (self.advancement or self.useful) + @property def flags(self) -> int: return self.classification.as_flag() + @property + def is_event(self) -> bool: + return self.code is None + def __eq__(self, other: object) -> bool: if not isinstance(other, Item): return NotImplemented @@ -1382,14 +1584,21 @@ class Spoiler: # second phase, sphere 0 removed_precollected: List[Item] = [] - for item in (i for i in chain.from_iterable(multiworld.precollected_items.values()) if i.advancement): - logging.debug('Checking if %s (Player %d) is required to beat the game.', item.name, item.player) - multiworld.precollected_items[item.player].remove(item) - multiworld.state.remove(item) - if not multiworld.can_beat_game(): - multiworld.push_precollected(item) - else: - removed_precollected.append(item) + + for precollected_items in multiworld.precollected_items.values(): + # The list of items is mutated by removing one item at a time to determine if each item is required to beat + # the game, and re-adding that item if it was required, so a copy needs to be made before iterating. + for item in precollected_items.copy(): + if not item.advancement: + continue + logging.debug('Checking if %s (Player %d) is required to beat the game.', item.name, item.player) + precollected_items.remove(item) + multiworld.state.remove(item) + if not multiworld.can_beat_game(): + # Add the item back into `precollected_items` and collect it into `multiworld.state`. + multiworld.push_precollected(item) + else: + removed_precollected.append(item) # we are now down to just the required progress items in collection_spheres. Unfortunately # the previous pruning stage could potentially have made certain items dependant on others @@ -1528,7 +1737,7 @@ class Spoiler: [f" {location}: {item}" for (location, item) in sphere.items()] if isinstance(sphere, dict) else [f" {item}" for item in sphere])) for (sphere_nr, sphere) in self.playthrough.items()])) if self.unreachables: - outfile.write('\n\nUnreachable Items:\n\n') + outfile.write('\n\nUnreachable Progression Items:\n\n') outfile.write( '\n'.join(['%s: %s' % (unreachable.item, unreachable) for unreachable in self.unreachables])) diff --git a/CommonClient.py b/CommonClient.py index 77ed85b5c6..94c558bf8a 100644 --- a/CommonClient.py +++ b/CommonClient.py @@ -23,7 +23,7 @@ if __name__ == "__main__": from MultiServer import CommandProcessor from NetUtils import (Endpoint, decode, NetworkItem, encode, JSONtoTextParser, ClientStatus, Permission, NetworkSlot, - RawJSONtoTextParser, add_json_text, add_json_location, add_json_item, JSONTypes, SlotType) + RawJSONtoTextParser, add_json_text, add_json_location, add_json_item, JSONTypes, HintStatus, SlotType) from Utils import Version, stream_input, async_start from worlds import network_data_package, AutoWorldRegister import os @@ -31,6 +31,7 @@ import ssl if typing.TYPE_CHECKING: import kvui + import argparse logger = logging.getLogger("Client") @@ -195,25 +196,11 @@ class CommonContext: self.lookup_type: typing.Literal["item", "location"] = lookup_type self._unknown_item: typing.Callable[[int], str] = lambda key: f"Unknown {lookup_type} (ID: {key})" self._archipelago_lookup: typing.Dict[int, str] = {} - self._flat_store: typing.Dict[int, str] = Utils.KeyedDefaultDict(self._unknown_item) self._game_store: typing.Dict[str, typing.ChainMap[int, str]] = collections.defaultdict( lambda: collections.ChainMap(self._archipelago_lookup, Utils.KeyedDefaultDict(self._unknown_item))) - self.warned: bool = False # noinspection PyTypeChecker def __getitem__(self, key: str) -> typing.Mapping[int, str]: - # 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: @@ -253,7 +240,6 @@ class CommonContext: id_to_name_lookup_table = Utils.KeyedDefaultDict(self._unknown_item) id_to_name_lookup_table.update({code: name for name, code in name_to_id_lookup_table.items()}) self._game_store[game] = collections.ChainMap(self._archipelago_lookup, id_to_name_lookup_table) - self._flat_store.update(id_to_name_lookup_table) # Only needed for legacy lookup method. if game == "Archipelago": # Keep track of the Archipelago data package separately so if it gets updated in a custom datapackage, # it updates in all chain maps automatically. @@ -355,7 +341,6 @@ class CommonContext: self.item_names = self.NameLookupDict(self, "item") self.location_names = self.NameLookupDict(self, "location") - self.versions = {} self.checksums = {} self.jsontotextparser = JSONtoTextParser(self) @@ -412,6 +397,8 @@ class CommonContext: await self.server.socket.close() if self.server_task is not None: await self.server_task + if self.ui: + self.ui.update_hints() async def send_msgs(self, msgs: typing.List[typing.Any]) -> None: """ `msgs` JSON serializable """ @@ -458,6 +445,13 @@ class CommonContext: await self.send_msgs([payload]) await self.send_msgs([{"cmd": "Get", "keys": ["_read_race_mode"]}]) + async def check_locations(self, locations: typing.Collection[int]) -> set[int]: + """Send new location checks to the server. Returns the set of actually new locations that were sent.""" + locations = set(locations) & self.missing_locations + if locations: + await self.send_msgs([{"cmd": 'LocationChecks', "locations": tuple(locations)}]) + return locations + async def console_input(self) -> str: if self.ui: self.ui.focus_textinput() @@ -551,10 +545,16 @@ class CommonContext: await self.ui_task if self.input_task: self.input_task.cancel() - + + # Hints + def update_hint(self, location: int, finding_player: int, status: typing.Optional[HintStatus]) -> None: + msg = {"cmd": "UpdateHint", "location": location, "player": finding_player} + if status is not None: + msg["status"] = status + async_start(self.send_msgs([msg]), name="update_hint") + # DataPackage async def prepare_data_package(self, relevant_games: typing.Set[str], - remote_date_package_versions: typing.Dict[str, int], remote_data_package_checksums: typing.Dict[str, str]): """Validate that all data is present for the current multiworld. Download, assimilate and cache missing data from the server.""" @@ -563,33 +563,26 @@ class CommonContext: needed_updates: typing.Set[str] = set() for game in relevant_games: - if game not in remote_date_package_versions and game not in remote_data_package_checksums: + if game not in remote_data_package_checksums: continue - remote_version: int = remote_date_package_versions.get(game, 0) remote_checksum: typing.Optional[str] = remote_data_package_checksums.get(game) - if remote_version == 0 and not remote_checksum: # custom data package and no checksum for this game + if not remote_checksum: # custom data package and no checksum for this game needed_updates.add(game) continue - cached_version: int = self.versions.get(game, 0) cached_checksum: typing.Optional[str] = self.checksums.get(game) # no action required if cached version is new enough - if (not remote_checksum and (remote_version > cached_version or remote_version == 0)) \ - or remote_checksum != cached_checksum: - local_version: int = network_data_package["games"].get(game, {}).get("version", 0) + if remote_checksum != cached_checksum: local_checksum: typing.Optional[str] = network_data_package["games"].get(game, {}).get("checksum") - if ((remote_checksum or remote_version <= local_version and remote_version != 0) - and remote_checksum == local_checksum): + if remote_checksum == local_checksum: self.update_game(network_data_package["games"][game], game) else: cached_game = Utils.load_data_package_for_checksum(game, remote_checksum) - cache_version: int = cached_game.get("version", 0) cache_checksum: typing.Optional[str] = cached_game.get("checksum") # download remote version if cache is not new enough - if (not remote_checksum and (remote_version > cache_version or remote_version == 0)) \ - or remote_checksum != cache_checksum: + if remote_checksum != cache_checksum: needed_updates.add(game) else: self.update_game(cached_game, game) @@ -599,7 +592,6 @@ class CommonContext: def update_game(self, game_package: dict, game: str): self.item_names.update_game(game, game_package["item_name_to_id"]) self.location_names.update_game(game, game_package["location_name_to_id"]) - self.versions[game] = game_package.get("version", 0) self.checksums[game] = game_package.get("checksum") def update_data_package(self, data_package: dict): @@ -608,9 +600,6 @@ class CommonContext: def consume_network_data_package(self, data_package: dict): self.update_data_package(data_package) - current_cache = Utils.persistent_load().get("datapackage", {}).get("games", {}) - current_cache.update(data_package["games"]) - Utils.persistent_store("datapackage", "games", current_cache) logger.info(f"Got new ID/Name DataPackage for {', '.join(data_package['games'])}") for game, game_data in data_package["games"].items(): Utils.store_data_package_for_checksum(game, game_data) @@ -693,8 +682,16 @@ class CommonContext: logger.exception(msg, exc_info=exc_info, extra={'compact_gui': True}) self._messagebox_connection_loss = self.gui_error(msg, exc_info[1]) - def make_gui(self) -> typing.Type["kvui.GameManager"]: - """To return the Kivy App class needed for run_gui so it can be overridden before being built""" + def make_gui(self) -> "type[kvui.GameManager]": + """ + To return the Kivy `App` class needed for `run_gui` so it can be overridden before being built + + Common changes are changing `base_title` to update the window title of the client and + updating `logging_pairs` to automatically make new tabs that can be filled with their respective logger. + + ex. `logging_pairs.append(("Foo", "Bar"))` + will add a "Bar" tab which follows the logger returned from `logging.getLogger("Foo")` + """ from kvui import GameManager class TextManager(GameManager): @@ -710,6 +707,11 @@ class CommonContext: def run_cli(self): if sys.stdin: + if sys.stdin.fileno() != 0: + from multiprocessing import parent_process + if parent_process(): + return # ignore MultiProcessing pipe + # steam overlay breaks when starting console_loop if 'gameoverlayrenderer' in os.environ.get('LD_PRELOAD', ''): logger.info("Skipping terminal input, due to conflicting Steam Overlay detected. Please use GUI only.") @@ -860,9 +862,8 @@ async def process_server_cmd(ctx: CommonContext, args: dict): logger.info(' %s (Player %d)' % (network_player.alias, network_player.slot)) # update data package - data_package_versions = args.get("datapackage_versions", {}) data_package_checksums = args.get("datapackage_checksums", {}) - await ctx.prepare_data_package(set(args["games"]), data_package_versions, data_package_checksums) + await ctx.prepare_data_package(set(args["games"]), data_package_checksums) await ctx.server_auth(args['password']) @@ -878,6 +879,7 @@ async def process_server_cmd(ctx: CommonContext, args: dict): ctx.disconnected_intentionally = True ctx.event_invalid_game() elif 'IncompatibleVersion' in errors: + ctx.disconnected_intentionally = True raise Exception('Server reported your client version as incompatible. ' 'This probably means you have to update.') elif 'InvalidItemsHandling' in errors: @@ -1028,6 +1030,32 @@ def get_base_parser(description: typing.Optional[str] = None): return parser +def handle_url_arg(args: "argparse.Namespace", + parser: "typing.Optional[argparse.ArgumentParser]" = None) -> "argparse.Namespace": + """ + Parse the url arg "archipelago://name:pass@host:port" from launcher into correct launch args for CommonClient + If alternate data is required the urlparse response is saved back to args.url if valid + """ + if not args.url: + return args + + url = urllib.parse.urlparse(args.url) + if url.scheme != "archipelago": + if not parser: + parser = get_base_parser() + parser.error(f"bad url, found {args.url}, expected url in form of archipelago://archipelago.gg:38281") + return args + + args.url = url + args.connect = url.netloc + if url.username: + args.name = urllib.parse.unquote(url.username) + if url.password: + args.password = urllib.parse.unquote(url.password) + + return args + + def run_as_textclient(*args): class TextContext(CommonContext): # Text Mode to use !hint and such with games that have no text entry @@ -1040,7 +1068,7 @@ def run_as_textclient(*args): if password_requested and not self.password: await super(TextContext, self).server_auth(password_requested) await self.get_username() - await self.send_connect() + await self.send_connect(game="") def on_package(self, cmd: str, args: dict): if cmd == "Connected": @@ -1069,20 +1097,10 @@ def run_as_textclient(*args): parser.add_argument("url", nargs="?", help="Archipelago connection url") args = parser.parse_args(args) - # handle if text client is launched using the "archipelago://name:pass@host:port" url from webhost - if args.url: - url = urllib.parse.urlparse(args.url) - if url.scheme == "archipelago": - args.connect = url.netloc - if url.username: - args.name = urllib.parse.unquote(url.username) - if url.password: - args.password = urllib.parse.unquote(url.password) - else: - parser.error(f"bad url, found {args.url}, expected url in form of archipelago://archipelago.gg:38281") + args = handle_url_arg(args, parser=parser) # use colorama to display colored text highlighting on windows - colorama.init() + colorama.just_fix_windows_console() asyncio.run(main(args)) colorama.deinit() diff --git a/FF1Client.py b/FF1Client.py index b7c58e2061..748a95b72c 100644 --- a/FF1Client.py +++ b/FF1Client.py @@ -261,7 +261,7 @@ if __name__ == '__main__': parser = get_base_parser() args = parser.parse_args() - colorama.init() + colorama.just_fix_windows_console() asyncio.run(main(args)) colorama.deinit() diff --git a/FactorioClient.py b/FactorioClient.py deleted file mode 100644 index 070ca50326..0000000000 --- a/FactorioClient.py +++ /dev/null @@ -1,12 +0,0 @@ -from __future__ import annotations - -import ModuleUpdate -ModuleUpdate.update() - -from worlds.factorio.Client import check_stdin, launch -import Utils - -if __name__ == "__main__": - Utils.init_logging("FactorioClient", exception_logger="Client") - check_stdin() - launch() diff --git a/Fill.py b/Fill.py index 706cca6574..ff59aa22cb 100644 --- a/Fill.py +++ b/Fill.py @@ -4,7 +4,7 @@ import logging import typing from collections import Counter, deque -from BaseClasses import CollectionState, Item, Location, LocationProgressType, MultiWorld +from BaseClasses import CollectionState, Item, Location, LocationProgressType, MultiWorld, PlandoItemBlock from Options import Accessibility from worlds.AutoWorld import call_all @@ -36,7 +36,8 @@ def sweep_from_pool(base_state: CollectionState, itempool: typing.Sequence[Item] 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: + allow_partial: bool = False, allow_excluded: bool = False, one_item_per_player: bool = True, + name: str = "Unknown") -> None: """ :param multiworld: Multiworld to be filled. :param base_state: State assumed before fill. @@ -63,14 +64,24 @@ def fill_restrictive(multiworld: MultiWorld, base_state: CollectionState, locati placed = 0 while any(reachable_items.values()) and locations: - # grab one item per player - items_to_place = [items.pop() - for items in reachable_items.values() if items] + if one_item_per_player: + # grab one item per player + items_to_place = [items.pop() + for items in reachable_items.values() if items] + else: + next_player = multiworld.random.choice([player for player, items in reachable_items.items() if items]) + items_to_place = [] + if item_pool: + items_to_place.append(reachable_items[next_player].pop()) + for item in items_to_place: - for p, pool_item in enumerate(item_pool): + # The items added into `reachable_items` are placed starting from the end of each deque in + # `reachable_items`, so the items being placed are more likely to be found towards the end of `item_pool`. + for p, pool_item in enumerate(reversed(item_pool), start=1): if pool_item is item: - item_pool.pop(p) + del item_pool[-p] break + maximum_exploration_state = sweep_from_pool( base_state, item_pool + unplaced_items, multiworld.get_filled_locations(item.player) if single_player_placement else None) @@ -89,7 +100,7 @@ def fill_restrictive(multiworld: MultiWorld, base_state: CollectionState, locati # if minimal accessibility, only check whether location is reachable if game not beatable if multiworld.worlds[item_to_place.player].options.accessibility == Accessibility.option_minimal: perform_access_check = not multiworld.has_beaten_game(maximum_exploration_state, - item_to_place.player) \ + item_to_place.player) \ if single_player_placement else not has_beaten_game else: perform_access_check = True @@ -226,18 +237,30 @@ def remaining_fill(multiworld: MultiWorld, locations: typing.List[Location], itempool: typing.List[Item], name: str = "Remaining", - move_unplaceable_to_start_inventory: bool = False) -> None: + move_unplaceable_to_start_inventory: bool = False, + check_location_can_fill: bool = False) -> None: unplaced_items: typing.List[Item] = [] placements: typing.List[Location] = [] swapped_items: typing.Counter[typing.Tuple[int, str]] = Counter() - total = min(len(itempool), len(locations)) + total = min(len(itempool), len(locations)) placed = 0 + + # Optimisation: Decide whether to do full location.can_fill check (respect excluded), or only check the item rule + if check_location_can_fill: + state = CollectionState(multiworld) + + def location_can_fill_item(location_to_fill: Location, item_to_fill: Item): + return location_to_fill.can_fill(state, item_to_fill, check_access=False) + else: + def location_can_fill_item(location_to_fill: Location, item_to_fill: Item): + return location_to_fill.item_rule(item_to_fill) + while locations and itempool: item_to_place = itempool.pop() spot_to_fill: typing.Optional[Location] = None for i, location in enumerate(locations): - if location.item_rule(item_to_place): + if location_can_fill_item(location, item_to_place): # popping by index is faster than removing by content, spot_to_fill = locations.pop(i) # skipping a scan for the element @@ -258,7 +281,7 @@ def remaining_fill(multiworld: MultiWorld, location.item = None placed_item.location = None - if location.item_rule(item_to_place): + if location_can_fill_item(location, item_to_place): # Add this item to the existing placement, and # add the old item to the back of the queue spot_to_fill = placements.pop(i) @@ -320,17 +343,19 @@ def fast_fill(multiworld: MultiWorld, def accessibility_corrections(multiworld: MultiWorld, state: CollectionState, locations, pool=[]): maximum_exploration_state = sweep_from_pool(state, pool) - minimal_players = {player for player in multiworld.player_ids if multiworld.worlds[player].options.accessibility == "minimal"} - unreachable_locations = [location for location in multiworld.get_locations() if location.player in minimal_players and + minimal_players = {player for player in multiworld.player_ids if + multiworld.worlds[player].options.accessibility == "minimal"} + unreachable_locations = [location for location in multiworld.get_locations() if + location.player in minimal_players and not location.can_reach(maximum_exploration_state)] for location in unreachable_locations: if (location.item is not None and location.item.advancement and location.address is not None and not location.locked and location.item.player not in minimal_players): pool.append(location.item) - state.remove(location.item) location.item = None if location in state.advancements: state.advancements.remove(location) + state.remove(location.item) locations.append(location) if pool and locations: locations.sort(key=lambda loc: loc.progress_type != LocationProgressType.PRIORITY) @@ -342,7 +367,7 @@ def inaccessible_location_rules(multiworld: MultiWorld, state: CollectionState, unreachable_locations = [location for location in locations if not location.can_reach(maximum_exploration_state)] if unreachable_locations: def forbid_important_item_rule(item: Item): - return not ((item.classification & 0b0011) and multiworld.worlds[item.player].options.accessibility != 'minimal') + return not ((item.classification & 0b0011) and multiworld.worlds[item.player].options.accessibility != "minimal") for location in unreachable_locations: add_item_rule(location, forbid_important_item_rule) @@ -479,21 +504,31 @@ def distribute_items_restrictive(multiworld: MultiWorld, if prioritylocations: # "priority fill" - fill_restrictive(multiworld, multiworld.state, prioritylocations, progitempool, - single_player_placement=single_player, swap=False, on_place=mark_for_locking, name="Priority") + maximum_exploration_state = sweep_from_pool(multiworld.state) + fill_restrictive(multiworld, maximum_exploration_state, prioritylocations, progitempool, + single_player_placement=single_player, swap=False, on_place=mark_for_locking, + name="Priority", one_item_per_player=True, allow_partial=True) + + if prioritylocations: + # retry with one_item_per_player off because some priority fills can fail to fill with that optimization + maximum_exploration_state = sweep_from_pool(multiworld.state) + fill_restrictive(multiworld, maximum_exploration_state, prioritylocations, progitempool, + single_player_placement=single_player, swap=False, on_place=mark_for_locking, + name="Priority Retry", one_item_per_player=False) accessibility_corrections(multiworld, multiworld.state, prioritylocations, progitempool) defaultlocations = prioritylocations + defaultlocations if progitempool: # "advancement/progression fill" + maximum_exploration_state = sweep_from_pool(multiworld.state) if panic_method == "swap": - fill_restrictive(multiworld, multiworld.state, defaultlocations, progitempool, swap=True, + fill_restrictive(multiworld, maximum_exploration_state, defaultlocations, progitempool, swap=True, name="Progression", single_player_placement=single_player) elif panic_method == "raise": - fill_restrictive(multiworld, multiworld.state, defaultlocations, progitempool, swap=False, + fill_restrictive(multiworld, maximum_exploration_state, defaultlocations, progitempool, swap=False, name="Progression", single_player_placement=single_player) elif panic_method == "start_inventory": - fill_restrictive(multiworld, multiworld.state, defaultlocations, progitempool, swap=False, + fill_restrictive(multiworld, maximum_exploration_state, defaultlocations, progitempool, swap=False, allow_partial=True, name="Progression", single_player_placement=single_player) if progitempool: for item in progitempool: @@ -509,7 +544,8 @@ def distribute_items_restrictive(multiworld: MultiWorld, if progitempool: raise FillError( f"Not enough locations for progression items. " - f"There are {len(progitempool)} more progression items than there are available locations.", + f"There are {len(progitempool)} more progression items than there are available locations.\n" + f"Unfilled locations:\n{multiworld.get_unfilled_locations()}.", multiworld=multiworld, ) accessibility_corrections(multiworld, multiworld.state, defaultlocations) @@ -527,7 +563,7 @@ def distribute_items_restrictive(multiworld: MultiWorld, if excludedlocations: raise FillError( f"Not enough filler items for excluded locations. " - f"There are {len(excludedlocations)} more excluded locations than filler or trap items.", + f"There are {len(excludedlocations)} more excluded locations than excludable items.", multiworld=multiworld, ) @@ -548,6 +584,26 @@ def distribute_items_restrictive(multiworld: MultiWorld, print_data = {"items": items_counter, "locations": locations_counter} logging.info(f"Per-Player counts: {print_data})") + more_locations = locations_counter - items_counter + more_items = items_counter - locations_counter + for player in multiworld.player_ids: + if more_locations[player]: + logging.error( + f"Player {multiworld.get_player_name(player)} had {more_locations[player]} more locations than items.") + elif more_items[player]: + logging.warning( + f"Player {multiworld.get_player_name(player)} had {more_items[player]} more items than locations.") + if unfilled: + raise FillError( + f"Unable to fill all locations.\n" + + f"Unfilled locations({len(unfilled)}): {unfilled}" + ) + else: + logging.warning( + f"Unable to place all items.\n" + + f"Unplaced items({len(unplaced)}): {unplaced}" + ) + def flood_items(multiworld: MultiWorld) -> None: # get items to distribute @@ -623,9 +679,9 @@ def balance_multiworld_progression(multiworld: MultiWorld) -> None: if multiworld.worlds[player].options.progression_balancing > 0 } if not balanceable_players: - logging.info('Skipping multiworld progression balancing.') + logging.info("Skipping multiworld progression balancing.") else: - logging.info(f'Balancing multiworld progression for {len(balanceable_players)} Players.') + logging.info(f"Balancing multiworld progression for {len(balanceable_players)} Players.") logging.debug(balanceable_players) state: CollectionState = CollectionState(multiworld) checked_locations: typing.Set[Location] = set() @@ -723,7 +779,7 @@ def balance_multiworld_progression(multiworld: MultiWorld) -> None: if player in threshold_percentages): break elif not balancing_sphere: - raise RuntimeError('Not all required items reachable. Something went terribly wrong here.') + raise RuntimeError("Not all required items reachable. Something went terribly wrong here.") # Gather a set of locations which we can swap items into unlocked_locations: typing.Dict[int, typing.Set[Location]] = collections.defaultdict(set) for l in unchecked_locations: @@ -739,8 +795,8 @@ def balance_multiworld_progression(multiworld: MultiWorld) -> None: testing = items_to_test.pop() reducing_state = state.copy() for location in itertools.chain(( - l for l in items_to_replace - if l.item.player == player + l for l in items_to_replace + if l.item.player == player ), items_to_test): reducing_state.collect(location.item, True, location) @@ -813,52 +869,30 @@ def swap_location_item(location_1: Location, location_2: Location, check_locked: location_2.item.location = location_2 -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}') +def parse_planned_blocks(multiworld: MultiWorld) -> dict[int, list[PlandoItemBlock]]: + def warn(warning: str, force: bool | str) -> None: + if isinstance(force, bool): + logging.warning(f"{warning}") else: - logging.debug(f'{warning}') + logging.debug(f"{warning}") - def failed(warning: str, force: typing.Union[bool, str]) -> None: - if force in [True, 'fail', 'failure']: + def failed(warning: str, force: bool | str) -> None: + if force is True: raise Exception(warning) else: warn(warning, force) - swept_state = multiworld.state.copy() - swept_state.sweep_for_advancements() - reachable = frozenset(multiworld.get_reachable_locations(swept_state)) - early_locations: typing.Dict[int, typing.List[str]] = collections.defaultdict(list) - non_early_locations: typing.Dict[int, typing.List[str]] = collections.defaultdict(list) - for loc in multiworld.get_unfilled_locations(): - if loc in reachable: - early_locations[loc.player].append(loc.name) - else: # not reachable with swept state - non_early_locations[loc.player].append(loc.name) - world_name_lookup = multiworld.world_name_lookup - block_value = typing.Union[typing.List[str], typing.Dict[str, typing.Any], str] - plando_blocks: typing.List[typing.Dict[str, typing.Any]] = [] - player_ids = set(multiworld.player_ids) + plando_blocks: dict[int, list[PlandoItemBlock]] = dict() + player_ids: set[int] = set(multiworld.player_ids) for player in player_ids: - for block in multiworld.plando_items[player]: - block['player'] = player - if 'force' not in block: - block['force'] = 'silent' - if 'from_pool' not in block: - block['from_pool'] = True - elif not isinstance(block['from_pool'], bool): - from_pool_type = type(block['from_pool']) - raise Exception(f'Plando "from_pool" has to be boolean, not {from_pool_type} for player {player}.') - if 'world' not in block: - target_world = False - else: - target_world = block['world'] - + plando_blocks[player] = [] + for block in multiworld.worlds[player].options.plando_items: + new_block: PlandoItemBlock = PlandoItemBlock(player, block.from_pool, block.force) + target_world = block.world if target_world is False or multiworld.players == 1: # target own world - worlds: typing.Set[int] = {player} + worlds: set[int] = {player} elif target_world is True: # target any worlds besides own worlds = set(multiworld.player_ids) - {player} elif target_world is None: # target all worlds @@ -868,155 +902,197 @@ def distribute_planned(multiworld: MultiWorld) -> None: for listed_world in target_world: if listed_world not in world_name_lookup: failed(f"Cannot place item to {target_world}'s world as that world does not exist.", - block['force']) + block.force) continue worlds.add(world_name_lookup[listed_world]) elif type(target_world) == int: # target world by slot number if target_world not in range(1, multiworld.players + 1): failed( f"Cannot place item in world {target_world} as it is not in range of (1, {multiworld.players})", - block['force']) + block.force) continue worlds = {target_world} else: # target world by slot name if target_world not in world_name_lookup: failed(f"Cannot place item to {target_world}'s world as that world does not exist.", - block['force']) + block.force) continue worlds = {world_name_lookup[target_world]} - block['world'] = worlds + new_block.worlds = worlds - items: block_value = [] - if "items" in block: - items = block["items"] - if 'count' not in block: - block['count'] = False - elif "item" in block: - items = block["item"] - if 'count' not in block: - block['count'] = 1 - else: - failed("You must specify at least one item to place items with plando.", block['force']) - continue + items: list[str] | dict[str, typing.Any] = block.items if isinstance(items, dict): - item_list: typing.List[str] = [] + item_list: list[str] = [] for key, value in items.items(): if value is True: value = multiworld.itempool.count(multiworld.worlds[player].create_item(key)) item_list += [key] * value items = item_list - if isinstance(items, str): - items = [items] - block['items'] = items + new_block.items = items - locations: block_value = [] - if 'location' in block: - locations = block['location'] # just allow 'location' to keep old yamls compatible - elif 'locations' in block: - locations = block['locations'] + locations: list[str] = block.locations if isinstance(locations, str): locations = [locations] - if isinstance(locations, dict): - location_list = [] - for key, value in locations.items(): - location_list += [key] * value - locations = location_list + locations_from_groups: list[str] = [] + resolved_locations: list[Location] = [] + for target_player in worlds: + world_locations = multiworld.get_unfilled_locations(target_player) + for group in multiworld.worlds[target_player].location_name_groups: + if group in locations: + locations_from_groups.extend(multiworld.worlds[target_player].location_name_groups[group]) + resolved_locations.extend(location for location in world_locations + if location.name in [*locations, *locations_from_groups]) + new_block.locations = sorted(dict.fromkeys(locations)) + new_block.resolved_locations = sorted(set(resolved_locations)) + count = block.count + if not count: + count = len(new_block.items) + if isinstance(count, int): + count = {"min": count, "max": count} + if "min" not in count: + count["min"] = 0 + if "max" not in count: + count["max"] = len(new_block.items) + + new_block.count = count + plando_blocks[player].append(new_block) + + return plando_blocks + + +def resolve_early_locations_for_planned(multiworld: MultiWorld): + def warn(warning: str, force: bool | str) -> None: + if isinstance(force, bool): + logging.warning(f"{warning}") + else: + logging.debug(f"{warning}") + + def failed(warning: str, force: bool | str) -> None: + if force is True: + raise Exception(warning) + else: + warn(warning, force) + + swept_state = multiworld.state.copy() + swept_state.sweep_for_advancements() + reachable = frozenset(multiworld.get_reachable_locations(swept_state)) + early_locations: dict[int, list[Location]] = collections.defaultdict(list) + non_early_locations: dict[int, list[Location]] = collections.defaultdict(list) + for loc in multiworld.get_unfilled_locations(): + if loc in reachable: + early_locations[loc.player].append(loc) + else: # not reachable with swept state + non_early_locations[loc.player].append(loc) + + for player in multiworld.plando_item_blocks: + removed = [] + for block in multiworld.plando_item_blocks[player]: + locations = block.locations + resolved_locations = block.resolved_locations + worlds = block.worlds if "early_locations" in locations: - locations.remove("early_locations") for target_player in worlds: - locations += early_locations[target_player] + resolved_locations += early_locations[target_player] if "non_early_locations" in locations: - locations.remove("non_early_locations") for target_player in worlds: - locations += non_early_locations[target_player] + resolved_locations += non_early_locations[target_player] - block['locations'] = list(dict.fromkeys(locations)) + if block.count["max"] > len(block.items): + count = block.count["max"] + failed(f"Plando count {count} greater than items specified", block.force) + block.count["max"] = len(block.items) + if block.count["min"] > len(block.items): + block.count["min"] = len(block.items) + if block.count["max"] > len(block.resolved_locations) > 0: + count = block.count["max"] + failed(f"Plando count {count} greater than locations specified", block.force) + block.count["max"] = len(block.resolved_locations) + if block.count["min"] > len(block.resolved_locations): + block.count["min"] = len(block.resolved_locations) + block.count["target"] = multiworld.random.randint(block.count["min"], + block.count["max"]) - if not block['count']: - block['count'] = (min(len(block['items']), len(block['locations'])) if - len(block['locations']) > 0 else len(block['items'])) - if isinstance(block['count'], int): - block['count'] = {'min': block['count'], 'max': block['count']} - if 'min' not in block['count']: - block['count']['min'] = 0 - if 'max' not in block['count']: - block['count']['max'] = (min(len(block['items']), len(block['locations'])) if - len(block['locations']) > 0 else len(block['items'])) - if block['count']['max'] > len(block['items']): - count = block['count'] - failed(f"Plando count {count} greater than items specified", block['force']) - block['count'] = len(block['items']) - if block['count']['max'] > len(block['locations']) > 0: - count = block['count'] - failed(f"Plando count {count} greater than locations specified", block['force']) - block['count'] = len(block['locations']) - block['count']['target'] = multiworld.random.randint(block['count']['min'], block['count']['max']) + if not block.count["target"]: + removed.append(block) - if block['count']['target'] > 0: - plando_blocks.append(block) + for block in removed: + multiworld.plando_item_blocks[player].remove(block) + + +def distribute_planned_blocks(multiworld: MultiWorld, plando_blocks: list[PlandoItemBlock]): + def warn(warning: str, force: bool | str) -> None: + if isinstance(force, bool): + logging.warning(f"{warning}") + else: + logging.debug(f"{warning}") + + def failed(warning: str, force: bool | str) -> None: + if force is True: + raise Exception(warning) + else: + warn(warning, force) # shuffle, but then sort blocks by number of locations minus number of items, # so less-flexible blocks get priority multiworld.random.shuffle(plando_blocks) - plando_blocks.sort(key=lambda block: (len(block['locations']) - block['count']['target'] - if len(block['locations']) > 0 - else len(multiworld.get_unfilled_locations(player)) - block['count']['target'])) - + plando_blocks.sort(key=lambda block: (len(block.resolved_locations) - block.count["target"] + if len(block.resolved_locations) > 0 + else len(multiworld.get_unfilled_locations(block.player)) - + block.count["target"])) for placement in plando_blocks: - player = placement['player'] + player = placement.player try: - worlds = placement['world'] - locations = placement['locations'] - items = placement['items'] - maxcount = placement['count']['target'] - from_pool = placement['from_pool'] + worlds = placement.worlds + locations = placement.resolved_locations + items = placement.items + maxcount = placement.count["target"] + from_pool = placement.from_pool - candidates = list(multiworld.get_unfilled_locations_for_players(locations, sorted(worlds))) - multiworld.random.shuffle(candidates) - multiworld.random.shuffle(items) - count = 0 - err: typing.List[str] = [] - successful_pairs: typing.List[typing.Tuple[Item, Location]] = [] - for item_name in items: - item = multiworld.worlds[player].create_item(item_name) - for location in reversed(candidates): - if (location.address is None) == (item.code is None): # either both None or both not None - if not location.item: - if location.item_rule(item): - if location.can_fill(multiworld.state, item, False): - successful_pairs.append((item, location)) - candidates.remove(location) - count = count + 1 - break - else: - err.append(f"Can't place item at {location} due to fill condition not met.") - else: - err.append(f"{item_name} not allowed at {location}.") - else: - err.append(f"Cannot place {item_name} into already filled location {location}.") + item_candidates = [] + if from_pool: + instances = [item for item in multiworld.itempool if item.player == player and item.name in items] + for item in multiworld.random.sample(items, maxcount): + candidate = next((i for i in instances if i.name == item), None) + if candidate is None: + warn(f"Could not remove {item} from pool for {multiworld.player_name[player]} as " + f"it's already missing from it", placement.force) + candidate = multiworld.worlds[player].create_item(item) else: - err.append(f"Mismatch between {item_name} and {location}, only one is an event.") - if count == maxcount: - break - if count < placement['count']['min']: - m = placement['count']['min'] - failed( - f"Plando block failed to place {m - count} of {m} item(s) for {multiworld.player_name[player]}, error(s): {' '.join(err)}", - placement['force']) - for (item, location) in successful_pairs: - multiworld.push_item(location, item, collect=False) - location.locked = True - logging.debug(f"Plando placed {item} at {location}") - if from_pool: - try: - multiworld.itempool.remove(item) - except ValueError: - warn( - f"Could not remove {item} from pool for {multiworld.player_name[player]} as it's already missing from it.", - placement['force']) + multiworld.itempool.remove(candidate) + instances.remove(candidate) + item_candidates.append(candidate) + else: + item_candidates = [multiworld.worlds[player].create_item(item) + for item in multiworld.random.sample(items, maxcount)] + if any(item.code is None for item in item_candidates) \ + and not all(item.code is None for item in item_candidates): + failed(f"Plando block for player {player} ({multiworld.player_name[player]}) contains both " + f"event items and non-event items. " + f"Event items: {[item for item in item_candidates if item.code is None]}, " + f"Non-event items: {[item for item in item_candidates if item.code is not None]}", + placement.force) + continue + else: + is_real = item_candidates[0].code is not None + candidates = [candidate for candidate in locations if candidate.item is None + and bool(candidate.address) == is_real] + multiworld.random.shuffle(candidates) + allstate = multiworld.get_all_state(False) + mincount = placement.count["min"] + allowed_margin = len(item_candidates) - mincount + fill_restrictive(multiworld, allstate, candidates, item_candidates, lock=True, + allow_partial=True, name="Plando Main Fill") + if len(item_candidates) > allowed_margin: + failed(f"Could not place {len(item_candidates)} " + f"of {mincount + allowed_margin} item(s) " + f"for {multiworld.player_name[player]}, " + f"remaining items: {item_candidates}", + placement.force) + if from_pool: + multiworld.itempool.extend([item for item in item_candidates if item.code is not None]) except Exception as e: raise Exception( f"Error running plando for player {player} ({multiworld.player_name[player]})") from e diff --git a/Generate.py b/Generate.py index 52babdf188..9bc8d1066f 100644 --- a/Generate.py +++ b/Generate.py @@ -10,8 +10,8 @@ import sys import urllib.parse import urllib.request from collections import Counter -from typing import Any, Dict, Tuple, Union from itertools import chain +from typing import Any import ModuleUpdate @@ -42,7 +42,9 @@ def mystery_argparse(): help="Path to output folder. Absolute or relative to cwd.") # absolute or relative to cwd parser.add_argument('--race', action='store_true', default=defaults.race) parser.add_argument('--meta_file_path', default=defaults.meta_file_path) - parser.add_argument('--log_level', default='info', help='Sets log level') + parser.add_argument('--log_level', default=defaults.loglevel, help='Sets log level') + parser.add_argument('--log_time', help="Add timestamps to STDOUT", + default=defaults.logtime, action='store_true') parser.add_argument("--csv_output", action="store_true", help="Output rolled player options to csv (made for async multiworld).") parser.add_argument("--plando", default=defaults.plando_options, @@ -52,12 +54,22 @@ def mystery_argparse(): parser.add_argument("--skip_output", action="store_true", help="Skips generation assertion and output stages and skips multidata and spoiler output. " "Intended for debugging and testing purposes.") + parser.add_argument("--spoiler_only", action="store_true", + help="Skips generation assertion and multidata, outputting only a spoiler log. " + "Intended for debugging and testing purposes.") args = parser.parse_args() + + if args.skip_output and args.spoiler_only: + parser.error("Cannot mix --skip_output and --spoiler_only") + elif args.spoiler == 0 and args.spoiler_only: + parser.error("Cannot use --spoiler_only when --spoiler=0. Use --skip_output or set --spoiler to a different value") + if not os.path.isabs(args.weights_file_path): args.weights_file_path = os.path.join(args.player_files_path, args.weights_file_path) if not os.path.isabs(args.meta_file_path): args.meta_file_path = os.path.join(args.player_files_path, args.meta_file_path) args.plando: PlandoOptions = PlandoOptions.from_option_string(args.plando) + return args @@ -65,7 +77,7 @@ def get_seed_name(random_source) -> str: return f"{random_source.randint(0, pow(10, seeddigits) - 1)}".zfill(seeddigits) -def main(args=None) -> Tuple[argparse.Namespace, int]: +def main(args=None) -> tuple[argparse.Namespace, int]: # __name__ == "__main__" check so unittests that already imported worlds don't trip this. if __name__ == "__main__" and "worlds" in sys.modules: raise Exception("Worlds system should not be loaded before logging init.") @@ -75,7 +87,7 @@ def main(args=None) -> Tuple[argparse.Namespace, int]: seed = get_seed(args.seed) - Utils.init_logging(f"Generate_{seed}", loglevel=args.log_level) + Utils.init_logging(f"Generate_{seed}", loglevel=args.log_level, add_timestamp=args.log_time) random.seed(seed) seed_name = get_seed_name(random) @@ -83,7 +95,7 @@ def main(args=None) -> Tuple[argparse.Namespace, int]: logging.info("Race mode enabled. Using non-deterministic random source.") random.seed() # reset to time-based random source - weights_cache: Dict[str, Tuple[Any, ...]] = {} + weights_cache: dict[str, tuple[Any, ...]] = {} if args.weights_file_path and os.path.exists(args.weights_file_path): try: weights_cache[args.weights_file_path] = read_weights_yamls(args.weights_file_path) @@ -106,15 +118,24 @@ def main(args=None) -> Tuple[argparse.Namespace, int]: raise Exception("Cannot mix --sameoptions with --meta") else: meta_weights = None + + player_id = 1 player_files = {} for file in os.scandir(args.player_files_path): fname = file.name - if file.is_file() and not fname.startswith(".") and \ + if file.is_file() and not fname.startswith(".") and not fname.lower().endswith(".ini") and \ os.path.join(args.player_files_path, fname) not in {args.meta_file_path, args.weights_file_path}: path = os.path.join(args.player_files_path, fname) try: - weights_cache[fname] = read_weights_yamls(path) + weights_for_file = [] + for doc_idx, yaml in enumerate(read_weights_yamls(path)): + if yaml is None: + logging.warning(f"Ignoring empty yaml document #{doc_idx + 1} in {fname}") + else: + weights_for_file.append(yaml) + weights_cache[fname] = tuple(weights_for_file) + except Exception as e: raise ValueError(f"File {fname} is invalid. Please fix your yaml.") from e @@ -155,10 +176,11 @@ def main(args=None) -> Tuple[argparse.Namespace, int]: erargs.outputpath = args.outputpath erargs.skip_prog_balancing = args.skip_prog_balancing erargs.skip_output = args.skip_output + erargs.spoiler_only = args.spoiler_only erargs.name = {} erargs.csv_output = args.csv_output - settings_cache: Dict[str, Tuple[argparse.Namespace, ...]] = \ + settings_cache: dict[str, tuple[argparse.Namespace, ...]] = \ {fname: (tuple(roll_settings(yaml, args.plando) for yaml in yamls) if args.sameoptions else None) for fname, yamls in weights_cache.items()} @@ -190,7 +212,7 @@ def main(args=None) -> Tuple[argparse.Namespace, int]: path = player_path_cache[player] if path: try: - settings: Tuple[argparse.Namespace, ...] = settings_cache[path] if settings_cache[path] else \ + settings: tuple[argparse.Namespace, ...] = settings_cache[path] if settings_cache[path] else \ tuple(roll_settings(yaml, args.plando) for yaml in weights_cache[path]) for settingsObject in settings: for k, v in vars(settingsObject).items(): @@ -220,7 +242,7 @@ def main(args=None) -> Tuple[argparse.Namespace, int]: return erargs, seed -def read_weights_yamls(path) -> Tuple[Any, ...]: +def read_weights_yamls(path) -> tuple[Any, ...]: try: if urllib.parse.urlparse(path).scheme in ('https', 'file'): yaml = str(urllib.request.urlopen(path).read(), "utf-8-sig") @@ -230,7 +252,20 @@ def read_weights_yamls(path) -> Tuple[Any, ...]: except Exception as e: raise Exception(f"Failed to read weights ({path})") from e - return tuple(parse_yamls(yaml)) + from yaml.error import MarkedYAMLError + try: + return tuple(parse_yamls(yaml)) + except MarkedYAMLError as ex: + if ex.problem_mark: + lines = yaml.splitlines() + if ex.context_mark: + relevant_lines = "\n".join(lines[ex.context_mark.line:ex.problem_mark.line+1]) + else: + relevant_lines = lines[ex.problem_mark.line] + error_line = " " * ex.problem_mark.column + "^" + raise Exception(f"{ex.context} {ex.problem} on line {ex.problem_mark.line}:" + f"\n{relevant_lines}\n{error_line}") + raise ex def interpret_on_off(value) -> bool: @@ -270,33 +305,35 @@ def get_choice(option, root, value=None) -> Any: raise RuntimeError(f"All options specified in \"{option}\" are weighted as zero.") -class SafeDict(dict): - def __missing__(self, key): - return '{' + key + '}' +class SafeFormatter(string.Formatter): + def get_value(self, key, args, kwargs): + if isinstance(key, int): + if key < len(args): + return args[key] + else: + return "{" + str(key) + "}" + else: + return kwargs.get(key, "{" + key + "}") def handle_name(name: str, player: int, name_counter: Counter): name_counter[name.lower()] += 1 number = name_counter[name.lower()] new_name = "%".join([x.replace("%number%", "{number}").replace("%player%", "{player}") for x in name.split("%%")]) - new_name = string.Formatter().vformat(new_name, (), SafeDict(number=number, - NUMBER=(number if number > 1 else ''), - player=player, - PLAYER=(player if player > 1 else ''))) + + new_name = SafeFormatter().vformat(new_name, (), {"number": number, + "NUMBER": (number if number > 1 else ''), + "player": player, + "PLAYER": (player if player > 1 else '')}) # Run .strip twice for edge case where after the initial .slice new_name has a leading whitespace. # Could cause issues for some clients that cannot handle the additional whitespace. new_name = new_name.strip()[:16].strip() + if new_name == "Archipelago": raise Exception(f"You cannot name yourself \"{new_name}\"") return new_name -def roll_percentage(percentage: Union[int, float]) -> bool: - """Roll a percentage chance. - percentage is expected to be in range [0, 100]""" - return random.random() < (float(percentage) / 100) - - def update_weights(weights: dict, new_weights: dict, update_type: str, name: str) -> dict: logging.debug(f'Applying {new_weights}') cleaned_weights = {} @@ -341,7 +378,7 @@ def update_weights(weights: dict, new_weights: dict, update_type: str, name: str return weights -def roll_meta_option(option_key, game: str, category_dict: Dict) -> Any: +def roll_meta_option(option_key, game: str, category_dict: dict) -> Any: from worlds import AutoWorldRegister if not game: @@ -362,7 +399,7 @@ def roll_linked_options(weights: dict) -> dict: if "name" not in option_set: raise ValueError("One of your linked options does not have a name.") try: - if roll_percentage(option_set["percentage"]): + if Options.roll_percentage(option_set["percentage"]): logging.debug(f"Linked option {option_set['name']} triggered.") new_options = option_set["options"] for category_name, category_options in new_options.items(): @@ -395,7 +432,7 @@ def roll_triggers(weights: dict, triggers: list, valid_keys: set) -> dict: trigger_result = get_choice("option_result", option_set) result = get_choice(key, currently_targeted_weights) currently_targeted_weights[key] = result - if result == trigger_result and roll_percentage(get_choice("percentage", option_set, 100)): + if result == trigger_result and Options.roll_percentage(get_choice("percentage", option_set, 100)): for category_name, category_options in option_set["options"].items(): currently_targeted_weights = weights if category_name: @@ -426,12 +463,20 @@ def handle_option(ret: argparse.Namespace, game_weights: dict, option_key: str, def roll_settings(weights: dict, plando_options: PlandoOptions = PlandoOptions.bosses): + """ + Roll options from specified weights, usually originating from a .yaml options file. + + Important note: + The same weights dict is shared between all slots using the same yaml (e.g. generic weights file for filler slots). + This means it should never be modified without making a deepcopy first. + """ + from worlds import AutoWorldRegister if "linked_options" in weights: weights = roll_linked_options(weights) - valid_keys = set() + valid_keys = {"triggers"} if "triggers" in weights: weights = roll_triggers(weights, weights["triggers"], valid_keys) @@ -453,6 +498,10 @@ def roll_settings(weights: dict, plando_options: PlandoOptions = PlandoOptions.b raise Exception(f"Option {option_key} has to be in a game's section, not on its own.") ret.game = get_choice("game", weights) + if not isinstance(ret.game, str): + if ret.game is None: + raise Exception('"game" not specified') + raise Exception(f"Invalid game: {ret.game}") if ret.game not in AutoWorldRegister.world_types: from worlds import failed_world_loads picks = Utils.get_fuzzy_results(ret.game, list(AutoWorldRegister.world_types) + failed_world_loads, limit=1)[0] @@ -486,15 +535,19 @@ 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 = copy.deepcopy(game_weights.get("plando_items", [])) + if ret.game == "A Link to the Past": + # TODO there are still more LTTP options not on the options system + valid_keys |= {"sprite_pool", "sprite", "random_sprite_on_event"} roll_alttp_settings(ret, game_weights) + # log a warning for options within a game section that aren't determined as valid + for option_key in game_weights: + if option_key in valid_keys: + continue + logging.warning(f"{option_key} is not a valid option name for {ret.game} and is not present in triggers " + f"for player {ret.name}.") + return ret diff --git a/LICENSE b/LICENSE index 40716cff42..60d31b7b7d 100644 --- a/LICENSE +++ b/LICENSE @@ -1,7 +1,7 @@ MIT License Copyright (c) 2017 LLCoolDave -Copyright (c) 2022 Berserker66 +Copyright (c) 2025 Berserker66 Copyright (c) 2022 CaitSith2 Copyright (c) 2021 LegendaryLinux diff --git a/Launcher.py b/Launcher.py index 85e49da7e5..503490243d 100644 --- a/Launcher.py +++ b/Launcher.py @@ -1,16 +1,14 @@ """ -Archipelago launcher for bundled app. +Archipelago Launcher -* if run with APBP as argument, launch corresponding client. -* if run with executable as argument, run it passing argv[2:] as arguments -* if run without arguments, open launcher GUI +* If run with a patch file as argument, launch corresponding client with the patch file as an argument. +* If run with component name as argument, run it passing argv[2:] as arguments. +* If run without arguments or unknown arguments, open launcher GUI. -Scroll down to components= to add components to the launcher as well as setup.py +Additional components can be added to worlds.LauncherComponents.components. """ - import argparse -import itertools import logging import multiprocessing import shlex @@ -18,20 +16,21 @@ import subprocess import sys import urllib.parse import webbrowser +from collections.abc import Callable, Sequence from os.path import isfile from shutil import which -from typing import Callable, Optional, Sequence, Tuple, Union - -import Utils -import settings -from worlds.LauncherComponents import Component, components, Type, SuffixIdentifier, icon_paths +from typing import Any if __name__ == "__main__": import ModuleUpdate + ModuleUpdate.update() -from Utils import is_frozen, user_path, local_path, init_logging, open_filename, messagebox, \ - is_windows, is_macos, is_linux +import settings +import Utils +from Utils import (init_logging, is_frozen, is_linux, is_macos, is_windows, local_path, messagebox, open_filename, + user_path) +from worlds.LauncherComponents import Component, components, icon_paths, SuffixIdentifier, Type def open_host_yaml(): @@ -86,12 +85,16 @@ def browse_files(): def open_folder(folder_path): if is_linux: exe = which('xdg-open') or which('gnome-open') or which('kde-open') - subprocess.Popen([exe, folder_path]) elif is_macos: exe = which("open") - subprocess.Popen([exe, folder_path]) else: webbrowser.open(folder_path) + return + + if exe: + subprocess.Popen([exe, folder_path]) + else: + logging.warning(f"No file browser available to open {folder_path}") def update_settings(): @@ -104,17 +107,19 @@ components.extend([ Component("Open host.yaml", func=open_host_yaml), Component("Open Patch", func=open_patch), Component("Generate Template Options", func=generate_yamls), + Component("Archipelago Website", func=lambda: webbrowser.open("https://archipelago.gg/")), Component("Discord Server", icon="discord", func=lambda: webbrowser.open("https://discord.gg/8Z65BR2")), - Component("Unrated/18+ Discord Server", icon="discord", func=lambda: webbrowser.open("https://discord.gg/fqvNCCRsu4")), + Component("Unrated/18+ Discord Server", icon="discord", + func=lambda: webbrowser.open("https://discord.gg/fqvNCCRsu4")), Component("Browse Files", func=browse_files), ]) -def handle_uri(path: str, launch_args: Tuple[str, ...]) -> None: +def handle_uri(path: str, launch_args: tuple[str, ...]) -> None: url = urllib.parse.urlparse(path) queries = urllib.parse.parse_qs(url.query) launch_args = (path, *launch_args) - client_component = None + client_component = [] text_client_component = None if "game" in queries: game = queries["game"][0] @@ -122,69 +127,43 @@ def handle_uri(path: str, launch_args: Tuple[str, ...]) -> None: game = "Archipelago" for component in components: if component.supports_uri and component.game_name == game: - client_component = component + client_component.append(component) elif component.display_name == "Text Client": text_client_component = component - from kvui import App, Button, BoxLayout, Label, Clock, Window + from kvui import MDButton, MDButtonText + from kivymd.uix.dialog import MDDialog, MDDialogHeadlineText, MDDialogContentContainer, MDDialogSupportingText + from kivymd.uix.divider import MDDivider - class Popup(App): - timer_label: Label - remaining_time: Optional[int] + if not client_component: + run_component(text_client_component, *launch_args) + return + else: + popup_text = MDDialogSupportingText(text="Select client to open and connect with.") + component_buttons = [MDDivider()] + for component in [text_client_component, *client_component]: + component_buttons.append(MDButton( + MDButtonText(text=component.display_name), + on_release=lambda *args, comp=component: run_component(comp, *launch_args), + style="text" + )) + component_buttons.append(MDDivider()) - def __init__(self): - self.title = "Connect to Multiworld" - self.icon = r"data/icon.png" - super().__init__() + MDDialog( + # Headline + MDDialogHeadlineText(text="Connect to Multiworld"), + # Text + popup_text, + # Content + MDDialogContentContainer( + *component_buttons, + orientation="vertical" + ), - def build(self): - layout = BoxLayout(orientation="vertical") - - if client_component is None: - self.remaining_time = 7 - label_text = (f"A game client able to parse URIs was not detected for {game}.\n" - f"Launching Text Client in 7 seconds...") - self.timer_label = Label(text=label_text) - layout.add_widget(self.timer_label) - Clock.schedule_interval(self.update_label, 1) - else: - layout.add_widget(Label(text="Select client to open and connect with.")) - button_row = BoxLayout(orientation="horizontal", size_hint=(1, 0.4)) - - text_client_button = Button( - text=text_client_component.display_name, - on_release=lambda *args: run_component(text_client_component, *launch_args) - ) - button_row.add_widget(text_client_button) - - game_client_button = Button( - text=client_component.display_name, - on_release=lambda *args: run_component(client_component, *launch_args) - ) - button_row.add_widget(game_client_button) - - layout.add_widget(button_row) - - return layout - - def update_label(self, dt): - if self.remaining_time > 1: - # countdown the timer and string replace the number - self.remaining_time -= 1 - self.timer_label.text = self.timer_label.text.replace( - str(self.remaining_time + 1), str(self.remaining_time) - ) - else: - # our timer is finished so launch text client and close down - run_component(text_client_component, *launch_args) - Clock.unschedule(self.update_label) - App.get_running_app().stop() - Window.close() - - Popup().run() + ).open() -def identify(path: Union[None, str]) -> Tuple[Union[None, str], Union[None, Component]]: +def identify(path: None | str) -> tuple[None | str, None | Component]: if path is None: return None, None for component in components: @@ -195,7 +174,7 @@ def identify(path: Union[None, str]) -> Tuple[Union[None, str], Union[None, Comp return None, None -def get_exe(component: Union[str, Component]) -> Optional[Sequence[str]]: +def get_exe(component: str | Component) -> Sequence[str] | None: if isinstance(component, str): name = component component = None @@ -237,101 +216,189 @@ def launch(exe, in_terminal=False): subprocess.Popen(exe) -refresh_components: Optional[Callable[[], None]] = None +def create_shortcut(button: Any, component: Component) -> None: + from pyshortcuts import make_shortcut + script = sys.argv[0] + wkdir = Utils.local_path() + + script = f"{script} \"{component.display_name}\"" + make_shortcut(script, name=f"Archipelago {component.display_name}", icon=local_path("data", "icon.ico"), + startmenu=False, terminal=False, working_dir=wkdir) + button.menu.dismiss() -def run_gui(): - from kvui import App, ContainerLayout, GridLayout, Button, Label, ScrollBox, Widget +refresh_components: Callable[[], None] | None = None + + +def run_gui(path: str, args: Any) -> None: + from kvui import (ThemedApp, MDFloatLayout, MDGridLayout, ScrollBox) + from kivy.properties import ObjectProperty from kivy.core.window import Window - from kivy.uix.image import AsyncImage - from kivy.uix.relativelayout import RelativeLayout + from kivy.metrics import dp + from kivymd.uix.button import MDIconButton, MDButton + from kivymd.uix.card import MDCard + from kivymd.uix.menu import MDDropdownMenu + from kivymd.uix.snackbar import MDSnackbar, MDSnackbarText + from kivymd.uix.textfield import MDTextField - class Launcher(App): + from kivy.lang.builder import Builder + + class LauncherCard(MDCard): + component: Component | None + image: str + context_button: MDIconButton = ObjectProperty(None) + + def __init__(self, *args, component: Component | None = None, image_path: str = "", **kwargs): + self.component = component + self.image = image_path + super().__init__(args, kwargs) + + class Launcher(ThemedApp): base_title: str = "Archipelago Launcher" - container: ContainerLayout - grid: GridLayout - _tool_layout: Optional[ScrollBox] = None - _client_layout: Optional[ScrollBox] = None + top_screen: MDFloatLayout = ObjectProperty(None) + navigation: MDGridLayout = ObjectProperty(None) + grid: MDGridLayout = ObjectProperty(None) + button_layout: ScrollBox = ObjectProperty(None) + search_box: MDTextField = ObjectProperty(None) + cards: list[LauncherCard] + current_filter: Sequence[str | Type] | None - def __init__(self, ctx=None): - self.title = self.base_title + def __init__(self, ctx=None, path=None, args=None): + self.title = self.base_title + " " + Utils.__version__ self.ctx = ctx self.icon = r"data/icon.png" + self.favorites = [] + self.launch_uri = path + self.launch_args = args + self.cards = [] + self.current_filter = (Type.CLIENT, Type.TOOL, Type.ADJUSTER, Type.MISC) + persistent = Utils.persistent_load() + if "launcher" in persistent: + if "favorites" in persistent["launcher"]: + self.favorites.extend(persistent["launcher"]["favorites"]) + if "filter" in persistent["launcher"]: + if persistent["launcher"]["filter"]: + filters = [] + for filter in persistent["launcher"]["filter"].split(", "): + if filter == "favorites": + filters.append(filter) + else: + filters.append(Type[filter]) + self.current_filter = filters super().__init__() - def _refresh_components(self) -> None: + def set_favorite(self, caller): + if caller.component.display_name in self.favorites: + self.favorites.remove(caller.component.display_name) + caller.icon = "star-outline" + else: + self.favorites.append(caller.component.display_name) + caller.icon = "star" - def build_button(component: Component) -> Widget: + def build_card(self, component: Component) -> LauncherCard: + """ + Builds a card widget for a given component. + + :param component: The component associated with the button. + + :return: The created Card Widget. """ - Builds a button widget for a given component. + button_card = LauncherCard(component=component, + image_path=icon_paths[component.icon]) - Args: - component (Component): The component associated with the button. + def open_menu(caller): + caller.menu.open() - Returns: - None. The button is added to the parent grid layout. + menu_items = [ + { + "text": "Add shortcut on desktop", + "leading_icon": "laptop", + "on_release": lambda: create_shortcut(button_card.context_button, component) + } + ] + button_card.context_button.menu = MDDropdownMenu(caller=button_card.context_button, items=menu_items) + button_card.context_button.bind(on_release=open_menu) - """ - button = Button(text=component.display_name, size_hint_y=None, height=40) - button.component = component - button.bind(on_release=self.component_action) - if component.icon != "icon": - image = AsyncImage(source=icon_paths[component.icon], - size=(38, 38), size_hint=(None, 1), pos=(5, 0)) - box_layout = RelativeLayout(size_hint_y=None, height=40) - box_layout.add_widget(button) - box_layout.add_widget(image) - return box_layout - return button + return button_card + + def _refresh_components(self, type_filter: Sequence[str | Type] | None = None) -> None: + if not type_filter: + type_filter = [Type.CLIENT, Type.ADJUSTER, Type.TOOL, Type.MISC] + favorites = "favorites" in type_filter # clear before repopulating - assert self._tool_layout and self._client_layout, "must call `build` first" - tool_children = reversed(self._tool_layout.layout.children) + assert self.button_layout, "must call `build` first" + tool_children = reversed(self.button_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) + self.button_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} + cards = [card for card in self.cards if card.component.type in type_filter + or favorites and card.component.display_name in self.favorites] - for (tool, client) in itertools.zip_longest(itertools.chain( - _tools.items(), _miscs.items(), _adjusters.items() - ), _clients.items()): - # column 1 - if tool: - self._tool_layout.layout.add_widget(build_button(tool[1])) - # column 2 - if client: - self._client_layout.layout.add_widget(build_button(client[1])) + self.current_filter = type_filter + + for card in cards: + self.button_layout.layout.add_widget(card) + + top = self.button_layout.children[0].y + self.button_layout.children[0].height \ + - self.button_layout.height + scroll_percent = self.button_layout.convert_distance_to_scroll(0, top) + self.button_layout.scroll_y = max(0, min(1, scroll_percent[1])) + + def filter_clients_by_type(self, caller: MDButton): + self._refresh_components(caller.type) + self.search_box.text = "" + + def filter_clients_by_name(self, caller: MDTextField, name: str) -> None: + if len(name) == 0: + self._refresh_components(self.current_filter) + return + + sub_matches = [ + card for card in self.cards + if name.lower() in card.component.display_name.lower() and card.component.type != Type.HIDDEN + ] + self.button_layout.layout.clear_widgets() + for card in sub_matches: + self.button_layout.layout.add_widget(card) 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() + self.top_screen = Builder.load_file(Utils.local_path("data/launcher.kv")) + self.grid = self.top_screen.ids.grid + self.navigation = self.top_screen.ids.navigation + self.button_layout = self.top_screen.ids.button_layout + self.search_box = self.top_screen.ids.search_box + self.set_colors() + self.top_screen.md_bg_color = self.theme_cls.backgroundColor global refresh_components refresh_components = self._refresh_components Window.bind(on_drop_file=self._on_drop_file) + Window.bind(on_keyboard=self._on_keyboard) - return self.container + for component in components: + self.cards.append(self.build_card(component)) + + self._refresh_components(self.current_filter) + + # Uncomment to re-enable the Kivy console/live editor + # Ctrl-E to enable it, make sure numlock/capslock is disabled + # from kivy.modules.console import create_console + # create_console(Window, self.top_screen) + + return self.top_screen + + def on_start(self): + if self.launch_uri: + handle_uri(self.launch_uri, self.launch_args) + self.launch_uri = None + self.launch_args = None @staticmethod def component_action(button): + MDSnackbar(MDSnackbarText(text="Opening in a new window..."), y=dp(24), pos_hint={"center_x": 0.5}, + size_hint_x=0.5).open() if button.component.func: button.component.func() else: @@ -345,13 +412,28 @@ def run_gui(): else: logging.warning(f"unable to identify component for {file}") + def _on_keyboard(self, window: Window, key: int, scancode: int, codepoint: str, modifier: list[str]): + # Activate search as soon as we start typing, no matter if we are focused on the search box or not. + # Focus first, then capture the first character we type, otherwise it gets swallowed and lost. + # Limit text input to ASCII non-control characters (space bar to tilde). + if not self.search_box.focus: + self.search_box.focus = True + if key in range(32, 126): + self.search_box.text += codepoint + def _stop(self, *largs): # ran into what appears to be https://groups.google.com/g/kivy-users/c/saWDLoYCSZ4 with PyCharm. # Closing the window explicitly cleans it up. self.root_window.close() super()._stop(*largs) - Launcher().run() + def on_stop(self): + Utils.persistent_store("launcher", "favorites", self.favorites) + Utils.persistent_store("launcher", "filter", ", ".join(filter.name if isinstance(filter, Type) else filter + for filter in self.current_filter)) + super().on_stop() + + Launcher(path=path, args=args).run() # avoiding Launcher reference leak # and don't try to do something with widgets after window closed @@ -370,7 +452,7 @@ def run_component(component: Component, *args): logging.warning(f"Component {component} does not appear to be executable.") -def main(args: Optional[Union[argparse.Namespace, dict]] = None): +def main(args: argparse.Namespace | dict | None = None): if isinstance(args, argparse.Namespace): args = {k: v for k, v in args._get_kwargs()} elif not args: @@ -378,16 +460,14 @@ def main(args: Optional[Union[argparse.Namespace, dict]] = None): path = args.get("Patch|Game|Component|url", None) if path is not None: - if path.startswith("archipelago://"): - handle_uri(path, args.get("args", ())) - return - file, component = identify(path) - if file: - args['file'] = file - if component: - args['component'] = component - if not component: - logging.warning(f"Could not identify Component responsible for {path}") + if not path.startswith("archipelago://"): + file, component = identify(path) + if file: + args['file'] = file + if component: + args['component'] = component + if not component: + logging.warning(f"Could not identify Component responsible for {path}") if args["update_settings"]: update_settings() @@ -396,7 +476,7 @@ def main(args: Optional[Union[argparse.Namespace, dict]] = None): elif "component" in args: run_component(args["component"], *args["args"]) elif not args["update_settings"]: - run_gui() + run_gui(path, args.get("args", ())) if __name__ == '__main__': @@ -418,6 +498,7 @@ if __name__ == '__main__': main(parser.parse_args()) from worlds.LauncherComponents import processes + for process in processes: # we await all child processes to close before we tear down the process host # this makes it feel like each one is its own program, as the Launcher is closed now diff --git a/LinksAwakeningClient.py b/LinksAwakeningClient.py index 298788098d..14aaa415f1 100644 --- a/LinksAwakeningClient.py +++ b/LinksAwakeningClient.py @@ -26,12 +26,14 @@ import typing from CommonClient import (CommonContext, get_base_parser, gui_enabled, logger, server_loop) from NetUtils import ClientStatus +from worlds.ladx import LinksAwakeningWorld from worlds.ladx.Common import BASE_ID as LABaseID from worlds.ladx.GpsTracker import GpsTracker +from worlds.ladx.TrackerConsts import storage_key from worlds.ladx.ItemTracker import ItemTracker from worlds.ladx.LADXR.checkMetadata import checkMetadataTable from worlds.ladx.Locations import get_locations_to_id, meta_to_name -from worlds.ladx.Tracker import LocationTracker, MagpieBridge +from worlds.ladx.Tracker import LocationTracker, MagpieBridge, Check class GameboyException(Exception): @@ -50,22 +52,6 @@ class BadRetroArchResponse(GameboyException): pass -def magpie_logo(): - from kivy.uix.image import CoreImage - binary_data = """ -iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAIAAACQkWg2AAAAAXN -SR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA -7DAcdvqGQAAADGSURBVDhPhVLBEcIwDHOYhjHCBuXHj2OTbAL8+ -MEGZIxOQ1CinOOk0Op0bmo7tlXXeR9FJMYDLOD9mwcLjQK7+hSZ -wgcWMZJOAGeGKtChNHFL0j+FZD3jSCuo0w7l03wDrWdg00C4/aW -eDEYNenuzPOfPspBnxf0kssE80vN0L8361j10P03DK4x6FHabuV -ear8fHme+b17rwSjbAXeUMLb+EVTV2QHm46MWQanmnydA98KsVS -XkV+qFpGQXrLhT/fqraQeQLuplpNH5g+WkAAAAASUVORK5CYII=""" - binary_data = base64.b64decode(binary_data) - data = io.BytesIO(binary_data) - return CoreImage(data, ext="png").texture - - class LAClientConstants: # Connector version VERSION = 0x01 @@ -100,19 +86,23 @@ class LAClientConstants: WRamCheckSize = 0x4 WRamSafetyValue = bytearray([0]*WRamCheckSize) + wRamStart = 0xC000 + hRamStart = 0xFF80 + hRamSize = 0x80 + MinGameplayValue = 0x06 MaxGameplayValue = 0x1A VictoryGameplayAndSub = 0x0102 - class RAGameboy(): cache = [] - cache_start = 0 - cache_size = 0 last_cache_read = None socket = None def __init__(self, address, port) -> None: + self.cache_start = LAClientConstants.wRamStart + self.cache_size = LAClientConstants.hRamStart + LAClientConstants.hRamSize - LAClientConstants.wRamStart + self.address = address self.port = port self.socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) @@ -131,9 +121,14 @@ class RAGameboy(): async def get_retroarch_status(self): return await self.send_command("GET_STATUS") - def set_cache_limits(self, cache_start, cache_size): - self.cache_start = cache_start - self.cache_size = cache_size + def set_checks_range(self, checks_start, checks_size): + self.checks_start = checks_start + self.checks_size = checks_size + + def set_location_range(self, location_start, location_size, critical_addresses): + self.location_start = location_start + self.location_size = location_size + self.critical_location_addresses = critical_addresses def send(self, b): if type(b) is str: @@ -188,21 +183,57 @@ class RAGameboy(): if not await self.check_safe_gameplay(): return - cache = [] - remaining_size = self.cache_size - while remaining_size: - block = await self.async_read_memory(self.cache_start + len(cache), remaining_size) - remaining_size -= len(block) - cache += block + attempts = 0 + while True: + # RA doesn't let us do an atomic read of a large enough block of RAM + # Some bytes can't change in between reading location_block and hram_block + location_block = await self.read_memory_block(self.location_start, self.location_size) + hram_block = await self.read_memory_block(LAClientConstants.hRamStart, LAClientConstants.hRamSize) + verification_block = await self.read_memory_block(self.location_start, self.location_size) + + valid = True + for address in self.critical_location_addresses: + if location_block[address - self.location_start] != verification_block[address - self.location_start]: + valid = False + + if valid: + break + + attempts += 1 + + # Shouldn't really happen, but keep it from choking + if attempts > 5: + return + + checks_block = await self.read_memory_block(self.checks_start, self.checks_size) if not await self.check_safe_gameplay(): return - self.cache = cache + self.cache = bytearray(self.cache_size) + + start = self.checks_start - self.cache_start + self.cache[start:start + len(checks_block)] = checks_block + + start = self.location_start - self.cache_start + self.cache[start:start + len(location_block)] = location_block + + start = LAClientConstants.hRamStart - self.cache_start + self.cache[start:start + len(hram_block)] = hram_block + self.last_cache_read = time.time() + async def read_memory_block(self, address: int, size: int): + block = bytearray() + remaining_size = size + while remaining_size: + chunk = await self.async_read_memory(address + len(block), remaining_size) + remaining_size -= len(chunk) + block += chunk + + return block + async def read_memory_cache(self, addresses): - # TODO: can we just update once per frame? if not self.last_cache_read or self.last_cache_read + 0.1 < time.time(): await self.update_cache() if not self.cache: @@ -235,7 +266,7 @@ class RAGameboy(): def check_command_response(self, command: str, response: bytes): if command == "VERSION": - ok = re.match("\d+\.\d+\.\d+", response.decode('ascii')) is not None + ok = re.match(r"\d+\.\d+\.\d+", response.decode('ascii')) is not None else: ok = response.startswith(command.encode()) if not ok: @@ -359,11 +390,12 @@ class LinksAwakeningClient(): auth = binascii.hexlify(await self.gameboy.async_read_memory(0x0134, 12)).decode() self.auth = auth - async def wait_and_init_tracker(self): + async def wait_and_init_tracker(self, magpie: MagpieBridge): await self.wait_for_game_ready() self.tracker = LocationTracker(self.gameboy) self.item_tracker = ItemTracker(self.gameboy) self.gps_tracker = GpsTracker(self.gameboy) + magpie.gps_tracker = self.gps_tracker async def recved_item_from_ap(self, item_id, from_player, next_index): # Don't allow getting an item until you've got your first check @@ -405,9 +437,11 @@ class LinksAwakeningClient(): return (await self.gameboy.read_memory_cache([LAClientConstants.wGameplayType]))[LAClientConstants.wGameplayType] == 1 async def main_tick(self, item_get_cb, win_cb, deathlink_cb): + await self.gameboy.update_cache() await self.tracker.readChecks(item_get_cb) await self.item_tracker.readItems() await self.gps_tracker.read_location() + await self.gps_tracker.read_entrances() current_health = (await self.gameboy.read_memory_cache([LAClientConstants.wLinkHealth]))[LAClientConstants.wLinkHealth] if self.deathlink_debounce and current_health != 0: @@ -457,7 +491,7 @@ class LinksAwakeningContext(CommonContext): la_task = None client = None # TODO: does this need to re-read on reset? - found_checks = [] + found_checks = set() last_resend = time.time() magpie_enabled = False @@ -465,6 +499,10 @@ class LinksAwakeningContext(CommonContext): magpie_task = None won = False + @property + def slot_storage_key(self): + return f"{self.slot_info[self.slot].name}_{storage_key}" + def __init__(self, server_address: typing.Optional[str], password: typing.Optional[str], magpie: typing.Optional[bool]) -> None: self.client = LinksAwakeningClient() self.slot_data = {} @@ -476,9 +514,9 @@ class LinksAwakeningContext(CommonContext): def run_gui(self) -> None: import webbrowser - import kvui - from kvui import Button, GameManager - from kivy.uix.image import Image + from kvui import GameManager + from kivy.metrics import dp + from kivymd.uix.button import MDButton, MDButtonText class LADXManager(GameManager): logging_pairs = [ @@ -491,23 +529,27 @@ class LinksAwakeningContext(CommonContext): b = super().build() if self.ctx.magpie_enabled: - button = Button(text="", size=(30, 30), size_hint_x=None, - on_press=lambda _: webbrowser.open('https://magpietracker.us/?enable_autotracker=1')) - image = Image(size=(16, 16), texture=magpie_logo()) - button.add_widget(image) - - def set_center(_, center): - image.center = center - button.bind(center=set_center) - + button = MDButton(MDButtonText(text="Open Tracker"), style="filled", size=(dp(100), dp(70)), radius=5, + size_hint_x=None, size_hint_y=None, pos_hint={"center_y": 0.55}, + on_press=lambda _: webbrowser.open('https://magpietracker.us/?enable_autotracker=1')) + button.height = self.server_connect_bar.height self.connect_layout.add_widget(button) + return b self.ui = LADXManager(self) self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI") - async def send_checks(self): - message = [{"cmd": 'LocationChecks', "locations": self.found_checks}] + async def send_new_entrances(self, entrances: typing.Dict[str, str]): + # Store the entrances we find on the server for future sessions + message = [{ + "cmd": "Set", + "key": self.slot_storage_key, + "default": {}, + "want_reply": False, + "operations": [{"operation": "update", "value": entrances}], + }] + await self.send_msgs(message) had_invalid_slot_data = None @@ -537,13 +579,19 @@ class LinksAwakeningContext(CommonContext): await self.send_msgs(message) self.won = True + async def request_found_entrances(self): + await self.send_msgs([{"cmd": "Get", "keys": [self.slot_storage_key]}]) + + # Ask for updates so that players can co-op entrances in a seed + await self.send_msgs([{"cmd": "SetNotify", "keys": [self.slot_storage_key]}]) + async def on_deathlink(self, data: typing.Dict[str, typing.Any]) -> None: if self.ENABLE_DEATHLINK: self.client.pending_deathlink = True def new_checks(self, item_ids, ladxr_ids): - self.found_checks += item_ids - create_task_log_exception(self.send_checks()) + self.found_checks.update(item_ids) + create_task_log_exception(self.check_locations(self.found_checks)) if self.magpie_enabled: create_task_log_exception(self.magpie.send_new_checks(ladxr_ids)) @@ -560,6 +608,10 @@ class LinksAwakeningContext(CommonContext): while self.client.auth == None: await asyncio.sleep(0.1) + + # Just return if we're closing + if self.exit_event.is_set(): + return self.auth = self.client.auth await self.send_connect() @@ -567,16 +619,40 @@ class LinksAwakeningContext(CommonContext): if cmd == "Connected": self.game = self.slot_info[self.slot].game self.slot_data = args.get("slot_data", {}) - + # This is sent to magpie over local websocket to make its own connection + self.slot_data.update({ + "server_address": self.server_address, + "slot_name": self.player_names[self.slot], + "password": self.password, + }) + + # We can process linked items on already-checked checks now that we have slot_data + if self.client.tracker: + checked_checks = set(self.client.tracker.all_checks) - set(self.client.tracker.remaining_checks) + self.add_linked_items(checked_checks) + # TODO - use watcher_event if cmd == "ReceivedItems": for index, item in enumerate(args["items"], start=args["index"]): self.client.recvd_checks[index] = item + if cmd == "Retrieved" and self.magpie_enabled and self.slot_storage_key in args["keys"]: + self.client.gps_tracker.receive_found_entrances(args["keys"][self.slot_storage_key]) + + if cmd == "SetReply" and self.magpie_enabled and args["key"] == self.slot_storage_key: + self.client.gps_tracker.receive_found_entrances(args["value"]) + async def sync(self): sync_msg = [{'cmd': 'Sync'}] await self.send_msgs(sync_msg) + def add_linked_items(self, checks: typing.List[Check]): + for check in checks: + if check.value and check.linkedItem: + linkedItem = check.linkedItem + if 'condition' not in linkedItem or (self.slot_data and linkedItem['condition'](self.slot_data)): + self.client.item_tracker.setExtraItem(check.linkedItem['item'], check.linkedItem['qty']) + item_id_lookup = get_locations_to_id() async def run_game_loop(self): @@ -585,6 +661,8 @@ class LinksAwakeningContext(CommonContext): checkMetadataTable[check.id])] for check in ladxr_checks] self.new_checks(checks, [check.id for check in ladxr_checks]) + self.add_linked_items(ladxr_checks) + async def victory(): await self.send_victory() @@ -618,21 +696,38 @@ class LinksAwakeningContext(CommonContext): if not self.client.recvd_checks: await self.sync() - await self.client.wait_and_init_tracker() + await self.client.wait_and_init_tracker(self.magpie) + min_tick_duration = 0.1 + last_tick = time.time() while True: await self.client.main_tick(on_item_get, victory, deathlink) - await asyncio.sleep(0.1) + now = time.time() + tick_duration = now - last_tick + sleep_duration = max(min_tick_duration - tick_duration, 0) + await asyncio.sleep(sleep_duration) + + last_tick = now + if self.last_resend + 5.0 < now: self.last_resend = now - await self.send_checks() + await self.check_locations(self.found_checks) if self.magpie_enabled: try: self.magpie.set_checks(self.client.tracker.all_checks) await self.magpie.set_item_tracker(self.client.item_tracker) - await self.magpie.send_gps(self.client.gps_tracker) - self.magpie.slot_data = self.slot_data + if self.slot_data and "slot_data" in self.magpie.features and not self.magpie.has_sent_slot_data: + self.magpie.slot_data = self.slot_data + await self.magpie.send_slot_data() + + if self.client.gps_tracker.needs_found_entrances: + await self.request_found_entrances() + self.client.gps_tracker.needs_found_entrances = False + + new_entrances = await self.magpie.send_gps(self.client.gps_tracker) + if new_entrances: + await self.send_new_entrances(new_entrances) except Exception: # Don't let magpie errors take out the client pass @@ -643,8 +738,8 @@ class LinksAwakeningContext(CommonContext): await asyncio.sleep(1.0) def run_game(romfile: str) -> None: - auto_start = typing.cast(typing.Union[bool, str], - Utils.get_options()["ladx_options"].get("rom_start", True)) + auto_start = LinksAwakeningWorld.settings.rom_start + if auto_start is True: import webbrowser webbrowser.open(romfile) @@ -701,6 +796,6 @@ async def main(): await ctx.shutdown() if __name__ == '__main__': - colorama.init() + colorama.just_fix_windows_console() asyncio.run(main()) colorama.deinit() diff --git a/LttPAdjuster.py b/LttPAdjuster.py index 7e33a3d5ef..963557e8da 100644 --- a/LttPAdjuster.py +++ b/LttPAdjuster.py @@ -33,10 +33,15 @@ WINDOW_MIN_HEIGHT = 525 WINDOW_MIN_WIDTH = 425 class AdjusterWorld(object): + class AdjusterSubWorld(object): + def __init__(self, random): + self.random = random + def __init__(self, sprite_pool): import random self.sprite_pool = {1: sprite_pool} self.per_slot_randoms = {1: random} + self.worlds = {1: self.AdjusterSubWorld(random)} class ArgumentDefaultsHelpFormatter(argparse.RawTextHelpFormatter): diff --git a/MMBN3Client.py b/MMBN3Client.py index 140a98745c..4945d49221 100644 --- a/MMBN3Client.py +++ b/MMBN3Client.py @@ -370,7 +370,7 @@ if __name__ == "__main__": import colorama - colorama.init() + colorama.just_fix_windows_console() asyncio.run(main()) colorama.deinit() diff --git a/Main.py b/Main.py index 4008ca5e90..442c2ff404 100644 --- a/Main.py +++ b/Main.py @@ -7,14 +7,13 @@ import tempfile import time import zipfile import zlib -from typing import Dict, List, Optional, Set, Tuple, Union import worlds -from BaseClasses import CollectionState, Item, Location, LocationProgressType, MultiWorld, Region -from Fill import FillError, balance_multiworld_progression, distribute_items_restrictive, distribute_planned, \ - flood_items +from BaseClasses import CollectionState, Item, Location, LocationProgressType, MultiWorld +from Fill import FillError, balance_multiworld_progression, distribute_items_restrictive, flood_items, \ + parse_planned_blocks, distribute_planned_blocks, resolve_early_locations_for_planned from Options import StartInventoryPool -from Utils import __version__, output_path, version_tuple, get_settings +from Utils import __version__, output_path, version_tuple from settings import get_settings from worlds import AutoWorld from worlds.generic.Rules import exclusion_rules, locality_rules @@ -22,7 +21,7 @@ from worlds.generic.Rules import exclusion_rules, locality_rules __all__ = ["main"] -def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = None): +def main(args, seed=None, baked_server_options: dict[str, object] | None = None): if not baked_server_options: baked_server_options = get_settings().server_options.as_dict() assert isinstance(baked_server_options, dict) @@ -37,9 +36,6 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No logger = logging.getLogger() multiworld.set_seed(seed, args.race, str(args.outputname) if args.outputname else None) multiworld.plando_options = args.plando_options - multiworld.plando_items = args.plando_items.copy() - multiworld.plando_texts = args.plando_texts.copy() - multiworld.plando_connections = args.plando_connections.copy() multiworld.game = args.game.copy() multiworld.player_name = args.name.copy() multiworld.sprite = args.sprite.copy() @@ -56,32 +52,18 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No logger.info(f"Found {len(AutoWorld.AutoWorldRegister.world_types)} World Types:") longest_name = max(len(text) for text in AutoWorld.AutoWorldRegister.world_types) - max_item = 0 - max_location = 0 - for cls in AutoWorld.AutoWorldRegister.world_types.values(): - if cls.item_id_to_name: - max_item = max(max_item, max(cls.item_id_to_name)) - max_location = max(max_location, max(cls.location_id_to_name)) - - item_digits = len(str(max_item)) - location_digits = len(str(max_location)) item_count = len(str(max(len(cls.item_names) for cls in AutoWorld.AutoWorldRegister.world_types.values()))) location_count = len(str(max(len(cls.location_names) for cls in AutoWorld.AutoWorldRegister.world_types.values()))) - del max_item, max_location for name, cls in AutoWorld.AutoWorldRegister.world_types.items(): if not cls.hidden and len(cls.item_names) > 0: - logger.info(f" {name:{longest_name}}: {len(cls.item_names):{item_count}} " - f"Items (IDs: {min(cls.item_id_to_name):{item_digits}} - " - f"{max(cls.item_id_to_name):{item_digits}}) | " - f"{len(cls.location_names):{location_count}} " - f"Locations (IDs: {min(cls.location_id_to_name):{location_digits}} - " - f"{max(cls.location_id_to_name):{location_digits}})") + logger.info(f" {name:{longest_name}}: Items: {len(cls.item_names):{item_count}} | " + f"Locations: {len(cls.location_names):{location_count}}") - del item_digits, location_digits, item_count, location_count + del item_count, location_count # This assertion method should not be necessary to run if we are not outputting any multidata. - if not args.skip_output: + if not args.skip_output and not args.spoiler_only: AutoWorld.call_stage(multiworld, "assert_generate") AutoWorld.call_all(multiworld, "generate_early") @@ -148,50 +130,46 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No else: multiworld.worlds[1].options.non_local_items.value = set() multiworld.worlds[1].options.local_items.value = set() - + + multiworld.plando_item_blocks = parse_planned_blocks(multiworld) + + AutoWorld.call_all(multiworld, "connect_entrances") 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(getattr(multiworld.worlds[player].options, "start_inventory_from_pool", None) for player in multiworld.player_ids): - new_items: List[Item] = [] - old_items: List[Item] = [] - depletion_pool: Dict[int, Dict[str, int]] = { - player: getattr(multiworld.worlds[player].options, - "start_inventory_from_pool", - StartInventoryPool({})).value.copy() - for player in multiworld.player_ids - } - for player, items in depletion_pool.items(): - player_world: AutoWorld.World = multiworld.worlds[player] - for count in items.values(): - for _ in range(count): - new_items.append(player_world.create_filler()) - target: int = sum(sum(items.values()) for items in depletion_pool.values()) - for i, item in enumerate(multiworld.itempool): - if depletion_pool[item.player].get(item.name, 0): - target -= 1 - depletion_pool[item.player][item.name] -= 1 - # quick abort if we have found all items - if not target: - old_items.extend(multiworld.itempool[i+1:]) - break - else: - old_items.append(item) + fallback_inventory = StartInventoryPool({}) + depletion_pool: dict[int, dict[str, int]] = { + player: getattr(multiworld.worlds[player].options, "start_inventory_from_pool", fallback_inventory).value.copy() + for player in multiworld.player_ids + } + target_per_player = { + player: sum(target_items.values()) for player, target_items in depletion_pool.items() if target_items + } - # leftovers? - if target: - for player, remaining_items in depletion_pool.items(): - remaining_items = {name: count for name, count in remaining_items.items() if count} - if remaining_items: - logger.warning(f"{multiworld.get_player_name(player)}" - f" is trying to remove items from their pool that don't exist: {remaining_items}") - # find all filler we generated for the current player and remove until it matches - removables = [item for item in new_items if item.player == player] - for _ in range(sum(remaining_items.values())): - new_items.remove(removables.pop()) - assert len(multiworld.itempool) == len(new_items + old_items), "Item Pool amounts should not change." - multiworld.itempool[:] = new_items + old_items + if target_per_player: + new_itempool: list[Item] = [] + + # Make new itempool with start_inventory_from_pool items removed + for item in multiworld.itempool: + if depletion_pool[item.player].get(item.name, 0): + depletion_pool[item.player][item.name] -= 1 + else: + new_itempool.append(item) + + # Create filler in place of the removed items, warn if any items couldn't be found in the multiworld itempool + for player, target in target_per_player.items(): + unfound_items = {item: count for item, count in depletion_pool[player].items() if count} + + if unfound_items: + player_name = multiworld.get_player_name(player) + logger.warning(f"{player_name} tried to remove items from their pool that don't exist: {unfound_items}") + + needed_items = target_per_player[player] - sum(unfound_items.values()) + new_itempool += [multiworld.worlds[player].create_filler() for _ in range(needed_items)] + + assert len(multiworld.itempool) == len(new_itempool), "Item Pool amounts should not change." + multiworld.itempool[:] = new_itempool multiworld.link_items() @@ -199,8 +177,9 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No multiworld._all_state = None logger.info("Running Item Plando.") - - distribute_planned(multiworld) + resolve_early_locations_for_planned(multiworld) + distribute_planned_blocks(multiworld, [x for player in multiworld.plando_item_blocks + for x in multiworld.plando_item_blocks[player]]) logger.info('Running Pre Main Fill.') @@ -230,6 +209,15 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No logger.info(f'Beginning output...') outfilebase = 'AP_' + multiworld.seed_name + if args.spoiler_only: + if args.spoiler > 1: + logger.info('Calculating playthrough.') + multiworld.spoiler.create_playthrough(create_paths=args.spoiler > 2) + + multiworld.spoiler.to_file(output_path('%s_Spoiler.txt' % outfilebase)) + logger.info('Done. Skipped multidata modification. Total time: %s', time.perf_counter() - start) + return multiworld + output = tempfile.TemporaryDirectory() with output as temp_dir: output_players = [player for player in multiworld.player_ids if AutoWorld.World.generate_output.__code__ @@ -244,11 +232,12 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No pool.submit(AutoWorld.call_single, multiworld, "generate_output", player, temp_dir)) # collect ER hint info - er_hint_data: Dict[int, Dict[int, str]] = {} + er_hint_data: dict[int, dict[int, str]] = {} AutoWorld.call_all(multiworld, 'extend_hint_information', er_hint_data) def write_multidata(): import NetUtils + from NetUtils import HintStatus slot_data = {} client_versions = {} games = {} @@ -273,10 +262,10 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No for slot in multiworld.player_ids: slot_data[slot] = multiworld.worlds[slot].fill_slot_data() - def precollect_hint(location): + def precollect_hint(location: Location, auto_status: HintStatus): 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) + location.item.code, False, entrance, location.item.flags, auto_status) precollected_hints[location.player].add(hint) if location.item.player not in multiworld.groups: precollected_hints[location.item.player].add(hint) @@ -284,40 +273,43 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No for player in multiworld.groups[location.item.player]["players"]: precollected_hints[player].add(hint) - locations_data: Dict[int, Dict[int, Tuple[int, int, int]]] = {player: {} for player in multiworld.player_ids} + locations_data: dict[int, dict[int, tuple[int, int, int]]] = {player: {} for player in multiworld.player_ids} for location in multiworld.get_filled_locations(): if type(location.address) == int: assert location.item.code is not None, "item code None should be event, " \ "location.address should then also be None. Location: " \ - f" {location}" + f" {location}, Item: {location.item}" assert location.address not in locations_data[location.player], ( f"Locations with duplicate address. {location} and " f"{locations_data[location.player][location.address]}") locations_data[location.player][location.address] = \ location.item.code, location.item.player, location.item.flags + auto_status = HintStatus.HINT_AVOID if location.item.trap else HintStatus.HINT_PRIORITY if location.name in multiworld.worlds[location.player].options.start_location_hints: - precollect_hint(location) + if not location.item.trap: # Unspecified status for location hints, except traps + auto_status = HintStatus.HINT_UNSPECIFIED + precollect_hint(location, auto_status) elif location.item.name in multiworld.worlds[location.item.player].options.start_hints: - precollect_hint(location) + precollect_hint(location, auto_status) 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) + precollect_hint(location, auto_status) # embedded data package data_package = { game_world.game: worlds.network_data_package["games"][game_world.game] for game_world in multiworld.worlds.values() } + data_package["Archipelago"] = worlds.network_data_package["games"]["Archipelago"] - checks_in_area: Dict[int, Dict[str, Union[int, List[int]]]] = {} + checks_in_area: dict[int, dict[str, 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) + spheres: list[dict[int, set[int]]] = [] + for sphere in multiworld.get_sendable_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) + current_sphere[sphere_location.player].add(sphere_location.address) if current_sphere: spheres.append(dict(current_sphere)) diff --git a/ModuleUpdate.py b/ModuleUpdate.py index f49182bb78..04cf25ea55 100644 --- a/ModuleUpdate.py +++ b/ModuleUpdate.py @@ -5,8 +5,15 @@ import multiprocessing import warnings -if sys.version_info < (3, 8, 6): - raise RuntimeError("Incompatible Python Version. 3.8.7+ is supported.") +if sys.platform in ("win32", "darwin") and sys.version_info < (3, 10, 11): + # Official micro version updates. This should match the number in docs/running from source.md. + raise RuntimeError(f"Incompatible Python Version found: {sys.version_info}. Official 3.10.15+ is supported.") +elif sys.platform in ("win32", "darwin") and sys.version_info < (3, 10, 15): + # There are known security issues, but no easy way to install fixed versions on Windows for testing. + warnings.warn(f"Python Version {sys.version_info} has security issues. Don't use in production.") +elif sys.version_info < (3, 10, 1): + # Other platforms may get security backports instead of micro updates, so the number is unreliable. + raise RuntimeError(f"Incompatible Python Version found: {sys.version_info}. 3.10.1+ is supported.") # don't run update if environment is frozen/compiled or if not the parent process (skip in subprocess) _skip_update = bool(getattr(sys, "frozen", False) or multiprocessing.parent_process()) diff --git a/MultiServer.py b/MultiServer.py index f019b9de8e..a66ad77fc9 100644 --- a/MultiServer.py +++ b/MultiServer.py @@ -28,9 +28,11 @@ ModuleUpdate.update() if typing.TYPE_CHECKING: import ssl + from NetUtils import ServerConnection -import websockets import colorama +import websockets +from websockets.extensions.permessage_deflate import PerMessageDeflate try: # ponyorm is a requirement for webhost, not default server, so may not be importable from pony.orm.dbapiprovider import OperationalError @@ -41,10 +43,12 @@ import NetUtils import Utils from Utils import version_tuple, restricted_loads, Version, async_start, get_intended_text from NetUtils import Endpoint, ClientStatus, NetworkItem, decode, encode, NetworkPlayer, Permission, NetworkSlot, \ - SlotType, LocationStore + SlotType, LocationStore, Hint, HintStatus +from BaseClasses import ItemClassification -min_client_version = Version(0, 1, 6) -colorama.init() + +min_client_version = Version(0, 5, 0) +colorama.just_fix_windows_console() def remove_from_list(container, value): @@ -63,9 +67,13 @@ def pop_from_container(container, value): return container -def update_dict(dictionary, entries): - dictionary.update(entries) - return dictionary +def update_container_unique(container, entries): + if isinstance(container, list): + existing_container_as_set = set(container) + container.extend([entry for entry in entries if entry not in existing_container_as_set]) + else: + container.update(entries) + return container def queue_gc(): @@ -106,7 +114,7 @@ modify_functions = { # lists/dicts: "remove": remove_from_list, "pop": pop_from_container, - "update": update_dict, + "update": update_container_unique, } @@ -118,13 +126,14 @@ def get_saving_second(seed_name: str, interval: int = 60) -> int: class Client(Endpoint): version = Version(0, 0, 0) - tags: typing.List[str] = [] + tags: typing.List[str] remote_items: bool remote_start_inventory: bool no_items: bool no_locations: bool + no_text: bool - def __init__(self, socket: websockets.WebSocketServerProtocol, ctx: Context): + def __init__(self, socket: "ServerConnection", ctx: Context) -> None: super().__init__(socket) self.auth = False self.team = None @@ -174,6 +183,7 @@ class Context: "compatibility": int} # team -> slot id -> list of clients authenticated to slot. clients: typing.Dict[int, typing.Dict[int, typing.List[Client]]] + endpoints: list[Client] locations: LocationStore # typing.Dict[int, typing.Dict[int, typing.Tuple[int, int, int]]] location_checks: typing.Dict[typing.Tuple[int, int], typing.Set[int]] hints_used: typing.Dict[typing.Tuple[int, int], int] @@ -228,7 +238,7 @@ class Context: self.hint_cost = hint_cost self.location_check_points = location_check_points self.hints_used = collections.defaultdict(int) - self.hints: typing.Dict[team_slot, typing.Set[NetUtils.Hint]] = collections.defaultdict(set) + self.hints: typing.Dict[team_slot, typing.Set[Hint]] = collections.defaultdict(set) self.release_mode: str = release_mode self.remaining_mode: str = remaining_mode self.collect_mode: str = collect_mode @@ -363,18 +373,28 @@ class Context: return True def broadcast_all(self, msgs: typing.List[dict]): - msgs = self.dumper(msgs) - endpoints = (endpoint for endpoint in self.endpoints if endpoint.auth) - async_start(self.broadcast_send_encoded_msgs(endpoints, msgs)) + msg_is_text = all(msg["cmd"] == "PrintJSON" for msg in msgs) + data = self.dumper(msgs) + endpoints = ( + endpoint + for endpoint in self.endpoints + if endpoint.auth and not (msg_is_text and endpoint.no_text) + ) + async_start(self.broadcast_send_encoded_msgs(endpoints, data)) def broadcast_text_all(self, text: str, additional_arguments: dict = {}): 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]): - msgs = self.dumper(msgs) - endpoints = (endpoint for endpoint in itertools.chain.from_iterable(self.clients[team].values())) - async_start(self.broadcast_send_encoded_msgs(endpoints, msgs)) + msg_is_text = all(msg["cmd"] == "PrintJSON" for msg in msgs) + data = self.dumper(msgs) + endpoints = ( + endpoint + for endpoint in itertools.chain.from_iterable(self.clients[team].values()) + if not (msg_is_text and endpoint.no_text) + ) + async_start(self.broadcast_send_encoded_msgs(endpoints, data)) def broadcast(self, endpoints: typing.Iterable[Client], msgs: typing.List[dict]): msgs = self.dumper(msgs) @@ -388,13 +408,13 @@ class Context: await on_client_disconnected(self, endpoint) def notify_client(self, client: Client, text: str, additional_arguments: dict = {}): - if not client.auth: + if not client.auth or client.no_text: return 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 = {}): - if not client.auth: + if not client.auth or client.no_text: return async_start(self.send_msgs(client, [{"cmd": "PrintJSON", "data": [{ "text": text }], **additional_arguments} @@ -443,7 +463,7 @@ class Context: self.slot_info = decoded_obj["slot_info"] self.games = {slot: slot_info.game for slot, slot_info in self.slot_info.items()} - self.groups = {slot: slot_info.group_members for slot, slot_info in self.slot_info.items() + self.groups = {slot: set(slot_info.group_members) for slot, slot_info in self.slot_info.items() if slot_info.type == SlotType.group} self.clients = {0: {}} @@ -656,13 +676,29 @@ class Context: return max(1, int(self.hint_cost * 0.01 * len(self.locations[slot]))) return 0 - def recheck_hints(self, team: typing.Optional[int] = None, slot: typing.Optional[int] = None): + def recheck_hints(self, team: typing.Optional[int] = None, slot: typing.Optional[int] = None, + changed: typing.Optional[typing.Set[team_slot]] = None) -> None: + """Refreshes the hints for the specified team/slot. Providing 'None' for either team or slot + will refresh all teams or all slots respectively. If a set is passed for 'changed', each (team,slot) + pair that has at least one hint modified will be added to the set. + """ for hint_team, hint_slot in self.hints: - if (team is None or team == hint_team) and (slot is None or slot == hint_slot): - self.hints[hint_team, hint_slot] = { - hint.re_check(self, hint_team) for hint in - self.hints[hint_team, hint_slot] - } + if team != hint_team and team is not None: + continue # Check specified team only, all if team is None + if slot != hint_slot and slot is not None: + continue # Check specified slot only, all if slot is None + new_hints: typing.Set[Hint] = set() + for hint in self.hints[hint_team, hint_slot]: + new_hint = hint.re_check(self, hint_team) + new_hints.add(new_hint) + if hint == new_hint: + continue + for player in self.slot_set(hint.receiving_player) | {hint.finding_player}: + if changed is not None: + changed.add((hint_team,player)) + if slot is not None and slot != player: + self.replace_hint(hint_team, player, hint, new_hint) + self.hints[hint_team, hint_slot] = new_hints def get_rechecked_hints(self, team: int, slot: int): self.recheck_hints(team, slot) @@ -711,7 +747,7 @@ class Context: else: return self.player_names[team, slot] - def notify_hints(self, team: int, hints: typing.List[NetUtils.Hint], only_new: bool = False, + def notify_hints(self, team: int, hints: typing.List[Hint], only_new: bool = False, recipients: typing.Sequence[int] = None): """Send and remember hints.""" if only_new: @@ -726,7 +762,8 @@ class Context: concerns[player].append(data) if not hint.local and data not in concerns[hint.finding_player]: concerns[hint.finding_player].append(data) - # remember hints in all cases + + # only remember hints that were not already found at the time of creation if not hint.found: # since hints are bidirectional, finding player and receiving player, # we can check once if hint already exists @@ -742,13 +779,24 @@ class Context: self.on_new_hint(team, slot) for slot, hint_data in concerns.items(): if recipients is None or slot in recipients: - clients = self.clients[team].get(slot) + clients = filter(lambda c: not c.no_text, 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)) + def get_hint(self, team: int, finding_player: int, seeked_location: int) -> typing.Optional[Hint]: + for hint in self.hints[team, finding_player]: + if hint.location == seeked_location and hint.finding_player == finding_player: + return hint + return None + + def replace_hint(self, team: int, slot: int, old_hint: Hint, new_hint: Hint) -> None: + if old_hint in self.hints[team, slot]: + self.hints[team, slot].remove(old_hint) + self.hints[team, slot].add(new_hint) + # "events" def on_goal_achieved(self, client: Client): @@ -790,7 +838,7 @@ def update_aliases(ctx: Context, team: int): async_start(ctx.send_encoded_msgs(client, cmd)) -async def server(websocket, path: str = "/", ctx: Context = None): +async def server(websocket: "ServerConnection", path: str = "/", ctx: Context = None) -> None: client = Client(websocket, ctx) ctx.endpoints.append(client) @@ -881,6 +929,10 @@ async def on_client_joined(ctx: Context, client: Client): "If your client supports it, " "you may have additional local commands you can list with /help.", {"type": "Tutorial"}) + if not any(isinstance(extension, PerMessageDeflate) for extension in client.socket.extensions): + ctx.notify_client(client, "Warning: your client does not support compressed websocket connections! " + "It may stop working in the future. If you are a player, please report this to the " + "client's developer.") ctx.client_connection_timers[client.team, client.slot] = datetime.datetime.now(datetime.timezone.utc) @@ -947,9 +999,13 @@ def get_status_string(ctx: Context, team: int, tag: str): tagged = len([client for client in ctx.clients[team][slot] if tag in client.tags]) completion_text = f"({len(ctx.location_checks[team, slot])}/{len(ctx.locations[slot])})" tag_text = f" {tagged} of which are tagged {tag}" if connected and tag else "" - goal_text = " and has finished." if ctx.client_game_state[team, slot] == ClientStatus.CLIENT_GOAL else "." + status_text = ( + " and has finished." if ctx.client_game_state[team, slot] == ClientStatus.CLIENT_GOAL else + " and is ready." if ctx.client_game_state[team, slot] == ClientStatus.CLIENT_READY else + "." + ) text += f"\n{ctx.get_aliased_name(team, slot)} has {connected} connection{'' if connected == 1 else 's'}" \ - f"{tag_text}{goal_text} {completion_text}" + f"{tag_text}{status_text} {completion_text}" return text @@ -1027,21 +1083,37 @@ def send_items_to(ctx: Context, team: int, target_slot: int, *items: NetworkItem def register_location_checks(ctx: Context, team: int, slot: int, locations: typing.Iterable[int], count_activity: bool = True): + slot_locations = ctx.locations[slot] new_locations = set(locations) - ctx.location_checks[team, slot] - new_locations.intersection_update(ctx.locations[slot]) # ignore location IDs unknown to this multidata + new_locations.intersection_update(slot_locations) # ignore location IDs unknown to this multidata if new_locations: if count_activity: ctx.client_activity_timers[team, slot] = datetime.datetime.now(datetime.timezone.utc) + + sortable: list[tuple[int, int, int, int]] = [] for location in new_locations: - item_id, target_player, flags = ctx.locations[slot][location] + # extract all fields to avoid runtime overhead in LocationStore + item_id, target_player, flags = slot_locations[location] + # sort/group by receiver and item + sortable.append((target_player, item_id, location, flags)) + + info_texts: list[dict[str, typing.Any]] = [] + for target_player, item_id, location, flags in sorted(sortable): new_item = NetworkItem(item_id, location, slot, flags) send_items_to(ctx, team, target_player, new_item) ctx.logger.info('(Team #%d) %s sent %s to %s (%s)' % ( team + 1, ctx.player_names[(team, slot)], ctx.item_names[ctx.slot_info[target_player].game][item_id], ctx.player_names[(team, target_player)], ctx.location_names[ctx.slot_info[slot].game][location])) - info_text = json_format_send_event(new_item, target_player) - ctx.broadcast_team(team, [info_text]) + if len(info_texts) >= 140: + # split into chunks that are close to compression window of 64K but not too big on the wire + # (roughly 1300-2600 bytes after compression depending on repetitiveness) + ctx.broadcast_team(team, info_texts) + info_texts.clear() + info_texts.append(json_format_send_event(new_item, target_player)) + ctx.broadcast_team(team, info_texts) + del info_texts + del sortable ctx.location_checks[team, slot] |= new_locations send_new_items(ctx) @@ -1050,14 +1122,15 @@ 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) + updated_slots: typing.Set[tuple[int, int]] = set() + ctx.recheck_hints(team, slot, updated_slots) + for hint_team, hint_slot in updated_slots: + ctx.on_changed_hints(hint_team, hint_slot) ctx.save() -def collect_hints(ctx: Context, team: int, slot: int, item: typing.Union[int, str]) -> typing.List[NetUtils.Hint]: +def collect_hints(ctx: Context, team: int, slot: int, item: typing.Union[int, str], auto_status: HintStatus) \ + -> typing.List[Hint]: hints = [] slots: typing.Set[int] = {slot} for group_id, group in ctx.groups.items(): @@ -1067,31 +1140,58 @@ def collect_hints(ctx: Context, team: int, slot: int, item: typing.Union[int, st seeked_item_id = item if isinstance(item, int) else ctx.item_names_for_game(ctx.games[slot])[item] for finding_player, location_id, item_id, receiving_player, item_flags \ in ctx.locations.find_item(slots, seeked_item_id): - found = location_id in ctx.location_checks[team, finding_player] - entrance = ctx.er_hint_data.get(finding_player, {}).get(location_id, "") - hints.append(NetUtils.Hint(receiving_player, finding_player, location_id, item_id, found, entrance, - item_flags)) + prev_hint = ctx.get_hint(team, finding_player, location_id) + if prev_hint: + hints.append(prev_hint) + else: + found = location_id in ctx.location_checks[team, finding_player] + entrance = ctx.er_hint_data.get(finding_player, {}).get(location_id, "") + new_status = auto_status + if found: + new_status = HintStatus.HINT_FOUND + elif item_flags & ItemClassification.trap: + new_status = HintStatus.HINT_AVOID + hints.append(Hint(receiving_player, finding_player, location_id, item_id, found, entrance, + item_flags, new_status)) return hints -def collect_hint_location_name(ctx: Context, team: int, slot: int, location: str) -> typing.List[NetUtils.Hint]: +def collect_hint_location_name(ctx: Context, team: int, slot: int, location: str, auto_status: HintStatus) \ + -> typing.List[Hint]: seeked_location: int = ctx.location_names_for_game(ctx.games[slot])[location] - return collect_hint_location_id(ctx, team, slot, seeked_location) + return collect_hint_location_id(ctx, team, slot, seeked_location, auto_status) -def collect_hint_location_id(ctx: Context, team: int, slot: int, seeked_location: int) -> typing.List[NetUtils.Hint]: +def collect_hint_location_id(ctx: Context, team: int, slot: int, seeked_location: int, auto_status: HintStatus) \ + -> typing.List[Hint]: + prev_hint = ctx.get_hint(team, slot, seeked_location) + if prev_hint: + return [prev_hint] result = ctx.locations[slot].get(seeked_location, (None, None, None)) if any(result): item_id, receiving_player, item_flags = result found = seeked_location in ctx.location_checks[team, slot] entrance = ctx.er_hint_data.get(slot, {}).get(seeked_location, "") - return [NetUtils.Hint(receiving_player, slot, seeked_location, item_id, found, entrance, item_flags)] + new_status = auto_status + if found: + new_status = HintStatus.HINT_FOUND + elif item_flags & ItemClassification.trap: + new_status = HintStatus.HINT_AVOID + return [Hint(receiving_player, slot, seeked_location, item_id, found, entrance, item_flags, + new_status)] return [] -def format_hint(ctx: Context, team: int, hint: NetUtils.Hint) -> str: +status_names: typing.Dict[HintStatus, str] = { + HintStatus.HINT_FOUND: "(found)", + HintStatus.HINT_UNSPECIFIED: "(unspecified)", + HintStatus.HINT_NO_PRIORITY: "(no priority)", + HintStatus.HINT_AVOID: "(avoid)", + HintStatus.HINT_PRIORITY: "(priority)", +} +def format_hint(ctx: Context, team: int, hint: Hint) -> str: text = f"[Hint]: {ctx.player_names[team, hint.receiving_player]}'s " \ f"{ctx.item_names[ctx.slot_info[hint.receiving_player].game][hint.item]} is " \ f"at {ctx.location_names[ctx.slot_info[hint.finding_player].game][hint.location]} " \ @@ -1099,7 +1199,8 @@ def format_hint(ctx: Context, team: int, hint: NetUtils.Hint) -> str: if hint.entrance: text += f" at {hint.entrance}" - return text + (". (found)" if hint.found else ".") + + return text + ". " + status_names.get(hint.status, "(unknown)") def json_format_send_event(net_item: NetworkItem, receiving_player: int): @@ -1503,7 +1604,7 @@ class ClientMessageProcessor(CommonCommandProcessor): def get_hints(self, input_text: str, for_location: bool = False) -> bool: points_available = get_client_points(self.ctx, self.client) cost = self.ctx.get_hint_cost(self.client.slot) - + auto_status = HintStatus.HINT_UNSPECIFIED if for_location else HintStatus.HINT_PRIORITY if not input_text: hints = {hint.re_check(self.ctx, self.client.team) for hint in self.ctx.hints[self.client.team, self.client.slot]} @@ -1529,9 +1630,9 @@ class ClientMessageProcessor(CommonCommandProcessor): self.output(f"Sorry, \"{hint_name}\" is marked as non-hintable.") hints = [] elif not for_location: - hints = collect_hints(self.ctx, self.client.team, self.client.slot, hint_id) + hints = collect_hints(self.ctx, self.client.team, self.client.slot, hint_id, auto_status) else: - hints = collect_hint_location_id(self.ctx, self.client.team, self.client.slot, hint_id) + hints = collect_hint_location_id(self.ctx, self.client.team, self.client.slot, hint_id, auto_status) else: game = self.ctx.games[self.client.slot] @@ -1551,16 +1652,16 @@ class ClientMessageProcessor(CommonCommandProcessor): hints = [] for item_name in self.ctx.item_name_groups[game][hint_name]: if item_name in self.ctx.item_names_for_game(game): # ensure item has an ID - hints.extend(collect_hints(self.ctx, self.client.team, self.client.slot, item_name)) + hints.extend(collect_hints(self.ctx, self.client.team, self.client.slot, item_name, auto_status)) elif not for_location and hint_name in self.ctx.item_names_for_game(game): # item name - hints = collect_hints(self.ctx, self.client.team, self.client.slot, hint_name) + hints = collect_hints(self.ctx, self.client.team, self.client.slot, hint_name, auto_status) elif hint_name in self.ctx.location_name_groups[game]: # location group name hints = [] for loc_name in self.ctx.location_name_groups[game][hint_name]: if loc_name in self.ctx.location_names_for_game(game): - hints.extend(collect_hint_location_name(self.ctx, self.client.team, self.client.slot, loc_name)) + hints.extend(collect_hint_location_name(self.ctx, self.client.team, self.client.slot, loc_name, auto_status)) else: # location name - hints = collect_hint_location_name(self.ctx, self.client.team, self.client.slot, hint_name) + hints = collect_hint_location_name(self.ctx, self.client.team, self.client.slot, hint_name, auto_status) else: self.output(response) @@ -1725,7 +1826,9 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict): ctx.clients[team][slot].append(client) client.version = args['version'] client.tags = args['tags'] - client.no_locations = 'TextOnly' in client.tags or 'Tracker' in client.tags + client.no_locations = bool(client.tags & _non_game_messages.keys()) + # set NoText for old PopTracker clients that predate the tag to save traffic + client.no_text = "NoText" in client.tags or ("PopTracker" in client.tags and client.version < (0, 5, 1)) connected_packet = { "cmd": "Connected", "team": client.team, "slot": client.slot, @@ -1797,7 +1900,10 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict): old_tags = client.tags client.tags = args["tags"] if set(old_tags) != set(client.tags): - client.no_locations = 'TextOnly' in client.tags or 'Tracker' in client.tags + client.no_locations = bool(client.tags & _non_game_messages.keys()) + client.no_text = "NoText" in client.tags or ( + "PopTracker" in client.tags and client.version < (0, 5, 1) + ) ctx.broadcast_text_all( f"{ctx.get_aliased_name(client.team, client.slot)} (Team #{client.team + 1}) has changed tags " f"from {old_tags} to {client.tags}.", @@ -1826,21 +1932,72 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict): for location in args["locations"]: if type(location) is not int: await ctx.send_msgs(client, - [{'cmd': 'InvalidPacket', "type": "arguments", "text": 'LocationScouts', + [{'cmd': 'InvalidPacket', "type": "arguments", + "text": 'Locations has to be a list of integers', "original_cmd": cmd}]) return target_item, target_player, flags = ctx.locations[client.slot][location] if create_as_hint: - hints.extend(collect_hint_location_id(ctx, client.team, client.slot, location)) + hints.extend(collect_hint_location_id(ctx, client.team, client.slot, location, + HintStatus.HINT_UNSPECIFIED)) locs.append(NetworkItem(target_item, location, target_player, flags)) ctx.notify_hints(client.team, hints, only_new=create_as_hint == 2) if locs and create_as_hint: ctx.save() await ctx.send_msgs(client, [{'cmd': 'LocationInfo', 'locations': locs}]) + + elif cmd == 'UpdateHint': + location = args["location"] + player = args["player"] + status = args["status"] + if not isinstance(player, int) or not isinstance(location, int) \ + or (status is not None and not isinstance(status, int)): + await ctx.send_msgs(client, + [{'cmd': 'InvalidPacket', "type": "arguments", "text": 'UpdateHint', + "original_cmd": cmd}]) + return + hint = ctx.get_hint(client.team, player, location) + if not hint: + return # Ignored safely + if client.slot not in ctx.slot_set(hint.receiving_player): + await ctx.send_msgs(client, + [{'cmd': 'InvalidPacket', "type": "arguments", "text": 'UpdateHint: No Permission', + "original_cmd": cmd}]) + return + new_hint = hint + if status is None: + return + try: + status = HintStatus(status) + except ValueError: + await ctx.send_msgs(client, + [{'cmd': 'InvalidPacket', "type": "arguments", + "text": 'UpdateHint: Invalid Status', "original_cmd": cmd}]) + return + if status == HintStatus.HINT_FOUND: + await ctx.send_msgs(client, + [{'cmd': 'InvalidPacket', "type": "arguments", + "text": 'UpdateHint: Cannot manually update status to "HINT_FOUND"', "original_cmd": cmd}]) + return + new_hint = new_hint.re_prioritize(ctx, status) + if hint == new_hint: + return + + concerning_slots = ctx.slot_set(hint.receiving_player) | {hint.finding_player} + for slot in concerning_slots: + ctx.replace_hint(client.team, slot, hint, new_hint) + ctx.save() + for slot in concerning_slots: + ctx.on_changed_hints(client.team, slot) elif cmd == 'StatusUpdate': - update_client_status(ctx, client, args["status"]) + if client.no_locations and args["status"] == ClientStatus.CLIENT_GOAL: + await ctx.send_msgs(client, [{'cmd': 'InvalidPacket', "type": "cmd", + "text": "Trackers can't register Goal Complete", + "original_cmd": cmd}]) + else: + update_client_status(ctx, client, args["status"]) elif cmd == 'Say': if "text" not in args or type(args["text"]) is not str or not args["text"].isprintable(): @@ -1886,12 +2043,13 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict): args["cmd"] = "SetReply" value = ctx.stored_data.get(args["key"], args.get("default", 0)) args["original_value"] = copy.copy(value) + args["slot"] = client.slot for operation in args["operations"]: func = modify_functions[operation["operation"]] value = func(value, operation["value"]) ctx.stored_data[args["key"]] = args["value"] = value targets = set(ctx.stored_data_notification_clients[args["key"]]) - if args.get("want_reply", True): + if args.get("want_reply", False): targets.add(client) if targets: ctx.broadcast(targets, [args]) @@ -1960,8 +2118,10 @@ class ServerCommandProcessor(CommonCommandProcessor): def _cmd_exit(self) -> bool: """Shutdown the server""" - self.ctx.server.ws_server.close() - self.ctx.exit_event.set() + try: + self.ctx.server.ws_server.close() + finally: + self.ctx.exit_event.set() return True @mark_raw @@ -2141,9 +2301,9 @@ class ServerCommandProcessor(CommonCommandProcessor): hints = [] for item_name_from_group in self.ctx.item_name_groups[game][item]: if item_name_from_group in self.ctx.item_names_for_game(game): # ensure item has an ID - hints.extend(collect_hints(self.ctx, team, slot, item_name_from_group)) + hints.extend(collect_hints(self.ctx, team, slot, item_name_from_group, HintStatus.HINT_PRIORITY)) else: # item name or id - hints = collect_hints(self.ctx, team, slot, item) + hints = collect_hints(self.ctx, team, slot, item, HintStatus.HINT_PRIORITY) if hints: self.ctx.notify_hints(team, hints) @@ -2177,14 +2337,17 @@ class ServerCommandProcessor(CommonCommandProcessor): if usable: if isinstance(location, int): - hints = collect_hint_location_id(self.ctx, team, slot, location) + hints = collect_hint_location_id(self.ctx, team, slot, location, + HintStatus.HINT_UNSPECIFIED) elif game in self.ctx.location_name_groups and location in self.ctx.location_name_groups[game]: hints = [] for loc_name_from_group in self.ctx.location_name_groups[game][location]: if loc_name_from_group in self.ctx.location_names_for_game(game): - hints.extend(collect_hint_location_name(self.ctx, team, slot, loc_name_from_group)) + hints.extend(collect_hint_location_name(self.ctx, team, slot, loc_name_from_group, + HintStatus.HINT_UNSPECIFIED)) else: - hints = collect_hint_location_name(self.ctx, team, slot, location) + hints = collect_hint_location_name(self.ctx, team, slot, location, + HintStatus.HINT_UNSPECIFIED) if hints: self.ctx.notify_hints(team, hints) else: @@ -2329,8 +2492,10 @@ async def console(ctx: Context): def parse_args() -> argparse.Namespace: + from settings import get_settings + parser = argparse.ArgumentParser() - defaults = Utils.get_settings()["server_options"].as_dict() + defaults = 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) @@ -2342,6 +2507,8 @@ def parse_args() -> argparse.Namespace: parser.add_argument('--cert_key', help="Path to SSL Certificate Key file") parser.add_argument('--loglevel', default=defaults["loglevel"], choices=['debug', 'info', 'warning', 'error', 'critical']) + parser.add_argument('--logtime', help="Add timestamps to STDOUT", + default=defaults["logtime"], action='store_true') parser.add_argument('--location_check_points', default=defaults["location_check_points"], type=int) parser.add_argument('--hint_cost', default=defaults["hint_cost"], type=int) parser.add_argument('--disable_item_cheat', default=defaults["disable_item_cheat"], action='store_true') @@ -2422,7 +2589,9 @@ def load_server_cert(path: str, cert_key: typing.Optional[str]) -> "ssl.SSLConte async def main(args: argparse.Namespace): - Utils.init_logging("Server", loglevel=args.loglevel.lower()) + Utils.init_logging(name="Server", + loglevel=args.loglevel.lower(), + add_timestamp=args.logtime) ctx = Context(args.host, args.port, args.server_password, args.password, args.location_check_points, args.hint_cost, not args.disable_item_cheat, args.release_mode, args.collect_mode, diff --git a/NetUtils.py b/NetUtils.py index 4776b228db..f2ae2a63a0 100644 --- a/NetUtils.py +++ b/NetUtils.py @@ -5,11 +5,20 @@ import enum import warnings from json import JSONEncoder, JSONDecoder -import websockets +if typing.TYPE_CHECKING: + from websockets import WebSocketServerProtocol as ServerConnection from Utils import ByValue, Version +class HintStatus(ByValue, enum.IntEnum): + HINT_UNSPECIFIED = 0 + HINT_NO_PRIORITY = 10 + HINT_AVOID = 20 + HINT_PRIORITY = 30 + HINT_FOUND = 40 + + class JSONMessagePart(typing.TypedDict, total=False): text: str # optional @@ -19,6 +28,8 @@ class JSONMessagePart(typing.TypedDict, total=False): player: int # if type == item indicates item flags flags: int + # if type == hint_status + hint_status: HintStatus class ClientStatus(ByValue, enum.IntEnum): @@ -141,7 +152,7 @@ decode = JSONDecoder(object_hook=_object_hook).decode class Endpoint: - socket: websockets.WebSocketServerProtocol + socket: "ServerConnection" def __init__(self, socket): self.socket = socket @@ -184,6 +195,7 @@ class JSONTypes(str, enum.Enum): location_name = "location_name" location_id = "location_id" entrance_name = "entrance_name" + hint_status = "hint_status" class JSONtoTextParser(metaclass=HandlerMeta): @@ -224,7 +236,7 @@ class JSONtoTextParser(metaclass=HandlerMeta): def _handle_player_id(self, node: JSONMessagePart): player = int(node["text"]) - node["color"] = 'magenta' if player == self.ctx.slot else 'yellow' + node["color"] = 'magenta' if self.ctx.slot_concerns_self(player) else 'yellow' node["text"] = self.ctx.player_names[player] return self._handle_color(node) @@ -265,6 +277,10 @@ class JSONtoTextParser(metaclass=HandlerMeta): node["color"] = 'blue' return self._handle_color(node) + def _handle_hint_status(self, node: JSONMessagePart): + node["color"] = status_colors.get(node["hint_status"], "red") + return self._handle_color(node) + class RawJSONtoTextParser(JSONtoTextParser): def _handle_color(self, node: JSONMessagePart): @@ -297,6 +313,27 @@ def add_json_location(parts: list, location_id: int, player: int = 0, **kwargs) parts.append({"text": str(location_id), "player": player, "type": JSONTypes.location_id, **kwargs}) +status_names: typing.Dict[HintStatus, str] = { + HintStatus.HINT_FOUND: "(found)", + HintStatus.HINT_UNSPECIFIED: "(unspecified)", + HintStatus.HINT_NO_PRIORITY: "(no priority)", + HintStatus.HINT_AVOID: "(avoid)", + HintStatus.HINT_PRIORITY: "(priority)", +} +status_colors: typing.Dict[HintStatus, str] = { + HintStatus.HINT_FOUND: "green", + HintStatus.HINT_UNSPECIFIED: "white", + HintStatus.HINT_NO_PRIORITY: "slateblue", + HintStatus.HINT_AVOID: "salmon", + HintStatus.HINT_PRIORITY: "plum", +} + + +def add_json_hint_status(parts: list, hint_status: HintStatus, text: typing.Optional[str] = None, **kwargs): + parts.append({"text": text if text != None else status_names.get(hint_status, "(unknown)"), + "hint_status": hint_status, "type": JSONTypes.hint_status, **kwargs}) + + class Hint(typing.NamedTuple): receiving_player: int finding_player: int @@ -305,14 +342,21 @@ class Hint(typing.NamedTuple): found: bool entrance: str = "" item_flags: int = 0 + status: HintStatus = HintStatus.HINT_UNSPECIFIED def re_check(self, ctx, team) -> Hint: - if self.found: + if self.found and self.status == HintStatus.HINT_FOUND: return self found = self.location in ctx.location_checks[team, self.finding_player] if found: - return Hint(self.receiving_player, self.finding_player, self.location, self.item, found, self.entrance, - self.item_flags) + return self._replace(found=found, status=HintStatus.HINT_FOUND) + return self + + def re_prioritize(self, ctx, status: HintStatus) -> Hint: + if self.found and status != HintStatus.HINT_FOUND: + status = HintStatus.HINT_FOUND + if status != self.status: + return self._replace(status=status) return self def __hash__(self): @@ -334,10 +378,7 @@ class Hint(typing.NamedTuple): else: add_json_text(parts, "'s World") add_json_text(parts, ". ") - if self.found: - add_json_text(parts, "(found)", type="color", color="green") - else: - add_json_text(parts, "(not found)", type="color", color="red") + add_json_hint_status(parts, self.status) return {"cmd": "PrintJSON", "data": parts, "type": "Hint", "receiving": self.receiving_player, @@ -383,6 +424,8 @@ class _LocationStore(dict, typing.MutableMapping[int, typing.Dict[int, typing.Tu checked = state[team, slot] if not checked: # This optimizes the case where everyone connects to a fresh game at the same time. + if slot not in self: + raise KeyError(slot) return [] return [location_id for location_id in self[slot] if diff --git a/OoTAdjuster.py b/OoTAdjuster.py index 9519b191e7..1581d65398 100644 --- a/OoTAdjuster.py +++ b/OoTAdjuster.py @@ -1,7 +1,6 @@ import tkinter as tk import argparse import logging -import random import os import zipfile from itertools import chain @@ -197,7 +196,6 @@ def set_icon(window): def adjust(args): # Create a fake multiworld and OOTWorld to use as a base multiworld = MultiWorld(1) - multiworld.per_slot_randoms = {1: random} ootworld = OOTWorld(multiworld, 1) # Set options in the fake OOTWorld for name, option in chain(cosmetic_options.items(), sfx_options.items()): diff --git a/OoTClient.py b/OoTClient.py index 1154904173..6a87b9e722 100644 --- a/OoTClient.py +++ b/OoTClient.py @@ -346,7 +346,7 @@ if __name__ == '__main__': import colorama - colorama.init() + colorama.just_fix_windows_console() asyncio.run(main()) colorama.deinit() diff --git a/Options.py b/Options.py index aa6f175fa5..86e58ca64a 100644 --- a/Options.py +++ b/Options.py @@ -1,6 +1,7 @@ from __future__ import annotations import abc +import collections import functools import logging import math @@ -15,7 +16,7 @@ from dataclasses import dataclass from schema import And, Optional, Or, Schema from typing_extensions import Self -from Utils import get_fuzzy_results, is_iterable_except_str, output_path +from Utils import get_file_safe_name, get_fuzzy_results, is_iterable_except_str, output_path if typing.TYPE_CHECKING: from BaseClasses import MultiWorld, PlandoOptions @@ -23,6 +24,12 @@ if typing.TYPE_CHECKING: import pathlib +def roll_percentage(percentage: int | float) -> bool: + """Roll a percentage chance. + percentage is expected to be in range [0, 100]""" + return random.random() < (float(percentage) / 100) + + class OptionError(ValueError): pass @@ -137,7 +144,7 @@ class Option(typing.Generic[T], metaclass=AssembleOptions): 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 + If this is None, it inherits the value of `WebWorld.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. @@ -496,7 +503,7 @@ class TextChoice(Choice): def __init__(self, value: typing.Union[str, int]): assert isinstance(value, str) or isinstance(value, int), \ - f"{value} is not a valid option for {self.__class__.__name__}" + f"'{value}' is not a valid option for '{self.__class__.__name__}'" self.value = value @property @@ -617,17 +624,17 @@ class PlandoBosses(TextChoice, metaclass=BossMeta): used_locations.append(location) used_bosses.append(boss) if not cls.valid_boss_name(boss): - raise ValueError(f"{boss.title()} is not a valid boss name.") + raise ValueError(f"'{boss.title()}' is not a valid boss name.") if not cls.valid_location_name(location): - raise ValueError(f"{location.title()} is not a valid boss location name.") + raise ValueError(f"'{location.title()}' is not a valid boss location name.") if not cls.can_place_boss(boss, location): - raise ValueError(f"{location.title()} is not a valid location for {boss.title()} to be placed.") + raise ValueError(f"'{location.title()}' is not a valid location for {boss.title()} to be placed.") else: if cls.duplicate_bosses: if not cls.valid_boss_name(option): - raise ValueError(f"{option} is not a valid boss name.") + raise ValueError(f"'{option}' is not a valid boss name.") else: - raise ValueError(f"{option.title()} is not formatted correctly.") + raise ValueError(f"'{option.title()}' is not formatted correctly.") @classmethod def can_place_boss(cls, boss: str, location: str) -> bool: @@ -689,9 +696,9 @@ class Range(NumericOption): @classmethod def weighted_range(cls, text) -> Range: if text == "random-low": - return cls(cls.triangular(cls.range_start, cls.range_end, cls.range_start)) + return cls(cls.triangular(cls.range_start, cls.range_end, 0.0)) elif text == "random-high": - return cls(cls.triangular(cls.range_start, cls.range_end, cls.range_end)) + return cls(cls.triangular(cls.range_start, cls.range_end, 1.0)) elif text == "random-middle": return cls(cls.triangular(cls.range_start, cls.range_end)) elif text.startswith("random-range-"): @@ -717,11 +724,11 @@ class Range(NumericOption): f"{random_range[0]}-{random_range[1]} is outside allowed range " f"{cls.range_start}-{cls.range_end} for option {cls.__name__}") if text.startswith("random-range-low"): - return cls(cls.triangular(random_range[0], random_range[1], random_range[0])) + return cls(cls.triangular(random_range[0], random_range[1], 0.0)) elif text.startswith("random-range-middle"): return cls(cls.triangular(random_range[0], random_range[1])) elif text.startswith("random-range-high"): - return cls(cls.triangular(random_range[0], random_range[1], random_range[1])) + return cls(cls.triangular(random_range[0], random_range[1], 1.0)) else: return cls(random.randint(random_range[0], random_range[1])) @@ -739,8 +746,16 @@ class Range(NumericOption): return str(self.value) @staticmethod - def triangular(lower: int, end: int, tri: typing.Optional[int] = None) -> int: - return int(round(random.triangular(lower, end, tri), 0)) + def triangular(lower: int, end: int, tri: float = 0.5) -> int: + """ + Integer triangular distribution for `lower` inclusive to `end` inclusive. + + Expects `lower <= end` and `0.0 <= tri <= 1.0`. The result of other inputs is undefined. + """ + # Use the continuous range [lower, end + 1) to produce an integer result in [lower, end]. + # random.triangular is actually [a, b] and not [a, b), so there is a very small chance of getting exactly b even + # when a != b, so ensure the result is never more than `end`. + return min(end, math.floor(random.triangular(0.0, 1.0, tri) * (end - lower + 1) + lower)) class NamedRange(Range): @@ -754,7 +769,7 @@ class NamedRange(Range): 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(): @@ -817,18 +832,21 @@ class VerifyKeys(metaclass=FreezeValidKeys): for item_name in self.value: if item_name not in world.item_names: picks = get_fuzzy_results(item_name, world.item_names, limit=1) - raise Exception(f"Item {item_name} from option {self} " - f"is not a valid item name from {world.game}. " + raise Exception(f"Item '{item_name}' from option '{self}' " + f"is not a valid item name from '{world.game}'. " f"Did you mean '{picks[0][0]}' ({picks[0][1]}% sure)") elif self.verify_location_name: for location_name in self.value: if location_name not in world.location_names: picks = get_fuzzy_results(location_name, world.location_names, limit=1) - raise Exception(f"Location {location_name} from option {self} " - f"is not a valid location name from {world.game}. " + raise Exception(f"Location '{location_name}' from option '{self}' " + f"is not a valid location name from '{world.game}'. " f"Did you mean '{picks[0][0]}' ({picks[0][1]}% sure)") + def __iter__(self) -> typing.Iterator[typing.Any]: + return self.value.__iter__() + class OptionDict(Option[typing.Dict[str, typing.Any]], VerifyKeys, typing.Mapping[str, typing.Any]): default = {} supports_weighting = False @@ -855,13 +873,49 @@ class OptionDict(Option[typing.Dict[str, typing.Any]], VerifyKeys, typing.Mappin def __len__(self) -> int: return self.value.__len__() + # __getitem__ fallback fails for Counters, so we define this explicitly + def __contains__(self, item) -> bool: + return item in self.value -class ItemDict(OptionDict): + +class OptionCounter(OptionDict): + min: int | None = None + max: int | None = None + + def __init__(self, value: dict[str, int]) -> None: + super(OptionCounter, self).__init__(collections.Counter(value)) + + def verify(self, world: type[World], player_name: str, plando_options: PlandoOptions) -> None: + super(OptionCounter, self).verify(world, player_name, plando_options) + + range_errors = [] + + if self.max is not None: + range_errors += [ + f"\"{key}: {value}\" is higher than maximum allowed value {self.max}." + for key, value in self.value.items() if value > self.max + ] + + if self.min is not None: + range_errors += [ + f"\"{key}: {value}\" is lower than minimum allowed value {self.min}." + for key, value in self.value.items() if value < self.min + ] + + if range_errors: + range_errors = [f"For option {getattr(self, 'display_name', self)}:"] + range_errors + raise OptionError("\n".join(range_errors)) + + +class ItemDict(OptionCounter): verify_item_name = True - def __init__(self, value: typing.Dict[str, int]): - if any(item_count < 1 for item_count in value.values()): - raise Exception("Cannot have non-positive item counts.") + min = 0 + + def __init__(self, value: dict[str, int]) -> None: + # Backwards compatibility: Cull 0s to make "in" checks behave the same as when this wasn't a OptionCounter + value = {item_name: amount for item_name, amount in value.items() if amount != 0} + super(ItemDict, self).__init__(value) @@ -971,7 +1025,7 @@ class PlandoTexts(Option[typing.List[PlandoText]], VerifyKeys): if isinstance(data, typing.Iterable): for text in data: if isinstance(text, typing.Mapping): - if random.random() < float(text.get("percentage", 100)/100): + if roll_percentage(text.get("percentage", 100)): at = text.get("at", None) if at is not None: if isinstance(at, dict): @@ -997,7 +1051,7 @@ class PlandoTexts(Option[typing.List[PlandoText]], VerifyKeys): else: raise OptionError("\"at\" must be a valid string or weighted list of strings!") elif isinstance(text, PlandoText): - if random.random() < float(text.percentage/100): + if roll_percentage(text.percentage): texts.append(text) else: raise Exception(f"Cannot create plando text from non-dictionary type, got {type(text)}") @@ -1106,11 +1160,11 @@ class PlandoConnections(Option[typing.List[PlandoConnection]], metaclass=Connect 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.") + 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.") + 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.") + raise ValueError(f"Connection between '{entrance.title()}' and '{exit.title()}' is invalid.") @classmethod def from_any(cls, data: PlandoConFromAnyType) -> Self: @@ -1121,7 +1175,7 @@ class PlandoConnections(Option[typing.List[PlandoConnection]], metaclass=Connect for connection in data: if isinstance(connection, typing.Mapping): percentage = connection.get("percentage", 100) - if random.random() < float(percentage / 100): + if roll_percentage(percentage): entrance = connection.get("entrance", None) if is_iterable_except_str(entrance): entrance = random.choice(sorted(entrance)) @@ -1139,7 +1193,7 @@ class PlandoConnections(Option[typing.List[PlandoConnection]], metaclass=Connect percentage )) elif isinstance(connection, PlandoConnection): - if random.random() < float(connection.percentage / 100): + if roll_percentage(connection.percentage): value.append(connection) else: raise Exception(f"Cannot create connection from non-Dict type, got {type(connection)}.") @@ -1175,7 +1229,7 @@ class PlandoConnections(Option[typing.List[PlandoConnection]], metaclass=Connect class Accessibility(Choice): """ Set rules for reachability of your items/locations. - + **Full:** ensure everything can be reached and acquired. **Minimal:** ensure what is needed to reach your goal can be acquired. @@ -1193,7 +1247,7 @@ class Accessibility(Choice): 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. @@ -1244,36 +1298,47 @@ class CommonOptions(metaclass=OptionsMetaProperty): progression_balancing: ProgressionBalancing accessibility: Accessibility - def as_dict(self, *option_names: str, casing: str = "snake") -> typing.Dict[str, typing.Any]: + def as_dict( + self, + *option_names: str, + casing: typing.Literal["snake", "camel", "pascal", "kebab"] = "snake", + toggles_as_bools: bool = False, + ) -> 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` + :param option_names: Names of the options to get the values of. + :param casing: Casing of the keys to return. Supports `snake`, `camel`, `pascal`, `kebab`. + :param toggles_as_bools: Whether toggle options should be returned as bools instead of ints. + + :return: A dictionary of each option name to the value of its Option. If the option is an OptionSet, the value + will be returned as a sorted list. """ assert option_names, "options.as_dict() was used without any option names." option_results = {} for option_name in option_names: - if option_name in type(self).type_hints: - if casing == "snake": - display_name = option_name - elif casing == "camel": - split_name = [name.title() for name in option_name.split("_")] - split_name[0] = split_name[0].lower() - display_name = "".join(split_name) - elif casing == "pascal": - display_name = "".join([name.title() for name in option_name.split("_")]) - elif casing == "kebab": - display_name = option_name.replace("_", "-") - else: - raise ValueError(f"{casing} is invalid casing for as_dict. " - "Valid names are 'snake', 'camel', 'pascal', 'kebab'.") - value = getattr(self, option_name).value - if isinstance(value, set): - value = sorted(value) - option_results[display_name] = value - else: + if option_name not in type(self).type_hints: raise ValueError(f"{option_name} not found in {tuple(type(self).type_hints)}") + + if casing == "snake": + display_name = option_name + elif casing == "camel": + split_name = [name.title() for name in option_name.split("_")] + split_name[0] = split_name[0].lower() + display_name = "".join(split_name) + elif casing == "pascal": + display_name = "".join([name.title() for name in option_name.split("_")]) + elif casing == "kebab": + display_name = option_name.replace("_", "-") + else: + raise ValueError(f"{casing} is invalid casing for as_dict. " + "Valid names are 'snake', 'camel', 'pascal', 'kebab'.") + value = getattr(self, option_name).value + if isinstance(value, set): + value = sorted(value) + elif toggles_as_bools and issubclass(type(self).type_hints[option_name], Toggle): + value = bool(value) + option_results[display_name] = value return option_results @@ -1294,6 +1359,7 @@ class StartInventory(ItemDict): verify_item_name = True display_name = "Start Inventory" rich_text_doc = True + max = 10000 class StartInventoryPool(StartInventory): @@ -1368,8 +1434,8 @@ class ItemLinks(OptionList): picks_group = get_fuzzy_results(item_name, world.item_name_groups.keys(), limit=1) picks_group = f" or '{picks_group[0][0]}' ({picks_group[0][1]}% sure)" if allow_item_groups else "" - raise Exception(f"Item {item_name} from item link {item_link} " - f"is not a valid item from {world.game} for {pool_name}. " + raise Exception(f"Item '{item_name}' from item link '{item_link}' " + f"is not a valid item from '{world.game}' for '{pool_name}'. " f"Did you mean '{picks[0][0]}' ({picks[0][1]}% sure){picks_group}") if allow_item_groups: pool |= world.item_name_groups.get(item_name, {item_name}) @@ -1409,6 +1475,131 @@ class ItemLinks(OptionList): link["item_pool"] = list(pool) +@dataclass(frozen=True) +class PlandoItem: + items: list[str] | dict[str, typing.Any] + locations: list[str] + world: int | str | bool | None | typing.Iterable[str] | set[int] = False + from_pool: bool = True + force: bool | typing.Literal["silent"] = "silent" + count: int | bool | dict[str, int] = False + percentage: int = 100 + + +class PlandoItems(Option[typing.List[PlandoItem]]): + """Generic items plando.""" + default = () + supports_weighting = False + display_name = "Plando Items" + + def __init__(self, value: typing.Iterable[PlandoItem]) -> None: + self.value = list(deepcopy(value)) + super().__init__() + + @classmethod + def from_any(cls, data: typing.Any) -> Option[typing.List[PlandoItem]]: + if not isinstance(data, typing.Iterable): + raise OptionError(f"Cannot create plando items from non-Iterable type, got {type(data)}") + + value: typing.List[PlandoItem] = [] + for item in data: + if isinstance(item, typing.Mapping): + percentage = item.get("percentage", 100) + if not isinstance(percentage, int): + raise OptionError(f"Plando `percentage` has to be int, not {type(percentage)}.") + if not (0 <= percentage <= 100): + raise OptionError(f"Plando `percentage` has to be between 0 and 100 (inclusive) not {percentage}.") + if roll_percentage(percentage): + count = item.get("count", False) + items = item.get("items", []) + if not items: + items = item.get("item", None) # explicitly throw an error here if not present + if not items: + raise OptionError("You must specify at least one item to place items with plando.") + count = 1 + if isinstance(items, str): + items = [items] + elif not isinstance(items, (dict, list)): + raise OptionError(f"Plando 'items' has to be string, list, or " + f"dictionary, not {type(items)}") + locations = item.get("locations", []) + if not locations: + locations = item.get("location", ["Everywhere"]) + if locations: + count = 1 + if isinstance(locations, str): + locations = [locations] + if not isinstance(locations, list): + raise OptionError(f"Plando `location` has to be string or list, not {type(locations)}") + world = item.get("world", False) + from_pool = item.get("from_pool", True) + force = item.get("force", "silent") + if not isinstance(from_pool, bool): + raise OptionError(f"Plando 'from_pool' has to be true or false, not {from_pool!r}.") + if not (isinstance(force, bool) or force == "silent"): + raise OptionError(f"Plando `force` has to be true or false or `silent`, not {force!r}.") + value.append(PlandoItem(items, locations, world, from_pool, force, count, percentage)) + elif isinstance(item, PlandoItem): + if roll_percentage(item.percentage): + value.append(item) + else: + raise OptionError(f"Cannot create plando item from non-Dict type, got {type(item)}.") + return cls(value) + + def verify(self, world: typing.Type[World], player_name: str, plando_options: "PlandoOptions") -> None: + if not self.value: + return + from BaseClasses import PlandoOptions + if not (PlandoOptions.items & plando_options): + # plando is disabled but plando options were given so overwrite the options + self.value = [] + logging.warning(f"The plando items module is turned off, " + f"so items for {player_name} will be ignored.") + else: + # filter down item groups + for plando in self.value: + # confirm a valid count + if isinstance(plando.count, dict): + if "min" in plando.count and "max" in plando.count: + if plando.count["min"] > plando.count["max"]: + raise OptionError("Plando cannot have count `min` greater than `max`.") + items_copy = plando.items.copy() + if isinstance(plando.items, dict): + for item in items_copy: + if item in world.item_name_groups: + value = plando.items.pop(item) + group = world.item_name_groups[item] + filtered_items = sorted(group.difference(list(plando.items.keys()))) + if not filtered_items: + raise OptionError(f"Plando `items` contains the group \"{item}\" " + f"and every item in it. This is not allowed.") + if value is True: + for key in filtered_items: + plando.items[key] = True + else: + for key in random.choices(filtered_items, k=value): + plando.items[key] = plando.items.get(key, 0) + 1 + else: + assert isinstance(plando.items, list) # pycharm can't figure out the hinting without the hint + for item in items_copy: + if item in world.item_name_groups: + plando.items.remove(item) + plando.items.extend(sorted(world.item_name_groups[item])) + + @classmethod + def get_option_name(cls, value: list[PlandoItem]) -> str: + return ", ".join(["(%s: %s)" % (item.items, item.locations) for item in value]) #TODO: see what a better way to display would be + + def __getitem__(self, index: typing.SupportsIndex) -> PlandoItem: + return self.value.__getitem__(index) + + def __iter__(self) -> typing.Iterator[PlandoItem]: + yield from self.value + + def __len__(self) -> int: + return len(self.value) + + class Removed(FreeText): """This Option has been Removed.""" rich_text_doc = True @@ -1431,6 +1622,7 @@ class PerGameCommonOptions(CommonOptions): exclude_locations: ExcludeLocations priority_locations: PriorityLocations item_links: ItemLinks + plando_items: PlandoItems @dataclass @@ -1460,22 +1652,26 @@ 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} + option_to_name = {option: option_name for option_name, option in world.options_dataclass.type_hints.items()} + + ordered_groups = {group.name: group.options for group in world.web.option_groups} + # 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 "Game Options" not in ordered_groups: + grouped_options = set(option for group in ordered_groups.values() for option in group) + ungrouped_options = [option for option in option_to_name if option not in grouped_options] + # only add the game options group if we have ungrouped options + if ungrouped_options: + ordered_groups = {**{"Game Options": ungrouped_options}, **ordered_groups} - # 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 + return { + group: { + option_to_name[option]: option + for option in group_options + if (visibility_level in option.visibility and option in option_to_name) + } + for group, group_options in ordered_groups.items() + } def generate_yaml_templates(target_folder: typing.Union[str, "pathlib.Path"], generate_hidden: bool = True) -> None: @@ -1531,7 +1727,7 @@ def generate_yaml_templates(target_folder: typing.Union[str, "pathlib.Path"], ge del file_data - with open(os.path.join(target_folder, game_name + ".yaml"), "w", encoding="utf-8-sig") as f: + with open(os.path.join(target_folder, get_file_safe_name(game_name) + ".yaml"), "w", encoding="utf-8-sig") as f: f.write(res) @@ -1556,10 +1752,11 @@ def dump_player_options(multiworld: MultiWorld) -> None: player_output = { "Game": multiworld.game[player], "Name": multiworld.get_player_name(player), + "ID": player, } output.append(player_output) for option_key, option in world.options_dataclass.type_hints.items(): - if issubclass(Removed, option): + if option.visibility == Visibility.none: continue display_name = getattr(option, "display_name", option_key) player_output[display_name] = getattr(world.options, option_key).current_option_name @@ -1568,7 +1765,7 @@ def dump_player_options(multiworld: MultiWorld) -> None: game_option_names.append(display_name) with open(output_path(f"generate_{multiworld.seed_name}.csv"), mode="w", newline="") as file: - fields = ["Game", "Name", *all_option_names] + fields = ["ID", "Game", "Name", *all_option_names] writer = DictWriter(file, fields) writer.writeheader() writer.writerows(output) diff --git a/README.md b/README.md index 0e57bce53b..c1e89bac7c 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,6 @@ Currently, the following games are supported: * Factorio * Minecraft * Subnautica -* Slay the Spire * Risk of Rain 2 * The Legend of Zelda: Ocarina of Time * Timespinner @@ -63,7 +62,6 @@ Currently, the following games are supported: * TUNIC * Kirby's Dream Land 3 * Celeste 64 -* Zork Grand Inquisitor * Castlevania 64 * A Short Hike * Yoshi's Island @@ -76,6 +74,12 @@ Currently, the following games are supported: * Kingdom Hearts 1 * Mega Man 2 * Yacht Dice +* Faxanadu +* Saving Princess +* Castlevania: Circle of the Moon +* Inscryption +* Civilization VI +* The Legend of Zelda: The Wind Waker 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 diff --git a/SNIClient.py b/SNIClient.py index 222ed54f5c..1156bf6040 100644 --- a/SNIClient.py +++ b/SNIClient.py @@ -243,6 +243,9 @@ class SNIContext(CommonContext): # Once the games handled by SNIClient gets made to be remote items, # this will no longer be needed. async_start(self.send_msgs([{"cmd": "LocationScouts", "locations": list(new_locations)}])) + + if self.client_handler is not None: + self.client_handler.on_package(self, cmd, args) def run_gui(self) -> None: from kvui import GameManager @@ -633,7 +636,13 @@ async def game_watcher(ctx: SNIContext) -> None: if not ctx.client_handler: continue - rom_validated = await ctx.client_handler.validate_rom(ctx) + try: + rom_validated = await ctx.client_handler.validate_rom(ctx) + except Exception as e: + snes_logger.error(f"An error occurred, see logs for details: {e}") + text_file_logger = logging.getLogger() + text_file_logger.exception(e) + rom_validated = False if not rom_validated or (ctx.auth and ctx.auth != ctx.rom): snes_logger.warning("ROM change detected, please reconnect to the multiworld server") @@ -649,7 +658,13 @@ async def game_watcher(ctx: SNIContext) -> None: perf_counter = time.perf_counter() - await ctx.client_handler.game_watcher(ctx) + try: + await ctx.client_handler.game_watcher(ctx) + except Exception as e: + snes_logger.error(f"An error occurred, see logs for details: {e}") + text_file_logger = logging.getLogger() + text_file_logger.exception(e) + await snes_disconnect(ctx) async def run_game(romfile: str) -> None: @@ -720,6 +735,6 @@ async def main() -> None: if __name__ == '__main__': - colorama.init() + colorama.just_fix_windows_console() asyncio.run(main()) colorama.deinit() diff --git a/UndertaleClient.py b/UndertaleClient.py index dfacee148a..1c522fac92 100644 --- a/UndertaleClient.py +++ b/UndertaleClient.py @@ -500,7 +500,7 @@ def main(): import colorama - colorama.init() + colorama.just_fix_windows_console() asyncio.run(_main()) colorama.deinit() diff --git a/Utils.py b/Utils.py index d6709431d3..f930335b24 100644 --- a/Utils.py +++ b/Utils.py @@ -18,8 +18,8 @@ import warnings from argparse import Namespace from settings import Settings, get_settings -from typing import BinaryIO, Coroutine, Optional, Set, Dict, Any, Union -from typing_extensions import TypeGuard +from time import sleep +from typing import BinaryIO, Coroutine, Optional, Set, Dict, Any, Union, TypeGuard from yaml import load, load_all, dump try: @@ -31,6 +31,7 @@ if typing.TYPE_CHECKING: import tkinter import pathlib from BaseClasses import Region + import multiprocessing def tuplize_version(version: str) -> Version: @@ -46,7 +47,7 @@ class Version(typing.NamedTuple): return ".".join(str(item) for item in self) -__version__ = "0.5.1" +__version__ = "0.6.2" version_tuple = tuplize_version(__version__) is_linux = sys.platform.startswith("linux") @@ -113,6 +114,8 @@ def cache_self1(function: typing.Callable[[S, T], RetType]) -> typing.Callable[[ cache[arg] = res return res + wrap.__defaults__ = function.__defaults__ + return wrap @@ -136,8 +139,11 @@ def local_path(*path: str) -> str: local_path.cached_path = os.path.dirname(os.path.abspath(sys.argv[0])) else: import __main__ - if hasattr(__main__, "__file__") and os.path.isfile(__main__.__file__): + if globals().get("__file__") and os.path.isfile(__file__): # we are running in a normal Python environment + local_path.cached_path = os.path.dirname(os.path.abspath(__file__)) + elif hasattr(__main__, "__file__") and os.path.isfile(__main__.__file__): + # we are running in a normal Python environment, but AP was imported weirdly local_path.cached_path = os.path.dirname(os.path.abspath(__main__.__file__)) else: # pray @@ -151,8 +157,15 @@ def home_path(*path: str) -> str: if hasattr(home_path, 'cached_path'): pass elif sys.platform.startswith('linux'): - home_path.cached_path = os.path.expanduser('~/Archipelago') - os.makedirs(home_path.cached_path, 0o700, exist_ok=True) + xdg_data_home = os.getenv('XDG_DATA_HOME', os.path.expanduser('~/.local/share')) + home_path.cached_path = xdg_data_home + '/Archipelago' + if not os.path.isdir(home_path.cached_path): + legacy_home_path = os.path.expanduser('~/Archipelago') + if os.path.isdir(legacy_home_path): + os.renames(legacy_home_path, home_path.cached_path) + os.symlink(home_path.cached_path, legacy_home_path) + else: + os.makedirs(home_path.cached_path, 0o700, exist_ok=True) else: # not implemented home_path.cached_path = local_path() # this will generate the same exceptions we got previously @@ -419,11 +432,15 @@ class RestrictedUnpickler(pickle.Unpickler): def find_class(self, module: str, name: str) -> type: if module == "builtins" and name in safe_builtins: return getattr(builtins, name) + # used by OptionCounter + if module == "collections" and name == "Counter": + return collections.Counter # used by MultiServer -> savegame/multidata - if module == "NetUtils" and name in {"NetworkItem", "ClientStatus", "Hint", "SlotType", "NetworkSlot"}: + if module == "NetUtils" and name in {"NetworkItem", "ClientStatus", "Hint", + "SlotType", "NetworkSlot", "HintStatus"}: return getattr(self.net_utils_module, name) # Options and Plando are unpickled by WebHost -> Generate - if module == "worlds.generic" and name in {"PlandoItem", "PlandoConnection"}: + if module == "worlds.generic" and name == "PlandoItem": if not self.generic_properties_module: self.generic_properties_module = importlib.import_module("worlds.generic") return getattr(self.generic_properties_module, name) @@ -434,7 +451,8 @@ class RestrictedUnpickler(pickle.Unpickler): else: mod = importlib.import_module(module) obj = getattr(mod, name) - if issubclass(obj, self.options_module.Option): + if issubclass(obj, (self.options_module.Option, self.options_module.PlandoConnection, + self.options_module.PlandoText)): return obj # Forbid everything else. raise pickle.UnpicklingError(f"global '{module}.{name}' is forbidden") @@ -483,9 +501,9 @@ def get_text_after(text: str, start: str) -> str: loglevel_mapping = {'error': logging.ERROR, 'info': logging.INFO, 'warning': logging.WARNING, 'debug': logging.DEBUG} -def init_logging(name: str, loglevel: typing.Union[str, int] = logging.INFO, write_mode: str = "w", - log_format: str = "[%(name)s at %(asctime)s]: %(message)s", - exception_logger: typing.Optional[str] = None): +def init_logging(name: str, loglevel: typing.Union[str, int] = logging.INFO, + write_mode: str = "w", log_format: str = "[%(name)s at %(asctime)s]: %(message)s", + add_timestamp: bool = False, exception_logger: typing.Optional[str] = None): import datetime loglevel: int = loglevel_mapping.get(loglevel, loglevel) log_folder = user_path("logs") @@ -512,11 +530,15 @@ def init_logging(name: str, loglevel: typing.Union[str, int] = logging.INFO, wri def filter(self, record: logging.LogRecord) -> bool: return self.condition(record) - file_handler.addFilter(Filter("NoStream", lambda record: not getattr(record, "NoFile", False))) + file_handler.addFilter(Filter("NoStream", lambda record: not getattr(record, "NoFile", False))) + file_handler.addFilter(Filter("NoCarriageReturn", lambda record: '\r' not in record.getMessage())) root_logger.addHandler(file_handler) if sys.stdout: + formatter = logging.Formatter(fmt='[%(asctime)s] %(message)s', datefmt='%Y-%m-%d %H:%M:%S') stream_handler = logging.StreamHandler(sys.stdout) stream_handler.addFilter(Filter("NoFile", lambda record: not getattr(record, "NoStream", False))) + if add_timestamp: + stream_handler.setFormatter(formatter) root_logger.addHandler(stream_handler) # Relay unhandled exceptions to logger. @@ -528,7 +550,8 @@ def init_logging(name: str, loglevel: typing.Union[str, int] = logging.INFO, wri sys.__excepthook__(exc_type, exc_value, exc_traceback) return logging.getLogger(exception_logger).exception("Uncaught exception", - exc_info=(exc_type, exc_value, exc_traceback)) + exc_info=(exc_type, exc_value, exc_traceback), + extra={"NoStream": exception_logger is None}) return orig_hook(exc_type, exc_value, exc_traceback) handle_exception._wrapped = True @@ -551,7 +574,7 @@ def init_logging(name: str, loglevel: typing.Union[str, int] = logging.INFO, wri import platform logging.info( f"Archipelago ({__version__}) logging initialized" - f" on {platform.platform()}" + f" on {platform.platform()} process {os.getpid()}" f" running Python {sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}" f"{' (frozen)' if is_frozen() else ''}" ) @@ -567,6 +590,8 @@ def stream_input(stream: typing.TextIO, queue: "asyncio.Queue[str]"): else: if text: queue.put_nowait(text) + else: + sleep(0.01) # non-blocking stream from threading import Thread thread = Thread(target=queuer, name=f"Stream handler for {stream.name}", daemon=True) @@ -613,6 +638,8 @@ def get_fuzzy_results(input_word: str, word_list: typing.Collection[str], limit: import jellyfish def get_fuzzy_ratio(word1: str, word2: str) -> float: + if word1 == word2: + return 1.01 return (1 - jellyfish.damerau_levenshtein_distance(word1.lower(), word2.lower()) / max(len(word1), len(word2))) @@ -633,8 +660,10 @@ def get_intended_text(input_text: str, possible_answers) -> typing.Tuple[str, bo picks = get_fuzzy_results(input_text, possible_answers, limit=2) if len(picks) > 1: dif = picks[0][1] - picks[1][1] - if picks[0][1] == 100: + if picks[0][1] == 101: return picks[0][0], True, "Perfect Match" + elif picks[0][1] == 100: + return picks[0][0], True, "Case Insensitive Perfect Match" elif picks[0][1] < 75: return picks[0][0], False, f"Didn't find something that closely matches '{input_text}', " \ f"did you mean '{picks[0][0]}'? ({picks[0][1]}% sure)" @@ -664,6 +693,19 @@ def get_input_text_from_response(text: str, command: str) -> typing.Optional[str return None +def is_kivy_running() -> bool: + if "kivy" in sys.modules: + from kivy.app import App + return App.get_running_app() is not None + return False + + +def _mp_open_filename(res: "multiprocessing.Queue[typing.Optional[str]]", *args: Any) -> None: + if is_kivy_running(): + raise RuntimeError("kivy should not be running in multiprocess") + res.put(open_filename(*args)) + + def open_filename(title: str, filetypes: typing.Iterable[typing.Tuple[str, typing.Iterable[str]]], suggest: str = "") \ -> typing.Optional[str]: logging.info(f"Opening file input dialog for {title}.") @@ -693,6 +735,13 @@ def open_filename(title: str, filetypes: typing.Iterable[typing.Tuple[str, typin f'This attempt was made because open_filename was used for "{title}".') raise e else: + if is_macos and is_kivy_running(): + # on macOS, mixing kivy and tk does not work, so spawn a new process + # FIXME: performance of this is pretty bad, and we should (also) look into alternatives + from multiprocessing import Process, Queue + res: "Queue[typing.Optional[str]]" = Queue() + Process(target=_mp_open_filename, args=(res, title, filetypes, suggest)).start() + return res.get() try: root = tkinter.Tk() except tkinter.TclError: @@ -702,6 +751,12 @@ def open_filename(title: str, filetypes: typing.Iterable[typing.Tuple[str, typin initialfile=suggest or None) +def _mp_open_directory(res: "multiprocessing.Queue[typing.Optional[str]]", *args: Any) -> None: + if is_kivy_running(): + raise RuntimeError("kivy should not be running in multiprocess") + res.put(open_directory(*args)) + + def open_directory(title: str, suggest: str = "") -> typing.Optional[str]: def run(*args: str): return subprocess.run(args, capture_output=True, text=True).stdout.split("\n", 1)[0] or None @@ -725,9 +780,16 @@ def open_directory(title: str, suggest: str = "") -> typing.Optional[str]: import tkinter.filedialog except Exception as e: logging.error('Could not load tkinter, which is likely not installed. ' - f'This attempt was made because open_filename was used for "{title}".') + f'This attempt was made because open_directory was used for "{title}".') raise e else: + if is_macos and is_kivy_running(): + # on macOS, mixing kivy and tk does not work, so spawn a new process + # FIXME: performance of this is pretty bad, and we should (also) look into alternatives + from multiprocessing import Process, Queue + res: "Queue[typing.Optional[str]]" = Queue() + Process(target=_mp_open_directory, args=(res, title, suggest)).start() + return res.get() try: root = tkinter.Tk() except tkinter.TclError: @@ -740,12 +802,6 @@ def messagebox(title: str, text: str, error: bool = False) -> None: def run(*args: str): return subprocess.run(args, capture_output=True, text=True).stdout.split("\n", 1)[0] or None - def is_kivy_running(): - if "kivy" in sys.modules: - from kivy.app import App - return App.get_running_app() is not None - return False - if is_kivy_running(): from kvui import MessageBox MessageBox(title, text, error).open() @@ -824,11 +880,10 @@ def async_start(co: Coroutine[None, None, typing.Any], name: Optional[str] = Non task.add_done_callback(_faf_tasks.discard) -def deprecate(message: str): +def deprecate(message: str, add_stacklevels: int = 0): if __debug__: raise Exception(message) - import warnings - warnings.warn(message) + warnings.warn(message, stacklevel=2 + add_stacklevels) class DeprecateDict(dict): @@ -842,10 +897,9 @@ class DeprecateDict(dict): def __getitem__(self, item: Any) -> Any: if self.should_error: - deprecate(self.log_message) + deprecate(self.log_message, add_stacklevels=1) elif __debug__: - import warnings - warnings.warn(self.log_message) + warnings.warn(self.log_message, stacklevel=2) return super().__getitem__(item) @@ -899,7 +953,7 @@ def freeze_support() -> None: def visualize_regions(root_region: Region, file_name: str, *, show_entrance_names: bool = False, show_locations: bool = True, show_other_regions: bool = True, - linetype_ortho: bool = True) -> None: + linetype_ortho: bool = True, regions_to_highlight: set[Region] | None = None) -> None: """Visualize the layout of a world as a PlantUML diagram. :param root_region: The region from which to start the diagram from. (Usually the "Menu" region of your world.) @@ -915,16 +969,22 @@ def visualize_regions(root_region: Region, file_name: str, *, Items without ID will be shown in italics. :param show_other_regions: (default True) If enabled, regions that can't be reached by traversing exits are shown. :param linetype_ortho: (default True) If enabled, orthogonal straight line parts will be used; otherwise polylines. + :param regions_to_highlight: Regions that will be highlighted in green if they are reachable. Example usage in World code: from Utils import visualize_regions - visualize_regions(self.multiworld.get_region("Menu", self.player), "my_world.puml") + state = self.multiworld.get_all_state(False) + state.update_reachable_regions(self.player) + visualize_regions(self.get_region("Menu"), "my_world.puml", show_entrance_names=True, + regions_to_highlight=state.reachable_regions[self.player]) Example usage in Main code: from Utils import visualize_regions for player in multiworld.player_ids: visualize_regions(multiworld.get_region("Menu", player), f"{multiworld.get_out_file_name_base(player)}.puml") """ + if regions_to_highlight is None: + regions_to_highlight = set() assert root_region.multiworld, "The multiworld attribute of root_region has to be filled" from BaseClasses import Entrance, Item, Location, LocationProgressType, MultiWorld, Region from collections import deque @@ -977,7 +1037,7 @@ def visualize_regions(root_region: Region, file_name: str, *, uml.append(f"\"{fmt(region)}\" : {{field}} {lock}{fmt(location)}") def visualize_region(region: Region) -> None: - uml.append(f"class \"{fmt(region)}\"") + uml.append(f"class \"{fmt(region)}\" {'#00FF00' if region in regions_to_highlight else ''}") if show_locations: visualize_locations(region) visualize_exits(region) diff --git a/WargrooveClient.py b/WargrooveClient.py index f9971f7a6c..595a221cd2 100644 --- a/WargrooveClient.py +++ b/WargrooveClient.py @@ -214,17 +214,11 @@ class WargrooveContext(CommonContext): def run_gui(self): """Import kivy UI system and start running it as self.ui_task.""" from kvui import GameManager, HoverBehavior, ServerToolTip - from kivy.uix.tabbedpanel import TabbedPanelItem + from kivymd.uix.tab import MDTabsItem, MDTabsItemText from kivy.lang import Builder - from kivy.uix.button import Button from kivy.uix.togglebutton import ToggleButton from kivy.uix.boxlayout import BoxLayout - from kivy.uix.gridlayout import GridLayout - from kivy.uix.image import AsyncImage, Image - from kivy.uix.stacklayout import StackLayout from kivy.uix.label import Label - from kivy.properties import ColorProperty - from kivy.uix.image import Image import pkgutil class TrackerLayout(BoxLayout): @@ -446,6 +440,6 @@ if __name__ == '__main__': parser = get_base_parser(description="Wargroove Client, for text interfacing.") args, rest = parser.parse_known_args() - colorama.init() + colorama.just_fix_windows_console() asyncio.run(main(args)) colorama.deinit() diff --git a/WebHost.py b/WebHost.py index e597de2476..768eeb5122 100644 --- a/WebHost.py +++ b/WebHost.py @@ -12,11 +12,12 @@ ModuleUpdate.update() # in case app gets imported by something like gunicorn import Utils import settings +from Utils import get_file_safe_name if typing.TYPE_CHECKING: from flask import Flask -Utils.local_path.cached_path = os.path.dirname(__file__) or "." # py3.8 is not abs. remove "." when dropping 3.8 +Utils.local_path.cached_path = os.path.dirname(__file__) settings.no_gui = True configpath = os.path.abspath("config.yaml") if not os.path.exists(configpath): # fall back to config.yaml in home @@ -33,7 +34,7 @@ def get_app() -> "Flask": app.config.from_file(configpath, yaml.safe_load) logging.info(f"Updated config from {configpath}") # inside get_app() so it's usable in systems like gunicorn, which do not run WebHost.py, but import it. - parser = argparse.ArgumentParser() + parser = argparse.ArgumentParser(allow_abbrev=False) parser.add_argument('--config_override', default=None, help="Path to yaml config file that overrules config.yaml.") args = parser.parse_known_args()[0] @@ -71,7 +72,7 @@ def create_ordered_tutorials_file() -> typing.List[typing.Dict[str, typing.Any]] 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) + target_path = os.path.join(base_target_path, get_file_safe_name(game)) os.makedirs(target_path, exist_ok=True) if world.zip_path: diff --git a/WebHostLib/__init__.py b/WebHostLib/__init__.py index fdf3037fe0..9c713419c9 100644 --- a/WebHostLib/__init__.py +++ b/WebHostLib/__init__.py @@ -9,7 +9,7 @@ from flask_compress import Compress from pony.flask import Pony from werkzeug.routing import BaseConverter -from Utils import title_sorted +from Utils import title_sorted, get_file_safe_name UPLOAD_FOLDER = os.path.relpath('uploads') LOGS_FOLDER = os.path.relpath('logs') @@ -20,6 +20,7 @@ Pony(app) app.jinja_env.filters['any'] = any app.jinja_env.filters['all'] = all +app.jinja_env.filters['get_file_safe_name'] = get_file_safe_name app.config["SELFHOST"] = True # application process is in charge of running the websites app.config["GENERATORS"] = 8 # maximum concurrent world gens @@ -38,6 +39,8 @@ app.config["SECRET_KEY"] = bytes(socket.gethostname(), encoding="utf-8") app.config["JOB_THRESHOLD"] = 1 # after what time in seconds should generation be aborted, freeing the queue slot. Can be set to None to disable. app.config["JOB_TIME"] = 600 +# memory limit for generator processes in bytes +app.config["GENERATOR_MEMORY_LIMIT"] = 4294967296 app.config['SESSION_PERMANENT'] = True # waitress uses one thread for I/O, these are for processing of views that then get sent @@ -84,6 +87,6 @@ def register(): from WebHostLib.customserver import run_server_process # to trigger app routing picking up on it - from . import tracker, upload, landing, check, generate, downloads, api, stats, misc, robots, options + from . import tracker, upload, landing, check, generate, downloads, api, stats, misc, robots, options, session app.register_blueprint(api.api_endpoints) diff --git a/WebHostLib/api/__init__.py b/WebHostLib/api/__init__.py index cf05e87374..d0b9d05c16 100644 --- a/WebHostLib/api/__init__.py +++ b/WebHostLib/api/__init__.py @@ -3,13 +3,13 @@ from typing import List, Tuple from flask import Blueprint -from ..models import Seed +from ..models import Seed, Slot api_endpoints = Blueprint('api', __name__, url_prefix="/api") def get_players(seed: Seed) -> List[Tuple[str, str]]: - return [(slot.player_name, slot.game) for slot in seed.slots] + return [(slot.player_name, slot.game) for slot in seed.slots.order_by(Slot.player_id)] from . import datapackage, generate, room, user # trigger registration diff --git a/WebHostLib/api/user.py b/WebHostLib/api/user.py index 116d3afa22..2524cc40a6 100644 --- a/WebHostLib/api/user.py +++ b/WebHostLib/api/user.py @@ -28,6 +28,6 @@ def get_seeds(): response.append({ "seed_id": seed.id, "creation_time": seed.creation_time, - "players": get_players(seed.slots), + "players": get_players(seed), }) - return jsonify(response) \ No newline at end of file + return jsonify(response) diff --git a/WebHostLib/autolauncher.py b/WebHostLib/autolauncher.py index 08a1309ebc..b330146277 100644 --- a/WebHostLib/autolauncher.py +++ b/WebHostLib/autolauncher.py @@ -6,9 +6,10 @@ import multiprocessing import typing from datetime import timedelta, datetime from threading import Event, Thread +from typing import Any from uuid import UUID -from pony.orm import db_session, select, commit +from pony.orm import db_session, select, commit, PrimaryKey from Utils import restricted_loads from .locker import Locker, AlreadyRunningException @@ -35,12 +36,21 @@ def handle_generation_failure(result: BaseException): logging.exception(e) +def _mp_gen_game(gen_options: dict, meta: dict[str, Any] | None = None, owner=None, sid=None) -> PrimaryKey | None: + from setproctitle import setproctitle + + setproctitle(f"Generator ({sid})") + res = gen_game(gen_options, meta=meta, owner=owner, sid=sid) + setproctitle(f"Generator (idle)") + return res + + def launch_generator(pool: multiprocessing.pool.Pool, generation: Generation): try: meta = json.loads(generation.meta) options = restricted_loads(generation.options) logging.info(f"Generating {generation.id} for {len(options)} players") - pool.apply_async(gen_game, (options,), + pool.apply_async(_mp_gen_game, (options,), {"meta": meta, "sid": generation.id, "owner": generation.owner}, @@ -53,7 +63,25 @@ def launch_generator(pool: multiprocessing.pool.Pool, generation: Generation): generation.state = STATE_STARTED -def init_db(pony_config: dict): +def init_generator(config: dict[str, Any]) -> None: + from setproctitle import setproctitle + + setproctitle("Generator (idle)") + + try: + import resource + except ModuleNotFoundError: + pass # unix only module + else: + # set soft limit for memory to from config (default 4GiB) + soft_limit = config["GENERATOR_MEMORY_LIMIT"] + old_limit, hard_limit = resource.getrlimit(resource.RLIMIT_AS) + if soft_limit != old_limit: + resource.setrlimit(resource.RLIMIT_AS, (soft_limit, hard_limit)) + logging.debug(f"Changed AS mem limit {old_limit} -> {soft_limit}") + del resource, soft_limit, hard_limit + + pony_config = config["PONY"] db.bind(**pony_config) db.generate_mapping() @@ -105,8 +133,8 @@ def autogen(config: dict): try: with Locker("autogen"): - with multiprocessing.Pool(config["GENERATORS"], initializer=init_db, - initargs=(config["PONY"],), maxtasksperchild=10) as generator_pool: + with multiprocessing.Pool(config["GENERATORS"], initializer=init_generator, + initargs=(config,), maxtasksperchild=10) as generator_pool: with db_session: to_start = select(generation for generation in Generation if generation.state == STATE_STARTED) diff --git a/WebHostLib/check.py b/WebHostLib/check.py index 97cb797f7a..4e0cf1178f 100644 --- a/WebHostLib/check.py +++ b/WebHostLib/check.py @@ -105,8 +105,9 @@ def roll_options(options: Dict[str, Union[dict, str]], plando_options=plando_options) else: for i, yaml_data in enumerate(yaml_datas): - rolled_results[f"{filename}/{i + 1}"] = roll_settings(yaml_data, - plando_options=plando_options) + if yaml_data is not None: + rolled_results[f"{filename}/{i + 1}"] = roll_settings(yaml_data, + plando_options=plando_options) except Exception as e: if e.__cause__: results[filename] = f"Failed to generate options in {filename}: {e} - {e.__cause__}" diff --git a/WebHostLib/customserver.py b/WebHostLib/customserver.py index a2eef108b0..2ebb40d673 100644 --- a/WebHostLib/customserver.py +++ b/WebHostLib/customserver.py @@ -117,6 +117,7 @@ class WebHostContext(Context): 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", {})} + missing_checksum = False for game in list(multidata.get("datapackage", {})): game_data = multidata["datapackage"][game] @@ -132,11 +133,13 @@ class WebHostContext(Context): continue else: self.logger.warning(f"Did not find game_data_package for {game}: {game_data['checksum']}") + else: + missing_checksum = True # Game rolled on old AP and will load data package from multidata 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: + if not game_data_packages and not missing_checksum: # all static -> use the static dicts directly self.gamespackage = static_gamespackage self.item_name_groups = static_item_name_groups @@ -224,6 +227,9 @@ def set_up_logging(room_id) -> logging.Logger: def run_server_process(name: str, ponyconfig: dict, static_server_data: dict, cert_file: typing.Optional[str], cert_key_file: typing.Optional[str], host: str, rooms_to_run: multiprocessing.Queue, rooms_shutting_down: multiprocessing.Queue): + from setproctitle import setproctitle + + setproctitle(name) Utils.init_logging(name) try: import resource @@ -244,8 +250,23 @@ def run_server_process(name: str, ponyconfig: dict, static_server_data: dict, raise Exception("Worlds system should not be loaded in the custom server.") import gc - ssl_context = load_server_cert(cert_file, cert_key_file) if cert_file else None - del cert_file, cert_key_file, ponyconfig + + if not cert_file: + def get_ssl_context(): + return None + else: + load_date = None + ssl_context = load_server_cert(cert_file, cert_key_file) + + def get_ssl_context(): + nonlocal load_date, ssl_context + today = datetime.date.today() + if load_date != today: + ssl_context = load_server_cert(cert_file, cert_key_file) + load_date = today + return ssl_context + + del ponyconfig gc.collect() # free intermediate objects used during setup loop = asyncio.get_event_loop() @@ -260,12 +281,12 @@ def run_server_process(name: str, ponyconfig: dict, static_server_data: dict, assert ctx.server is None try: ctx.server = websockets.serve( - functools.partial(server, ctx=ctx), ctx.host, ctx.port, ssl=ssl_context) + functools.partial(server, ctx=ctx), ctx.host, ctx.port, ssl=get_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) + functools.partial(server, ctx=ctx), ctx.host, 0, ssl=get_ssl_context()) await ctx.server port = 0 diff --git a/WebHostLib/generate.py b/WebHostLib/generate.py index b19f3d4835..34033a0854 100644 --- a/WebHostLib/generate.py +++ b/WebHostLib/generate.py @@ -31,11 +31,11 @@ def get_meta(options_source: dict, race: bool = False) -> Dict[str, Union[List[s server_options = { "hint_cost": int(options_source.get("hint_cost", ServerOptions.hint_cost)), - "release_mode": options_source.get("release_mode", ServerOptions.release_mode), - "remaining_mode": options_source.get("remaining_mode", ServerOptions.remaining_mode), - "collect_mode": options_source.get("collect_mode", ServerOptions.collect_mode), + "release_mode": str(options_source.get("release_mode", ServerOptions.release_mode)), + "remaining_mode": str(options_source.get("remaining_mode", ServerOptions.remaining_mode)), + "collect_mode": str(options_source.get("collect_mode", ServerOptions.collect_mode)), "item_cheat": bool(int(options_source.get("item_cheat", not ServerOptions.disable_item_cheat))), - "server_password": options_source.get("server_password", None), + "server_password": str(options_source.get("server_password", None)), } generator_options = { "spoiler": int(options_source.get("spoiler", GeneratorOptions.spoiler)), @@ -135,6 +135,7 @@ def gen_game(gen_options: dict, meta: Optional[Dict[str, Any]] = None, owner=Non {"bosses", "items", "connections", "texts"})) erargs.skip_prog_balancing = False erargs.skip_output = False + erargs.spoiler_only = False erargs.csv_output = False name_counter = Counter() diff --git a/WebHostLib/misc.py b/WebHostLib/misc.py index 1f86e21066..98731b65bd 100644 --- a/WebHostLib/misc.py +++ b/WebHostLib/misc.py @@ -18,13 +18,6 @@ def get_world_theme(game_name: str): return 'grass' -@app.before_request -def register_session(): - session.permanent = True # technically 31 days after the last visit - if not session.get("_id", None): - session["_id"] = uuid4() # uniquely identify each session without needing a login - - @app.errorhandler(404) @app.errorhandler(jinja2.exceptions.TemplateNotFound) def page_not_found(err): @@ -42,6 +35,12 @@ def start_playing(): @app.route('/games//info/') @cache.cached() def game_info(game, lang): + try: + world = AutoWorldRegister.world_types[game] + if lang not in world.web.game_info_languages: + raise KeyError("Sorry, this game's info page is not available in that language yet.") + except KeyError: + return abort(404) return render_template('gameInfo.html', game=game, lang=lang, theme=get_world_theme(game)) @@ -59,6 +58,12 @@ def games(): @app.route('/tutorial///') @cache.cached() def tutorial(game, file, lang): + try: + world = AutoWorldRegister.world_types[game] + if lang not in [tut.link.split("/")[1] for tut in world.web.tutorials]: + raise KeyError("Sorry, the tutorial is not available in that language yet.") + except KeyError: + return abort(404) return render_template("tutorial.html", game=game, file=file, lang=lang, theme=get_world_theme(game)) @@ -77,7 +82,13 @@ def faq(lang: str): return render_template( "markdown_document.html", title="Frequently Asked Questions", - html_from_markdown=markdown.markdown(document, extensions=["mdx_breakless_lists"]), + html_from_markdown=markdown.markdown( + document, + extensions=["toc", "mdx_breakless_lists"], + extension_configs={ + "toc": {"anchorlink": True} + } + ), ) @@ -90,7 +101,13 @@ def glossary(lang: str): return render_template( "markdown_document.html", title="Glossary", - html_from_markdown=markdown.markdown(document, extensions=["mdx_breakless_lists"]), + html_from_markdown=markdown.markdown( + document, + extensions=["toc", "mdx_breakless_lists"], + extension_configs={ + "toc": {"anchorlink": True} + } + ), ) diff --git a/WebHostLib/options.py b/WebHostLib/options.py index 15b7bd61ce..38489cee3c 100644 --- a/WebHostLib/options.py +++ b/WebHostLib/options.py @@ -6,7 +6,7 @@ from typing import Dict, Union from docutils.core import publish_parts import yaml -from flask import redirect, render_template, request, Response +from flask import redirect, render_template, request, Response, abort import Options from Utils import local_path @@ -108,7 +108,7 @@ def option_presets(game: str) -> Response: f"Expected {option.special_range_names.keys()} or {option.range_start}-{option.range_end}." presets[preset_name][preset_option_name] = option.value - elif isinstance(option, (Options.Range, Options.OptionSet, Options.OptionList, Options.ItemDict)): + elif isinstance(option, (Options.Range, Options.OptionSet, Options.OptionList, Options.OptionCounter)): presets[preset_name][preset_option_name] = option.value elif isinstance(preset_option, str): # Ensure the option value is valid for Choice and Toggle options @@ -142,7 +142,10 @@ def weighted_options_old(): @app.route("/games//weighted-options") @cache.cached() def weighted_options(game: str): - return render_options_page("weightedOptions/weightedOptions.html", game, is_complex=True) + try: + return render_options_page("weightedOptions/weightedOptions.html", game, is_complex=True) + except KeyError: + return abort(404) @app.route("/games//generate-weighted-yaml", methods=["POST"]) @@ -197,7 +200,10 @@ def generate_weighted_yaml(game: str): @app.route("/games//player-options") @cache.cached() def player_options(game: str): - return render_options_page("playerOptions/playerOptions.html", game, is_complex=False) + try: + return render_options_page("playerOptions/playerOptions.html", game, is_complex=False) + except KeyError: + return abort(404) # YAML generator for player-options @@ -216,7 +222,7 @@ def generate_yaml(game: str): for key, val in options.copy().items(): key_parts = key.rsplit("||", 2) - # Detect and build ItemDict options from their name pattern + # Detect and build OptionCounter options from their name pattern if key_parts[-1] == "qty": if key_parts[0] not in options: options[key_parts[0]] = {} diff --git a/WebHostLib/requirements.txt b/WebHostLib/requirements.txt index 2020387053..a9cd33dd6d 100644 --- a/WebHostLib/requirements.txt +++ b/WebHostLib/requirements.txt @@ -1,13 +1,12 @@ -flask>=3.0.3 -werkzeug>=3.0.4 +flask>=3.1.0 +werkzeug>=3.1.3 pony>=0.7.19 -waitress>=3.0.0 +waitress>=3.0.2 Flask-Caching>=2.3.0 -Flask-Compress>=1.15 -Flask-Limiter>=3.8.0 -bokeh>=3.1.1; python_version <= '3.8' -bokeh>=3.4.3; python_version == '3.9' -bokeh>=3.5.2; python_version >= '3.10' -markupsafe>=2.1.5 +Flask-Compress>=1.17 +Flask-Limiter>=3.12 +bokeh>=3.6.3 +markupsafe>=3.0.2 Markdown>=3.7 mdx-breakless-lists>=1.0.1 +setproctitle>=1.3.5 diff --git a/WebHostLib/session.py b/WebHostLib/session.py new file mode 100644 index 0000000000..d5dab7d6e6 --- /dev/null +++ b/WebHostLib/session.py @@ -0,0 +1,31 @@ +from uuid import uuid4, UUID + +from flask import session, render_template + +from WebHostLib import app + + +@app.before_request +def register_session(): + session.permanent = True # technically 31 days after the last visit + if not session.get("_id", None): + session["_id"] = uuid4() # uniquely identify each session without needing a login + + +@app.route('/session') +def show_session(): + return render_template( + "session.html", + ) + + +@app.route('/session/') +def set_session(_id: str): + new_id: UUID = UUID(_id, version=4) + old_id: UUID = session["_id"] + if old_id != new_id: + session["_id"] = new_id + return render_template( + "session.html", + old_id=old_id, + ) diff --git a/WebHostLib/static/assets/faq/en.md b/WebHostLib/static/assets/faq/en.md index e64535b42d..96e526612b 100644 --- a/WebHostLib/static/assets/faq/en.md +++ b/WebHostLib/static/assets/faq/en.md @@ -22,7 +22,7 @@ players to rely upon each other to complete their game. While a multiworld game traditionally requires all players to be playing the same game, a multi-game multiworld allows players to randomize any of the supported games, and send items between them. This allows players of different -games to interact with one another in a single multiplayer environment. Archipelago supports multi-game multiworld. +games to interact with one another in a single multiplayer environment. Archipelago supports multi-game multiworlds. Here is a list of our [Supported Games](https://archipelago.gg/games). ## Can I generate a single-player game with Archipelago? diff --git a/WebHostLib/static/assets/gameInfo.js b/WebHostLib/static/assets/gameInfo.js index b8c56905a5..797c9f6448 100644 --- a/WebHostLib/static/assets/gameInfo.js +++ b/WebHostLib/static/assets/gameInfo.js @@ -23,7 +23,6 @@ window.addEventListener('load', () => { showdown.setOption('strikethrough', true); showdown.setOption('literalMidWordUnderscores', true); gameInfo.innerHTML += (new showdown.Converter()).makeHtml(results); - adjustHeaderWidth(); // Reset the id of all header divs to something nicer for (const header of document.querySelectorAll('h1, h2, h3, h4, h5, h6')) { @@ -42,10 +41,5 @@ window.addEventListener('load', () => { scrollTarget?.scrollIntoView(); } }); - }).catch((error) => { - console.error(error); - gameInfo.innerHTML = - `

This page is out of logic!

-

Click here to return to safety.

`; }); }); diff --git a/WebHostLib/static/assets/hostGame.js b/WebHostLib/static/assets/hostGame.js index db1ab1ddde..01a8da06e5 100644 --- a/WebHostLib/static/assets/hostGame.js +++ b/WebHostLib/static/assets/hostGame.js @@ -6,6 +6,4 @@ window.addEventListener('load', () => { document.getElementById('file-input').addEventListener('change', () => { document.getElementById('host-game-form').submit(); }); - - adjustFooterHeight(); }); diff --git a/WebHostLib/static/assets/styleController.js b/WebHostLib/static/assets/styleController.js deleted file mode 100644 index 924e86ee26..0000000000 --- a/WebHostLib/static/assets/styleController.js +++ /dev/null @@ -1,47 +0,0 @@ -const adjustFooterHeight = () => { - // If there is no footer on this page, do nothing - const footer = document.getElementById('island-footer'); - if (!footer) { return; } - - // If the body is taller than the window, also do nothing - if (document.body.offsetHeight > window.innerHeight) { - footer.style.marginTop = '0'; - return; - } - - // Add a margin-top to the footer to position it at the bottom of the screen - const sibling = footer.previousElementSibling; - const margin = (window.innerHeight - sibling.offsetTop - sibling.offsetHeight - footer.offsetHeight); - if (margin < 1) { - footer.style.marginTop = '0'; - return; - } - footer.style.marginTop = `${margin}px`; -}; - -const adjustHeaderWidth = () => { - // If there is no header, do nothing - const header = document.getElementById('base-header'); - if (!header) { return; } - - const tempDiv = document.createElement('div'); - tempDiv.style.width = '100px'; - tempDiv.style.height = '100px'; - tempDiv.style.overflow = 'scroll'; - tempDiv.style.position = 'absolute'; - tempDiv.style.top = '-500px'; - document.body.appendChild(tempDiv); - const scrollbarWidth = tempDiv.offsetWidth - tempDiv.clientWidth; - document.body.removeChild(tempDiv); - - const documentRoot = document.compatMode === 'BackCompat' ? document.body : document.documentElement; - const margin = (documentRoot.scrollHeight > documentRoot.clientHeight) ? 0-scrollbarWidth : 0; - document.getElementById('base-header-right').style.marginRight = `${margin}px`; -}; - -window.addEventListener('load', () => { - window.addEventListener('resize', adjustFooterHeight); - window.addEventListener('resize', adjustHeaderWidth); - adjustFooterHeight(); - adjustHeaderWidth(); -}); diff --git a/WebHostLib/static/assets/tutorial.js b/WebHostLib/static/assets/tutorial.js index 1db08d85b3..c9022719fb 100644 --- a/WebHostLib/static/assets/tutorial.js +++ b/WebHostLib/static/assets/tutorial.js @@ -25,7 +25,6 @@ window.addEventListener('load', () => { showdown.setOption('literalMidWordUnderscores', true); showdown.setOption('disableForced4SpacesIndentedSublists', true); tutorialWrapper.innerHTML += (new showdown.Converter()).makeHtml(results); - adjustHeaderWidth(); const title = document.querySelector('h1') if (title) { @@ -49,10 +48,5 @@ window.addEventListener('load', () => { scrollTarget?.scrollIntoView(); } }); - }).catch((error) => { - console.error(error); - tutorialWrapper.innerHTML = - `

This page is out of logic!

-

Click here to return to safety.

`; }); }); diff --git a/WebHostLib/static/static/backgrounds/cliffs/grass/cliff-atlas.png b/WebHostLib/static/static/backgrounds/cliffs/grass/cliff-atlas.png new file mode 100644 index 0000000000..537e279791 Binary files /dev/null and b/WebHostLib/static/static/backgrounds/cliffs/grass/cliff-atlas.png differ diff --git a/WebHostLib/static/static/backgrounds/cliffs/grass/cliff-atlas.webp b/WebHostLib/static/static/backgrounds/cliffs/grass/cliff-atlas.webp new file mode 100644 index 0000000000..f34cd5ff2e Binary files /dev/null and b/WebHostLib/static/static/backgrounds/cliffs/grass/cliff-atlas.webp differ diff --git a/WebHostLib/static/static/backgrounds/cliffs/grass/cliff-bottom-left-corner.png b/WebHostLib/static/static/backgrounds/cliffs/grass/cliff-bottom-left-corner.png index 326670b7eb..a0b41b0f8c 100644 Binary files a/WebHostLib/static/static/backgrounds/cliffs/grass/cliff-bottom-left-corner.png and b/WebHostLib/static/static/backgrounds/cliffs/grass/cliff-bottom-left-corner.png differ diff --git a/WebHostLib/static/static/backgrounds/cliffs/grass/cliff-bottom-left-corner.webp b/WebHostLib/static/static/backgrounds/cliffs/grass/cliff-bottom-left-corner.webp new file mode 100644 index 0000000000..4a5f2d75a0 Binary files /dev/null and b/WebHostLib/static/static/backgrounds/cliffs/grass/cliff-bottom-left-corner.webp differ diff --git a/WebHostLib/static/static/backgrounds/cliffs/grass/cliff-bottom-right-corner.png b/WebHostLib/static/static/backgrounds/cliffs/grass/cliff-bottom-right-corner.png index c8297d3457..6e1608d82b 100644 Binary files a/WebHostLib/static/static/backgrounds/cliffs/grass/cliff-bottom-right-corner.png and b/WebHostLib/static/static/backgrounds/cliffs/grass/cliff-bottom-right-corner.png differ diff --git a/WebHostLib/static/static/backgrounds/cliffs/grass/cliff-bottom-right-corner.webp b/WebHostLib/static/static/backgrounds/cliffs/grass/cliff-bottom-right-corner.webp new file mode 100644 index 0000000000..30bd2d047a Binary files /dev/null and b/WebHostLib/static/static/backgrounds/cliffs/grass/cliff-bottom-right-corner.webp differ diff --git a/WebHostLib/static/static/backgrounds/cliffs/grass/cliff-bottom.png b/WebHostLib/static/static/backgrounds/cliffs/grass/cliff-bottom.png index 2a28958e09..3d3e089ef7 100644 Binary files a/WebHostLib/static/static/backgrounds/cliffs/grass/cliff-bottom.png and b/WebHostLib/static/static/backgrounds/cliffs/grass/cliff-bottom.png differ diff --git a/WebHostLib/static/static/backgrounds/cliffs/grass/cliff-bottom.webp b/WebHostLib/static/static/backgrounds/cliffs/grass/cliff-bottom.webp new file mode 100644 index 0000000000..f575ac5d9d Binary files /dev/null and b/WebHostLib/static/static/backgrounds/cliffs/grass/cliff-bottom.webp differ diff --git a/WebHostLib/static/static/backgrounds/cliffs/grass/cliff-left.png b/WebHostLib/static/static/backgrounds/cliffs/grass/cliff-left.png index 9bc84ff603..08730d9848 100644 Binary files a/WebHostLib/static/static/backgrounds/cliffs/grass/cliff-left.png and b/WebHostLib/static/static/backgrounds/cliffs/grass/cliff-left.png differ diff --git a/WebHostLib/static/static/backgrounds/cliffs/grass/cliff-left.webp b/WebHostLib/static/static/backgrounds/cliffs/grass/cliff-left.webp new file mode 100644 index 0000000000..f9227e8f22 Binary files /dev/null and b/WebHostLib/static/static/backgrounds/cliffs/grass/cliff-left.webp differ diff --git a/WebHostLib/static/static/backgrounds/cliffs/grass/cliff-right.png b/WebHostLib/static/static/backgrounds/cliffs/grass/cliff-right.png index a1e9c7c8b6..0bc82fa70e 100644 Binary files a/WebHostLib/static/static/backgrounds/cliffs/grass/cliff-right.png and b/WebHostLib/static/static/backgrounds/cliffs/grass/cliff-right.png differ diff --git a/WebHostLib/static/static/backgrounds/cliffs/grass/cliff-right.webp b/WebHostLib/static/static/backgrounds/cliffs/grass/cliff-right.webp new file mode 100644 index 0000000000..3c0a577402 Binary files /dev/null and b/WebHostLib/static/static/backgrounds/cliffs/grass/cliff-right.webp differ diff --git a/WebHostLib/static/static/backgrounds/cliffs/grass/cliff-top-left-corner.png b/WebHostLib/static/static/backgrounds/cliffs/grass/cliff-top-left-corner.png index a40bca60f0..05e675d6a9 100644 Binary files a/WebHostLib/static/static/backgrounds/cliffs/grass/cliff-top-left-corner.png and b/WebHostLib/static/static/backgrounds/cliffs/grass/cliff-top-left-corner.png differ diff --git a/WebHostLib/static/static/backgrounds/cliffs/grass/cliff-top-left-corner.webp b/WebHostLib/static/static/backgrounds/cliffs/grass/cliff-top-left-corner.webp new file mode 100644 index 0000000000..4283cd42b1 Binary files /dev/null and b/WebHostLib/static/static/backgrounds/cliffs/grass/cliff-top-left-corner.webp differ diff --git a/WebHostLib/static/static/backgrounds/cliffs/grass/cliff-top-right-corner.png b/WebHostLib/static/static/backgrounds/cliffs/grass/cliff-top-right-corner.png index b8a8c6a726..e0683a74bb 100644 Binary files a/WebHostLib/static/static/backgrounds/cliffs/grass/cliff-top-right-corner.png and b/WebHostLib/static/static/backgrounds/cliffs/grass/cliff-top-right-corner.png differ diff --git a/WebHostLib/static/static/backgrounds/cliffs/grass/cliff-top-right-corner.webp b/WebHostLib/static/static/backgrounds/cliffs/grass/cliff-top-right-corner.webp new file mode 100644 index 0000000000..3075cec96a Binary files /dev/null and b/WebHostLib/static/static/backgrounds/cliffs/grass/cliff-top-right-corner.webp differ diff --git a/WebHostLib/static/static/backgrounds/cliffs/grass/cliff-top.png b/WebHostLib/static/static/backgrounds/cliffs/grass/cliff-top.png index bb6ccec3d5..cded7ad108 100644 Binary files a/WebHostLib/static/static/backgrounds/cliffs/grass/cliff-top.png and b/WebHostLib/static/static/backgrounds/cliffs/grass/cliff-top.png differ diff --git a/WebHostLib/static/static/backgrounds/cliffs/grass/cliff-top.webp b/WebHostLib/static/static/backgrounds/cliffs/grass/cliff-top.webp new file mode 100644 index 0000000000..781b8e4df0 Binary files /dev/null and b/WebHostLib/static/static/backgrounds/cliffs/grass/cliff-top.webp differ diff --git a/WebHostLib/static/static/backgrounds/clouds/cloud-0001.png b/WebHostLib/static/static/backgrounds/clouds/cloud-0001.png index dba338f585..1015819bc8 100644 Binary files a/WebHostLib/static/static/backgrounds/clouds/cloud-0001.png and b/WebHostLib/static/static/backgrounds/clouds/cloud-0001.png differ diff --git a/WebHostLib/static/static/backgrounds/clouds/cloud-0001.webp b/WebHostLib/static/static/backgrounds/clouds/cloud-0001.webp new file mode 100644 index 0000000000..73e249f6e5 Binary files /dev/null and b/WebHostLib/static/static/backgrounds/clouds/cloud-0001.webp differ diff --git a/WebHostLib/static/static/backgrounds/clouds/cloud-0002.png b/WebHostLib/static/static/backgrounds/clouds/cloud-0002.png index 33f09b19ce..7b479bfe7b 100644 Binary files a/WebHostLib/static/static/backgrounds/clouds/cloud-0002.png and b/WebHostLib/static/static/backgrounds/clouds/cloud-0002.png differ diff --git a/WebHostLib/static/static/backgrounds/clouds/cloud-0002.webp b/WebHostLib/static/static/backgrounds/clouds/cloud-0002.webp new file mode 100644 index 0000000000..e4ac19bef6 Binary files /dev/null and b/WebHostLib/static/static/backgrounds/clouds/cloud-0002.webp differ diff --git a/WebHostLib/static/static/backgrounds/clouds/cloud-0003.png b/WebHostLib/static/static/backgrounds/clouds/cloud-0003.png index f665015b0d..59844e31ac 100644 Binary files a/WebHostLib/static/static/backgrounds/clouds/cloud-0003.png and b/WebHostLib/static/static/backgrounds/clouds/cloud-0003.png differ diff --git a/WebHostLib/static/static/backgrounds/clouds/cloud-0003.webp b/WebHostLib/static/static/backgrounds/clouds/cloud-0003.webp new file mode 100644 index 0000000000..36abe6e552 Binary files /dev/null and b/WebHostLib/static/static/backgrounds/clouds/cloud-0003.webp differ diff --git a/WebHostLib/static/static/backgrounds/dirt.png b/WebHostLib/static/static/backgrounds/dirt.png index 4ac930edc6..db6bc34635 100644 Binary files a/WebHostLib/static/static/backgrounds/dirt.png and b/WebHostLib/static/static/backgrounds/dirt.png differ diff --git a/WebHostLib/static/static/backgrounds/dirt.webp b/WebHostLib/static/static/backgrounds/dirt.webp new file mode 100644 index 0000000000..5a8635506f Binary files /dev/null and b/WebHostLib/static/static/backgrounds/dirt.webp differ diff --git a/WebHostLib/static/static/backgrounds/footer/footer-0001.png b/WebHostLib/static/static/backgrounds/footer/footer-0001.png index b863a3d429..6752ab4e32 100644 Binary files a/WebHostLib/static/static/backgrounds/footer/footer-0001.png and b/WebHostLib/static/static/backgrounds/footer/footer-0001.png differ diff --git a/WebHostLib/static/static/backgrounds/footer/footer-0001.webp b/WebHostLib/static/static/backgrounds/footer/footer-0001.webp new file mode 100644 index 0000000000..fb278c3b16 Binary files /dev/null and b/WebHostLib/static/static/backgrounds/footer/footer-0001.webp differ diff --git a/WebHostLib/static/static/backgrounds/footer/footer-0002.png b/WebHostLib/static/static/backgrounds/footer/footer-0002.png index 90fdfe95d0..3bacab4134 100644 Binary files a/WebHostLib/static/static/backgrounds/footer/footer-0002.png and b/WebHostLib/static/static/backgrounds/footer/footer-0002.png differ diff --git a/WebHostLib/static/static/backgrounds/footer/footer-0002.webp b/WebHostLib/static/static/backgrounds/footer/footer-0002.webp new file mode 100644 index 0000000000..9b8e457c52 Binary files /dev/null and b/WebHostLib/static/static/backgrounds/footer/footer-0002.webp differ diff --git a/WebHostLib/static/static/backgrounds/footer/footer-0003.png b/WebHostLib/static/static/backgrounds/footer/footer-0003.png index 5fc31d1ee9..f8223e6901 100644 Binary files a/WebHostLib/static/static/backgrounds/footer/footer-0003.png and b/WebHostLib/static/static/backgrounds/footer/footer-0003.png differ diff --git a/WebHostLib/static/static/backgrounds/footer/footer-0003.webp b/WebHostLib/static/static/backgrounds/footer/footer-0003.webp new file mode 100644 index 0000000000..c2ded77536 Binary files /dev/null and b/WebHostLib/static/static/backgrounds/footer/footer-0003.webp differ diff --git a/WebHostLib/static/static/backgrounds/footer/footer-0004.png b/WebHostLib/static/static/backgrounds/footer/footer-0004.png index 4a95ce9a3a..d4476e53f7 100644 Binary files a/WebHostLib/static/static/backgrounds/footer/footer-0004.png and b/WebHostLib/static/static/backgrounds/footer/footer-0004.png differ diff --git a/WebHostLib/static/static/backgrounds/footer/footer-0004.webp b/WebHostLib/static/static/backgrounds/footer/footer-0004.webp new file mode 100644 index 0000000000..a210081746 Binary files /dev/null and b/WebHostLib/static/static/backgrounds/footer/footer-0004.webp differ diff --git a/WebHostLib/static/static/backgrounds/footer/footer-0005.png b/WebHostLib/static/static/backgrounds/footer/footer-0005.png index 7b7cd502f3..7946159624 100644 Binary files a/WebHostLib/static/static/backgrounds/footer/footer-0005.png and b/WebHostLib/static/static/backgrounds/footer/footer-0005.png differ diff --git a/WebHostLib/static/static/backgrounds/footer/footer-0005.webp b/WebHostLib/static/static/backgrounds/footer/footer-0005.webp new file mode 100644 index 0000000000..c0ee5205ca Binary files /dev/null and b/WebHostLib/static/static/backgrounds/footer/footer-0005.webp differ diff --git a/WebHostLib/static/static/backgrounds/grass-flowers.png b/WebHostLib/static/static/backgrounds/grass-flowers.png index 464fdbe581..ea39c54190 100644 Binary files a/WebHostLib/static/static/backgrounds/grass-flowers.png and b/WebHostLib/static/static/backgrounds/grass-flowers.png differ diff --git a/WebHostLib/static/static/backgrounds/grass-flowers.webp b/WebHostLib/static/static/backgrounds/grass-flowers.webp new file mode 100644 index 0000000000..1b8ebd7706 Binary files /dev/null and b/WebHostLib/static/static/backgrounds/grass-flowers.webp differ diff --git a/WebHostLib/static/static/backgrounds/grass.png b/WebHostLib/static/static/backgrounds/grass.png index b88c33dec4..6a99c4d943 100644 Binary files a/WebHostLib/static/static/backgrounds/grass.png and b/WebHostLib/static/static/backgrounds/grass.png differ diff --git a/WebHostLib/static/static/backgrounds/grass.webp b/WebHostLib/static/static/backgrounds/grass.webp new file mode 100644 index 0000000000..212ab377a6 Binary files /dev/null and b/WebHostLib/static/static/backgrounds/grass.webp differ diff --git a/WebHostLib/static/static/backgrounds/header/dirt-header.png b/WebHostLib/static/static/backgrounds/header/dirt-header.png index 7c9e298e22..8a9c0963e7 100644 Binary files a/WebHostLib/static/static/backgrounds/header/dirt-header.png and b/WebHostLib/static/static/backgrounds/header/dirt-header.png differ diff --git a/WebHostLib/static/static/backgrounds/header/dirt-header.webp b/WebHostLib/static/static/backgrounds/header/dirt-header.webp new file mode 100644 index 0000000000..6c2b0bd8bf Binary files /dev/null and b/WebHostLib/static/static/backgrounds/header/dirt-header.webp differ diff --git a/WebHostLib/static/static/backgrounds/header/grass-header.png b/WebHostLib/static/static/backgrounds/header/grass-header.png index c2acc58807..6d620e5033 100644 Binary files a/WebHostLib/static/static/backgrounds/header/grass-header.png and b/WebHostLib/static/static/backgrounds/header/grass-header.png differ diff --git a/WebHostLib/static/static/backgrounds/header/grass-header.webp b/WebHostLib/static/static/backgrounds/header/grass-header.webp new file mode 100644 index 0000000000..ca5d1e23bc Binary files /dev/null and b/WebHostLib/static/static/backgrounds/header/grass-header.webp differ diff --git a/WebHostLib/static/static/backgrounds/header/ocean-header.png b/WebHostLib/static/static/backgrounds/header/ocean-header.png index a0ff51f924..1e1c18e93c 100644 Binary files a/WebHostLib/static/static/backgrounds/header/ocean-header.png and b/WebHostLib/static/static/backgrounds/header/ocean-header.png differ diff --git a/WebHostLib/static/static/backgrounds/header/ocean-header.webp b/WebHostLib/static/static/backgrounds/header/ocean-header.webp new file mode 100644 index 0000000000..fc1803ca0e Binary files /dev/null and b/WebHostLib/static/static/backgrounds/header/ocean-header.webp differ diff --git a/WebHostLib/static/static/backgrounds/header/party-time-header.png b/WebHostLib/static/static/backgrounds/header/party-time-header.png index 799f32f228..601ad829f1 100644 Binary files a/WebHostLib/static/static/backgrounds/header/party-time-header.png and b/WebHostLib/static/static/backgrounds/header/party-time-header.png differ diff --git a/WebHostLib/static/static/backgrounds/header/party-time-header.webp b/WebHostLib/static/static/backgrounds/header/party-time-header.webp new file mode 100644 index 0000000000..0b3c70871a Binary files /dev/null and b/WebHostLib/static/static/backgrounds/header/party-time-header.webp differ diff --git a/WebHostLib/static/static/backgrounds/header/stone-header.png b/WebHostLib/static/static/backgrounds/header/stone-header.png index e0c9787e57..f0d2f2fee5 100644 Binary files a/WebHostLib/static/static/backgrounds/header/stone-header.png and b/WebHostLib/static/static/backgrounds/header/stone-header.png differ diff --git a/WebHostLib/static/static/backgrounds/header/stone-header.webp b/WebHostLib/static/static/backgrounds/header/stone-header.webp new file mode 100644 index 0000000000..9f26d1a505 Binary files /dev/null and b/WebHostLib/static/static/backgrounds/header/stone-header.webp differ diff --git a/WebHostLib/static/static/backgrounds/ice.png b/WebHostLib/static/static/backgrounds/ice.png index fcf7299b35..c64f1b20f3 100644 Binary files a/WebHostLib/static/static/backgrounds/ice.png and b/WebHostLib/static/static/backgrounds/ice.png differ diff --git a/WebHostLib/static/static/backgrounds/ice.webp b/WebHostLib/static/static/backgrounds/ice.webp new file mode 100644 index 0000000000..a129d5f439 Binary files /dev/null and b/WebHostLib/static/static/backgrounds/ice.webp differ diff --git a/WebHostLib/static/static/backgrounds/jungle.png b/WebHostLib/static/static/backgrounds/jungle.png index e27d7e9920..c4ec5b9648 100644 Binary files a/WebHostLib/static/static/backgrounds/jungle.png and b/WebHostLib/static/static/backgrounds/jungle.png differ diff --git a/WebHostLib/static/static/backgrounds/jungle.webp b/WebHostLib/static/static/backgrounds/jungle.webp new file mode 100644 index 0000000000..d21edc8e55 Binary files /dev/null and b/WebHostLib/static/static/backgrounds/jungle.webp differ diff --git a/WebHostLib/static/static/backgrounds/ocean.png b/WebHostLib/static/static/backgrounds/ocean.png index 5c22c0b92a..d6c9d285c9 100644 Binary files a/WebHostLib/static/static/backgrounds/ocean.png and b/WebHostLib/static/static/backgrounds/ocean.png differ diff --git a/WebHostLib/static/static/backgrounds/ocean.webp b/WebHostLib/static/static/backgrounds/ocean.webp new file mode 100644 index 0000000000..a50b7b27f7 Binary files /dev/null and b/WebHostLib/static/static/backgrounds/ocean.webp differ diff --git a/WebHostLib/static/static/backgrounds/party-time.png b/WebHostLib/static/static/backgrounds/party-time.png index ad00851ba4..3fcea8a46e 100644 Binary files a/WebHostLib/static/static/backgrounds/party-time.png and b/WebHostLib/static/static/backgrounds/party-time.png differ diff --git a/WebHostLib/static/static/backgrounds/party-time.webp b/WebHostLib/static/static/backgrounds/party-time.webp new file mode 100644 index 0000000000..7cd547329a Binary files /dev/null and b/WebHostLib/static/static/backgrounds/party-time.webp differ diff --git a/WebHostLib/static/static/backgrounds/stone.png b/WebHostLib/static/static/backgrounds/stone.png index 9e15a34375..2956beaaa8 100644 Binary files a/WebHostLib/static/static/backgrounds/stone.png and b/WebHostLib/static/static/backgrounds/stone.png differ diff --git a/WebHostLib/static/static/backgrounds/stone.webp b/WebHostLib/static/static/backgrounds/stone.webp new file mode 100644 index 0000000000..96303c8162 Binary files /dev/null and b/WebHostLib/static/static/backgrounds/stone.webp differ diff --git a/WebHostLib/static/static/branding/header-logo-full.svg b/WebHostLib/static/static/branding/header-logo-full.svg new file mode 100644 index 0000000000..3e22500905 --- /dev/null +++ b/WebHostLib/static/static/branding/header-logo-full.svg @@ -0,0 +1,66 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/WebHostLib/static/static/branding/header-logo.png b/WebHostLib/static/static/branding/header-logo.png index e5d7f9b4a0..5a3dbe7daf 100644 Binary files a/WebHostLib/static/static/branding/header-logo.png and b/WebHostLib/static/static/branding/header-logo.png differ diff --git a/WebHostLib/static/static/branding/header-logo.svg b/WebHostLib/static/static/branding/header-logo.svg index 3e22500905..ceedba4338 100644 --- a/WebHostLib/static/static/branding/header-logo.svg +++ b/WebHostLib/static/static/branding/header-logo.svg @@ -1,66 +1 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - + \ No newline at end of file diff --git a/WebHostLib/static/static/branding/header-logo.webp b/WebHostLib/static/static/branding/header-logo.webp new file mode 100644 index 0000000000..c8088e8262 Binary files /dev/null and b/WebHostLib/static/static/branding/header-logo.webp differ diff --git a/WebHostLib/static/static/branding/landing-logo.png b/WebHostLib/static/static/branding/landing-logo.png index 1f2b967a98..d4845a475d 100644 Binary files a/WebHostLib/static/static/branding/landing-logo.png and b/WebHostLib/static/static/branding/landing-logo.png differ diff --git a/WebHostLib/static/static/branding/landing-logo.webp b/WebHostLib/static/static/branding/landing-logo.webp new file mode 100644 index 0000000000..7bd4673e99 Binary files /dev/null and b/WebHostLib/static/static/branding/landing-logo.webp differ diff --git a/WebHostLib/static/static/button-images/hamburger-menu-icon.png b/WebHostLib/static/static/button-images/hamburger-menu-icon.png index f1c9631635..c834501453 100644 Binary files a/WebHostLib/static/static/button-images/hamburger-menu-icon.png and b/WebHostLib/static/static/button-images/hamburger-menu-icon.png differ diff --git a/WebHostLib/static/static/button-images/hamburger-menu-icon.webp b/WebHostLib/static/static/button-images/hamburger-menu-icon.webp new file mode 100644 index 0000000000..970754d7bf Binary files /dev/null and b/WebHostLib/static/static/button-images/hamburger-menu-icon.webp differ diff --git a/WebHostLib/static/static/button-images/island-button-a.png b/WebHostLib/static/static/button-images/island-button-a.png index f3872dfd6c..552e4d8f6d 100644 Binary files a/WebHostLib/static/static/button-images/island-button-a.png and b/WebHostLib/static/static/button-images/island-button-a.png differ diff --git a/WebHostLib/static/static/button-images/island-button-a.webp b/WebHostLib/static/static/button-images/island-button-a.webp new file mode 100644 index 0000000000..6da0c17200 Binary files /dev/null and b/WebHostLib/static/static/button-images/island-button-a.webp differ diff --git a/WebHostLib/static/static/button-images/island-button-b.png b/WebHostLib/static/static/button-images/island-button-b.png index 65008eaf59..fd4a256c7c 100644 Binary files a/WebHostLib/static/static/button-images/island-button-b.png and b/WebHostLib/static/static/button-images/island-button-b.png differ diff --git a/WebHostLib/static/static/button-images/island-button-b.webp b/WebHostLib/static/static/button-images/island-button-b.webp new file mode 100644 index 0000000000..6b7c3a279e Binary files /dev/null and b/WebHostLib/static/static/button-images/island-button-b.webp differ diff --git a/WebHostLib/static/static/button-images/island-button-c.png b/WebHostLib/static/static/button-images/island-button-c.png index 9e5f9f50d2..2f10f45828 100644 Binary files a/WebHostLib/static/static/button-images/island-button-c.png and b/WebHostLib/static/static/button-images/island-button-c.png differ diff --git a/WebHostLib/static/static/button-images/island-button-c.webp b/WebHostLib/static/static/button-images/island-button-c.webp new file mode 100644 index 0000000000..83ce413da8 Binary files /dev/null and b/WebHostLib/static/static/button-images/island-button-c.webp differ diff --git a/WebHostLib/static/static/button-images/popover.png b/WebHostLib/static/static/button-images/popover.png index cbc8634104..e3247194b0 100644 Binary files a/WebHostLib/static/static/button-images/popover.png and b/WebHostLib/static/static/button-images/popover.png differ diff --git a/WebHostLib/static/static/button-images/popover.webp b/WebHostLib/static/static/button-images/popover.webp new file mode 100644 index 0000000000..cd1c006221 Binary files /dev/null and b/WebHostLib/static/static/button-images/popover.webp differ diff --git a/WebHostLib/static/static/decorations/island-a.png b/WebHostLib/static/static/decorations/island-a.png index d931aed0bd..4f5d7c2641 100644 Binary files a/WebHostLib/static/static/decorations/island-a.png and b/WebHostLib/static/static/decorations/island-a.png differ diff --git a/WebHostLib/static/static/decorations/island-a.webp b/WebHostLib/static/static/decorations/island-a.webp new file mode 100644 index 0000000000..32c9cc8f6b Binary files /dev/null and b/WebHostLib/static/static/decorations/island-a.webp differ diff --git a/WebHostLib/static/static/decorations/island-b.png b/WebHostLib/static/static/decorations/island-b.png index d690228192..cceb79af33 100644 Binary files a/WebHostLib/static/static/decorations/island-b.png and b/WebHostLib/static/static/decorations/island-b.png differ diff --git a/WebHostLib/static/static/decorations/island-b.webp b/WebHostLib/static/static/decorations/island-b.webp new file mode 100644 index 0000000000..3ec6aae438 Binary files /dev/null and b/WebHostLib/static/static/decorations/island-b.webp differ diff --git a/WebHostLib/static/static/decorations/island-c.png b/WebHostLib/static/static/decorations/island-c.png index 790c7b01d5..2beedce19d 100644 Binary files a/WebHostLib/static/static/decorations/island-c.png and b/WebHostLib/static/static/decorations/island-c.png differ diff --git a/WebHostLib/static/static/decorations/island-c.webp b/WebHostLib/static/static/decorations/island-c.webp new file mode 100644 index 0000000000..98e1add91e Binary files /dev/null and b/WebHostLib/static/static/decorations/island-c.webp differ diff --git a/WebHostLib/static/static/decorations/rock-in-water.png b/WebHostLib/static/static/decorations/rock-in-water.png index 25c62acd24..1320bef7ce 100644 Binary files a/WebHostLib/static/static/decorations/rock-in-water.png and b/WebHostLib/static/static/decorations/rock-in-water.png differ diff --git a/WebHostLib/static/static/decorations/rock-in-water.webp b/WebHostLib/static/static/decorations/rock-in-water.webp new file mode 100644 index 0000000000..2c8af460d5 Binary files /dev/null and b/WebHostLib/static/static/decorations/rock-in-water.webp differ diff --git a/WebHostLib/static/static/decorations/rock-single.png b/WebHostLib/static/static/decorations/rock-single.png index cc237d132e..c003abe0d1 100644 Binary files a/WebHostLib/static/static/decorations/rock-single.png and b/WebHostLib/static/static/decorations/rock-single.png differ diff --git a/WebHostLib/static/static/decorations/rock-single.webp b/WebHostLib/static/static/decorations/rock-single.webp new file mode 100644 index 0000000000..e53a2fb5c4 Binary files /dev/null and b/WebHostLib/static/static/decorations/rock-single.webp differ diff --git a/WebHostLib/static/styles/globalStyles.css b/WebHostLib/static/styles/globalStyles.css index 1a0144830e..adcee6581b 100644 --- a/WebHostLib/static/styles/globalStyles.css +++ b/WebHostLib/static/styles/globalStyles.css @@ -36,6 +36,13 @@ html{ body{ margin: 0; + display: flex; + flex-direction: column; + min-height: calc(100vh - 110px); +} + +main { + flex-grow: 1; } a{ diff --git a/WebHostLib/static/styles/markdown.css b/WebHostLib/static/styles/markdown.css index e0165b7489..5ead2c60f7 100644 --- a/WebHostLib/static/styles/markdown.css +++ b/WebHostLib/static/styles/markdown.css @@ -28,7 +28,7 @@ font-weight: normal; font-family: LondrinaSolid-Regular, sans-serif; text-transform: uppercase; - cursor: pointer; + cursor: pointer; /* TODO: remove once we drop showdown.js */ width: 100%; text-shadow: 1px 1px 4px #000000; } @@ -37,7 +37,7 @@ font-size: 38px; font-weight: normal; font-family: LondrinaSolid-Light, sans-serif; - cursor: pointer; + cursor: pointer; /* TODO: remove once we drop showdown.js */ width: 100%; margin-top: 20px; margin-bottom: 0.5rem; @@ -50,7 +50,7 @@ font-family: LexendDeca-Regular, sans-serif; text-transform: none; text-align: left; - cursor: pointer; + cursor: pointer; /* TODO: remove once we drop showdown.js */ width: 100%; margin-bottom: 0.5rem; } @@ -59,7 +59,7 @@ font-family: LexendDeca-Regular, sans-serif; text-transform: none; font-size: 24px; - cursor: pointer; + cursor: pointer; /* TODO: remove once we drop showdown.js */ margin-bottom: 24px; } @@ -67,20 +67,29 @@ font-family: LexendDeca-Regular, sans-serif; text-transform: none; font-size: 22px; - cursor: pointer; + cursor: pointer; /* TODO: remove once we drop showdown.js */ } .markdown h6, .markdown details summary.h6{ font-family: LexendDeca-Regular, sans-serif; text-transform: none; font-size: 20px; - cursor: pointer;; + cursor: pointer; /* TODO: remove once we drop showdown.js */ } .markdown h4, .markdown h5, .markdown h6{ margin-bottom: 0.5rem; } +.markdown h1 > a, +.markdown h2 > a, +.markdown h3 > a, +.markdown h4 > a, +.markdown h5 > a, +.markdown h6 > a { + color: inherit; +} + .markdown ul{ margin-top: 0.5rem; margin-bottom: 0.5rem; diff --git a/WebHostLib/static/styles/timespinnerTracker.css b/WebHostLib/static/styles/timespinnerTracker.css index 007c6a19ba..640b584684 100644 --- a/WebHostLib/static/styles/timespinnerTracker.css +++ b/WebHostLib/static/styles/timespinnerTracker.css @@ -75,6 +75,27 @@ #inventory-table img.acquired.green{ /*32CD32*/ filter: hue-rotate(84deg) saturate(10) brightness(0.7); } +#inventory-table img.acquired.hotpink{ /*FF69B4*/ + filter: sepia(100%) hue-rotate(300deg) saturate(10); +} +#inventory-table img.acquired.lightsalmon{ /*FFA07A*/ + filter: sepia(100%) hue-rotate(347deg) saturate(10); +} +#inventory-table img.acquired.crimson{ /*DB143B*/ + filter: sepia(100%) hue-rotate(318deg) saturate(10) brightness(0.86); +} + +#inventory-table span{ + color: #B4B4A0; + font-size: 40px; + max-width: 40px; + max-height: 40px; + filter: grayscale(100%) contrast(75%) brightness(30%); +} + +#inventory-table span.acquired{ + filter: none; +} #inventory-table div.image-stack{ display: grid; diff --git a/WebHostLib/templates/404.html b/WebHostLib/templates/404.html index 9d567510ee..6c91fed4ac 100644 --- a/WebHostLib/templates/404.html +++ b/WebHostLib/templates/404.html @@ -1,5 +1,6 @@ {% extends 'pageWrapper.html' %} {% import "macros.html" as macros %} +{% set show_footer = True %} {% block head %} Page Not Found (404) @@ -13,5 +14,4 @@ The page you're looking for doesn't exist.
Click here to return to safety. - {% include 'islandFooter.html' %} {% endblock %} diff --git a/WebHostLib/templates/gameInfo.html b/WebHostLib/templates/gameInfo.html index c5ebba8284..3b908004b1 100644 --- a/WebHostLib/templates/gameInfo.html +++ b/WebHostLib/templates/gameInfo.html @@ -11,7 +11,7 @@ {% block body %} {% include 'header/'+theme+'Header.html' %} -
+
{% endblock %} diff --git a/WebHostLib/templates/genericTracker.html b/WebHostLib/templates/genericTracker.html index 947cf28372..b92097ceea 100644 --- a/WebHostLib/templates/genericTracker.html +++ b/WebHostLib/templates/genericTracker.html @@ -98,6 +98,8 @@ {% if hint.finding_player == player %} {{ player_names_with_alias[(team, hint.finding_player)] }} + {% elif get_slot_info(team, hint.finding_player).type == 2 %} + {{ player_names_with_alias[(team, hint.finding_player)] }} {% else %} {{ player_names_with_alias[(team, hint.finding_player)] }} @@ -107,6 +109,8 @@ {% if hint.receiving_player == player %} {{ player_names_with_alias[(team, hint.receiving_player)] }} + {% elif get_slot_info(team, hint.receiving_player).type == 2 %} + {{ player_names_with_alias[(team, hint.receiving_player)] }} {% else %} {{ player_names_with_alias[(team, hint.receiving_player)] }} diff --git a/WebHostLib/templates/hostGame.html b/WebHostLib/templates/hostGame.html index 2bcb993af5..3840635153 100644 --- a/WebHostLib/templates/hostGame.html +++ b/WebHostLib/templates/hostGame.html @@ -1,4 +1,5 @@ {% extends 'pageWrapper.html' %} +{% set show_footer = True %} {% block head %} Upload Multidata @@ -16,7 +17,9 @@ This page allows you to host a game which was not generated by the website. For example, if you have generated a game on your own computer, you may upload the zip file created by the generator to host the game here. This will also provide a tracker, and the ability for your players to download - their patch files. + their patch files if the game is core-verified. For Custom Games, you can find the patch files in + the output .zip file you are uploading here. You need to manually distribute those patch files to + your players.

In addition to the zip file created by the generator, you may upload a multidata file here as well.

@@ -27,6 +30,4 @@
- - {% include 'islandFooter.html' %} {% endblock %} diff --git a/WebHostLib/templates/hostRoom.html b/WebHostLib/templates/hostRoom.html index 8e76dafc12..c5996d181e 100644 --- a/WebHostLib/templates/hostRoom.html +++ b/WebHostLib/templates/hostRoom.html @@ -178,8 +178,15 @@ }) .then(text => new DOMParser().parseFromString(text, 'text/html')) .then(newDocument => { - let el = newDocument.getElementById("host-room-info"); - document.getElementById("host-room-info").innerHTML = el.innerHTML; + ["host-room-info", "slots-table"].forEach(function(id) { + const newEl = newDocument.getElementById(id); + const oldEl = document.getElementById(id); + if (oldEl && newEl) { + oldEl.innerHTML = newEl.innerHTML; + } else if (newEl) { + console.warn(`Did not find element to replace for ${id}`) + } + }); }); } diff --git a/WebHostLib/templates/islandFooter.html b/WebHostLib/templates/islandFooter.html index 08cf227990..7de14f0d82 100644 --- a/WebHostLib/templates/islandFooter.html +++ b/WebHostLib/templates/islandFooter.html @@ -1,6 +1,6 @@ {% block footer %}