diff --git a/.github/pyright-config.json b/.github/pyright-config.json index 64a46d80cceb..7bd4698c7496 100644 --- a/.github/pyright-config.json +++ b/.github/pyright-config.json @@ -2,11 +2,16 @@ "include": [ "../BizHawkClient.py", "../Patch.py", + "../rule_builder/cached_world.py", + "../rule_builder/field_resolvers.py", + "../rule_builder/options.py", + "../rule_builder/rules.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/general/test_rule_builder.py", "../test/multiworld/__init__.py", "../test/multiworld/test_multiworlds.py", "../test/netutils/__init__.py", @@ -14,6 +19,7 @@ "../test/programs/test_multi_server.py", "../test/utils/__init__.py", "../test/webhost/test_descriptions.py", + "../test/webhost/test_suuid.py", "../worlds/AutoSNIClient.py", "type_check.py" ], diff --git a/.github/workflows/analyze-modified-files.yml b/.github/workflows/analyze-modified-files.yml index 862a050c517e..79c4f983a482 100644 --- a/.github/workflows/analyze-modified-files.yml +++ b/.github/workflows/analyze-modified-files.yml @@ -14,6 +14,8 @@ env: BEFORE: ${{ github.event.before }} AFTER: ${{ github.event.after }} +permissions: {} + jobs: flake8-or-mypy: strategy: @@ -25,7 +27,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6.0.2 - name: "Determine modified files (pull_request)" if: github.event_name == 'pull_request' @@ -50,7 +52,7 @@ jobs: run: | echo "diff=." >> $GITHUB_ENV - - uses: actions/setup-python@v5 + - uses: actions/setup-python@v6.2.0 if: env.diff != '' with: python-version: '3.11' diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 9c0cd14f8b27..8ed0c3523c33 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,4 +1,5 @@ -# This workflow will build a release-like distribution when manually dispatched +# This workflow will build a release-like distribution when manually dispatched: +# a Windows x64 7zip, a Windows x64 Installer, a Linux AppImage and a Linux binary .tar.gz. name: Build @@ -40,9 +41,9 @@ jobs: runs-on: windows-latest steps: # - copy code below to release.yml - - - uses: actions/checkout@v4 + - uses: actions/checkout@v6.0.2 - name: Install python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6.2.0 with: python-version: '~3.12.7' check-latest: true @@ -50,7 +51,7 @@ jobs: 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 + choco install innosetup --version=6.7.0 --allow-downgrade - name: Build run: | python -m pip install --upgrade pip @@ -81,7 +82,7 @@ jobs: # - copy code above to release.yml - - name: Attest Build if: ${{ github.event_name == 'workflow_dispatch' }} - uses: actions/attest-build-provenance@v2 + uses: actions/attest@v4.1.0 with: subject-path: | build/exe.*/ArchipelagoLauncher.exe @@ -109,18 +110,17 @@ jobs: cp Players/Templates/VVVVVV.yaml Players/ timeout 30 ./ArchipelagoGenerate - name: Store 7z - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7.0.0 with: - name: ${{ env.ZIP_NAME }} path: dist/${{ env.ZIP_NAME }} - compression-level: 0 # .7z is incompressible by zip + archive: false if-no-files-found: error retention-days: 7 # keep for 7 days, should be enough - name: Store Setup - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7.0.0 with: - name: ${{ env.SETUP_NAME }} path: setups/${{ env.SETUP_NAME }} + archive: false if-no-files-found: error retention-days: 7 # keep for 7 days, should be enough @@ -128,14 +128,14 @@ jobs: runs-on: ubuntu-22.04 steps: # - copy code below to release.yml - - - uses: actions/checkout@v4 + - uses: actions/checkout@v6.0.2 - name: Install base dependencies run: | sudo apt update sudo apt -y install build-essential p7zip xz-utils wget libglib2.0-0 sudo apt -y install python3-gi libgirepository1.0-dev # should pull dependencies for gi installation below - name: Get a recent python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6.2.0 with: python-version: '~3.12.7' check-latest: true @@ -172,7 +172,7 @@ jobs: # - copy code above to release.yml - - name: Attest Build if: ${{ github.event_name == 'workflow_dispatch' }} - uses: actions/attest-build-provenance@v2 + uses: actions/attest@v4.1.0 with: subject-path: | build/exe.*/ArchipelagoLauncher @@ -203,17 +203,17 @@ jobs: cp Players/Templates/VVVVVV.yaml Players/ timeout 30 ./ArchipelagoGenerate - name: Store AppImage - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7.0.0 with: - name: ${{ env.APPIMAGE_NAME }} path: dist/${{ env.APPIMAGE_NAME }} + archive: false + # TODO: decide if we want to also upload the zsync if-no-files-found: error retention-days: 7 - name: Store .tar.gz - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7.0.0 with: - name: ${{ env.TAR_NAME }} path: dist/${{ env.TAR_NAME }} - compression-level: 0 # .gz is incompressible by zip + archive: false if-no-files-found: error retention-days: 7 diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 3abbb5f6449f..5751dce8571a 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -17,17 +17,26 @@ on: paths: - '**.py' - '**.js' - - '.github/workflows/codeql-analysis.yml' + - '.github/workflows/*.yml' + - '.github/workflows/*.yaml' + - '**/action.yml' + - '**/action.yaml' pull_request: # The branches below must be a subset of the branches above branches: [ main ] paths: - '**.py' - '**.js' - - '.github/workflows/codeql-analysis.yml' + - '.github/workflows/*.yml' + - '.github/workflows/*.yaml' + - '**/action.yml' + - '**/action.yaml' schedule: - cron: '44 8 * * 1' +permissions: + security-events: write + jobs: analyze: name: Analyze @@ -36,18 +45,17 @@ jobs: strategy: fail-fast: false matrix: - language: [ 'javascript', 'python' ] - # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ] + language: [ 'javascript', 'python', 'actions' ] # Learn more: # https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v6.0.2 # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@v3 + uses: github/codeql-action/init@v4.35.1 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. @@ -58,7 +66,7 @@ jobs: # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). # If this step fails, then you should remove it and run the build manually (see below) - name: Autobuild - uses: github/codeql-action/autobuild@v3 + uses: github/codeql-action/autobuild@v4.35.1 # â„šī¸ Command-line programs to run using the OS shell. # 📚 https://git.io/JvXDl @@ -72,4 +80,4 @@ jobs: # make release - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v3 + uses: github/codeql-action/analyze@v4.35.1 diff --git a/.github/workflows/ctest.yml b/.github/workflows/ctest.yml index 610f6d747779..1a39afa11dc7 100644 --- a/.github/workflows/ctest.yml +++ b/.github/workflows/ctest.yml @@ -24,6 +24,8 @@ on: - '**/CMakeLists.txt' - '.github/workflows/ctest.yml' +permissions: {} + jobs: ctest: runs-on: ${{ matrix.os }} @@ -35,7 +37,7 @@ jobs: os: [ubuntu-latest, windows-latest] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6.0.2 - uses: ilammy/msvc-dev-cmd@0b201ec74fa43914dc39ae48a89fd1d8cb592756 if: startsWith(matrix.os,'windows') - uses: Bacondish2023/setup-googletest@49065d1f7a6d21f6134864dd65980fe5dbe06c73 diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 0061dd15b000..231fb59dc556 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -19,6 +19,8 @@ on: env: REGISTRY: ghcr.io +permissions: {} + jobs: prepare: runs-on: ubuntu-latest @@ -29,7 +31,7 @@ jobs: package-name: ${{ steps.package.outputs.name }} steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v6.0.2 - name: Set lowercase image name id: image @@ -43,7 +45,7 @@ jobs: - name: Extract metadata id: meta - uses: docker/metadata-action@v5 + uses: docker/metadata-action@v6.0.0 with: images: ${{ env.REGISTRY }}/${{ steps.image.outputs.name }} tags: | @@ -92,13 +94,13 @@ jobs: cache-scope: arm64 steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v6.0.2 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 - name: Log in to GitHub Container Registry - uses: docker/login-action@v3 + uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} @@ -115,7 +117,7 @@ jobs: echo "tags=$(IFS=','; echo "${suffixed[*]}")" >> $GITHUB_OUTPUT - name: Build and push Docker image - uses: docker/build-push-action@v5 + uses: docker/build-push-action@v7.0.0 with: context: . file: ./Dockerfile @@ -135,7 +137,7 @@ jobs: packages: write steps: - name: Log in to GitHub Container Registry - uses: docker/login-action@v3 + uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} diff --git a/.github/workflows/label-pull-requests.yml b/.github/workflows/label-pull-requests.yml index 1675c942bddb..341735e5dd1a 100644 --- a/.github/workflows/label-pull-requests.yml +++ b/.github/workflows/label-pull-requests.yml @@ -14,7 +14,7 @@ jobs: name: 'Apply content-based labels' runs-on: ubuntu-latest steps: - - uses: actions/labeler@v5 + - uses: actions/labeler@v6.0.1 with: sync-labels: false peer_review: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7f81e5750746..21e1a24b8889 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -48,9 +48,9 @@ jobs: shell: bash run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV # - code below copied from build.yml - - - uses: actions/checkout@v4 + - uses: actions/checkout@v6.0.2 - name: Install python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6.2.0 with: python-version: '~3.12.7' check-latest: true @@ -88,7 +88,7 @@ jobs: echo "SETUP_NAME=$SETUP_NAME" >> $Env:GITHUB_ENV # - code above copied from build.yml - - name: Attest Build - uses: actions/attest-build-provenance@v2 + uses: actions/attest@v4.1.0 with: subject-path: | build/exe.*/ArchipelagoLauncher.exe @@ -114,14 +114,14 @@ jobs: - name: Set env run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV # - code below copied from build.yml - - - uses: actions/checkout@v4 + - uses: actions/checkout@v6.0.2 - name: Install base dependencies run: | sudo apt update sudo apt -y install build-essential p7zip xz-utils wget libglib2.0-0 sudo apt -y install python3-gi libgirepository1.0-dev # should pull dependencies for gi installation below - name: Get a recent python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6.2.0 with: python-version: '~3.12.7' check-latest: true @@ -157,7 +157,7 @@ jobs: echo "TAR_NAME=$TAR_NAME" >> $GITHUB_ENV # - code above copied from build.yml - - name: Attest Build - uses: actions/attest-build-provenance@v2 + uses: actions/attest@v4.1.0 with: subject-path: | build/exe.*/ArchipelagoLauncher diff --git a/.github/workflows/scan-build.yml b/.github/workflows/scan-build.yml index ac842070625f..64f51af4a258 100644 --- a/.github/workflows/scan-build.yml +++ b/.github/workflows/scan-build.yml @@ -28,12 +28,14 @@ on: - 'requirements.txt' - '.github/workflows/scan-build.yml' +permissions: {} + jobs: scan-build: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6.0.2 with: submodules: recursive - name: Install newer Clang @@ -45,7 +47,7 @@ jobs: run: | sudo apt install clang-tools-19 - name: Get a recent python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6.2.0 with: python-version: '3.11' - name: Install dependencies @@ -59,7 +61,9 @@ jobs: 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 + uses: actions/upload-artifact@v7.0.0 with: name: scan-build-reports path: scan-build-reports + compression-level: 9 # highly compressible + if-no-files-found: error diff --git a/.github/workflows/strict-type-check.yml b/.github/workflows/strict-type-check.yml index 2ccdad8d11af..4a876bf98ebf 100644 --- a/.github/workflows/strict-type-check.yml +++ b/.github/workflows/strict-type-check.yml @@ -14,13 +14,15 @@ on: - ".github/workflows/strict-type-check.yml" - "**.pyi" +permissions: {} + jobs: pyright: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6.0.2 - - uses: actions/setup-python@v5 + - uses: actions/setup-python@v6.2.0 with: python-version: "3.11" diff --git a/.github/workflows/unittests.yml b/.github/workflows/unittests.yml index b08b389005ec..cfffa6cc4a51 100644 --- a/.github/workflows/unittests.yml +++ b/.github/workflows/unittests.yml @@ -29,6 +29,8 @@ on: - '!.github/workflows/**' - '.github/workflows/unittests.yml' +permissions: {} + jobs: unit: runs-on: ${{ matrix.os }} @@ -51,9 +53,9 @@ jobs: os: macos-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6.0.2 - name: Set up Python ${{ matrix.python.version }} - uses: actions/setup-python@v5 + uses: actions/setup-python@v6.2.0 with: python-version: ${{ matrix.python.version }} - name: Install dependencies @@ -78,9 +80,9 @@ jobs: - {version: '3.13'} # current steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6.0.2 - name: Set up Python ${{ matrix.python.version }} - uses: actions/setup-python@v5 + uses: actions/setup-python@v6.2.0 with: python-version: ${{ matrix.python.version }} - name: Install dependencies diff --git a/.gitignore b/.gitignore index f4415ad740c0..8f9ed6df14fe 100644 --- a/.gitignore +++ b/.gitignore @@ -45,6 +45,7 @@ EnemizerCLI/ /SNI/ /sni-*/ /appimagetool* +/VC_redist.x64.exe /host.yaml /options.yaml /config.yaml @@ -65,6 +66,8 @@ Output Logs/ /datapackage /datapackage_export.json /custom_worlds +# stubgen output +/out/ # Byte-compiled / optimized / DLL files __pycache__/ diff --git a/BaseClasses.py b/BaseClasses.py index 4d88fde4f3db..69b900212c50 100644 --- a/BaseClasses.py +++ b/BaseClasses.py @@ -8,10 +8,10 @@ import warnings from argparse import Namespace from collections import Counter, deque, defaultdict -from collections.abc import Collection, MutableSequence +from collections.abc import Callable, Collection, Iterable, Iterator, Mapping, MutableSequence, Set from enum import IntEnum, IntFlag -from typing import (AbstractSet, Any, Callable, ClassVar, Dict, Iterable, Iterator, List, Literal, Mapping, NamedTuple, - Optional, Protocol, Set, Tuple, Union, TYPE_CHECKING, Literal, overload) +from typing import (AbstractSet, Any, ClassVar, Dict, List, Literal, NamedTuple, + Optional, Protocol, Tuple, Union, TYPE_CHECKING, overload) import dataclasses from typing_extensions import NotRequired, TypedDict @@ -22,6 +22,7 @@ if TYPE_CHECKING: from entrance_rando import ERPlacementState + from rule_builder.rules import Rule from worlds import AutoWorld @@ -85,7 +86,7 @@ class MultiWorld(): local_items: Dict[int, Options.LocalItems] non_local_items: Dict[int, Options.NonLocalItems] progression_balancing: Dict[int, Options.ProgressionBalancing] - completion_condition: Dict[int, Callable[[CollectionState], bool]] + completion_condition: Dict[int, CollectionRule] indirect_connections: Dict[Region, Set[Entrance]] exclude_locations: Dict[int, Options.ExcludeLocations] priority_locations: Dict[int, Options.PriorityLocations] @@ -726,6 +727,7 @@ class CollectionState(): advancements: Set[Location] path: Dict[Union[Region, Entrance], PathValue] locations_checked: Set[Location] + """Internal cache for Advancement Locations already checked by this CollectionState. Not for use in logic.""" stale: Dict[int, bool] allow_partial_entrances: bool additional_init_functions: List[Callable[[CollectionState, MultiWorld], None]] = [] @@ -766,7 +768,7 @@ def update_reachable_regions(self, player: int): else: self._update_reachable_regions_auto_indirect_conditions(player, queue) - def _update_reachable_regions_explicit_indirect_conditions(self, player: int, queue: deque): + def _update_reachable_regions_explicit_indirect_conditions(self, player: int, queue: deque[Entrance]): reachable_regions = self.reachable_regions[player] blocked_connections = self.blocked_connections[player] # run BFS on all connections, and keep track of those blocked by missing items @@ -784,13 +786,16 @@ def _update_reachable_regions_explicit_indirect_conditions(self, player: int, qu blocked_connections.update(new_region.exits) queue.extend(new_region.exits) self.path[new_region] = (new_region.name, self.path.get(connection, None)) + self.multiworld.worlds[player].reached_region(self, new_region) # Retry connections if the new region can unblock them - for new_entrance in self.multiworld.indirect_connections.get(new_region, set()): - if new_entrance in blocked_connections and new_entrance not in queue: - queue.append(new_entrance) + entrances = self.multiworld.indirect_connections.get(new_region) + if entrances is not None: + relevant_entrances = entrances.intersection(blocked_connections) + relevant_entrances.difference_update(queue) + queue.extend(relevant_entrances) - def _update_reachable_regions_auto_indirect_conditions(self, player: int, queue: deque): + def _update_reachable_regions_auto_indirect_conditions(self, player: int, queue: deque[Entrance]): reachable_regions = self.reachable_regions[player] blocked_connections = self.blocked_connections[player] new_connection: bool = True @@ -812,6 +817,7 @@ def _update_reachable_regions_auto_indirect_conditions(self, player: int, queue: queue.extend(new_region.exits) self.path[new_region] = (new_region.name, self.path.get(connection, None)) new_connection = True + self.multiworld.worlds[player].reached_region(self, new_region) # sweep for indirect connections, mostly Entrance.can_reach(unrelated_Region) queue.extend(blocked_connections) @@ -1169,13 +1175,17 @@ def set_item(self, item: str, player: int, count: int) -> None: self.prog_items[player][item] = count +CollectionRule = Callable[[CollectionState], bool] +DEFAULT_COLLECTION_RULE: CollectionRule = staticmethod(lambda state: True) + + class EntranceType(IntEnum): ONE_WAY = 1 TWO_WAY = 2 class Entrance: - access_rule: Callable[[CollectionState], bool] = staticmethod(lambda state: True) + access_rule: CollectionRule = DEFAULT_COLLECTION_RULE hide_path: bool = False player: int name: str @@ -1362,7 +1372,7 @@ def add_event( self, location_name: str, item_name: str | None = None, - rule: Callable[[CollectionState], bool] | None = None, + rule: CollectionRule | Rule[Any] | None = None, location_type: type[Location] | None = None, item_type: type[Item] | None = None, show_in_spoiler: bool = True, @@ -1390,7 +1400,7 @@ def add_event( 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 + self.multiworld.worlds[self.player].set_rule(event_location, rule) event_item = item_type(item_name, ItemClassification.progression, None, self.player) @@ -1401,7 +1411,7 @@ def add_event( return event_item def connect(self, connecting_region: Region, name: Optional[str] = None, - rule: Optional[Callable[[CollectionState], bool]] = None) -> Entrance: + rule: Optional[CollectionRule | Rule[Any]] = None) -> Entrance: """ Connects this Region to another Region, placing the provided rule on the connection. @@ -1409,8 +1419,8 @@ def connect(self, connecting_region: Region, name: Optional[str] = None, :param name: name of the connection being created :param rule: callable to determine access of this connection to go from self to the exiting_region""" exit_ = self.create_exit(name if name else f"{self.name} -> {connecting_region.name}") - if rule: - exit_.access_rule = rule + if rule is not None: + self.multiworld.worlds[self.player].set_rule(exit_, rule) exit_.connect(connecting_region) return exit_ @@ -1435,7 +1445,7 @@ def create_er_target(self, name: str) -> Entrance: return entrance def add_exits(self, exits: Iterable[str] | Mapping[str, str | None], - rules: Mapping[str, Callable[[CollectionState], bool]] | None = None) -> List[Entrance]: + rules: Mapping[str, CollectionRule | Rule[Any]] | None = None) -> List[Entrance]: """ Connects current region to regions in exit dictionary. Passed region names must exist first. @@ -1474,7 +1484,7 @@ class Location: show_in_spoiler: bool = True progress_type: LocationProgressType = LocationProgressType.DEFAULT always_allow: Callable[[CollectionState, Item], bool] = staticmethod(lambda state, item: False) - access_rule: Callable[[CollectionState], bool] = staticmethod(lambda state: True) + access_rule: CollectionRule = DEFAULT_COLLECTION_RULE item_rule: Callable[[Item], bool] = staticmethod(lambda item: True) item: Optional[Item] = None @@ -1551,7 +1561,7 @@ class ItemClassification(IntFlag): skip_balancing = 0b01000 """ should technically never occur on its own Item that is logically relevant, but progression balancing should not touch. - + Possible reasons for why an item should not be pulled ahead by progression balancing: 1. This item is quite insignificant, so pulling it earlier doesn't help (currency/etc.) 2. It is important for the player experience that this item is evenly distributed in the seed (e.g. goal items) """ @@ -1559,13 +1569,13 @@ class ItemClassification(IntFlag): deprioritized = 0b10000 """ Should technically never occur on its own. Will not be considered for priority locations, - unless Priority Locations Fill runs out of regular progression items before filling all priority locations. - + unless Priority Locations Fill runs out of regular progression items before filling all priority locations. + Should be used for items that would feel bad for the player to find on a priority location. Usually, these are items that are plentiful or insignificant. """ progression_deprioritized_skip_balancing = 0b11001 - """ Since a common case of both skip_balancing and deprioritized is "insignificant progression", + """ Since a common case of both skip_balancing and deprioritized is "insignificant progression", these items often want both flags. """ progression_skip_balancing = 0b01001 # only progression gets balanced diff --git a/CommonClient.py b/CommonClient.py index 1111adb080cc..3f98a4eff1d0 100755 --- a/CommonClient.py +++ b/CommonClient.py @@ -24,7 +24,7 @@ from MultiServer import CommandProcessor, mark_raw from NetUtils import (Endpoint, decode, NetworkItem, encode, JSONtoTextParser, ClientStatus, Permission, NetworkSlot, RawJSONtoTextParser, add_json_text, add_json_location, add_json_item, JSONTypes, HintStatus, SlotType) -from Utils import Version, stream_input, async_start +from Utils import gui_enabled, Version, stream_input, async_start from worlds import network_data_package, AutoWorldRegister import os import ssl @@ -35,9 +35,6 @@ logger = logging.getLogger("Client") -# without terminal, we have to use gui mode -gui_enabled = not sys.stdout or "--nogui" not in sys.argv - @Utils.cache_argsless def get_ssl_context(): @@ -65,6 +62,8 @@ def output(self, text: str): def _cmd_exit(self) -> bool: """Close connections and client""" + if self.ctx.ui: + self.ctx.ui.stop() self.ctx.exit_event.set() return True @@ -774,7 +773,7 @@ def gui_error(self, title: str, text: typing.Union[Exception, str]) -> typing.Op if len(parts) == 1: parts = title.split(', ', 1) if len(parts) > 1: - text = parts[1] + '\n\n' + text + text = f"{parts[1]}\n\n{text}" if text else parts[1] title = parts[0] # display error self._messagebox = MessageBox(title, text, error=True) @@ -897,6 +896,8 @@ def reconnect_hint() -> str: "May not be running Archipelago on that address or port.") except websockets.InvalidURI: ctx.handle_connection_loss("Failed to connect to the multiworld server (invalid URI)") + except asyncio.TimeoutError: + ctx.handle_connection_loss("Failed to connect to the multiworld server. Connection timed out.") except OSError: ctx.handle_connection_loss("Failed to connect to the multiworld server") except Exception: diff --git a/Fill.py b/Fill.py index 48ed7253d9d1..7bd575662708 100644 --- a/Fill.py +++ b/Fill.py @@ -280,6 +280,7 @@ def location_can_fill_item(location_to_fill: Location, item_to_fill: Item): item_to_place = itempool.pop() spot_to_fill: typing.Optional[Location] = None + # going through locations in the same order as the provided `locations` argument for i, location in enumerate(locations): if location_can_fill_item(location, item_to_place): # popping by index is faster than removing by content, diff --git a/Generate.py b/Generate.py index 5ad50df2b757..509bf848d0d3 100644 --- a/Generate.py +++ b/Generate.py @@ -23,7 +23,7 @@ from Utils import parse_yamls, version_tuple, __version__, tuplize_version -def mystery_argparse(argv: list[str] | None = None): +def mystery_argparse(argv: list[str] | None = None) -> argparse.Namespace: from settings import get_settings settings = get_settings() defaults = settings.generator @@ -68,7 +68,7 @@ def mystery_argparse(argv: list[str] | None = None): 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) + args.plando = PlandoOptions.from_option_string(args.plando) return args @@ -87,7 +87,8 @@ def main(args=None) -> tuple[argparse.Namespace, int]: seed = get_seed(args.seed) - Utils.init_logging(f"Generate_{seed}", loglevel=args.log_level, add_timestamp=args.log_time) + if __name__ == "__main__": + Utils.init_logging(f"Generate_{seed}", loglevel=args.log_level, add_timestamp=args.log_time) random.seed(seed) seed_name = get_seed_name(random) @@ -135,7 +136,7 @@ def main(args=None) -> tuple[argparse.Namespace, int]: else: weights_for_file.append(yaml) weights_cache[fname] = tuple(weights_for_file) - + except Exception as e: logging.exception(f"Exception reading weights in file {fname}") player_errors.append( @@ -205,7 +206,7 @@ def main(args=None) -> tuple[argparse.Namespace, int]: else: yaml[category_name][key] = option - settings_cache: dict[str, tuple[argparse.Namespace, ...]] = {fname: None for fname in weights_cache} + settings_cache: dict[str, tuple[argparse.Namespace, ...] | None] = {fname: None for fname in weights_cache} if args.sameoptions: for fname, yamls in weights_cache.items(): try: @@ -225,7 +226,7 @@ def main(args=None) -> tuple[argparse.Namespace, int]: player_path_cache: dict[int, str] = {} for player in range(1, args.multi + 1): player_path_cache[player] = player_files.get(player, args.weights_file_path) - name_counter = Counter() + name_counter: Counter[str] = Counter() args.player_options = {} player = 1 @@ -241,13 +242,10 @@ def main(args=None) -> tuple[argparse.Namespace, int]: try: # Use the cached settings object if it exists, otherwise roll settings within the try-catch # Invariant: settings_cache[path] and weights_cache[path] have the same length - settingsObject: argparse.Namespace = ( - settings_cache[path][doc_index] - if settings_cache[path] - else roll_settings(yaml, args.plando) - ) - - for k, v in vars(settingsObject).items(): + cached = settings_cache[path] + settings_object: argparse.Namespace = (cached[doc_index] if cached else roll_settings(yaml, args.plando)) + + for k, v in vars(settings_object).items(): if v is not None: try: getattr(args, k)[player] = v @@ -365,7 +363,7 @@ def get_value(self, key, args, kwargs): return kwargs.get(key, "{" + key + "}") -def handle_name(name: str, player: int, name_counter: Counter): +def handle_name(name: str, player: int, name_counter: Counter[str]): 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("%%")]) @@ -503,7 +501,7 @@ def roll_triggers(weights: dict, triggers: list, valid_keys: set) -> dict: return weights -def handle_option(ret: argparse.Namespace, game_weights: dict, option_key: str, option: type(Options.Option), plando_options: PlandoOptions): +def handle_option(ret: argparse.Namespace, game_weights: dict, option_key: str, option: type[Options.Option], plando_options: PlandoOptions): try: if option_key in game_weights: if not option.supports_weighting: diff --git a/Launcher.py b/Launcher.py index 89421ff30508..0e7d4796c4a8 100644 --- a/Launcher.py +++ b/Launcher.py @@ -29,8 +29,12 @@ 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 Utils import (env_cleared_lib_path, init_logging, is_frozen, is_linux, is_macos, is_windows, local_path, + messagebox, open_filename, user_path) + +if __name__ == "__main__": + init_logging('Launcher') + from worlds.LauncherComponents import Component, components, icon_paths, SuffixIdentifier, Type @@ -48,10 +52,7 @@ def open_host_yaml(): webbrowser.open(file) return - env = os.environ - if "LD_LIBRARY_PATH" in env: - env = env.copy() - del env["LD_LIBRARY_PATH"] # exe is a system binary, so reset LD_LIBRARY_PATH + env = env_cleared_lib_path() subprocess.Popen([exe, file], env=env) def open_patch(): @@ -102,10 +103,7 @@ def open_folder(folder_path): return if exe: - env = os.environ - if "LD_LIBRARY_PATH" in env: - env = env.copy() - del env["LD_LIBRARY_PATH"] # exe is a system binary, so reset LD_LIBRARY_PATH + env = env_cleared_lib_path() subprocess.Popen([exe, folder_path], env=env) else: logging.warning(f"No file browser available to open {folder_path}") @@ -198,22 +196,32 @@ def get_exe(component: str | Component) -> Sequence[str] | None: return [sys.executable, local_path(f"{component.script_name}.py")] if component.script_name else None -def launch(exe, in_terminal=False): +def launch(exe: Sequence[str], in_terminal: bool = False) -> bool: + """Runs the given command/args in `exe` in a new process. + + If `in_terminal` is True, it will attempt to run in a terminal window, + and the return value will indicate whether one was found.""" if in_terminal: if is_windows: # intentionally using a window title with a space so it gets quoted and treated as a title subprocess.Popen(["start", "Running Archipelago", *exe], shell=True) - return + return True elif is_linux: - terminal = which('x-terminal-emulator') or which('gnome-terminal') or which('xterm') + terminal = which("x-terminal-emulator") or which("konsole") or which("gnome-terminal") or which("xterm") if terminal: - subprocess.Popen([terminal, '-e', shlex.join(exe)]) - return + # Clear LD_LIB_PATH during terminal startup, but set it again when running command in case it's needed + ld_lib_path = os.environ.get("LD_LIBRARY_PATH") + lib_path_setter = f"env LD_LIBRARY_PATH={shlex.quote(ld_lib_path)} " if ld_lib_path else "" + env = env_cleared_lib_path() + + subprocess.Popen([terminal, "-e", lib_path_setter + shlex.join(exe)], env=env) + return True elif is_macos: - terminal = [which('open'), '-W', '-a', 'Terminal.app'] + terminal = [which("open"), "-W", "-a", "Terminal.app"] subprocess.Popen([*terminal, *exe]) - return + return True subprocess.Popen(exe) + return False def create_shortcut(button: Any, component: Component) -> None: @@ -402,12 +410,17 @@ def on_start(self): @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() + open_text = "Opening in a new window..." if button.component.func: + # Note: if we want to draw the Snackbar before running func, func needs to be wrapped in schedule_once button.component.func() else: - launch(get_exe(button.component), button.component.cli) + # if launch returns False, it started the process in background (not in a new terminal) + if not launch(get_exe(button.component), button.component.cli) and button.component.cli: + open_text = "Running in the background..." + + MDSnackbar(MDSnackbarText(text=open_text), y=dp(24), pos_hint={"center_x": 0.5}, + size_hint_x=0.5).open() def _on_drop_file(self, window: Window, filename: bytes, x: int, y: int) -> None: """ When a patch file is dropped into the window, run the associated component. """ @@ -493,7 +506,6 @@ def main(args: argparse.Namespace | dict | None = None): if __name__ == '__main__': - init_logging('Launcher') multiprocessing.freeze_support() multiprocessing.set_start_method("spawn") # if launched process uses kivy, fork won't work parser = argparse.ArgumentParser( diff --git a/Main.py b/Main.py index 47a28813fce4..924def653b27 100644 --- a/Main.py +++ b/Main.py @@ -207,6 +207,9 @@ def main(args, seed=None, baked_server_options: dict[str, object] | None = None) else: logger.info("Progression balancing skipped.") + AutoWorld.call_all(multiworld, "finalize_multiworld") + AutoWorld.call_all(multiworld, "pre_output") + # we're about to output using multithreading, so we're removing the global random state to prevent accidental use multiworld.random.passthrough = False diff --git a/MultiServer.py b/MultiServer.py index 52c80c55402a..ed14b6506ff5 100644 --- a/MultiServer.py +++ b/MultiServer.py @@ -21,6 +21,7 @@ import typing import weakref import zlib +from signal import SIGINT, SIGTERM, signal import ModuleUpdate @@ -496,7 +497,8 @@ def _load(self, decoded_obj: MultiData, game_data_packages: typing.Dict[str, typ self.read_data = {} # there might be a better place to put this. - self.read_data["race_mode"] = lambda: decoded_obj.get("race_mode", 0) + race_mode = decoded_obj.get("race_mode", 0) + self.read_data["race_mode"] = lambda: race_mode mdata_ver = decoded_obj["minimum_versions"]["server"] if mdata_ver > version_tuple: raise RuntimeError(f"Supplied Multidata (.archipelago) requires a server of at least version {mdata_ver}, " @@ -1301,6 +1303,13 @@ def __new__(cls, name, bases, attrs): commands.update(base.commands) commands.update({command_name[5:]: method for command_name, method in attrs.items() if command_name.startswith("_cmd_")}) + for command_name, method in commands.items(): + # wrap async def functions so they run on default asyncio loop + if inspect.iscoroutinefunction(method): + def _wrapper(self, *args, _method=method, **kwargs): + return async_start(_method(self, *args, **kwargs)) + functools.update_wrapper(_wrapper, method) + commands[command_name] = _wrapper return super(CommandMeta, cls).__new__(cls, name, bases, attrs) @@ -2563,6 +2572,8 @@ async def console(ctx: Context): input_text = await queue.get() queue.task_done() ctx.commandprocessor(input_text) + except asyncio.exceptions.CancelledError: + ctx.logger.info("ConsoleTask cancelled") except: import traceback traceback.print_exc() @@ -2729,6 +2740,26 @@ async def main(args: argparse.Namespace): console_task = asyncio.create_task(console(ctx)) if ctx.auto_shutdown: ctx.shutdown_task = asyncio.create_task(auto_shutdown(ctx, [console_task])) + + def stop(): + try: + for remove_signal in [SIGINT, SIGTERM]: + asyncio.get_event_loop().remove_signal_handler(remove_signal) + except NotImplementedError: + pass + ctx.commandprocessor._cmd_exit() + + def shutdown(signum, frame): + stop() + + try: + for sig in [SIGINT, SIGTERM]: + asyncio.get_event_loop().add_signal_handler(sig, stop) + except NotImplementedError: + # add_signal_handler is only implemented for UNIX platforms + for sig in [SIGINT, SIGTERM]: + signal(sig, shutdown) + await ctx.exit_event.wait() console_task.cancel() if ctx.shutdown_task: diff --git a/Options.py b/Options.py index c37d0cee2810..57119ff66c1c 100644 --- a/Options.py +++ b/Options.py @@ -24,6 +24,39 @@ import pathlib +_RANDOM_OPTS = [ + "random", "random-low", "random-middle", "random-high", + "random-range-low--", "random-range-middle--", + "random-range-high--", "random-range--", +] + + +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)) + + +def random_weighted_range(text: str, range_start: int, range_end: int): + if text == "random-low": + return triangular(range_start, range_end, 0.0) + elif text == "random-high": + return triangular(range_start, range_end, 1.0) + elif text == "random-middle": + return triangular(range_start, range_end) + elif text == "random": + return random.randint(range_start, range_end) + else: + raise Exception(f"random text \"{text}\" did not resolve to a recognized pattern. " + f"Acceptable values are: {', '.join(_RANDOM_OPTS)}.") + + def roll_percentage(percentage: int | float) -> bool: """Roll a percentage chance. percentage is expected to be in range [0, 100]""" @@ -417,10 +450,12 @@ def __init__(self, value: int): def from_text(cls, text: str) -> Toggle: if text == "random": return cls(random.choice(list(cls.name_lookup))) - elif text.lower() in {"off", "0", "false", "none", "null", "no"}: + elif text.lower() in {"off", "0", "false", "none", "null", "no", "disabled"}: return cls(0) - else: + elif text.lower() in {"on", "1", "true", "yes", "enabled"}: return cls(1) + else: + raise OptionError(f"Option {cls.__name__} does not support a value of {text}") @classmethod def from_any(cls, data: typing.Any): @@ -523,9 +558,9 @@ def __ge__(self, other: typing.Union[Choice, int, str]): class TextChoice(Choice): """Allows custom string input and offers choices. Choices will resolve to int and text will resolve to string""" - value: typing.Union[str, int] + value: str | int - def __init__(self, value: typing.Union[str, int]): + def __init__(self, value: str | int): assert isinstance(value, str) or isinstance(value, int), \ f"'{value}' is not a valid option for '{self.__class__.__name__}'" self.value = value @@ -546,7 +581,7 @@ def from_text(cls, text: str) -> TextChoice: return cls(text) @classmethod - def get_option_name(cls, value: T) -> str: + def get_option_name(cls, value: str | int) -> str: if isinstance(value, str): return value return super().get_option_name(value) @@ -688,12 +723,6 @@ class Range(NumericOption): range_start = 0 range_end = 1 - _RANDOM_OPTS = [ - "random", "random-low", "random-middle", "random-high", - "random-range-low--", "random-range-middle--", - "random-range-high--", "random-range--", - ] - def __init__(self, value: int): if value < self.range_start: raise Exception(f"{value} is lower than minimum {self.range_start} for option {self.__class__.__name__}") @@ -742,25 +771,16 @@ def from_text(cls, text: str) -> Range: @classmethod def weighted_range(cls, text) -> Range: - if text == "random-low": - 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, 1.0)) - elif text == "random-middle": - return cls(cls.triangular(cls.range_start, cls.range_end)) - elif text.startswith("random-range-"): + if text.startswith("random-range-"): return cls.custom_range(text) - elif text == "random": - return cls(random.randint(cls.range_start, cls.range_end)) else: - raise Exception(f"random text \"{text}\" did not resolve to a recognized pattern. " - f"Acceptable values are: {', '.join(cls._RANDOM_OPTS)}.") + return cls(random_weighted_range(text, cls.range_start, cls.range_end)) @classmethod def custom_range(cls, text) -> Range: textsplit = text.split("-") try: - random_range = [int(textsplit[len(textsplit) - 2]), int(textsplit[len(textsplit) - 1])] + random_range = [int(textsplit[-2]), int(textsplit[-1])] except ValueError: raise ValueError(f"Invalid random range {text} for option {cls.__name__}") random_range.sort() @@ -768,14 +788,9 @@ def custom_range(cls, text) -> Range: raise Exception( 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], 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], 1.0)) - else: - return cls(random.randint(random_range[0], random_range[1])) + if textsplit[2] in ("low", "middle", "high"): + return cls(random_weighted_range(f"{textsplit[0]}-{textsplit[2]}", *random_range)) + return cls(random_weighted_range("random", *random_range)) @classmethod def from_any(cls, data: typing.Any) -> Range: @@ -790,18 +805,6 @@ def get_option_name(cls, value: int) -> str: def __str__(self) -> str: return str(self.value) - @staticmethod - 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): special_range_names: typing.Dict[str, int] = {} @@ -891,7 +894,7 @@ def verify(self, world: typing.Type[World], player_name: str, plando_options: "P 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 @@ -906,7 +909,8 @@ def from_any(cls, data: typing.Dict[str, typing.Any]) -> OptionDict: else: raise NotImplementedError(f"Cannot Convert from non-dictionary, got {type(data)}") - def get_option_name(self, value): + @classmethod + def get_option_name(cls, value): return ", ".join(f"{key}: {v}" for key, v in value.items()) def __getitem__(self, item: str) -> typing.Any: @@ -986,7 +990,8 @@ def from_any(cls, data: typing.Any): return cls(data) return cls.from_text(str(data)) - def get_option_name(self, value): + @classmethod + def get_option_name(cls, value): return ", ".join(map(str, value)) def __contains__(self, item): @@ -996,13 +1001,19 @@ def __contains__(self, item): class OptionSet(Option[typing.Set[str]], VerifyKeys): default = frozenset() supports_weighting = False + random_str: str | None - def __init__(self, value: typing.Iterable[str]): + def __init__(self, value: typing.Iterable[str], random_str: str | None = None): self.value = set(deepcopy(value)) + self.random_str = random_str super(OptionSet, self).__init__() @classmethod def from_text(cls, text: str): + check_text = text.lower().split(",") + if ((cls.valid_keys or cls.verify_item_name or cls.verify_location_name) + and len(check_text) == 1 and check_text[0].startswith("random")): + return cls((), check_text[0]) return cls([option.strip() for option in text.split(",")]) @classmethod @@ -1011,7 +1022,37 @@ def from_any(cls, data: typing.Any): return cls(data) return cls.from_text(str(data)) - def get_option_name(self, value): + def verify(self, world: typing.Type[World], player_name: str, plando_options: PlandoOptions) -> None: + if self.random_str and not self.value: + choice_list = sorted(self.valid_keys) + if self.verify_item_name: + choice_list.extend(sorted(world.item_names)) + if self.verify_location_name: + choice_list.extend(sorted(world.location_names)) + if self.random_str.startswith("random-range-"): + textsplit = self.random_str.split("-") + try: + random_range = [int(textsplit[-2]), int(textsplit[-1])] + except ValueError: + raise ValueError(f"Invalid random range {self.random_str} for option {self.__class__.__name__} " + f"for player {player_name}") + random_range.sort() + if random_range[0] < 0 or random_range[1] > len(choice_list): + raise Exception( + f"{random_range[0]}-{random_range[1]} is outside allowed range " + f"0-{len(choice_list)} for option {self.__class__.__name__} for player {player_name}") + if textsplit[2] in ("low", "middle", "high"): + choice_count = random_weighted_range(f"{textsplit[0]}-{textsplit[2]}", + random_range[0], random_range[1]) + else: + choice_count = random_weighted_range("random", random_range[0], random_range[1]) + else: + choice_count = random_weighted_range(self.random_str, 0, len(choice_list)) + self.value = set(random.sample(choice_list, k=choice_count)) + super(Option, self).verify(world, player_name, plando_options) + + @classmethod + def get_option_name(cls, value): return ", ".join(sorted(value)) def __contains__(self, item): @@ -1656,7 +1697,7 @@ def __iter__(self) -> typing.Iterator[PlandoItem]: def __len__(self) -> int: return len(self.value) - + class Removed(FreeText): """This Option has been Removed.""" rich_text_doc = True @@ -1742,8 +1783,10 @@ def generate_yaml_templates(target_folder: typing.Union[str, "pathlib.Path"], ge from Utils import local_path, __version__ full_path: str + preset_folder = os.path.join(target_folder, "Presets") os.makedirs(target_folder, exist_ok=True) + os.makedirs(preset_folder, exist_ok=True) # clean out old for file in os.listdir(target_folder): @@ -1751,11 +1794,16 @@ def generate_yaml_templates(target_folder: typing.Union[str, "pathlib.Path"], ge if os.path.isfile(full_path) and full_path.endswith(".yaml"): os.unlink(full_path) - def dictify_range(option: Range): - data = {option.default: 50} + for file in os.listdir(preset_folder): + full_path = os.path.join(preset_folder, file) + if os.path.isfile(full_path) and full_path.endswith(".yaml"): + os.unlink(full_path) + + def dictify_range(option: Range, option_val: int | str): + data = {option_val: 50} for sub_option in ["random", "random-low", "random-high", f"random-range-{option.range_start}-{option.range_end}"]: - if sub_option != option.default: + if sub_option != option_val: data[sub_option] = 0 notes = { "random-low": "random value weighted towards lower values", @@ -1768,6 +1816,8 @@ def dictify_range(option: Range): if number in data: data[name] = data[number] del data[number] + elif name in data: + pass else: data[name] = 0 @@ -1783,20 +1833,27 @@ def yaml_dump_scalar(scalar) -> str: for game_name, world in AutoWorldRegister.world_types.items(): if not world.hidden or generate_hidden: - option_groups = get_option_groups(world) + presets = world.web.options_presets.copy() + presets.update({"": {}}) - res = template.render( - option_groups=option_groups, - __version__=__version__, - game=game_name, - world_version=world.world_version.as_simple_string(), - yaml_dump=yaml_dump_scalar, - dictify_range=dictify_range, - cleandoc=cleandoc, - ) - - with open(os.path.join(target_folder, get_file_safe_name(game_name) + ".yaml"), "w", encoding="utf-8-sig") as f: - f.write(res) + option_groups = get_option_groups(world) + for name, preset in presets.items(): + res = template.render( + option_groups=option_groups, + __version__=__version__, + game=game_name, + world_version=world.world_version.as_simple_string(), + yaml_dump=yaml_dump_scalar, + dictify_range=dictify_range, + cleandoc=cleandoc, + preset_name=name, + preset=preset, + ) + preset_name = f" - {name}" if name else "" + with open(os.path.join(preset_folder if name else target_folder, + get_file_safe_name(game_name + preset_name) + ".yaml"), + "w", encoding="utf-8-sig") as f: + f.write(res) def dump_player_options(multiworld: MultiWorld) -> None: diff --git a/OptionsCreator.py b/OptionsCreator.py index 103dc763cf73..30833993e1d2 100644 --- a/OptionsCreator.py +++ b/OptionsCreator.py @@ -6,6 +6,7 @@ from kvui import (ThemedApp, ScrollBox, MainLayout, ContainerLayout, dp, Widget, MDBoxLayout, TooltipLabel, MDLabel, ToggleButton, MarkupDropdown, ResizableTextField) +from kivy.clock import Clock from kivy.uix.behaviors.button import ButtonBehavior from kivymd.uix.behaviors import RotateBehavior from kivymd.uix.anchorlayout import MDAnchorLayout @@ -28,7 +29,7 @@ import re from urllib.parse import urlparse from worlds.AutoWorld import AutoWorldRegister, World -from Options import (Option, Toggle, TextChoice, Choice, FreeText, NamedRange, Range, OptionSet, OptionList, Removed, +from Options import (Option, Toggle, TextChoice, Choice, FreeText, NamedRange, Range, OptionSet, OptionList, OptionCounter, Visibility) @@ -269,55 +270,76 @@ def __init__(self): self.options = {} super().__init__() - def export_options(self, button: Widget): - if 0 < len(self.name_input.text) < 17 and self.current_game: - file_name = Utils.save_filename("Export Options File As...", [("YAML", ["*.yaml"])], + @staticmethod + def show_result_snack(text: str) -> None: + MDSnackbar(MDSnackbarText(text=text), y=dp(24), pos_hint={"center_x": 0.5}, size_hint_x=0.5).open() + + def on_export_result(self, text: str | None) -> None: + self.container.disabled = False + if text is not None: + Clock.schedule_once(lambda _: self.show_result_snack(text), 0) + + def export_options_background(self, options: dict[str, typing.Any]) -> None: + try: + file_name = Utils.save_filename("Export Options File As...", [("YAML", [".yaml"])], Utils.get_file_safe_name(f"{self.name_input.text}.yaml")) + except Exception: + self.on_export_result("Could not open dialog. Already open?") + raise + + if not file_name: + self.on_export_result(None) # No file selected. No need to show a message for this. + return + + try: + with open(file_name, 'w') as f: + f.write(Utils.dump(options, sort_keys=False)) + f.close() + self.on_export_result("File saved successfully.") + except Exception: + self.on_export_result("Could not save file.") + raise + + def export_options(self, button: Widget) -> None: + if 0 < len(self.name_input.text) < 17 and self.current_game: + import threading options = { "name": self.name_input.text, "description": f"YAML generated by Archipelago {Utils.__version__}.", "game": self.current_game, self.current_game: {k: check_random(v) for k, v in self.options.items()} } - try: - with open(file_name, 'w') as f: - f.write(Utils.dump(options, sort_keys=False)) - f.close() - MDSnackbar(MDSnackbarText(text="File saved successfully."), y=dp(24), pos_hint={"center_x": 0.5}, - size_hint_x=0.5).open() - except FileNotFoundError: - MDSnackbar(MDSnackbarText(text="Saving cancelled."), y=dp(24), pos_hint={"center_x": 0.5}, - size_hint_x=0.5).open() + threading.Thread(target=self.export_options_background, args=(options,), daemon=True).start() + self.container.disabled = True elif not self.name_input.text: - MDSnackbar(MDSnackbarText(text="Name must not be empty."), y=dp(24), pos_hint={"center_x": 0.5}, - size_hint_x=0.5).open() + self.show_result_snack("Name must not be empty.") elif not self.current_game: - MDSnackbar(MDSnackbarText(text="You must select a game to play."), y=dp(24), pos_hint={"center_x": 0.5}, - size_hint_x=0.5).open() + self.show_result_snack("You must select a game to play.") else: - MDSnackbar(MDSnackbarText(text="Name cannot be longer than 16 characters."), y=dp(24), - pos_hint={"center_x": 0.5}, size_hint_x=0.5).open() + self.show_result_snack("Name cannot be longer than 16 characters.") - def create_range(self, option: typing.Type[Range], name: str): + def create_range(self, option: typing.Type[Range], name: str, bind=True): def update_text(range_box: VisualRange): self.options[name] = int(range_box.slider.value) range_box.tag.text = str(int(range_box.slider.value)) return box = VisualRange(option=option, name=name) - box.slider.bind(on_touch_move=lambda _, _1: update_text(box)) + if bind: + box.slider.bind(value=lambda _, _1: update_text(box)) self.options[name] = option.default return box def create_named_range(self, option: typing.Type[NamedRange], name: str): def set_to_custom(range_box: VisualNamedRange): - if (not self.options[name] == range_box.range.slider.value) \ - and (not self.options[name] in option.special_range_names or - range_box.range.slider.value != option.special_range_names[self.options[name]]): - # we should validate the touch here, - # but this is much cheaper + range_box.range.tag.text = str(int(range_box.range.slider.value)) + if range_box.range.slider.value in option.special_range_names.values(): + value = next(key for key, val in option.special_range_names.items() + if val == range_box.range.slider.value) + self.options[name] = value + set_button_text(box.choice, value.title()) + else: self.options[name] = int(range_box.range.slider.value) - range_box.range.tag.text = str(int(range_box.range.slider.value)) set_button_text(range_box.choice, "Custom") def set_button_text(button: MDButton, text: str): @@ -326,7 +348,7 @@ def set_button_text(button: MDButton, text: str): def set_value(text: str, range_box: VisualNamedRange): range_box.range.slider.value = min(max(option.special_range_names[text.lower()], option.range_start), option.range_end) - range_box.range.tag.text = str(int(range_box.range.slider.value)) + range_box.range.tag.text = str(option.special_range_names[text.lower()]) set_button_text(range_box.choice, text) self.options[name] = text.lower() range_box.range.slider.dropdown.dismiss() @@ -335,13 +357,18 @@ def open_dropdown(button): # for some reason this fixes an issue causing some to not open box.range.slider.dropdown.open() - box = VisualNamedRange(option=option, name=name, range_widget=self.create_range(option, name)) - if option.default in option.special_range_names: + box = VisualNamedRange(option=option, name=name, range_widget=self.create_range(option, name, bind=False)) + default: int | str = option.default + if default in option.special_range_names: # value can get mismatched in this case - box.range.slider.value = min(max(option.special_range_names[option.default], option.range_start), + box.range.slider.value = min(max(option.special_range_names[default], option.range_start), option.range_end) box.range.tag.text = str(int(box.range.slider.value)) - box.range.slider.bind(on_touch_move=lambda _, _2: set_to_custom(box)) + elif default in option.special_range_names.values(): + # better visual + default = next(key for key, val in option.special_range_names.items() if val == option.default) + set_button_text(box.choice, default.title()) + box.range.slider.bind(value=lambda _, _2: set_to_custom(box)) items = [ { "text": choice.title(), @@ -351,16 +378,17 @@ def open_dropdown(button): ] box.range.slider.dropdown = MDDropdownMenu(caller=box.choice, items=items) box.choice.bind(on_release=open_dropdown) - self.options[name] = option.default + self.options[name] = default return box def create_free_text(self, option: typing.Type[FreeText] | typing.Type[TextChoice], name: str): text = VisualFreeText(option=option, name=name) - def set_value(instance): - self.options[name] = instance.text + def set_value(instance, value): + self.options[name] = value - text.bind(on_text_validate=set_value) + text.bind(text=set_value) + self.options[name] = option.default return text def create_choice(self, option: typing.Type[Choice], name: str): @@ -427,8 +455,12 @@ def create_popup(self, option: typing.Type[OptionList] | typing.Type[OptionSet] valid_keys = sorted(option.valid_keys) if option.verify_item_name: valid_keys += list(world.item_name_to_id.keys()) + if option.convert_name_groups: + valid_keys += list(world.item_name_groups.keys()) if option.verify_location_name: valid_keys += list(world.location_name_to_id.keys()) + if option.convert_name_groups: + valid_keys += list(world.location_name_groups.keys()) if not issubclass(option, OptionCounter): def apply_changes(button): @@ -450,14 +482,6 @@ def apply_changes(button): dialog.scrollbox.layout.spacing = dp(5) dialog.scrollbox.layout.padding = [0, dp(5), 0, 0] - if name not in self.options: - # convert from non-mutable to mutable - # We use list syntax even for sets, set behavior is enforced through GUI - if issubclass(option, OptionCounter): - self.options[name] = deepcopy(option.default) - else: - self.options[name] = sorted(option.default) - if issubclass(option, OptionCounter): for value in sorted(self.options[name]): dialog.add_set_item(value, self.options[name].get(value, None)) @@ -471,6 +495,15 @@ def apply_changes(button): def create_option_set_list_counter(self, option: typing.Type[OptionList] | typing.Type[OptionSet] | typing.Type[OptionCounter], name: str, world: typing.Type[World]): main_button = MDButton(MDButtonText(text="Edit"), on_release=lambda x: self.create_popup(option, name, world)) + + if name not in self.options: + # convert from non-mutable to mutable + # We use list syntax even for sets, set behavior is enforced through GUI + if issubclass(option, OptionCounter): + self.options[name] = deepcopy(option.default) + else: + self.options[name] = sorted(option.default) + return main_button def create_option(self, option: typing.Type[Option], name: str, world: typing.Type[World]) -> Widget: diff --git a/README.md b/README.md index efa18bc1ef07..ffe96503588b 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,6 @@ Currently, the following games are supported: * The Witness * Sonic Adventure 2: Battle * Starcraft 2 -* Donkey Kong Country 3 * Dark Souls 3 * Super Mario World * PokÊmon Red and Blue @@ -85,6 +84,7 @@ Currently, the following games are supported: * APQuest * Satisfactory * EarthBound +* Mega Man 3 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/UndertaleClient.py b/UndertaleClient.py index 1c522fac924d..b0efce206ae5 100644 --- a/UndertaleClient.py +++ b/UndertaleClient.py @@ -1,6 +1,7 @@ from __future__ import annotations import os import sys +import time import asyncio import typing import bsdiff4 @@ -15,6 +16,9 @@ gui_enabled, ClientCommandProcessor, logger, get_base_parser from Utils import async_start +# Heartbeat for position sharing via bounces, in seconds +UNDERTALE_STATUS_INTERVAL = 30.0 +UNDERTALE_ONLINE_TIMEOUT = 60.0 class UndertaleCommandProcessor(ClientCommandProcessor): def __init__(self, ctx): @@ -109,6 +113,11 @@ def __init__(self, server_address, password): self.completed_routes = {"pacifist": 0, "genocide": 0, "neutral": 0} # self.save_game_folder: files go in this path to pass data between us and the actual game self.save_game_folder = os.path.expandvars(r"%localappdata%/UNDERTALE") + self.last_sent_position: typing.Optional[tuple] = None + self.last_room: typing.Optional[str] = None + self.last_status_write: float = 0.0 + self.other_undertale_status: dict[int, dict] = {} + def patch_game(self): with open(Utils.user_path("Undertale", "data.win"), "rb") as f: @@ -219,6 +228,9 @@ async def process_undertale_cmd(ctx: UndertaleContext, cmd: str, args: dict): await ctx.send_msgs([{"cmd": "SetNotify", "keys": [str(ctx.slot)+" RoutesDone neutral", str(ctx.slot)+" RoutesDone pacifist", str(ctx.slot)+" RoutesDone genocide"]}]) + if any(info.game == "Undertale" and slot != ctx.slot + for slot, info in ctx.slot_info.items()): + ctx.set_notify("undertale_room_status") if args["slot_data"]["only_flakes"]: with open(os.path.join(ctx.save_game_folder, "GenoNoChest.flag"), "w") as f: f.close() @@ -263,6 +275,12 @@ async def process_undertale_cmd(ctx: UndertaleContext, cmd: str, args: dict): if str(ctx.slot)+" RoutesDone pacifist" in args["keys"]: if args["keys"][str(ctx.slot) + " RoutesDone pacifist"] is not None: ctx.completed_routes["pacifist"] = args["keys"][str(ctx.slot)+" RoutesDone pacifist"] + if "undertale_room_status" in args["keys"] and args["keys"]["undertale_room_status"]: + status = args["keys"]["undertale_room_status"] + ctx.other_undertale_status = { + int(key): val for key, val in status.items() + if int(key) != ctx.slot + } elif cmd == "SetReply": if args["value"] is not None: if str(ctx.slot)+" RoutesDone pacifist" == args["key"]: @@ -271,17 +289,19 @@ async def process_undertale_cmd(ctx: UndertaleContext, cmd: str, args: dict): ctx.completed_routes["genocide"] = args["value"] elif str(ctx.slot)+" RoutesDone neutral" == args["key"]: ctx.completed_routes["neutral"] = args["value"] + if args.get("key") == "undertale_room_status" and args.get("value"): + ctx.other_undertale_status = { + int(key): val for key, val in args["value"].items() + if int(key) != ctx.slot + } elif cmd == "ReceivedItems": start_index = args["index"] if start_index == 0: ctx.items_received = [] elif start_index != len(ctx.items_received): - sync_msg = [{"cmd": "Sync"}] - if ctx.locations_checked: - sync_msg.append({"cmd": "LocationChecks", - "locations": list(ctx.locations_checked)}) - await ctx.send_msgs(sync_msg) + await ctx.check_locations(ctx.locations_checked) + await ctx.send_msgs([{"cmd": "Sync"}]) if start_index == len(ctx.items_received): counter = -1 placedWeapon = 0 @@ -368,9 +388,8 @@ async def process_undertale_cmd(ctx: UndertaleContext, cmd: str, args: dict): f.close() elif cmd == "Bounced": - tags = args.get("tags", []) - if "Online" in tags: - data = args.get("data", {}) + data = args.get("data", {}) + if "x" in data and "room" in data: if data["player"] != ctx.slot and data["player"] is not None: filename = f"FRISK" + str(data["player"]) + ".playerspot" with open(os.path.join(ctx.save_game_folder, filename), "w") as f: @@ -381,21 +400,63 @@ async def process_undertale_cmd(ctx: UndertaleContext, cmd: str, args: dict): async def multi_watcher(ctx: UndertaleContext): while not ctx.exit_event.is_set(): - path = ctx.save_game_folder - for root, dirs, files in os.walk(path): - for file in files: - if "spots.mine" in file and "Online" in ctx.tags: - with open(os.path.join(root, file), "r") as mine: - this_x = mine.readline() - this_y = mine.readline() - this_room = mine.readline() - this_sprite = mine.readline() - this_frame = mine.readline() - mine.close() - message = [{"cmd": "Bounce", "tags": ["Online"], - "data": {"player": ctx.slot, "x": this_x, "y": this_y, "room": this_room, - "spr": this_sprite, "frm": this_frame}}] - await ctx.send_msgs(message) + if "Online" in ctx.tags and any( + info.game == "Undertale" and slot != ctx.slot + for slot, info in ctx.slot_info.items()): + now = time.time() + path = ctx.save_game_folder + for root, dirs, files in os.walk(path): + for file in files: + if "spots.mine" in file: + with open(os.path.join(root, file), "r") as mine: + this_x = mine.readline() + this_y = mine.readline() + this_room = mine.readline() + this_sprite = mine.readline() + this_frame = mine.readline() + + if this_room != ctx.last_room or \ + now - ctx.last_status_write >= UNDERTALE_STATUS_INTERVAL: + ctx.last_room = this_room + ctx.last_status_write = now + await ctx.send_msgs([{ + "cmd": "Set", + "key": "undertale_room_status", + "default": {}, + "want_reply": False, + "operations": [{"operation": "update", + "value": {str(ctx.slot): {"room": this_room, + "time": now}}}] + }]) + + # If player was visible but timed out (heartbeat) or left the room, remove them. + for slot, entry in ctx.other_undertale_status.items(): + if entry.get("room") != this_room or \ + now - entry.get("time", now) > UNDERTALE_ONLINE_TIMEOUT: + playerspot = os.path.join(ctx.save_game_folder, + f"FRISK{slot}.playerspot") + if os.path.exists(playerspot): + os.remove(playerspot) + + current_position = (this_x, this_y, this_room, this_sprite, this_frame) + if current_position == ctx.last_sent_position: + continue + + # Empty status dict = no data yet → send to bootstrap. + online_in_room = any( + entry.get("room") == this_room and + now - entry.get("time", now) <= UNDERTALE_ONLINE_TIMEOUT + for entry in ctx.other_undertale_status.values() + ) + if ctx.other_undertale_status and not online_in_room: + continue + + message = [{"cmd": "Bounce", "games": ["Undertale"], + "data": {"player": ctx.slot, "x": this_x, "y": this_y, + "room": this_room, "spr": this_sprite, + "frm": this_frame}}] + await ctx.send_msgs(message) + ctx.last_sent_position = current_position await asyncio.sleep(0.1) @@ -409,10 +470,9 @@ async def game_watcher(ctx: UndertaleContext): for file in files: if ".item" in file: os.remove(os.path.join(root, file)) - sync_msg = [{"cmd": "Sync"}] - if ctx.locations_checked: - sync_msg.append({"cmd": "LocationChecks", "locations": list(ctx.locations_checked)}) - await ctx.send_msgs(sync_msg) + await ctx.check_locations(ctx.locations_checked) + await ctx.send_msgs([{"cmd": "Sync"}]) + ctx.syncing = False if ctx.got_deathlink: ctx.got_deathlink = False @@ -447,7 +507,7 @@ async def game_watcher(ctx: UndertaleContext): for l in lines: sending = sending+[(int(l.rstrip('\n')))+12000] finally: - await ctx.send_msgs([{"cmd": "LocationChecks", "locations": sending}]) + await ctx.check_locations(sending) if "victory" in file and str(ctx.route) in file: victory = True if ".playerspot" in file and "Online" not in ctx.tags: diff --git a/Utils.py b/Utils.py index db6ae371e7c3..0210086274f4 100644 --- a/Utils.py +++ b/Utils.py @@ -18,11 +18,14 @@ import warnings from argparse import Namespace +from datetime import datetime, timezone + from settings import Settings, get_settings from time import sleep -from typing import BinaryIO, Coroutine, Optional, Set, Dict, Any, Union, TypeGuard +from typing import BinaryIO, Coroutine, Mapping, Optional, Set, Dict, Any, Union, TypeGuard from yaml import load, load_all, dump from pathspec import PathSpec, GitIgnoreSpec +from typing_extensions import deprecated try: from yaml import CLoader as UnsafeLoader, CSafeLoader as SafeLoader, CDumper as Dumper @@ -233,10 +236,7 @@ def open_file(filename: typing.Union[str, "pathlib.Path"]) -> None: open_command = which("open") if is_macos else (which("xdg-open") or which("gnome-open") or which("kde-open")) assert open_command, "Didn't find program for open_file! Please report this together with system details." - env = os.environ - if "LD_LIBRARY_PATH" in env: - env = env.copy() - del env["LD_LIBRARY_PATH"] # exe is a system binary, so reset LD_LIBRARY_PATH + env = env_cleared_lib_path() subprocess.call([open_command, filename], env=env) @@ -315,6 +315,7 @@ def get_public_ipv6() -> str: return ip +@deprecated("Utils.get_options() is deprecated. Use the settings API instead.") def get_options() -> Settings: deprecate("Utils.get_options() is deprecated. Use the settings API instead.") return get_settings() @@ -341,6 +342,9 @@ def persistent_load() -> Dict[str, Dict[str, Any]]: try: with open(path, "r") as f: storage = unsafe_parse_yaml(f.read()) + if "datapackage" in storage: + del storage["datapackage"] + logging.debug("Removed old datapackage from persistent storage") except Exception as e: logging.debug(f"Could not read store: {e}") if storage is None: @@ -365,11 +369,6 @@ def load_data_package_for_checksum(game: str, checksum: typing.Optional[str]) -> except Exception as e: logging.debug(f"Could not load data package: {e}") - # fall back to old cache - cache = persistent_load().get("datapackage", {}).get("games", {}).get(game, {}) - if cache.get("checksum") == checksum: - return cache - # cache does not match return {} @@ -754,6 +753,19 @@ def is_kivy_running() -> bool: return False +def env_cleared_lib_path() -> Mapping[str, str]: + """ + Creates a copy of the current environment vars with the LD_LIBRARY_PATH removed if set, as this can interfere when + launching something in a subprocess. + """ + env = os.environ + if "LD_LIBRARY_PATH" in env: + env = env.copy() + del env["LD_LIBRARY_PATH"] + + return env + + 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") @@ -766,10 +778,7 @@ def _mp_save_filename(res: "multiprocessing.Queue[typing.Optional[str]]", *args: res.put(save_filename(*args)) def _run_for_stdout(*args: str): - env = os.environ - if "LD_LIBRARY_PATH" in env: - env = env.copy() - del env["LD_LIBRARY_PATH"] # exe is a system binary, so reset LD_LIBRARY_PATH + env = env_cleared_lib_path() return subprocess.run(args, capture_output=True, text=True, env=env).stdout.split("\n", 1)[0] or None @@ -811,29 +820,32 @@ def open_filename(title: str, filetypes: typing.Iterable[typing.Tuple[str, typin except tkinter.TclError: return None # GUI not available. None is the same as a user clicking "cancel" root.withdraw() - return tkinter.filedialog.askopenfilename(title=title, filetypes=((t[0], ' '.join(t[1])) for t in filetypes), - initialfile=suggest or None) + try: + return tkinter.filedialog.askopenfilename( + title=title, + filetypes=((t[0], ' '.join(t[1])) for t in filetypes), + initialfile=suggest or None, + ) + finally: + root.destroy() def save_filename(title: str, filetypes: typing.Iterable[typing.Tuple[str, typing.Iterable[str]]], suggest: str = "") \ -> typing.Optional[str]: logging.info(f"Opening file save dialog for {title}.") - def run(*args: str): - return subprocess.run(args, capture_output=True, text=True).stdout.split("\n", 1)[0] or None - if is_linux: # prefer native dialog from shutil import which kdialog = which("kdialog") if kdialog: k_filters = '|'.join((f'{text} (*{" *".join(ext)})' for (text, ext) in filetypes)) - return run(kdialog, f"--title={title}", "--getsavefilename", suggest or ".", k_filters) + return _run_for_stdout(kdialog, f"--title={title}", "--getsavefilename", suggest or ".", k_filters) zenity = which("zenity") if zenity: z_filters = (f'--file-filter={text} ({", ".join(ext)}) | *{" *".join(ext)}' for (text, ext) in filetypes) selection = (f"--filename={suggest}",) if suggest else () - return run(zenity, f"--title={title}", "--file-selection", "--save", *z_filters, *selection) + return _run_for_stdout(zenity, f"--title={title}", "--file-selection", "--save", *z_filters, *selection) # fall back to tk try: @@ -856,8 +868,14 @@ def run(*args: str): except tkinter.TclError: return None # GUI not available. None is the same as a user clicking "cancel" root.withdraw() - return tkinter.filedialog.asksaveasfilename(title=title, filetypes=((t[0], ' '.join(t[1])) for t in filetypes), - initialfile=suggest or None) + try: + return tkinter.filedialog.asksaveasfilename( + title=title, + filetypes=((t[0], ' '.join(t[1])) for t in filetypes), + initialfile=suggest or None, + ) + finally: + root.destroy() def _mp_open_directory(res: "multiprocessing.Queue[typing.Optional[str]]", *args: Any) -> None: @@ -905,6 +923,13 @@ def open_directory(title: str, suggest: str = "") -> typing.Optional[str]: def messagebox(title: str, text: str, error: bool = False) -> None: + if not gui_enabled: + if error: + logging.error(f"{title}: {text}") + else: + logging.info(f"{title}: {text}") + return + if is_kivy_running(): from kvui import MessageBox MessageBox(title, text, error).open() @@ -940,6 +965,9 @@ def messagebox(title: str, text: str, error: bool = False) -> None: root.update() +gui_enabled = not sys.stdout or "--nogui" not in sys.argv +"""Checks if the user wanted no GUI mode and has a terminal to use it with.""" + def title_sorted(data: typing.Iterable, key=None, ignore: typing.AbstractSet[str] = frozenset(("a", "the"))): """Sorts a sequence of text ignoring typical articles like "a" or "the" in the beginning.""" def sorter(element: Union[str, Dict[str, Any]]) -> str: @@ -984,6 +1012,7 @@ def async_start(co: Coroutine[None, None, typing.Any], name: Optional[str] = Non def deprecate(message: str, add_stacklevels: int = 0): + """also use typing_extensions.deprecated wherever you use this""" if __debug__: raise Exception(message) warnings.warn(message, stacklevel=2 + add_stacklevels) @@ -1048,6 +1077,7 @@ def _noop() -> None: multiprocessing.freeze_support = multiprocessing.spawn.freeze_support = _freeze_support if is_frozen() else _noop +@deprecated("Use multiprocessing.freeze_support() instead") def freeze_support() -> None: """This now only calls multiprocessing.freeze_support since we are patching freeze_support on module load.""" import multiprocessing @@ -1059,9 +1089,18 @@ def freeze_support() -> None: _extend_freeze_support() -def visualize_regions(root_region: Region, file_name: str, *, - show_entrance_names: bool = False, show_locations: bool = True, show_other_regions: bool = True, - linetype_ortho: bool = True, regions_to_highlight: set[Region] | None = None) -> None: +def visualize_regions( + root_region: Region, + file_name: str, + *, + show_entrance_names: bool = False, + show_locations: bool = True, + show_other_regions: bool = True, + linetype_ortho: bool = True, + regions_to_highlight: set[Region] | None = None, + entrance_highlighting: dict[int, int] | None = None, + detail_other_regions: bool = False, + auto_assign_colors: bool = False) -> 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.) @@ -1078,6 +1117,13 @@ def visualize_regions(root_region: Region, file_name: str, *, :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. + :param entrance_highlighting: a mapping from your world's entrance randomization groups to RGB values, used to color + your entrances + :param detail_other_regions: (default False) If enabled, will fully visualize regions that aren't reachable + from root_region. + :param auto_assign_colors: (default False) If enabled, will automatically assign random colors to entrances of the + same randomization group. Uses entrance_highlighting first, and only picks random colors for entrance groups + not found in the passed-in map Example usage in World code: from Utils import visualize_regions @@ -1103,6 +1149,34 @@ def visualize_regions(root_region: Region, file_name: str, *, regions: typing.Deque[Region] = deque((root_region,)) multiworld: MultiWorld = root_region.multiworld + colors_used: set[int] = set() + if entrance_highlighting: + for color in entrance_highlighting.values(): + # filter the colors to their most-significant bits to avoid too similar colors + colors_used.add(color & 0xF0F0F0) + else: + # assign an empty dict to not crash later + # the parameter is optional for ease of use when you don't care about colors + entrance_highlighting = {} + + def select_color(group: int) -> int: + # specifically spacing color indexes by three different prime numbers (3, 5, 7) for the RGB components to avoid + # obvious cyclical color patterns + COLOR_INDEX_SPACING: int = 0x357 + new_color_index: int = (group * COLOR_INDEX_SPACING) % 0x1000 + new_color = ((new_color_index & 0xF00) << 12) + \ + ((new_color_index & 0xF0) << 8) + \ + ((new_color_index & 0xF) << 4) + while new_color in colors_used: + # while this is technically unbounded, expected collisions are low. There are 4095 possible colors + # and worlds are unlikely to get to anywhere close to that many entrance groups + # intentionally not using multiworld.random to not affect output when debugging with this tool + new_color_index += COLOR_INDEX_SPACING + new_color = ((new_color_index & 0xF00) << 12) + \ + ((new_color_index & 0xF0) << 8) + \ + ((new_color_index & 0xF) << 4) + return new_color + def fmt(obj: Union[Entrance, Item, Location, Region]) -> str: name = obj.name if isinstance(obj, Item): @@ -1122,18 +1196,28 @@ def fmt(obj: Union[Entrance, Item, Location, Region]) -> str: def visualize_exits(region: Region) -> None: for exit_ in region.exits: + color_code: str = "" + if exit_.randomization_group in entrance_highlighting: + color_code = f" #{entrance_highlighting[exit_.randomization_group]:0>6X}" if exit_.connected_region: if show_entrance_names: - uml.append(f"\"{fmt(region)}\" --> \"{fmt(exit_.connected_region)}\" : \"{fmt(exit_)}\"") + uml.append(f"\"{fmt(region)}\" --> \"{fmt(exit_.connected_region)}\" : \"{fmt(exit_)}\"{color_code}") else: try: - uml.remove(f"\"{fmt(exit_.connected_region)}\" --> \"{fmt(region)}\"") - uml.append(f"\"{fmt(exit_.connected_region)}\" <--> \"{fmt(region)}\"") + uml.remove(f"\"{fmt(exit_.connected_region)}\" --> \"{fmt(region)}\"{color_code}") + uml.append(f"\"{fmt(exit_.connected_region)}\" <--> \"{fmt(region)}\"{color_code}") except ValueError: - uml.append(f"\"{fmt(region)}\" --> \"{fmt(exit_.connected_region)}\"") + uml.append(f"\"{fmt(region)}\" --> \"{fmt(exit_.connected_region)}\"{color_code}") else: - uml.append(f"circle \"unconnected exit:\\n{fmt(exit_)}\"") - uml.append(f"\"{fmt(region)}\" --> \"unconnected exit:\\n{fmt(exit_)}\"") + uml.append(f"circle \"unconnected exit:\\n{fmt(exit_)}\" {color_code}") + uml.append(f"\"{fmt(region)}\" --> \"unconnected exit:\\n{fmt(exit_)}\"{color_code}") + for entrance in region.entrances: + color_code: str = "" + if entrance.randomization_group in entrance_highlighting: + color_code = f" #{entrance_highlighting[entrance.randomization_group]:0>6X}" + if not entrance.parent_region: + uml.append(f"circle \"unconnected entrance:\\n{fmt(entrance)}\"{color_code}") + uml.append(f"\"unconnected entrance:\\n{fmt(entrance)}\" --> \"{fmt(region)}\"{color_code}") def visualize_locations(region: Region) -> None: any_lock = any(location.locked for location in region.locations) @@ -1154,9 +1238,27 @@ def visualize_other_regions() -> None: if other_regions := [region for region in multiworld.get_regions(root_region.player) if region not in seen]: uml.append("package \"other regions\" <> {") for region in other_regions: - uml.append(f"class \"{fmt(region)}\"") + if detail_other_regions: + visualize_region(region) + else: + uml.append(f"class \"{fmt(region)}\"") uml.append("}") + if auto_assign_colors: + all_entrances: list[Entrance] = [] + for region in multiworld.get_regions(root_region.player): + all_entrances.extend(region.entrances) + all_entrances.extend(region.exits) + all_groups: list[int] = sorted(set([entrance.randomization_group for entrance in all_entrances])) + for group in all_groups: + if group not in entrance_highlighting: + if len(colors_used) >= 0x1000: + # on the off chance someone makes 4096 different entrance groups, don't cycle forever + break + new_color: int = select_color(group) + entrance_highlighting[group] = new_color + colors_used.add(new_color) + uml.append("@startuml") uml.append("hide circle") uml.append("hide empty members") @@ -1167,7 +1269,7 @@ def visualize_other_regions() -> None: seen.add(current_region) visualize_region(current_region) regions.extend(exit_.connected_region for exit_ in current_region.exits if exit_.connected_region) - if show_other_regions: + if show_other_regions or detail_other_regions: visualize_other_regions() uml.append("@enduml") @@ -1196,6 +1298,15 @@ def is_iterable_except_str(obj: object) -> TypeGuard[typing.Iterable[typing.Any] return isinstance(obj, typing.Iterable) +def utcnow() -> datetime: + """ + Implementation of Python's datetime.utcnow() function for use after deprecation. + Needed for timezone-naive UTC datetimes stored in databases with PonyORM (upstream). + https://ponyorm.org/ponyorm-list/2014-August/000113.html + """ + return datetime.now(timezone.utc).replace(tzinfo=None) + + class DaemonThreadPoolExecutor(concurrent.futures.ThreadPoolExecutor): """ ThreadPoolExecutor that uses daemonic threads that do not keep the program alive. diff --git a/WebHost.py b/WebHost.py index db465be61beb..d8763d76599b 100644 --- a/WebHost.py +++ b/WebHost.py @@ -20,7 +20,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 +if not os.path.exists(configpath): + # fall back to config.yaml in user_path if config does not exist in cwd to match settings.py configpath = os.path.abspath(Utils.user_path('config.yaml')) @@ -109,13 +110,14 @@ def copy_tutorials_files_to_static() -> None: logging.exception(e) logging.warning("Could not update LttP sprites.") app = get_app() - from worlds import AutoWorldRegister + from worlds import AutoWorldRegister, network_data_package # Update to only valid WebHost worlds invalid_worlds = {name for name, world in AutoWorldRegister.world_types.items() if not hasattr(world.web, "tutorials")} if invalid_worlds: logging.error(f"Following worlds not loaded as they are invalid for WebHost: {invalid_worlds}") AutoWorldRegister.world_types = {k: v for k, v in AutoWorldRegister.world_types.items() if k not in invalid_worlds} + network_data_package["games"] = {k: v for k, v in network_data_package["games"].items() if k not in invalid_worlds} create_options_files() copy_tutorials_files_to_static() if app.config["SELFLAUNCH"]: diff --git a/WebHostLib/README.md b/WebHostLib/README.md index 52d4963aee87..cc19be1fb919 100644 --- a/WebHostLib/README.md +++ b/WebHostLib/README.md @@ -1,46 +1,20 @@ # WebHost -## Contribution Guidelines -**Thank you for your interest in contributing to the Archipelago website!** -Much of the content on the website is generated automatically, but there are some things -that need a personal touch. For those things, we rely on contributions from both the core -team and the community. The current primary maintainer of the website is Farrak Kilhn. -He may be found on Discord as `Farrak Kilhn#0418`, or on GitHub as `LegendaryLinux`. +## Asset License + +The image files used in the page design were specifically designed for archipelago.gg and are **not** covered by the top +level LICENSE. +See individual LICENSE files in `./static/static/**`. -### Small Changes -Little changes like adding a button or a couple new select elements are perfectly fine. -Tweaks to style specific to a PR's content are also probably not a problem. For example, if -you build a new page which needs two side by side tables, and you need to write a CSS file -specific to your page, that is perfectly reasonable. +You are only allowed to use them for personal use, testing and development. +If the site is reachable over the internet, have a robots.txt in place (see `ASSET_RIGHTS` in `config.yaml`) +and do not promote it publicly. Alternatively replace or remove the assets. -### Content Additions -Once you develop a new feature or add new content the website, make a pull request. It will -be reviewed by the community and there will probably be some discussion around it. Depending -on the size of the feature, and if new styles are required, there may be an additional step -before the PR is accepted wherein Farrak works with the designer to implement styles. +## Contribution Guidelines -### Restrictions on Style Changes -A professional designer is paid to develop the styles and assets for the Archipelago website. -In an effort to maintain a consistent look and feel, pull requests which *exclusively* -change site styles are rejected. Please note this applies to code which changes the overall -look and feel of the site, not to small tweaks to CSS for your custom page. The intention -behind these restrictions is to maintain a curated feel for the design of the site. If -any PR affects the overall feel of the site but includes additive changes, there will -likely be a conversation about how to implement those changes without compromising the -curated site style. It is therefore worth noting there are a couple files which, if -changed in your pull request, will cause it to draw additional scrutiny. +Pages should preferably be rendered on the server side with Jinja. Features should work with noscript if feasible. +Design changes have to fit the overall design. -These closely guarded files are: -- `globalStyles.css` -- `islandFooter.css` -- `landing.css` -- `markdown.css` -- `tooltip.css` +Introduction of JS dependencies should first be discussed on Discord or in a draft PR. -### Site Themes -There are several themes available for game pages. It is possible to request a new theme in -the `#art-and-design` channel on Discord. Because themes are created by the designer, they -are not free, and take some time to create. Farrak works closely with the designer to implement -these themes, and pays for the assets out of pocket. Therefore, only a couple themes per year -are added. If a proposed theme seems like a cool idea and the community likes it, there is a -good chance it will become a reality. +See also [docs/style.md](/docs/style.md) for the style guide. diff --git a/WebHostLib/__init__.py b/WebHostLib/__init__.py index f856eea4c538..9459845a1d55 100644 --- a/WebHostLib/__init__.py +++ b/WebHostLib/__init__.py @@ -11,6 +11,7 @@ from werkzeug.routing import BaseConverter from Utils import title_sorted, get_file_safe_name +from .cli import CLI UPLOAD_FOLDER = os.path.relpath('uploads') LOGS_FOLDER = os.path.relpath('logs') @@ -45,6 +46,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 +# maximum time in seconds since last activity for a room to be hosted +app.config["MAX_ROOM_TIMEOUT"] = 259200 # memory limit for generator processes in bytes app.config["GENERATOR_MEMORY_LIMIT"] = 4294967296 @@ -64,10 +67,13 @@ cache = Cache() Compress(app) +CLI(app) def to_python(value: str) -> uuid.UUID: - return uuid.UUID(bytes=base64.urlsafe_b64decode(value + '==')) + if "=" in value or any(c.isspace() for c in value): + raise ValueError("Invalid UUID format") + return uuid.UUID(bytes=base64.urlsafe_b64decode(value + '=' * (-len(value) % 4))) def to_url(value: uuid.UUID) -> str: diff --git a/WebHostLib/api/__init__.py b/WebHostLib/api/__init__.py index 54eb5c1de151..63914a06baef 100644 --- a/WebHostLib/api/__init__.py +++ b/WebHostLib/api/__init__.py @@ -2,10 +2,20 @@ from typing import List, Tuple from flask import Blueprint +from flask_cors import CORS from ..models import Seed, Slot api_endpoints = Blueprint('api', __name__, url_prefix="/api") +cors = CORS(api_endpoints, resources={ + r"/api/datapackage/*": {"origins": "*"}, + r"/api/datapackage": {"origins": "*"}, + r"/api/datapackage_checksum/*": {"origins": "*"}, + r"/api/room_status/*": {"origins": "*"}, + r"/api/tracker/*": {"origins": "*"}, + r"/api/static_tracker/*": {"origins": "*"}, + r"/api/slot_data_tracker/*": {"origins": "*"} + }) def get_players(seed: Seed) -> List[Tuple[str, str]]: diff --git a/WebHostLib/autolauncher.py b/WebHostLib/autolauncher.py index 96ffbe9e9540..1a6156450035 100644 --- a/WebHostLib/autolauncher.py +++ b/WebHostLib/autolauncher.py @@ -4,14 +4,14 @@ import logging import multiprocessing import typing -from datetime import timedelta, datetime +from datetime import timedelta from threading import Event, Thread from typing import Any from uuid import UUID -from pony.orm import db_session, select, commit, PrimaryKey +from pony.orm import db_session, select, commit, PrimaryKey, desc -from Utils import restricted_loads +from Utils import restricted_loads, utcnow from .locker import Locker, AlreadyRunningException _stop_event = Event() @@ -129,10 +129,11 @@ def keep_running(): with db_session: rooms = select( room for room in Room if - room.last_activity >= datetime.utcnow() - timedelta(days=3)) + room.last_activity >= utcnow() - timedelta( + seconds=config["MAX_ROOM_TIMEOUT"])).order_by(desc(Room.last_port)) for room in rooms: # we have to filter twice, as the per-room timeout can't currently be PonyORM transpiled. - if room.last_activity >= datetime.utcnow() - timedelta(seconds=room.timeout + 5): + if room.last_activity >= utcnow() - timedelta(seconds=room.timeout + 5): hosters[room.id.int % len(hosters)].start_room(room.id) except AlreadyRunningException: diff --git a/WebHostLib/cli/__init__.py b/WebHostLib/cli/__init__.py new file mode 100644 index 000000000000..a210e1475c1b --- /dev/null +++ b/WebHostLib/cli/__init__.py @@ -0,0 +1,8 @@ +from flask import Flask + + +class CLI: + def __init__(self, app: Flask) -> None: + from .stats import stats_cli + + app.cli.add_command(stats_cli) diff --git a/WebHostLib/cli/stats.py b/WebHostLib/cli/stats.py new file mode 100644 index 000000000000..85edfb4348ec --- /dev/null +++ b/WebHostLib/cli/stats.py @@ -0,0 +1,36 @@ +import click +from flask.cli import AppGroup +from pony.orm import raw_sql + +from Utils import format_SI_prefix + +stats_cli = AppGroup("stats") + + +@stats_cli.command("show") +def show() -> None: + from pony.orm import db_session, select + + from WebHostLib.models import GameDataPackage + + total_games_package_count: int = 0 + total_games_package_size: int + top_10_package_sizes: list[tuple[int, str]] = [] + + with db_session: + data_length = raw_sql("LENGTH(data)") + data_length_desc = raw_sql("LENGTH(data) DESC") + data_length_sum = raw_sql("SUM(LENGTH(data))") + total_games_package_count = GameDataPackage.select().count() + total_games_package_size = select(data_length_sum for _ in GameDataPackage).first() # type: ignore + top_10_package_sizes = list( + select((data_length, dp.checksum) for dp in GameDataPackage) # type: ignore + .order_by(lambda _, _2: data_length_desc) + .limit(10) + ) + + click.echo(f"Total number of games packages: {total_games_package_count}") + click.echo(f"Total size of games packages: {format_SI_prefix(total_games_package_size, power=1024)}B") + click.echo(f"Top {len(top_10_package_sizes)} biggest games packages:") + for size, checksum in top_10_package_sizes: + click.echo(f" {checksum}: {size:>8d}") diff --git a/WebHostLib/customserver.py b/WebHostLib/customserver.py index 7248bf3bacc6..4257c6aff3e4 100644 --- a/WebHostLib/customserver.py +++ b/WebHostLib/customserver.py @@ -89,19 +89,24 @@ def _load_game_data(self): setattr(self, key, value) self.non_hintable_names = collections.defaultdict(frozenset, self.non_hintable_names) - def listen_to_db_commands(self): + async def listen_to_db_commands(self): cmdprocessor = DBCommandProcessor(self) while not self.exit_event.is_set(): - with db_session: - commands = select(command for command in Command if command.room.id == self.room_id) - if commands: - for command in commands: - self.main_loop.call_soon_threadsafe(cmdprocessor, command.commandtext) - command.delete() - commit() - del commands - time.sleep(5) + await self.main_loop.run_in_executor(None, self._process_db_commands, cmdprocessor) + try: + await asyncio.wait_for(self.exit_event.wait(), 5) + except asyncio.TimeoutError: + pass + + def _process_db_commands(self, cmdprocessor): + with db_session: + commands = select(command for command in Command if command.room.id == self.room_id) + if commands: + for command in commands: + self.main_loop.call_soon_threadsafe(cmdprocessor, command.commandtext) + command.delete() + commit() @db_session def load(self, room_id: int): @@ -156,9 +161,9 @@ def init_save(self, enabled: bool = True): with db_session: savegame_data = Room.get(id=self.room_id).multisave if savegame_data: - self.set_save(restricted_loads(Room.get(id=self.room_id).multisave)) + self.set_save(restricted_loads(savegame_data)) self._start_async_saving(atexit_save=False) - threading.Thread(target=self.listen_to_db_commands, daemon=True).start() + asyncio.create_task(self.listen_to_db_commands()) @db_session def _save(self, exit_save: bool = False) -> bool: @@ -167,7 +172,7 @@ def _save(self, exit_save: bool = False) -> bool: room.multisave = pickle.dumps(self.get_save()) # saving only occurs on activity, so we can "abuse" this information to mark this as last_activity if not exit_save: # we don't want to count a shutdown as activity, which would restart the server again - room.last_activity = datetime.datetime.utcnow() + room.last_activity = Utils.utcnow() return True def get_save(self) -> dict: @@ -229,6 +234,17 @@ def set_up_logging(room_id) -> logging.Logger: return logger +def tear_down_logging(room_id): + """Close logging handling for a room.""" + logger_name = f"RoomLogger {room_id}" + if logger_name in logging.Logger.manager.loggerDict: + logger = logging.getLogger(logger_name) + for handler in logger.handlers[:]: + logger.removeHandler(handler) + handler.close() + del logging.Logger.manager.loggerDict[logger_name] + + 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): @@ -343,12 +359,17 @@ async def start_room(room_id): ctx.save_dirty = False # make sure the saving thread does not write to DB after final wakeup ctx.exit_event.set() # make sure the saving thread stops at some point # NOTE: async saving should probably be an async task and could be merged with shutdown_task + + if ctx.server and hasattr(ctx.server, "ws_server"): + ctx.server.ws_server.close() + await ctx.server.ws_server.wait_closed() + with db_session: # ensure the Room does not spin up again on its own, minute of safety buffer room = Room.get(id=room_id) - room.last_activity = datetime.datetime.utcnow() - \ - datetime.timedelta(minutes=1, seconds=room.timeout) + room.last_activity = Utils.utcnow() - datetime.timedelta(minutes=1, seconds=room.timeout) del room + tear_down_logging(room_id) logging.info(f"Shutting down room {room_id} on {name}.") finally: await asyncio.sleep(5) diff --git a/WebHostLib/landing.py b/WebHostLib/landing.py index 14e90cc28df4..f1b8de21bfbf 100644 --- a/WebHostLib/landing.py +++ b/WebHostLib/landing.py @@ -1,8 +1,9 @@ -from datetime import timedelta, datetime +from datetime import timedelta from flask import render_template from pony.orm import count +from Utils import utcnow from WebHostLib import app, cache from .models import Room, Seed @@ -10,6 +11,6 @@ @app.route('/', methods=['GET', 'POST']) @cache.cached(timeout=300) # cache has to appear under app route for caching to work def landing(): - rooms = count(room for room in Room if room.creation_time >= datetime.utcnow() - timedelta(days=7)) - seeds = count(seed for seed in Seed if seed.creation_time >= datetime.utcnow() - timedelta(days=7)) + rooms = count(room for room in Room if room.creation_time >= utcnow() - timedelta(days=7)) + seeds = count(seed for seed in Seed if seed.creation_time >= utcnow() - timedelta(days=7)) return render_template("landing.html", rooms=rooms, seeds=seeds) diff --git a/WebHostLib/misc.py b/WebHostLib/misc.py index e30f1a6dd413..8d04fe984eb5 100644 --- a/WebHostLib/misc.py +++ b/WebHostLib/misc.py @@ -9,11 +9,12 @@ from pony.orm import count, commit, db_session from werkzeug.utils import secure_filename + from worlds.AutoWorld import AutoWorldRegister, World from . import app, cache from .markdown import render_markdown from .models import Seed, Room, Command, UUID, uuid4 -from Utils import title_sorted +from Utils import title_sorted, utcnow class WebWorldTheme(StrEnum): DIRT = "dirt" @@ -233,11 +234,12 @@ def host_room(room: UUID): if room is None: return abort(404) - now = datetime.datetime.utcnow() + now = utcnow() # indicate that the page should reload to get the assigned port - should_refresh = ((not room.last_port and now - room.creation_time < datetime.timedelta(seconds=3)) - or room.last_activity < now - datetime.timedelta(seconds=room.timeout)) - + should_refresh = ( + (not room.last_port and now - room.creation_time < datetime.timedelta(seconds=3)) + or room.last_activity < now - datetime.timedelta(seconds=room.timeout) + ) if now - room.last_activity > datetime.timedelta(minutes=1): # we only set last_activity if needed, otherwise parallel access on /room will cause an internal server error # due to "pony.orm.core.OptimisticCheckError: Object Room was updated outside of current transaction" diff --git a/WebHostLib/models.py b/WebHostLib/models.py index 7fa54f26a004..9060bc0ca4c5 100644 --- a/WebHostLib/models.py +++ b/WebHostLib/models.py @@ -2,6 +2,8 @@ from uuid import UUID, uuid4 from pony.orm import Database, PrimaryKey, Required, Set, Optional, buffer, LongStr +from Utils import utcnow + db = Database() STATE_QUEUED = 0 @@ -20,8 +22,8 @@ class Slot(db.Entity): class Room(db.Entity): id = PrimaryKey(UUID, default=uuid4) - last_activity = Required(datetime, default=lambda: datetime.utcnow(), index=True) - creation_time = Required(datetime, default=lambda: datetime.utcnow(), index=True) # index used by landing page + last_activity: datetime = Required(datetime, default=lambda: utcnow(), index=True) + creation_time: datetime = Required(datetime, default=lambda: utcnow(), index=True) # index used by landing page owner = Required(UUID, index=True) commands = Set('Command') seed = Required('Seed', index=True) @@ -38,7 +40,7 @@ class Seed(db.Entity): rooms = Set(Room) multidata = Required(bytes, lazy=True) owner = Required(UUID, index=True) - creation_time = Required(datetime, default=lambda: datetime.utcnow(), index=True) # index used by landing page + creation_time: datetime = Required(datetime, default=lambda: utcnow(), index=True) # index used by landing page slots = Set(Slot) spoiler = Optional(LongStr, lazy=True) meta = Required(LongStr, default=lambda: "{\"race\": false}") # additional meta information/tags diff --git a/WebHostLib/requirements.txt b/WebHostLib/requirements.txt index c4267dc2846b..fd194f223221 100644 --- a/WebHostLib/requirements.txt +++ b/WebHostLib/requirements.txt @@ -1,13 +1,14 @@ -flask>=3.1.1 -werkzeug>=3.1.3 -pony>=0.7.19; python_version <= '3.12' +flask==3.1.3 +werkzeug==3.1.6 +pony==0.7.19; python_version <= '3.12' pony @ git+https://github.com/black-sliver/pony@7feb1221953b7fa4a6735466bf21a8b4d35e33ba#0.7.19; python_version >= '3.13' -waitress>=3.0.2 -Flask-Caching>=2.3.0 +waitress==3.0.2 +Flask-Caching==2.3.1 Flask-Compress==1.18 # pkg_resources can't resolve the "backports.zstd" dependency of >1.18, breaking ModuleUpdate.py -Flask-Limiter>=3.12 -bokeh>=3.6.3 -markupsafe>=3.0.2 -setproctitle>=1.3.5 -mistune>=3.1.3 -docutils>=0.22.2 +Flask-Limiter==4.1.1 +Flask-Cors==6.0.2 +bokeh==3.8.2 +markupsafe==3.0.3 +setproctitle==1.3.7 +mistune==3.2.0 +docutils==0.22.4 diff --git a/WebHostLib/templates/islandFooter.html b/WebHostLib/templates/islandFooter.html index 7de14f0d827c..1b90091c2934 100644 --- a/WebHostLib/templates/islandFooter.html +++ b/WebHostLib/templates/islandFooter.html @@ -1,6 +1,6 @@ {% block footer %}