diff --git a/.github/pyright-config.json b/.github/pyright-config.json index fba044da0652..c5432dbf3cf7 100644 --- a/.github/pyright-config.json +++ b/.github/pyright-config.json @@ -3,6 +3,7 @@ "../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", 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 772a6c0be359..8ed0c3523c33 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -41,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 @@ -82,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 @@ -110,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 @@ -129,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 @@ -173,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 @@ -204,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 cbc33e5858be..8f9ed6df14fe 100644 --- a/.gitignore +++ b/.gitignore @@ -45,6 +45,7 @@ EnemizerCLI/ /SNI/ /sni-*/ /appimagetool* +/VC_redist.x64.exe /host.yaml /options.yaml /config.yaml diff --git a/CommonClient.py b/CommonClient.py index 5fbc0f1b061a..3f98a4eff1d0 100755 --- a/CommonClient.py +++ b/CommonClient.py @@ -773,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) @@ -896,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/Generate.py b/Generate.py index b51064017063..509bf848d0d3 100644 --- a/Generate.py +++ b/Generate.py @@ -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) diff --git a/Launcher.py b/Launcher.py index cffd96b85d08..0e7d4796c4a8 100644 --- a/Launcher.py +++ b/Launcher.py @@ -29,8 +29,8 @@ 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') @@ -52,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(): @@ -106,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}") @@ -202,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: @@ -406,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. """ diff --git a/OptionsCreator.py b/OptionsCreator.py index 94ca8ba7acc1..30833993e1d2 100644 --- a/OptionsCreator.py +++ b/OptionsCreator.py @@ -384,10 +384,11 @@ def open_dropdown(button): 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): diff --git a/Utils.py b/Utils.py index 627235f24925..0210086274f4 100644 --- a/Utils.py +++ b/Utils.py @@ -22,7 +22,7 @@ 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 @@ -236,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) @@ -345,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: @@ -369,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 {} @@ -758,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") @@ -770,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 diff --git a/WebHostLib/__init__.py b/WebHostLib/__init__.py index d10c17bff8ad..f1ac7ad5585d 100644 --- a/WebHostLib/__init__.py +++ b/WebHostLib/__init__.py @@ -46,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 diff --git a/WebHostLib/autolauncher.py b/WebHostLib/autolauncher.py index b48c6a8cbbe1..1a6156450035 100644 --- a/WebHostLib/autolauncher.py +++ b/WebHostLib/autolauncher.py @@ -9,7 +9,7 @@ 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, utcnow from .locker import Locker, AlreadyRunningException @@ -129,7 +129,8 @@ def keep_running(): with db_session: rooms = select( room for room in Room if - room.last_activity >= 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 >= utcnow() - timedelta(seconds=room.timeout + 5): diff --git a/WebHostLib/requirements.txt b/WebHostLib/requirements.txt index c9a923680add..fd194f223221 100644 --- a/WebHostLib/requirements.txt +++ b/WebHostLib/requirements.txt @@ -1,14 +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 -Flask-Cors>=6.0.2 -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/supportedGames.html b/WebHostLib/templates/supportedGames.html index 759e748056a5..43028721b254 100644 --- a/WebHostLib/templates/supportedGames.html +++ b/WebHostLib/templates/supportedGames.html @@ -33,7 +33,9 @@

Currently Supported Games

Below are the games that are currently included with the Archipelago software. To play a game that is not on this page, please refer to the playing with - custom worlds section of the setup guide.

+ custom worlds section of the setup guide and the + other games and tools guide + to find more.


diff --git a/WebHostLib/templates/tutorialLanding.html b/WebHostLib/templates/tutorialLanding.html index a96da883b624..ac7f24f95ab5 100644 --- a/WebHostLib/templates/tutorialLanding.html +++ b/WebHostLib/templates/tutorialLanding.html @@ -20,11 +20,7 @@

{{ tutorial_name }}

{% for file_name, file_data in tutorial_data.files.items() %}
  • {{ file_data.language }} - by - {% for author in file_data.authors %} - {{ author }} - {% if not loop.last %}, {% endif %} - {% endfor %} + by {{ file_data.authors | join(", ") }}
  • {% endfor %} diff --git a/docs/CODEOWNERS b/docs/CODEOWNERS index 46afd3045692..30b61f5c85b8 100644 --- a/docs/CODEOWNERS +++ b/docs/CODEOWNERS @@ -19,8 +19,6 @@ # NewSoupVi is acting maintainer, but world belongs to core with the exception of the music /worlds/apquest/ @NewSoupVi -# Sudoku (APSudoku) -/worlds/apsudoku/ @EmilyV99 # Aquaria /worlds/aquaria/ @tioui diff --git a/docs/rule builder.md b/docs/rule builder.md index 4f9102a2ba0e..c3a8fcb6c42b 100644 --- a/docs/rule builder.md +++ b/docs/rule builder.md @@ -129,6 +129,42 @@ common_rule_only_on_easy = common_rule & easy_filter common_rule_skipped_on_easy = common_rule | easy_filter ``` +### Field resolvers + +When creating rules you may sometimes need to set a field to a value that depends on the world instance. You can use a `FieldResolver` to define how to populate that field when the rule is being resolved. + +There are two build-in field resolvers: + +- `FromOption`: Resolves to the value of the given option +- `FromWorldAttr`: Resolves to the value of the given world instance attribute, can specify a dotted path `a.b.c` to get a nested attribute or dict item + +```python +world.options.mcguffin_count = 5 +world.precalculated_value = 99 +rule = ( + Has("A", count=FromOption(McguffinCount)) + | HasGroup("Important items", count=FromWorldAttr("precalculated_value")) +) +# Results in Has("A", count=5) | HasGroup("Important items", count=99) +``` + +You can define your own resolvers by creating a class that inherits from `FieldResolver`, provides your game name, and implements a `resolve` function: + +```python +@dataclasses.dataclass(frozen=True) +class FromCustomResolution(FieldResolver, game="MyGame"): + modifier: str + + @override + def resolve(self, world: "World") -> Any: + return some_math_calculation(world, self.modifier) + + +rule = Has("Combat Level", count=FromCustomResolution("combat")) +``` + +If you want to support rule serialization and your resolver contains non-serializable properties you may need to override `to_dict` or `from_dict`. + ## Enabling caching The rule builder provides a `CachedRuleBuilderWorld` base class for your `World` class that enables caching on your rules. diff --git a/requirements.txt b/requirements.txt index 27bca5c7c29e..3a91b1668029 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,21 +1,21 @@ -colorama>=0.4.6 -websockets>=13.0.1,<14 -PyYAML>=6.0.3 -jellyfish>=1.2.1 -jinja2>=3.1.6 -schema>=0.7.8 -kivy>=2.3.1 -bsdiff4>=1.2.6 -platformdirs>=4.5.0 -certifi>=2025.11.12 -cython>=3.2.1 -cymem>=2.0.13 -orjson>=3.11.4 -typing_extensions>=4.15.0 -pyshortcuts>=1.9.6 -pathspec>=0.12.1 +colorama==0.4.6 +websockets==13.1 # ,<14 +PyYAML==6.0.3 +jellyfish==1.2.1 +jinja2==3.1.6 +schema==0.7.8 +kivy==2.3.1 +bsdiff4==1.2.6 +platformdirs==4.9.4 +certifi==2026.2.25 +cython==3.2.4 +cymem==2.0.13 +orjson==3.11.7 +typing_extensions==4.15.0 +pyshortcuts==1.9.7 +pathspec==1.0.4 kivymd @ git+https://github.com/kivymd/KivyMD@5ff9d0d kivymd>=2.0.1.dev0 # Legacy world dependencies that custom worlds rely on -Pymem>=1.13.0 +Pymem==1.14.0 diff --git a/rule_builder/field_resolvers.py b/rule_builder/field_resolvers.py new file mode 100644 index 000000000000..1e5def6b44b3 --- /dev/null +++ b/rule_builder/field_resolvers.py @@ -0,0 +1,162 @@ +import dataclasses +import importlib +from abc import ABC, abstractmethod +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any, ClassVar, Self, TypeVar, cast, overload + +from typing_extensions import override + +from Options import Option + +if TYPE_CHECKING: + from worlds.AutoWorld import World + + +class FieldResolverRegister: + """A container class to contain world custom resolvers""" + + custom_resolvers: ClassVar[dict[str, dict[str, type["FieldResolver"]]]] = {} + """ + A mapping of game name to mapping of resolver name to resolver class + to hold custom resolvers implemented by worlds + """ + + @classmethod + def get_resolver_cls(cls, game_name: str, resolver_name: str) -> type["FieldResolver"]: + """Returns the world-registered or default resolver with the given name""" + custom_resolver_classes = cls.custom_resolvers.get(game_name, {}) + if resolver_name not in DEFAULT_RESOLVERS and resolver_name not in custom_resolver_classes: + raise ValueError(f"Resolver '{resolver_name}' for game '{game_name}' not found") + return custom_resolver_classes.get(resolver_name) or DEFAULT_RESOLVERS[resolver_name] + + +@dataclasses.dataclass(frozen=True) +class FieldResolver(ABC): + @abstractmethod + def resolve(self, world: "World") -> Any: ... + + def to_dict(self) -> dict[str, Any]: + """Returns a JSON compatible dict representation of this resolver""" + fields = {field.name: getattr(self, field.name, None) for field in dataclasses.fields(self)} + return { + "resolver": self.__class__.__name__, + **fields, + } + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> Self: + """Returns a new instance of this resolver from a serialized dict representation""" + assert data.get("resolver", None) == cls.__name__ + return cls(**{k: v for k, v in data.items() if k != "resolver"}) + + @override + def __str__(self) -> str: + return self.__class__.__name__ + + @classmethod + def __init_subclass__(cls, /, game: str) -> None: + if game != "Archipelago": + custom_resolvers = FieldResolverRegister.custom_resolvers.setdefault(game, {}) + if cls.__qualname__ in custom_resolvers: + raise TypeError(f"Resolver {cls.__qualname__} has already been registered for game {game}") + custom_resolvers[cls.__qualname__] = cls + elif cls.__module__ != "rule_builder.field_resolvers": + raise TypeError("You cannot define custom resolvers for the base Archipelago world") + + +@dataclasses.dataclass(frozen=True) +class FromOption(FieldResolver, game="Archipelago"): + option: type[Option[Any]] + field: str = "value" + + @override + def resolve(self, world: "World") -> Any: + option_name = next( + (name for name, cls in world.options.__class__.type_hints.items() if cls is self.option), + None, + ) + + if option_name is None: + raise ValueError( + f"Cannot find option {self.option.__name__} in options class {world.options.__class__.__name__}" + ) + opt = cast(Option[Any] | None, getattr(world.options, option_name, None)) + if opt is None: + raise ValueError(f"Invalid option: {option_name}") + return getattr(opt, self.field) + + @override + def to_dict(self) -> dict[str, Any]: + return { + "resolver": "FromOption", + "option": f"{self.option.__module__}.{self.option.__name__}", + "field": self.field, + } + + @override + @classmethod + def from_dict(cls, data: dict[str, Any]) -> Self: + if "option" not in data: + raise ValueError("Missing required option") + + option_path = data["option"] + try: + option_mod_name, option_cls_name = option_path.rsplit(".", 1) + option_module = importlib.import_module(option_mod_name) + option = getattr(option_module, option_cls_name, None) + except (ValueError, ImportError) as e: + raise ValueError(f"Cannot parse option '{option_path}'") from e + if option is None or not issubclass(option, Option): + raise ValueError(f"Invalid option '{option_path}' returns type '{option}' instead of Option subclass") + + return cls(cast(type[Option[Any]], option), data.get("field", "value")) + + @override + def __str__(self) -> str: + field = f".{self.field}" if self.field != "value" else "" + return f"FromOption({self.option.__name__}{field})" + + +@dataclasses.dataclass(frozen=True) +class FromWorldAttr(FieldResolver, game="Archipelago"): + name: str + + @override + def resolve(self, world: "World") -> Any: + obj: Any = world + for field in self.name.split("."): + if obj is None: + return None + if isinstance(obj, Mapping): + obj = obj.get(field, None) # pyright: ignore[reportUnknownMemberType] + else: + obj = getattr(obj, field, None) + return obj + + @override + def __str__(self) -> str: + return f"FromWorldAttr({self.name})" + + +T = TypeVar("T") + + +@overload +def resolve_field(field: Any, world: "World", expected_type: type[T]) -> T: ... +@overload +def resolve_field(field: Any, world: "World", expected_type: None = None) -> Any: ... +def resolve_field(field: Any, world: "World", expected_type: type[T] | None = None) -> T | Any: + if isinstance(field, FieldResolver): + field = field.resolve(world) + if expected_type: + assert isinstance(field, expected_type), f"Expected type {expected_type} but got {type(field)}" + return field + + +DEFAULT_RESOLVERS = { + resolver_name: resolver_class + for resolver_name, resolver_class in locals().items() + if isinstance(resolver_class, type) + and issubclass(resolver_class, FieldResolver) + and resolver_class is not FieldResolver +} diff --git a/rule_builder/rules.py b/rule_builder/rules.py index 816ac9f0b7f0..07c0607c1fb3 100644 --- a/rule_builder/rules.py +++ b/rule_builder/rules.py @@ -7,6 +7,7 @@ from BaseClasses import CollectionState from NetUtils import JSONMessagePart +from .field_resolvers import FieldResolver, FieldResolverRegister, resolve_field from .options import OptionFilter if TYPE_CHECKING: @@ -108,11 +109,14 @@ def resolve(self, world: TWorld) -> "Resolved": def to_dict(self) -> dict[str, Any]: """Returns a JSON compatible dict representation of this rule""" - args = { - field.name: getattr(self, field.name, None) - for field in dataclasses.fields(self) - if field.name not in ("options", "filtered_resolution") - } + args = {} + for field in dataclasses.fields(self): + if field.name in ("options", "filtered_resolution"): + continue + value = getattr(self, field.name, None) + if isinstance(value, FieldResolver): + value = value.to_dict() + args[field.name] = value return { "rule": self.__class__.__qualname__, "options": [o.to_dict() for o in self.options], @@ -124,7 +128,19 @@ def to_dict(self) -> dict[str, Any]: def from_dict(cls, data: Mapping[str, Any], world_cls: "type[World]") -> Self: """Returns a new instance of this rule from a serialized dict representation""" options = OptionFilter.multiple_from_dict(data.get("options", ())) - return cls(**data.get("args", {}), options=options, filtered_resolution=data.get("filtered_resolution", False)) + args = cls._parse_field_resolvers(data.get("args", {}), world_cls.game) + return cls(**args, options=options, filtered_resolution=data.get("filtered_resolution", False)) + + @classmethod + def _parse_field_resolvers(cls, data: Mapping[str, Any], game_name: str) -> dict[str, Any]: + result: dict[str, Any] = {} + for name, value in data.items(): + if isinstance(value, dict) and "resolver" in value: + resolver_cls = FieldResolverRegister.get_resolver_cls(game_name, value["resolver"]) # pyright: ignore[reportUnknownArgumentType] + result[name] = resolver_cls.from_dict(value) # pyright: ignore[reportUnknownArgumentType] + else: + result[name] = value + return result def __and__(self, other: "Rule[Any] | Iterable[OptionFilter] | OptionFilter") -> "Rule[TWorld]": """Combines two rules or a rule and an option filter into an And rule""" @@ -527,7 +543,7 @@ def _instantiate(self, world: TWorld) -> Rule.Resolved: items[item] = 1 elif isinstance(child, HasAnyCount.Resolved): for item, count in child.item_counts: - if item not in items or items[item] < count: + if item not in items or count < items[item]: items[item] = count else: clauses.append(child) @@ -688,24 +704,24 @@ def _instantiate(self, world: TWorld) -> Rule.Resolved: class Has(Rule[TWorld], game="Archipelago"): """A rule that checks if the player has at least `count` of a given item""" - item_name: str + item_name: str | FieldResolver """The item to check for""" - count: int = 1 + count: int | FieldResolver = 1 """The count the player is required to have""" @override def _instantiate(self, world: TWorld) -> Rule.Resolved: return self.Resolved( - self.item_name, - self.count, + resolve_field(self.item_name, world, str), + count=resolve_field(self.count, world, int), player=world.player, caching_enabled=getattr(world, "rule_caching_enabled", False), ) @override def __str__(self) -> str: - count = f", count={self.count}" if self.count > 1 else "" + count = f", count={self.count}" if isinstance(self.count, FieldResolver) or self.count > 1 else "" options = f", options={self.options}" if self.options else "" return f"{self.__class__.__name__}({self.item_name}{count}{options})" @@ -991,7 +1007,7 @@ def __str__(self) -> str: class HasAllCounts(Rule[TWorld], game="Archipelago"): """A rule that checks if the player has all of the specified counts of the given items""" - item_counts: dict[str, int] + item_counts: Mapping[str, int | FieldResolver] """A mapping of item name to count to check for""" @override @@ -1002,12 +1018,30 @@ def _instantiate(self, world: TWorld) -> Rule.Resolved: if len(self.item_counts) == 1: item = next(iter(self.item_counts)) return Has(item, self.item_counts[item]).resolve(world) + item_counts = tuple((name, resolve_field(count, world, int)) for name, count in self.item_counts.items()) return self.Resolved( - tuple(self.item_counts.items()), + item_counts, player=world.player, caching_enabled=getattr(world, "rule_caching_enabled", False), ) + @override + def to_dict(self) -> dict[str, Any]: + output = super().to_dict() + output["args"]["item_counts"] = { + key: value.to_dict() if isinstance(value, FieldResolver) else value + for key, value in output["args"]["item_counts"].items() + } + return output + + @override + @classmethod + def from_dict(cls, data: Mapping[str, Any], world_cls: "type[World]") -> Self: + args = data.get("args", {}) + item_counts = cls._parse_field_resolvers(args.get("item_counts", {}), world_cls.game) + options = OptionFilter.multiple_from_dict(data.get("options", ())) + return cls(item_counts, options=options, filtered_resolution=data.get("filtered_resolution", False)) + @override def __str__(self) -> str: items = ", ".join([f"{item} x{count}" for item, count in self.item_counts.items()]) @@ -1096,7 +1130,7 @@ def __str__(self) -> str: class HasAnyCount(Rule[TWorld], game="Archipelago"): """A rule that checks if the player has any of the specified counts of the given items""" - item_counts: dict[str, int] + item_counts: Mapping[str, int | FieldResolver] """A mapping of item name to count to check for""" @override @@ -1107,12 +1141,30 @@ def _instantiate(self, world: TWorld) -> Rule.Resolved: if len(self.item_counts) == 1: item = next(iter(self.item_counts)) return Has(item, self.item_counts[item]).resolve(world) + item_counts = tuple((name, resolve_field(count, world, int)) for name, count in self.item_counts.items()) return self.Resolved( - tuple(self.item_counts.items()), + item_counts, player=world.player, caching_enabled=getattr(world, "rule_caching_enabled", False), ) + @override + def to_dict(self) -> dict[str, Any]: + output = super().to_dict() + output["args"]["item_counts"] = { + key: value.to_dict() if isinstance(value, FieldResolver) else value + for key, value in output["args"]["item_counts"].items() + } + return output + + @override + @classmethod + def from_dict(cls, data: Mapping[str, Any], world_cls: "type[World]") -> Self: + args = data.get("args", {}) + item_counts = cls._parse_field_resolvers(args.get("item_counts", {}), world_cls.game) + options = OptionFilter.multiple_from_dict(data.get("options", ())) + return cls(item_counts, options=options, filtered_resolution=data.get("filtered_resolution", False)) + @override def __str__(self) -> str: items = ", ".join([f"{item} x{count}" for item, count in self.item_counts.items()]) @@ -1204,13 +1256,13 @@ class HasFromList(Rule[TWorld], game="Archipelago"): item_names: tuple[str, ...] """A tuple of item names to check for""" - count: int = 1 + count: int | FieldResolver = 1 """The number of items the player needs to have""" def __init__( self, *item_names: str, - count: int = 1, + count: int | FieldResolver = 1, options: Iterable[OptionFilter] = (), filtered_resolution: bool = False, ) -> None: @@ -1227,7 +1279,7 @@ def _instantiate(self, world: TWorld) -> Rule.Resolved: return Has(self.item_names[0], self.count).resolve(world) return self.Resolved( self.item_names, - self.count, + count=resolve_field(self.count, world, int), player=world.player, caching_enabled=getattr(world, "rule_caching_enabled", False), ) @@ -1235,7 +1287,7 @@ def _instantiate(self, world: TWorld) -> Rule.Resolved: @override @classmethod def from_dict(cls, data: Mapping[str, Any], world_cls: "type[World]") -> Self: - args = {**data.get("args", {})} + args = cls._parse_field_resolvers(data.get("args", {}), world_cls.game) item_names = args.pop("item_names", ()) options = OptionFilter.multiple_from_dict(data.get("options", ())) return cls(*item_names, **args, options=options, filtered_resolution=data.get("filtered_resolution", False)) @@ -1338,13 +1390,13 @@ class HasFromListUnique(Rule[TWorld], game="Archipelago"): item_names: tuple[str, ...] """A tuple of item names to check for""" - count: int = 1 + count: int | FieldResolver = 1 """The number of items the player needs to have""" def __init__( self, *item_names: str, - count: int = 1, + count: int | FieldResolver = 1, options: Iterable[OptionFilter] = (), filtered_resolution: bool = False, ) -> None: @@ -1354,14 +1406,15 @@ def __init__( @override def _instantiate(self, world: TWorld) -> Rule.Resolved: - if len(self.item_names) == 0 or len(self.item_names) < self.count: + count = resolve_field(self.count, world, int) + if len(self.item_names) == 0 or len(self.item_names) < count: # match state.has_from_list_unique return False_().resolve(world) if len(self.item_names) == 1: return Has(self.item_names[0]).resolve(world) return self.Resolved( self.item_names, - self.count, + count, player=world.player, caching_enabled=getattr(world, "rule_caching_enabled", False), ) @@ -1369,7 +1422,7 @@ def _instantiate(self, world: TWorld) -> Rule.Resolved: @override @classmethod def from_dict(cls, data: Mapping[str, Any], world_cls: "type[World]") -> Self: - args = {**data.get("args", {})} + args = cls._parse_field_resolvers(data.get("args", {}), world_cls.game) item_names = args.pop("item_names", ()) options = OptionFilter.multiple_from_dict(data.get("options", ())) return cls(*item_names, **args, options=options, filtered_resolution=data.get("filtered_resolution", False)) @@ -1468,7 +1521,7 @@ class HasGroup(Rule[TWorld], game="Archipelago"): item_name_group: str """The name of the item group containing the items""" - count: int = 1 + count: int | FieldResolver = 1 """The number of items the player needs to have""" @override @@ -1477,14 +1530,14 @@ def _instantiate(self, world: TWorld) -> Rule.Resolved: return self.Resolved( self.item_name_group, item_names, - self.count, + count=resolve_field(self.count, world, int), player=world.player, caching_enabled=getattr(world, "rule_caching_enabled", False), ) @override def __str__(self) -> str: - count = f", count={self.count}" if self.count > 1 else "" + count = f", count={self.count}" if isinstance(self.count, FieldResolver) or self.count > 1 else "" options = f", options={self.options}" if self.options else "" return f"{self.__class__.__name__}({self.item_name_group}{count}{options})" @@ -1542,7 +1595,7 @@ class HasGroupUnique(Rule[TWorld], game="Archipelago"): item_name_group: str """The name of the item group containing the items""" - count: int = 1 + count: int | FieldResolver = 1 """The number of items the player needs to have""" @override @@ -1551,14 +1604,14 @@ def _instantiate(self, world: TWorld) -> Rule.Resolved: return self.Resolved( self.item_name_group, item_names, - self.count, + count=resolve_field(self.count, world, int), player=world.player, caching_enabled=getattr(world, "rule_caching_enabled", False), ) @override def __str__(self) -> str: - count = f", count={self.count}" if self.count > 1 else "" + count = f", count={self.count}" if isinstance(self.count, FieldResolver) or self.count > 1 else "" options = f", options={self.options}" if self.options else "" return f"{self.__class__.__name__}({self.item_name_group}{count}{options})" diff --git a/setup.py b/setup.py index 949b1e3e303b..3c40eab59eda 100644 --- a/setup.py +++ b/setup.py @@ -71,7 +71,6 @@ "Ocarina of Time", "Overcooked! 2", "Raft", - "Sudoku", "Super Mario 64", "VVVVVV", "Wargroove", @@ -658,7 +657,7 @@ def find_lib(lib: str, arch: str, libc: str) -> str | None: options={ "build_exe": { "packages": ["worlds", "kivy", "cymem", "websockets", "kivymd"], - "includes": [], + "includes": ["rule_builder.cached_world"], "excludes": ["numpy", "Cython", "PySide2", "PIL", "pandas"], "zip_includes": [], diff --git a/test/general/test_implemented.py b/test/general/test_implemented.py index add6e5321e7f..0bc7b62d5b34 100644 --- a/test/general/test_implemented.py +++ b/test/general/test_implemented.py @@ -11,7 +11,7 @@ class TestImplemented(unittest.TestCase): def test_completion_condition(self): """Ensure a completion condition is set that has requirements.""" for game_name, world_type in AutoWorldRegister.world_types.items(): - if not world_type.hidden and game_name not in {"Sudoku"}: + if not world_type.hidden: with self.subTest(game_name): multiworld = setup_solo_multiworld(world_type) self.assertFalse(multiworld.completion_condition[1](multiworld.state)) @@ -59,7 +59,7 @@ def test_no_failed_world_loads(self): def test_prefill_items(self): """Test that every world can reach every location from allstate before pre_fill.""" for gamename, world_type in AutoWorldRegister.world_types.items(): - if gamename not in ("Archipelago", "Sudoku", "Final Fantasy", "Test Game"): + if gamename not in ("Archipelago", "Final Fantasy", "Test Game"): with self.subTest(gamename): multiworld = setup_solo_multiworld(world_type, ("generate_early", "create_regions", "create_items", "set_rules", "connect_entrances", "generate_basic")) diff --git a/test/general/test_options.py b/test/general/test_options.py index 6b08c8e9b048..5d69b6820b84 100644 --- a/test/general/test_options.py +++ b/test/general/test_options.py @@ -109,7 +109,7 @@ def test_pickle_dumps_default(self): def test_option_set_keys_random(self): """Tests that option sets do not contain 'random' and its variants as valid keys""" for game_name, world_type in AutoWorldRegister.world_types.items(): - if game_name not in ("Archipelago", "Sudoku", "Super Metroid"): + if game_name not in ("Archipelago", "Super Metroid"): for option_key, option in world_type.options_dataclass.type_hints.items(): if issubclass(option, OptionSet): with self.subTest(game=game_name, option=option_key): diff --git a/test/general/test_rule_builder.py b/test/general/test_rule_builder.py index 52248b604727..85e239175d4d 100644 --- a/test/general/test_rule_builder.py +++ b/test/general/test_rule_builder.py @@ -6,8 +6,9 @@ from BaseClasses import CollectionState, Item, ItemClassification, Location, MultiWorld, Region from NetUtils import JSONMessagePart -from Options import Choice, FreeText, Option, OptionSet, PerGameCommonOptions, Toggle +from Options import Choice, FreeText, Option, OptionSet, PerGameCommonOptions, Range, Toggle from rule_builder.cached_world import CachedRuleBuilderWorld +from rule_builder.field_resolvers import FieldResolver, FromOption, FromWorldAttr, resolve_field from rule_builder.options import Operator, OptionFilter from rule_builder.rules import ( And, @@ -59,12 +60,20 @@ class SetOption(OptionSet): valid_keys: ClassVar[set[str]] = {"one", "two", "three"} # pyright: ignore[reportIncompatibleVariableOverride] +class RangeOption(Range): + auto_display_name = True + range_start = 1 + range_end = 10 + default = 5 + + @dataclass class RuleBuilderOptions(PerGameCommonOptions): toggle_option: ToggleOption choice_option: ChoiceOption text_option: FreeTextOption set_option: SetOption + range_option: RangeOption GAME_NAME = "Rule Builder Test Game" @@ -233,6 +242,14 @@ def get_filler_item_name(self) -> str: Or(Has("A"), HasAny("B", "C"), HasAnyCount({"D": 1, "E": 1})), HasAny.Resolved(("A", "B", "C", "D", "E"), player=1), ), + ( + And(HasAllCounts({"A": 1, "B": 2}), HasAllCounts({"A": 2, "B": 2})), + HasAllCounts.Resolved((("A", 2), ("B", 2)), player=1), + ), + ( + Or(HasAnyCount({"A": 1, "B": 2}), HasAnyCount({"A": 2, "B": 2})), + HasAnyCount.Resolved((("A", 1), ("B", 2)), player=1), + ), ) ) class TestSimplify(RuleBuilderTestCase): @@ -651,14 +668,15 @@ def test_has_all_counts(self) -> None: self.assertFalse(resolved_rule(self.state)) def test_has_any_count(self) -> None: - item_counts = {"Item 1": 1, "Item 2": 2} + item_counts: dict[str, int | FieldResolver] = {"Item 1": 1, "Item 2": 2} rule = HasAnyCount(item_counts) resolved_rule = rule.resolve(self.world) self.world.register_rule_dependencies(resolved_rule) for item_name, count in item_counts.items(): item = self.world.create_item(item_name) - for _ in range(count): + num_items = resolve_field(count, self.world, int) + for _ in range(num_items): self.assertFalse(resolved_rule(self.state)) self.state.collect(item) self.assertTrue(resolved_rule(self.state)) @@ -755,7 +773,7 @@ class TestSerialization(RuleBuilderTestCase): rule: ClassVar[Rule[Any]] = And( Or( - Has("i1", count=4), + Has("i1", count=FromOption(RangeOption)), HasFromList("i2", "i3", "i4", count=2), HasAnyCount({"i5": 2, "i6": 3}), options=[OptionFilter(ToggleOption, 0)], @@ -763,7 +781,7 @@ class TestSerialization(RuleBuilderTestCase): Or( HasAll("i7", "i8"), HasAllCounts( - {"i9": 1, "i10": 5}, + {"i9": 1, "i10": FromWorldAttr("instance_data.i10_count")}, options=[OptionFilter(ToggleOption, 1, operator="ne")], filtered_resolution=True, ), @@ -803,7 +821,14 @@ class TestSerialization(RuleBuilderTestCase): "rule": "Has", "options": [], "filtered_resolution": False, - "args": {"item_name": "i1", "count": 4}, + "args": { + "item_name": "i1", + "count": { + "resolver": "FromOption", + "option": "test.general.test_rule_builder.RangeOption", + "field": "value", + }, + }, }, { "rule": "HasFromList", @@ -840,7 +865,12 @@ class TestSerialization(RuleBuilderTestCase): }, ], "filtered_resolution": True, - "args": {"item_counts": {"i9": 1, "i10": 5}}, + "args": { + "item_counts": { + "i9": 1, + "i10": {"resolver": "FromWorldAttr", "name": "instance_data.i10_count"}, + } + }, }, { "rule": "CanReachRegion", @@ -915,7 +945,7 @@ def test_deserialize(self) -> None: multiworld = setup_solo_multiworld(self.world_cls, steps=(), seed=0) world = multiworld.worlds[1] deserialized_rule = world.rule_from_dict(self.rule_dict) - self.assertEqual(deserialized_rule, self.rule, str(deserialized_rule)) + self.assertEqual(deserialized_rule, self.rule, f"\n{deserialized_rule}\n{self.rule}") class TestExplain(RuleBuilderTestCase): @@ -1334,3 +1364,32 @@ def test_str(self) -> None: "& False)", ) assert str(self.resolved_rule) == " ".join(expected) + + +@classvar_matrix( + rules=( + ( + Has("A", FromOption(RangeOption)), + Has.Resolved("A", count=5, player=1), + ), + ( + Has("B", FromWorldAttr("pre_calculated")), + Has.Resolved("B", count=3, player=1), + ), + ( + Has("C", FromWorldAttr("instance_data.key")), + Has.Resolved("C", count=7, player=1), + ), + ) +) +class TestFieldResolvers(RuleBuilderTestCase): + rules: ClassVar[tuple[Rule[Any], Rule.Resolved]] + + def test_simplify(self) -> None: + multiworld = setup_solo_multiworld(self.world_cls, steps=("generate_early",), seed=0) + world = multiworld.worlds[1] + world.pre_calculated = 3 # pyright: ignore[reportAttributeAccessIssue] + world.instance_data = {"key": 7} # pyright: ignore[reportAttributeAccessIssue] + rule, expected = self.rules + resolved_rule = rule.resolve(world) + self.assertEqual(resolved_rule, expected, f"\n{resolved_rule}\n{expected}") diff --git a/worlds/alttp/requirements.txt b/worlds/alttp/requirements.txt index 8a96da2e634b..8eccc7e5f398 100644 --- a/worlds/alttp/requirements.txt +++ b/worlds/alttp/requirements.txt @@ -1,2 +1,2 @@ -maseya-z3pr>=1.0.0rc1 -xxtea>=3.0.0 +maseya-z3pr==1.0.0rc1 +xxtea==3.7.0 diff --git a/worlds/apquest/client/ap_quest_client.kv b/worlds/apquest/client/ap_quest_client.kv index 9f4024e62a02..e60136a835f5 100644 --- a/worlds/apquest/client/ap_quest_client.kv +++ b/worlds/apquest/client/ap_quest_client.kv @@ -30,7 +30,10 @@ C to fire available Confetti Cannons Number Keys + Backspace for Math Trap\n - Rebinding controls might be added in the future :)""" + [b]Click to move also works![/b] + + Click/tap Confetti Cannon to fire it + Submit Math Trap solution in the command line at the bottom""" : orientation: "horizontal" diff --git a/worlds/apquest/client/ap_quest_client.py b/worlds/apquest/client/ap_quest_client.py index c1edc8edb4b2..9b6b82b75f46 100644 --- a/worlds/apquest/client/ap_quest_client.py +++ b/worlds/apquest/client/ap_quest_client.py @@ -4,8 +4,9 @@ from enum import Enum from typing import TYPE_CHECKING, Any -from CommonClient import CommonContext, gui_enabled, logger, server_loop +from CommonClient import ClientCommandProcessor, CommonContext, logger, server_loop from NetUtils import ClientStatus +from Utils import gui_enabled from ..game.events import ConfettiFired, LocationClearedEvent, MathProblemSolved, MathProblemStarted, VictoryEvent from ..game.game import Game @@ -41,6 +42,16 @@ class ConnectionStatus(Enum): GAME_RUNNING = 3 +class APQuestClientCommandProcessor(ClientCommandProcessor): + ctx: "APQuestContext" + + def default(self, raw: str) -> None: + if self.ctx.external_math_trap_input(raw): + return + + super().default(raw) + + class APQuestContext(CommonContext): game = "APQuest" items_handling = 0b111 # full remote @@ -65,6 +76,7 @@ class APQuestContext(CommonContext): delay_intro_song: bool ui: APQuestManager + command_processor = APQuestClientCommandProcessor def __init__( self, server_address: str | None = None, password: str | None = None, delay_intro_song: bool = False @@ -244,6 +256,53 @@ def input_and_rerender(self, input_key: Input) -> None: self.ap_quest_game.input(input_key) self.render() + def queue_auto_move(self, target_x: int, target_y: int) -> None: + if self.ap_quest_game is None: + return + if not self.ap_quest_game.gameboard.ready: + return + self.ap_quest_game.queue_auto_move(target_x, target_y) + self.ui.start_auto_move() + + def do_auto_move_and_rerender(self) -> None: + if self.ap_quest_game is None: + return + if not self.ap_quest_game.gameboard.ready: + return + changed = self.ap_quest_game.do_auto_move() + if changed: + self.render() + + def confetti_and_rerender(self) -> None: + # Used by tap mode + if self.ap_quest_game is None: + return + if not self.ap_quest_game.gameboard.ready: + return + + if self.ap_quest_game.attempt_fire_confetti_cannon(): + self.render() + + def external_math_trap_input(self, raw: str) -> bool: + if self.ap_quest_game is None: + return False + if not self.ap_quest_game.gameboard.ready: + return False + if not self.ap_quest_game.active_math_problem: + return False + + raw = raw.strip() + + if not raw: + return False + if not raw.isnumeric(): + return False + + self.ap_quest_game.math_problem_replace([int(digit) for digit in raw]) + self.render() + + return True + def make_gui(self) -> "type[kvui.GameManager]": self.load_kv() return APQuestManager diff --git a/worlds/apquest/client/custom_views.py b/worlds/apquest/client/custom_views.py index 4c1d28c73250..cc44f991d630 100644 --- a/worlds/apquest/client/custom_views.py +++ b/worlds/apquest/client/custom_views.py @@ -8,15 +8,17 @@ from kivy.graphics import Color, Triangle from kivy.graphics.instructions import Canvas from kivy.input import MotionEvent +from kivy.uix.behaviors import ButtonBehavior from kivy.uix.boxlayout import BoxLayout from kivy.uix.gridlayout import GridLayout +from kivy.uix.image import Image +from kivy.uix.widget import Widget from kivymd.uix.recycleview import MDRecycleView from CommonClient import logger from ..game.inputs import Input - INPUT_MAP = { "up": Input.UP, "w": Input.UP, @@ -51,8 +53,9 @@ def __init__(self, input_function: Callable[[Input], None], **kwargs: Any) -> No self.input_function = input_function self.bind_keyboard() - def on_touch_down(self, touch: MotionEvent) -> None: + def on_touch_down(self, touch: MotionEvent) -> bool | None: self.bind_keyboard() + return super().on_touch_down(touch) def bind_keyboard(self) -> None: if self._keyboard is not None: @@ -77,7 +80,7 @@ def check_resize(self, _: int, _1: int) -> None: parent_width, parent_height = self.parent.size self_width_according_to_parent_height = parent_height * 12 / 11 - self_height_according_to_parent_width = parent_height * 11 / 12 + self_height_according_to_parent_width = parent_width * 11 / 12 if self_width_according_to_parent_height > parent_width: self.size = parent_width, self_height_according_to_parent_width @@ -203,13 +206,23 @@ def reduce_life(self, dt: float, canvas: Canvas) -> bool: return True -class ConfettiView(MDRecycleView): +class ConfettiView(Widget): confetti: list[Confetti] def __init__(self, **kwargs: Any) -> None: super().__init__(**kwargs) self.confetti = [] + # Don't eat tap events for the game grid under the confetti view + def on_touch_down(self, touch) -> bool: + return False + + def on_touch_move(self, touch) -> bool: + return False + + def on_touch_up(self, touch) -> bool: + return False + def check_resize(self, _: int, _1: int) -> None: parent_width, parent_height = self.parent.size @@ -254,3 +267,32 @@ class VolumeSliderView(BoxLayout): class APQuestControlsView(BoxLayout): pass + + +class TapImage(ButtonBehavior, Image): + callback: Callable[[], None] + + def __init__(self, callback: Callable[[], None], **kwargs) -> None: + self.callback = callback + super().__init__(**kwargs) + + def on_release(self) -> bool: + self.callback() + + return True + + +class TapIfConfettiCannonImage(ButtonBehavior, Image): + callback: Callable[[], None] + + is_confetti_cannon: bool = False + + def __init__(self, callback: Callable[[], None], **kwargs: dict[str, Any]) -> None: + self.callback = callback + super().__init__(**kwargs) + + def on_release(self) -> bool: + if self.is_confetti_cannon: + self.callback() + + return True diff --git a/worlds/apquest/client/game_manager.py b/worlds/apquest/client/game_manager.py index 86f4316d12a2..241fbec0ae72 100644 --- a/worlds/apquest/client/game_manager.py +++ b/worlds/apquest/client/game_manager.py @@ -6,6 +6,7 @@ # isort: on from typing import TYPE_CHECKING, Any +from kivy._clock import ClockEvent from kivy.clock import Clock from kivy.uix.gridlayout import GridLayout from kivy.uix.image import Image @@ -13,7 +14,16 @@ from kivymd.uix.recycleview import MDRecycleView from ..game.game import Game -from .custom_views import APQuestControlsView, APQuestGameView, APQuestGrid, ConfettiView, VolumeSliderView +from ..game.graphics import Graphic +from .custom_views import ( + APQuestControlsView, + APQuestGameView, + APQuestGrid, + ConfettiView, + TapIfConfettiCannonImage, + TapImage, + VolumeSliderView, +) from .graphics import PlayerSprite, get_texture from .sounds import SoundManager @@ -34,9 +44,11 @@ class APQuestManager(GameManager): sound_manager: SoundManager bottom_image_grid: list[list[Image]] - top_image_grid: list[list[Image]] + top_image_grid: list[list[TapImage]] confetti_view: ConfettiView + move_event: ClockEvent | None + bottom_grid_is_grass: bool def __init__(self, *args: Any, **kwargs: Any) -> None: @@ -45,6 +57,7 @@ def __init__(self, *args: Any, **kwargs: Any) -> None: self.sound_manager.allow_intro_to_play = not self.ctx.delay_intro_song self.top_image_grid = [] self.bottom_image_grid = [] + self.move_event = None self.bottom_grid_is_grass = False def allow_intro_song(self) -> None: @@ -74,7 +87,7 @@ def game_started(self) -> None: self.sound_manager.game_started = True def render(self, game: Game, player_sprite: PlayerSprite) -> None: - self.setup_game_grid_if_not_setup(game.gameboard.size) + self.setup_game_grid_if_not_setup(game) # This calls game.render(), which needs to happen to update the state of math traps self.render_gameboard(game, player_sprite) @@ -104,6 +117,8 @@ def render_item_column(self, game: Game) -> None: for item_graphic, image_row in zip(rendered_item_column, self.top_image_grid, strict=False): image = image_row[-1] + image.is_confetti_cannon = item_graphic == Graphic.CONFETTI_CANNON + texture = get_texture(item_graphic) if texture is None: image.opacity = 0 @@ -136,23 +151,25 @@ def render_background_game_grid(self, size: tuple[int, int], grass: bool) -> Non self.bottom_grid_is_grass = grass - def setup_game_grid_if_not_setup(self, size: tuple[int, int]) -> None: + def setup_game_grid_if_not_setup(self, game: Game) -> None: if self.upper_game_grid.children: return self.top_image_grid = [] self.bottom_image_grid = [] - for _row in range(size[1]): + size = game.gameboard.size + + for row in range(size[1]): self.top_image_grid.append([]) self.bottom_image_grid.append([]) - for _column in range(size[0]): + for column in range(size[0]): bottom_image = Image(fit_mode="fill", color=(0.3, 0.3, 0.3)) self.lower_game_grid.add_widget(bottom_image) self.bottom_image_grid[-1].append(bottom_image) - top_image = Image(fit_mode="fill") + top_image = TapImage(lambda y=row, x=column: self.ctx.queue_auto_move(x, y), fit_mode="fill") self.upper_game_grid.add_widget(top_image) self.top_image_grid[-1].append(top_image) @@ -160,11 +177,19 @@ def setup_game_grid_if_not_setup(self, size: tuple[int, int]) -> None: image = Image(fit_mode="fill", color=(0.3, 0.3, 0.3)) self.lower_game_grid.add_widget(image) - image2 = Image(fit_mode="fill", opacity=0) + image2 = TapIfConfettiCannonImage(lambda: self.ctx.confetti_and_rerender(), fit_mode="fill", opacity=0) self.upper_game_grid.add_widget(image2) self.top_image_grid[-1].append(image2) + def start_auto_move(self) -> None: + if self.move_event is not None: + self.move_event.cancel() + + self.ctx.do_auto_move_and_rerender() + + self.move_event = Clock.schedule_interval(lambda _: self.ctx.do_auto_move_and_rerender(), 0.10) + def build(self) -> Layout: container = super().build() diff --git a/worlds/apquest/client/graphics.py b/worlds/apquest/client/graphics.py index 535bf84ee852..0e31218c6f02 100644 --- a/worlds/apquest/client/graphics.py +++ b/worlds/apquest/client/graphics.py @@ -1,10 +1,10 @@ import pkgutil -from collections.abc import Buffer from enum import Enum from io import BytesIO from typing import Literal, NamedTuple, Protocol, cast from kivy.uix.image import CoreImage +from typing_extensions import Buffer from CommonClient import logger diff --git a/worlds/apquest/client/sounds.py b/worlds/apquest/client/sounds.py index f4c4ab769dc0..1e1999bff25e 100644 --- a/worlds/apquest/client/sounds.py +++ b/worlds/apquest/client/sounds.py @@ -1,12 +1,12 @@ import asyncio import pkgutil from asyncio import Task -from collections.abc import Buffer from pathlib import Path from typing import cast from kivy import Config from kivy.core.audio import Sound, SoundLoader +from typing_extensions import Buffer from CommonClient import logger @@ -85,7 +85,7 @@ def __init__(self) -> None: def ensure_config(self) -> None: Config.adddefaultsection("APQuest") - Config.setdefault("APQuest", "volume", 50) + Config.setdefault("APQuest", "volume", 30) self.set_volume_percentage(Config.getint("APQuest", "volume")) async def sound_manager_loop(self) -> None: @@ -149,6 +149,7 @@ def play_jingle(self, audio_filename: str) -> None: continue if sound_name == audio_filename: + sound.volume = self.volume_percentage / 100 sound.play() self.update_background_music() higher_priority_sound_is_playing = True @@ -213,6 +214,7 @@ def do_fade(self) -> None: # It ends up feeling better if this just always continues playing quietly after being started. # Even "fading in at a random spot" is better than restarting the song after a jingle / math trap. if self.game_started and song.state == "stop": + song.volume = self.current_background_music_volume * self.volume_percentage / 100 song.play() song.seek(0) continue @@ -228,6 +230,7 @@ def do_fade(self) -> None: if self.current_background_music_volume != 0: if song.state == "stop": + song.volume = self.current_background_music_volume * self.volume_percentage / 100 song.play() song.seek(0) diff --git a/worlds/apquest/game/entities.py b/worlds/apquest/game/entities.py index 64b89206f6f6..ae7c7e85bce6 100644 --- a/worlds/apquest/game/entities.py +++ b/worlds/apquest/game/entities.py @@ -17,8 +17,10 @@ class Entity: class InteractableMixin: + auto_move_attempt_passing_through = False + @abstractmethod - def interact(self, player: Player) -> None: + def interact(self, player: Player) -> bool: pass @@ -89,15 +91,16 @@ def open(self) -> None: self.is_open = True self.update_solidity() - def interact(self, player: Player) -> None: + def interact(self, player: Player) -> bool: if self.has_given_content: - return + return False if self.is_open: self.give_content(player) - return + return True self.open() + return True def content_success(self) -> None: self.update_solidity() @@ -135,47 +138,59 @@ def graphic(self) -> Graphic: class KeyDoor(Door, InteractableMixin): + auto_move_attempt_passing_through = True + closed_graphic = Graphic.KEY_DOOR - def interact(self, player: Player) -> None: + def interact(self, player: Player) -> bool: if self.is_open: - return + return False if not player.has_item(Item.KEY): - return + return False player.remove_item(Item.KEY) self.open() + return True + class BreakableBlock(Door, InteractableMixin): + auto_move_attempt_passing_through = True + closed_graphic = Graphic.BREAKABLE_BLOCK - def interact(self, player: Player) -> None: + def interact(self, player: Player) -> bool: if self.is_open: - return + return False if not player.has_item(Item.HAMMER): - return + return False player.remove_item(Item.HAMMER) self.open() + return True + class Bush(Door, InteractableMixin): + auto_move_attempt_passing_through = True + closed_graphic = Graphic.BUSH - def interact(self, player: Player) -> None: + def interact(self, player: Player) -> bool: if self.is_open: - return + return False if not player.has_item(Item.SWORD): - return + return False self.open() + return True + class Button(Entity, InteractableMixin): solid = True @@ -186,12 +201,13 @@ class Button(Entity, InteractableMixin): def __init__(self, activates: ActivatableMixin) -> None: self.activates = activates - def interact(self, player: Player) -> None: + def interact(self, player: Player) -> bool: if self.activated: - return + return False self.activated = True self.activates.activate(player) + return True @property def graphic(self) -> Graphic: @@ -240,9 +256,9 @@ def heal_if_not_dead(self) -> None: return self.current_health = self.max_health - def interact(self, player: Player) -> None: + def interact(self, player: Player) -> bool: if self.dead: - return + return False if player.has_item(Item.SWORD): self.current_health = max(0, self.current_health - 1) @@ -250,9 +266,10 @@ def interact(self, player: Player) -> None: if self.current_health == 0: if not self.dead: self.die() - return + return True player.damage(2) + return True @property def graphic(self) -> Graphic: @@ -270,13 +287,15 @@ def die(self) -> None: self.dead = True self.solid = not self.has_given_content - def interact(self, player: Player) -> None: + def interact(self, player: Player) -> bool: if self.dead: if not self.has_given_content: self.give_content(player) - return + return True + return False super().interact(player) + return True @property def graphic(self) -> Graphic: @@ -303,10 +322,12 @@ class FinalBoss(Enemy): } enemy_default_graphic = Graphic.BOSS_1_HEALTH - def interact(self, player: Player) -> None: + def interact(self, player: Player) -> bool: dead_before = self.dead - super().interact(player) + changed = super().interact(player) if not dead_before and self.dead: player.victory() + + return changed diff --git a/worlds/apquest/game/game.py b/worlds/apquest/game/game.py index 709e74850ab3..21bebca68137 100644 --- a/worlds/apquest/game/game.py +++ b/worlds/apquest/game/game.py @@ -23,6 +23,8 @@ class Game: active_math_problem: MathProblem | None active_math_problem_input: list[int] | None + auto_target_path: list[tuple[int, int]] = [] + remotely_received_items: set[tuple[int, int, int]] def __init__( @@ -94,29 +96,40 @@ def render_health_and_inventory(self, vertical: bool = False) -> tuple[Graphic, return tuple(graphics_array) - def attempt_player_movement(self, direction: Direction) -> None: + def attempt_player_movement(self, direction: Direction, cancel_auto_move: bool = True) -> bool: + if cancel_auto_move: + self.cancel_auto_move() + self.player.facing = direction delta_x, delta_y = direction.value new_x, new_y = self.player.current_x + delta_x, self.player.current_y + delta_y - if not self.gameboard.get_entity_at(new_x, new_y).solid: - self.player.current_x = new_x - self.player.current_y = new_y + if self.gameboard.get_entity_at(new_x, new_y).solid: + return False + + self.player.current_x = new_x + self.player.current_y = new_y + return True - def attempt_interact(self) -> None: + def attempt_interact(self) -> bool: delta_x, delta_y = self.player.facing.value entity_x, entity_y = self.player.current_x + delta_x, self.player.current_y + delta_y entity = self.gameboard.get_entity_at(entity_x, entity_y) if isinstance(entity, InteractableMixin): - entity.interact(self.player) + return entity.interact(self.player) + + return False + + def attempt_fire_confetti_cannon(self) -> bool: + if not self.player.has_item(Item.CONFETTI_CANNON): + return False - def attempt_fire_confetti_cannon(self) -> None: - if self.player.has_item(Item.CONFETTI_CANNON): - self.player.remove_item(Item.CONFETTI_CANNON) - self.queued_events.append(ConfettiFired(self.player.current_x, self.player.current_y)) + self.player.remove_item(Item.CONFETTI_CANNON) + self.queued_events.append(ConfettiFired(self.player.current_x, self.player.current_y)) + return True def math_problem_success(self) -> None: self.active_math_problem = None @@ -154,6 +167,12 @@ def math_problem_delete(self) -> None: self.active_math_problem_input.pop() self.check_math_problem_result() + def math_problem_replace(self, input: list[int]) -> None: + if self.active_math_problem_input is None: + return + self.active_math_problem_input = input[:2] + self.check_math_problem_result() + def input(self, input_key: Input) -> None: if not self.gameboard.ready: return @@ -201,3 +220,47 @@ def receive_item(self, remote_item_id: int, remote_location_id: int, remote_loca def force_clear_location(self, location_id: int) -> None: location = Location(location_id) self.gameboard.force_clear_location(location) + + def cancel_auto_move(self) -> None: + self.auto_target_path = [] + + def queue_auto_move(self, target_x: int, target_y: int) -> None: + self.cancel_auto_move() + path = self.gameboard.calculate_shortest_path(self.player.current_x, self.player.current_y, target_x, target_y) + self.auto_target_path = path + + def do_auto_move(self) -> bool: + if not self.auto_target_path: + return False + + target_x, target_y = self.auto_target_path.pop(0) + movement = target_x - self.player.current_x, target_y - self.player.current_y + direction = Direction(movement) + moved = self.attempt_player_movement(direction, cancel_auto_move=False) + + if moved: + return True + + # We are attempting to interact with something on the path. + # First, make the player face it. + if self.player.facing != direction: + self.player.facing = direction + self.auto_target_path.insert(0, (target_x, target_y)) + return True + + # If we are facing it, attempt to interact with it. + changed = self.attempt_interact() + + if not changed: + self.cancel_auto_move() + return False + + # If the interaction was successful, and this was the end of the path, stop + # (i.e. don't try to attack the attacked enemy over and over until it's dead) + if not self.auto_target_path: + self.cancel_auto_move() + return True + + # If there is more to go, keep going along the path + self.auto_target_path.insert(0, (target_x, target_y)) + return True diff --git a/worlds/apquest/game/gameboard.py b/worlds/apquest/game/gameboard.py index ec97491c872f..77688c2929f9 100644 --- a/worlds/apquest/game/gameboard.py +++ b/worlds/apquest/game/gameboard.py @@ -15,6 +15,7 @@ EnemyWithLoot, Entity, FinalBoss, + InteractableMixin, KeyDoor, LocationMixin, Wall, @@ -23,6 +24,7 @@ from .graphics import DIGIT_TO_GRAPHIC, DIGIT_TO_GRAPHIC_ZERO_EMPTY, MATH_PROBLEM_TYPE_TO_GRAPHIC, Graphic from .items import Item from .locations import DEFAULT_CONTENT, Location +from .path_finding import find_path_or_closest if TYPE_CHECKING: from .player import Player @@ -107,6 +109,21 @@ def render(self, player: Player) -> tuple[tuple[Graphic, ...], ...]: return tuple(graphics) + def as_traversability_bools(self) -> tuple[tuple[bool, ...], ...]: + traversability = [] + + for y, row in enumerate(self.gameboard): + traversable_row = [] + for x, entity in enumerate(row): + traversable_row.append( + not entity.solid + or (isinstance(entity, InteractableMixin) and entity.auto_move_attempt_passing_through) + ) + + traversability.append(tuple(traversable_row)) + + return tuple(traversability) + def render_math_problem( self, problem: MathProblem, current_input_digits: list[int], current_input_int: int | None ) -> tuple[tuple[Graphic, ...], ...]: @@ -186,6 +203,23 @@ def force_clear_location(self, location: Location) -> None: entity = self.remote_entity_by_location_id[location] entity.force_clear() + def calculate_shortest_path( + self, source_x: int, source_y: int, target_x: int, target_y: int + ) -> list[tuple[int, int]]: + gameboard_traversability = self.as_traversability_bools() + + path = find_path_or_closest(gameboard_traversability, source_x, source_y, target_x, target_y) + + if not path: + return path + + # If the path stops just short of target, attempt interacting with it at the end + if abs(path[-1][0] - target_x) + abs(path[-1][1] - target_y) == 1: + if isinstance(self.gameboard[target_y][target_x], InteractableMixin): + path.append((target_x, target_y)) + + return path[1:] # Cut off starting tile + @property def ready(self) -> bool: return self.content_filled diff --git a/worlds/apquest/game/generate_math_problem.py b/worlds/apquest/game/generate_math_problem.py index eb8ff0f01ec9..b93375e9ac41 100644 --- a/worlds/apquest/game/generate_math_problem.py +++ b/worlds/apquest/game/generate_math_problem.py @@ -6,6 +6,7 @@ _random = random.Random() + class NumberChoiceConstraints(NamedTuple): num_1_min: int num_1_max: int diff --git a/worlds/apquest/game/path_finding.py b/worlds/apquest/game/path_finding.py new file mode 100644 index 000000000000..8b3e649b1d77 --- /dev/null +++ b/worlds/apquest/game/path_finding.py @@ -0,0 +1,84 @@ +import heapq +from typing import Generator + +Point = tuple[int, int] + + +def heuristic(a: Point, b: Point) -> int: + # Manhattan distance (good for 4-directional grids) + return abs(a[0] - b[0]) + abs(a[1] - b[1]) + + +def reconstruct_path(came_from: dict[Point, Point], current: Point) -> list[Point]: + path = [current] + while current in came_from: + current = came_from[current] + path.append(current) + path.reverse() + return path + + +def find_path_or_closest( + grid: tuple[tuple[bool, ...], ...], source_x: int, source_y: int, target_x: int, target_y: int +) -> list[Point]: + start = source_x, source_y + goal = target_x, target_y + + rows, cols = len(grid), len(grid[0]) + + def in_bounds(p: Point) -> bool: + return 0 <= p[0] < rows and 0 <= p[1] < cols + + def passable(p: Point) -> bool: + return grid[p[1]][p[0]] + + def neighbors(p: Point) -> Generator[Point, None, None]: + x, y = p + for dx, dy in [(-1, 0), (1, 0), (0, -1), (0, 1)]: + np = (x + dx, y + dy) + if in_bounds(np) and passable(np): + yield np + + open_heap: list[tuple[int, tuple[int, int]]] = [] + heapq.heappush(open_heap, (0, start)) + + came_from: dict[Point, Point] = {} + g_score = {start: 0} + + # Track best fallback node + best_node = start + best_dist = heuristic(start, goal) + + visited = set() + + while open_heap: + _, current = heapq.heappop(open_heap) + + if current in visited: + continue + visited.add(current) + + # Check if we reached the goal + if current == goal: + return reconstruct_path(came_from, current) + + # Update "closest node" fallback + dist = heuristic(current, goal) + if dist < best_dist or (dist == best_dist and g_score[current] < g_score.get(best_node, float("inf"))): + best_node = current + best_dist = dist + + for neighbor in neighbors(current): + tentative_g = g_score[current] + 1 # cost is 1 per move + + if tentative_g < g_score.get(neighbor, float("inf")): + came_from[neighbor] = current + g_score[neighbor] = tentative_g + f_score = tentative_g + heuristic(neighbor, goal) + heapq.heappush(open_heap, (f_score, neighbor)) + + # Goal not reachable → return path to closest node + if best_node is not None: + return reconstruct_path(came_from, best_node) + + return [] diff --git a/worlds/apsudoku/__init__.py b/worlds/apsudoku/__init__.py deleted file mode 100644 index 04422ddb23c6..000000000000 --- a/worlds/apsudoku/__init__.py +++ /dev/null @@ -1,34 +0,0 @@ -from typing import Dict - -from BaseClasses import Tutorial -from ..AutoWorld import WebWorld, World - -class AP_SudokuWebWorld(WebWorld): - options_page = False - theme = 'partyTime' - - setup_en = Tutorial( - tutorial_name='Setup Guide', - description='A guide to playing APSudoku', - language='English', - file_name='setup_en.md', - link='setup/en', - authors=['EmilyV'] - ) - - tutorials = [setup_en] - -class AP_SudokuWorld(World): - """ - Play a little Sudoku while you're in BK mode to maybe get some useful hints - """ - game = "Sudoku" - web = AP_SudokuWebWorld() - - item_name_to_id: Dict[str, int] = {} - location_name_to_id: Dict[str, int] = {} - - @classmethod - def stage_assert_generate(cls, multiworld): - raise Exception("APSudoku cannot be used for generating worlds, the client can instead connect to any slot from any world") - diff --git a/worlds/apsudoku/docs/en_Sudoku.md b/worlds/apsudoku/docs/en_Sudoku.md deleted file mode 100644 index b56af0de79f3..000000000000 --- a/worlds/apsudoku/docs/en_Sudoku.md +++ /dev/null @@ -1,15 +0,0 @@ -# APSudoku - -## Hint Games - -HintGames do not need to be added at the start of a seed, and do not create a 'slot'- instead, you connect the HintGame client to a different game's slot. By playing a HintGame, you can earn hints for the connected slot. - -## What is this game? - -Play Sudoku puzzles of varying difficulties, earning a hint for each puzzle correctly solved. Harder puzzles are more likely to grant a hint towards a Progression item, though otherwise what hint is granted is random. - -## Where is the options page? - -There is no options page; this game cannot be used in your .yamls. Instead, the client can connect to any slot in a multiworld. - -By using the connected room's Admin Password on the Admin Panel tab, you can configure some settings at any time to affect the entire room. This allows disabling hints entirely, as well as altering the hint odds for each difficulty. diff --git a/worlds/apsudoku/docs/setup_en.md b/worlds/apsudoku/docs/setup_en.md deleted file mode 100644 index f80cd4333fe1..000000000000 --- a/worlds/apsudoku/docs/setup_en.md +++ /dev/null @@ -1,55 +0,0 @@ -# APSudoku Setup Guide - -## Required Software -- [APSudoku](https://github.com/APSudoku/APSudoku) - -## General Concept - -This is a HintGame client, which can connect to any multiworld slot, allowing you to play Sudoku to unlock random hints for that slot's locations. - -Does not need to be added at the start of a seed, as it does not create any slots of its own, nor does it have any YAML files. - -## Installation Procedures - -### Windows / Linux -Go to the latest release from the [github APSudoku Releases page](https://github.com/APSudoku/APSudoku/releases/latest). Download and extract the appropriate file for your platform. - -### Web -Go to the [github pages](apsudoku.github.io) or [itch.io](https://emilyv99.itch.io/apsudoku) site, and play in the browser. - -## Joining a MultiWorld Game - -1. Run the APSudoku executable. -2. Under `Settings` → `Connection` at the top-right: - - Enter the server address and port number - - Enter the name of the slot you wish to connect to - - Enter the room password (optional) - - Select DeathLink related settings (optional) - - Press `Connect` -4. Under the `Sudoku` tab - - Choose puzzle difficulty - - Click `Start` to generate a puzzle -5. Try to solve the Sudoku. Click `Check` when done - - A correct solution rewards you with 1 hint for a location in the world you are connected to - - An incorrect solution has no penalty, unless DeathLink is enabled (see below) - -Info: -- You can set various settings under `Settings` → `Sudoku`, and can change the colors used under `Settings` → `Theme`. -- While connected, you can view the `Console` and `Hints` tabs for standard TextClient-like features -- You can also use the `Tracking` tab to view either a basic tracker or a valid [GodotAP tracker pack](https://github.com/EmilyV99/GodotAP/blob/main/tracker_packs/GET_PACKS.md) -- While connected, the number of "unhinted" locations for your slot is shown in the upper-left of the the `Sudoku` tab. (If this reads 0, no further hints can be earned for this slot, as every locations is already hinted) -- Click the various `?` buttons for information on controls/how to play - -## Admin Settings - -By using the connected room's Admin Password on the Admin Panel tab, you can configure some settings at any time to affect the entire room. - -- You can disable APSudoku for the entire room, preventing any hints from being granted. -- You can customize the reward weights for each difficulty, making progression hints more or less likely, and/or adding a chance to get "no hint" after a solve. - -## DeathLink Support - -If `DeathLink` is enabled when you click `Connect`: -- Lose a life if you check an incorrect puzzle (not an _incomplete_ puzzle- if any cells are empty, you get off with a warning), or if you quit a puzzle without solving it (including disconnecting). -- Your life count is customizable (default 0). Dying with 0 lives left kills linked players AND resets your puzzle. -- On receiving a DeathLink from another player, your puzzle resets. diff --git a/worlds/aquaria/Items.py b/worlds/aquaria/Items.py index 3365c1fa5953..b510b24738cb 100644 --- a/worlds/aquaria/Items.py +++ b/worlds/aquaria/Items.py @@ -271,7 +271,7 @@ class ItemNames: ItemNames.TRIDENT: ItemData(698031, 1, ItemType.JUNK, ItemGroup.COLLECTIBLE), # collectible_trident_head ItemNames.TURTLE_EGG: ItemData(698032, 1, ItemType.JUNK, ItemGroup.COLLECTIBLE), # collectible_turtle_egg ItemNames.JELLY_EGG: ItemData(698033, 1, ItemType.JUNK, ItemGroup.COLLECTIBLE), # collectible_upsidedown_seed - ItemNames.URCHIN_COSTUME: ItemData(698034, 1, ItemType.JUNK, ItemGroup.COLLECTIBLE), # collectible_urchin_costume + ItemNames.URCHIN_COSTUME: ItemData(698034, 1, ItemType.PROGRESSION, ItemGroup.COLLECTIBLE), # collectible_urchin_costume ItemNames.BABY_WALKER: ItemData(698035, 1, ItemType.JUNK, ItemGroup.COLLECTIBLE), # collectible_walker ItemNames.VEDHA_S_CURE_ALL: ItemData(698036, 1, ItemType.NORMAL, ItemGroup.RECIPE), # ingredient_Vedha'sCure-All ItemNames.ZUUNA_S_PEROGI: ItemData(698037, 1, ItemType.NORMAL, ItemGroup.RECIPE), # ingredient_Zuuna'sperogi @@ -384,8 +384,8 @@ class ItemNames: ItemNames.MITHALAS_BANNER, ItemNames.MITHALAS_POT, ItemNames.MUTANT_COSTUME, ItemNames.SEED_BAG, ItemNames.KING_S_SKULL, ItemNames.SONG_PLANT_SPORE, ItemNames.STONE_HEAD, ItemNames.SUN_KEY, ItemNames.GIRL_COSTUME, ItemNames.ODD_CONTAINER, ItemNames.TRIDENT, ItemNames.TURTLE_EGG, - ItemNames.JELLY_EGG, ItemNames.URCHIN_COSTUME, ItemNames.BABY_WALKER, - ItemNames.RAINBOW_MUSHROOM, ItemNames.RAINBOW_MUSHROOM, ItemNames.RAINBOW_MUSHROOM, + ItemNames.JELLY_EGG, ItemNames.BABY_WALKER, ItemNames.RAINBOW_MUSHROOM, + ItemNames.RAINBOW_MUSHROOM, ItemNames.RAINBOW_MUSHROOM, ItemNames.FISH_OIL, ItemNames.LEAF_POULTICE, ItemNames.LEAF_POULTICE, ItemNames.LEAF_POULTICE, ItemNames.LEECHING_POULTICE, ItemNames.LEECHING_POULTICE, ItemNames.ARCANE_POULTICE, ItemNames.ROTTEN_MEAT, ItemNames.ROTTEN_MEAT, ItemNames.ROTTEN_MEAT, ItemNames.ROTTEN_MEAT, diff --git a/worlds/aquaria/Regions.py b/worlds/aquaria/Regions.py index 781d06e09f1a..b32f9119ec83 100755 --- a/worlds/aquaria/Regions.py +++ b/worlds/aquaria/Regions.py @@ -37,7 +37,7 @@ def _has_li(state: CollectionState, player: int) -> bool: DAMAGING_ITEMS:Iterable[str] = [ ItemNames.ENERGY_FORM, ItemNames.NATURE_FORM, ItemNames.BEAST_FORM, ItemNames.LI_AND_LI_SONG, ItemNames.BABY_NAUTILUS, ItemNames.BABY_PIRANHA, - ItemNames.BABY_BLASTER + ItemNames.BABY_BLASTER, ItemNames.URCHIN_COSTUME ] def _has_damaging_item(state: CollectionState, player: int, damaging_items:Iterable[str] = DAMAGING_ITEMS) -> bool: diff --git a/worlds/aquaria/__init__.py b/worlds/aquaria/__init__.py index 2997f21d0447..395d349154d4 100644 --- a/worlds/aquaria/__init__.py +++ b/worlds/aquaria/__init__.py @@ -76,7 +76,7 @@ class AquariaWorld(World): item_name_groups = { "Damage": {ItemNames.ENERGY_FORM, ItemNames.NATURE_FORM, ItemNames.BEAST_FORM, ItemNames.LI_AND_LI_SONG, ItemNames.BABY_NAUTILUS, ItemNames.BABY_PIRANHA, - ItemNames.BABY_BLASTER}, + ItemNames.BABY_BLASTER, ItemNames.URCHIN_COSTUME}, "Light": {ItemNames.SUN_FORM, ItemNames.BABY_DUMBO} } """Grouping item make it easier to find them""" diff --git a/worlds/factorio/__init__.py b/worlds/factorio/__init__.py index ceb758669d3a..4483cf0238d4 100644 --- a/worlds/factorio/__init__.py +++ b/worlds/factorio/__init__.py @@ -37,7 +37,7 @@ class FactorioWeb(WebWorld): "English", "setup_en.md", "setup/en", - ["Berserker, Farrak Kilhn"] + ["Berserker", "Farrak Kilhn"] )] option_groups = option_groups diff --git a/worlds/factorio/data/mod_template/data-final-fixes.lua b/worlds/factorio/data/mod_template/data-final-fixes.lua index 2ddcd8d8ab60..e45952301986 100644 --- a/worlds/factorio/data/mod_template/data-final-fixes.lua +++ b/worlds/factorio/data/mod_template/data-final-fixes.lua @@ -130,6 +130,7 @@ end data.raw["assembling-machine"]["assembling-machine-1"].crafting_categories = table.deepcopy(data.raw["assembling-machine"]["assembling-machine-3"].crafting_categories) data.raw["assembling-machine"]["assembling-machine-2"].crafting_categories = table.deepcopy(data.raw["assembling-machine"]["assembling-machine-3"].crafting_categories) data.raw["assembling-machine"]["assembling-machine-1"].fluid_boxes = table.deepcopy(data.raw["assembling-machine"]["assembling-machine-2"].fluid_boxes) +data.raw["assembling-machine"]["assembling-machine-1"].fluid_boxes_off_when_no_fluid_recipe = data.raw["assembling-machine"]["assembling-machine-2"].fluid_boxes_off_when_no_fluid_recipe if mods["factory-levels"] then -- Factory-Levels allows the assembling machines to get faster (and depending on settings), more productive at crafting products, the more the -- assembling machine crafts the product. If the machine crafts enough, it may auto-upgrade to the next tier. diff --git a/worlds/factorio/requirements.txt b/worlds/factorio/requirements.txt index 8d684401663b..b2b3804a50a8 100644 --- a/worlds/factorio/requirements.txt +++ b/worlds/factorio/requirements.txt @@ -1 +1 @@ -factorio-rcon-py>=2.1.2 +factorio-rcon-py==2.1.3 diff --git a/worlds/generic/__init__.py b/worlds/generic/__init__.py index c4aef4f67bb0..4cd80556ccf3 100644 --- a/worlds/generic/__init__.py +++ b/worlds/generic/__init__.py @@ -26,7 +26,10 @@ class GenericWeb(WebWorld): 'English', 'setup_en.md', 'setup/en', ['alwaysintreble']) triggers = Tutorial('Archipelago Triggers Guide', 'A guide to setting up and using triggers in your game settings.', 'English', 'triggers_en.md', 'triggers/en', ['alwaysintreble']) - tutorials = [setup, mac, commands, advanced_settings, triggers, plando] + other_games = Tutorial('Other Games and Tools', + 'A guide to additional games and tools that can be used with Archipelago.', + 'English', 'other_en.md', 'other/en', ['Berserker']) + tutorials = [setup, mac, commands, advanced_settings, triggers, plando, other_games] class GenericWorld(World): diff --git a/worlds/generic/docs/mac_en.md b/worlds/generic/docs/mac_en.md index 4db48d2abd3a..eaaeb13c159d 100644 --- a/worlds/generic/docs/mac_en.md +++ b/worlds/generic/docs/mac_en.md @@ -2,7 +2,7 @@ Archipelago does not have a compiled release on macOS. However, it is possible to run from source code on macOS. This guide expects you to have some experience with running software from the terminal. ## Prerequisite Software Here is a list of software to install and source code to download. -1. Python 3.11 "universal2" or newer from the [macOS Python downloads page](https://www.python.org/downloads/macos/). +1. Python 3.11.9 "universal2" or newer from the [macOS Python downloads page](https://www.python.org/downloads/macos/). **Python 3.14 is not supported yet.** 2. Xcode from the [macOS App Store](https://apps.apple.com/us/app/xcode/id497799835). 3. The source code from the [Archipelago releases page](https://github.com/ArchipelagoMW/Archipelago/releases). diff --git a/worlds/generic/docs/other_en.md b/worlds/generic/docs/other_en.md new file mode 100644 index 000000000000..caf8372170d6 --- /dev/null +++ b/worlds/generic/docs/other_en.md @@ -0,0 +1,37 @@ +# Other Games And Tools + +This page provides information and links regarding various tools that may be of use with Archipelago, including additional playable games not supported by this website. + +You should only download and use files from sources you trust; sources listed here are not officially vetted for safety, so use your own judgement and caution. + +## Discord + +Currently, Discord is the primary hub for Archipelago; whether it be finding people to play with, developing new game implementations, or finding new playable games. + +The [Archipelago Official Discord](https://discord.gg/8Z65BR2) is the main hub, while the [Archipelago After Dark Discord](https://discord.gg/fqvNCCRsu4) houses additional games that may be unrated or 18+ in some territories. + +The `#apworld-index` channels in each of these servers contain lists of playable games which should be easily downloadable and playable with an Archipelago installation. + +## Wiki + +The community-maintained [Archipelago Wiki](https://archipelago.miraheze.org/) has information on many games as well, and acts as a great discord-free source of information. + +## Hint Games + +Hint Games are a special type of game which are not included as part of the multiworld generation process. Instead, they can log in to an ongoing multiworld, connecting to a slot designated for any game. Rather than earning items for other games in the multiworld, a Hint Game will allow you to earn hints for the slot you are connected to. + +Hint Games can be found from sources such as the Discord and the [Hint Game Category](https://archipelago.miraheze.org/wiki/Category:Hint_games) of the wiki, as detailed above. + +## Notable Tools + +### Options Creator + +The Options Creator is included in the Archipelago installation, and is accessible from the Archipelago Launcher. Using this simple GUI tool, you can easily create randomization options for any installed `.apworld` - perfect when using custom worlds you've installed that don't have options pages on the website. + +### PopTracker + +[PopTracker](https://poptracker.github.io) is a popular tool in Randomizer communities, which many games support via custom PopTracker Packs. Many Archipelago packs include the ability to directly connect to your slot for auto-tracking capabilities. (Check each game's setup guide or Discord channel to see if it has PopTracker compatibility!) + +### Universal Tracker + +[Universal Tracker](https://github.com/FarisTheAncient/Archipelago/releases?q=Tracker) is a custom tracker client that uses your .yaml files from generation (as well as the .apworld files) to attempt to provide a view of what locations are currently in-logic or not, using the actual generation logic. Specific steps may need to be taken depending on the game, or the use of randomness in your yaml. Support for UT can be found in the [#universal-tracker](https://discord.com/channels/731205301247803413/1367270230635839539) channel of the Archipelago Official Discord. diff --git a/worlds/kh2/Locations.py b/worlds/kh2/Locations.py index 3b5a6e7e69cb..4b423d255a74 100644 --- a/worlds/kh2/Locations.py +++ b/worlds/kh2/Locations.py @@ -1281,7 +1281,7 @@ LocationName.HadesCupTrophyParadoxCups, LocationName.MusicalOrichalcumPlus, ], - "HitlistCasual": { + "HitlistCasual": [ LocationName.FuturePete, LocationName.BetwixtandBetweenBondofFlame, LocationName.GrimReaper2, @@ -1299,7 +1299,7 @@ LocationName.MCP, LocationName.Lvl50, LocationName.Lvl99 - }, + ], "Cups": { LocationName.ProtectBeltPainandPanicCup, LocationName.SerenityGemPainandPanicCup, diff --git a/worlds/lufia2ac/__init__.py b/worlds/lufia2ac/__init__.py index 96de24a4b6a0..d5b104dde4b6 100644 --- a/worlds/lufia2ac/__init__.py +++ b/worlds/lufia2ac/__init__.py @@ -5,11 +5,11 @@ from typing import Any, ClassVar, Dict, Iterator, List, Set, Tuple, Type import settings -from BaseClasses import Item, ItemClassification, Location, MultiWorld, Region, Tutorial +from BaseClasses import CollectionRule, Item, ItemClassification, Location, MultiWorld, Region, Tutorial from Options import PerGameCommonOptions from Utils import __version__ from worlds.AutoWorld import WebWorld, World -from worlds.generic.Rules import add_rule, CollectionRule, set_rule +from worlds.generic.Rules import add_rule, set_rule from .Client import L2ACSNIClient # noqa: F401 from .Items import ItemData, ItemType, l2ac_item_name_to_id, l2ac_item_table, L2ACItem, start_id as items_start_id from .Locations import l2ac_location_name_to_id, L2ACLocation diff --git a/worlds/marioland2/logic.py b/worlds/marioland2/logic.py index 54685f91a035..4ccb2eb4c919 100644 --- a/worlds/marioland2/logic.py +++ b/worlds/marioland2/logic.py @@ -478,7 +478,7 @@ def space_zone_2_boss(state, player): def space_zone_2_coins(state, player, coins): auto_scroll = is_auto_scroll(state, player, "Space Zone 2") - reachable_coins = 12 + reachable_coins = 9 if state.has_any(["Mushroom", "Fire Flower", "Carrot", "Space Physics"], player): reachable_coins += 15 if state.has("Space Physics", player) or not auto_scroll: @@ -487,7 +487,7 @@ def space_zone_2_coins(state, player, coins): state.has("Mushroom", player) and state.has_any(["Fire Flower", "Carrot"], player))): reachable_coins += 3 if state.has("Space Physics", player): - reachable_coins += 79 + reachable_coins += 82 if not auto_scroll: reachable_coins += 21 return coins <= reachable_coins diff --git a/worlds/messenger/rules.py b/worlds/messenger/rules.py index 7f17232cfbf8..bc1fc6aa9989 100644 --- a/worlds/messenger/rules.py +++ b/worlds/messenger/rules.py @@ -192,7 +192,7 @@ def __init__(self, world: "MessengerWorld") -> None: or (self.has_dart(state) and self.has_wingsuit(state)), # Dark Cave "Dark Cave - Right -> Dark Cave - Left": - lambda state: state.has("Candle", self.player) and self.has_dart(state), + lambda state: state.has("Candle", self.player) and self.has_dart(state) and self.has_wingsuit(state), # Riviere Turquoise "Riviere Turquoise - Waterfall Shop -> Riviere Turquoise - Flower Flight Checkpoint": lambda state: self.has_dart(state) or ( diff --git a/worlds/mm3/archipelago.json b/worlds/mm3/archipelago.json index ed5ecffc6cbe..d9946d34049c 100644 --- a/worlds/mm3/archipelago.json +++ b/worlds/mm3/archipelago.json @@ -1,6 +1,6 @@ { "game": "Mega Man 3", "authors": ["Silvris"], - "world_version": "0.1.7", + "world_version": "0.1.8", "minimum_ap_version": "0.6.4" } diff --git a/worlds/musedash/MuseDashCollection.py b/worlds/musedash/MuseDashCollection.py index 4c23a5c64f32..472b4c95a174 100644 --- a/worlds/musedash/MuseDashCollection.py +++ b/worlds/musedash/MuseDashCollection.py @@ -15,6 +15,7 @@ class MuseDashCollections: "Default Music", "Budget Is Burning: Nano Core", "Budget Is Burning Vol.1", + "Wuthering Waves Pioneer Podcast", ] MUSE_PLUS_DLC: str = "Muse Plus" @@ -40,6 +41,7 @@ class MuseDashCollections: "Heart Message feat. Aoi Tokimori Secret", "Meow Rock feat. Chun Ge, Yuan Shen", "Stra Stella Secret", + "Musepyoi Legend", ] song_items = SONG_DATA diff --git a/worlds/musedash/MuseDashData.py b/worlds/musedash/MuseDashData.py index 86486a4929b0..6cb294bd98a6 100644 --- a/worlds/musedash/MuseDashData.py +++ b/worlds/musedash/MuseDashData.py @@ -696,11 +696,20 @@ "Otsukimi Koete Otsukiai": SongData(2900820, "43-70", "MD Plus Project", True, 6, 8, 10), "Obenkyou Time": SongData(2900821, "43-71", "MD Plus Project", False, 6, 8, 11), "Retry Now": SongData(2900822, "43-72", "MD Plus Project", False, 3, 6, 9), - "Master Bancho's Sushi Class ": SongData(2900823, "93-0", "Welcome to the Blue Hole!", False, None, None, None), + "Master Bancho's Sushi Class": SongData(2900823, "93-0", "Welcome to the Blue Hole!", False, None, 7, None), "CHAOTiC BATTLE": SongData(2900824, "94-0", "Cosmic Radio 2025", False, 7, 9, 11), "FATAL GAME": SongData(2900825, "94-1", "Cosmic Radio 2025", False, 3, 6, 9), "Aria": SongData(2900826, "94-2", "Cosmic Radio 2025", False, 4, 6, 9), "+1 UNKNOWN -NUMBER": SongData(2900827, "94-3", "Cosmic Radio 2025", True, 4, 7, 10), "To the Beyond, from the Nameless Seaside": SongData(2900828, "94-4", "Cosmic Radio 2025", False, 5, 8, 10), "REK421": SongData(2900829, "94-5", "Cosmic Radio 2025", True, 7, 9, 11), -} + "Musepyoi Legend": SongData(2900830, "95-0", "Ay-Aye Horse", True, None, None, None), + "Not Regret": SongData(2900831, "95-1", "Ay-Aye Horse", False, 7, 9, 11), + "-Toryanna-": SongData(2900832, "95-2", "Ay-Aye Horse", True, 4, 6, 9), + "Icecream Angels": SongData(2900833, "95-3", "Ay-Aye Horse", False, 3, 6, 9), + "MEGA TSKR": SongData(2900834, "95-4", "Ay-Aye Horse", False, 4, 7, 10), + "777 Vocal ver.": SongData(2900835, "95-5", "Ay-Aye Horse", False, 7, 9, 11), + "Chasing Daylight": SongData(2900836, "96-0", "Wuthering Waves Pioneer Podcast", False, 3, 5, 8), + "CATCH ME IF YOU CAN": SongData(2900837, "96-1", "Wuthering Waves Pioneer Podcast", False, 4, 6, 9), + "RUNNING FOR YOUR LIFE": SongData(2900838, "96-2", "Wuthering Waves Pioneer Podcast", False, 2, 5, 8), +} \ No newline at end of file diff --git a/worlds/musedash/__init__.py b/worlds/musedash/__init__.py index 239d640e6882..55767cf04b65 100644 --- a/worlds/musedash/__init__.py +++ b/worlds/musedash/__init__.py @@ -124,7 +124,8 @@ def handle_plando(self, available_song_keys: List[str], dlc_songs: Set[str]) -> self.starting_songs = [s for s in start_items if s in song_items] self.starting_songs = self.md_collection.filter_songs_to_dlc(self.starting_songs, dlc_songs) - self.included_songs = [s for s in include_songs if s in song_items and s not in self.starting_songs] + # Sort first for deterministic iteration order. + self.included_songs = [s for s in sorted(include_songs) if s in song_items and s not in self.starting_songs] self.included_songs = self.md_collection.filter_songs_to_dlc(self.included_songs, dlc_songs) # Making sure songs chosen for goal are allowed by DLC and remove the chosen from being added to the pool. diff --git a/worlds/musedash/archipelago.json b/worlds/musedash/archipelago.json index dea7846b4f38..0580d85e77c1 100644 --- a/worlds/musedash/archipelago.json +++ b/worlds/musedash/archipelago.json @@ -1,6 +1,6 @@ { "game": "Muse Dash", "authors": ["DeamonHunter"], - "world_version": "1.5.29", + "world_version": "1.5.30", "minimum_ap_version": "0.6.3" } \ No newline at end of file diff --git a/worlds/musedash/test/TestDifficultyRanges.py b/worlds/musedash/test/TestDifficultyRanges.py index 27798243a559..f41e80717160 100644 --- a/worlds/musedash/test/TestDifficultyRanges.py +++ b/worlds/musedash/test/TestDifficultyRanges.py @@ -10,6 +10,7 @@ class DifficultyRanges(MuseDashTestBase): "PeroPero in the Universe", "umpopoff", "P E R O P E R O Brother Dance", + "Master Bancho's Sushi Class", ] def test_all_difficulty_ranges(self) -> None: @@ -78,7 +79,7 @@ def test_songs_have_difficulty(self) -> None: # Some songs are weird and have less than the usual 3 difficulties. # So this override is to avoid failing on these songs. - if song_name in ("umpopoff", "P E R O P E R O Brother Dance"): + if song_name in ("umpopoff", "P E R O P E R O Brother Dance", "Master Bancho's Sushi Class"): self.assertTrue(song.easy is None and song.hard is not None and song.master is None, f"Song '{song_name}' difficulty not set when it should be.") else: diff --git a/worlds/pokemon_emerald/CHANGELOG.md b/worlds/pokemon_emerald/CHANGELOG.md index 3921e33400bf..d2173beb10fe 100644 --- a/worlds/pokemon_emerald/CHANGELOG.md +++ b/worlds/pokemon_emerald/CHANGELOG.md @@ -1,3 +1,16 @@ +# 2.5.0 + +### Features + +- Added a new option `dexsanity_encounter_types` to enable/disable dexsanity locations based on whether they can be +found in the allowed encounters. In other words, if Bulbasaur can only be found by fishing and fishing is not enabled, +a dexsanity location will not be created for Bulbasaur. + +### Fixes + +- Fixed generator error if Wailord or Relicanth are blacklisted during a dexsanity seed. +- Fixed generator error if player greatly restricts allowed opponent pokemon while force fully evolved is active. + # 2.4.1 ### Fixes diff --git a/worlds/pokemon_emerald/__init__.py b/worlds/pokemon_emerald/__init__.py index 10abed539f70..fb683df90d05 100644 --- a/worlds/pokemon_emerald/__init__.py +++ b/worlds/pokemon_emerald/__init__.py @@ -263,6 +263,14 @@ def generate_early(self) -> None: if self.options.hms == RandomizeHms.option_shuffle: self.options.local_items.value.update(self.item_name_groups["HM"]) + # Manually enable Latios as a dexsanity location if we're doing legendary hunt (which confines Latios to + # the roamer encounter), the player allows Latios as a valid legendary hunt target, and they didn't also + # blacklist Latios to remove its dexsanity location + if self.options.goal == Goal.option_legendary_hunt and self.options.dexsanity \ + and "Latios" in self.options.allowed_legendary_hunt_encounters.value \ + and emerald_data.constants["SPECIES_LATIOS"] not in self.blacklisted_wilds: + self.allowed_dexsanity_species.add(emerald_data.constants["SPECIES_LATIOS"]) + def create_regions(self) -> None: from .regions import create_regions all_regions = create_regions(self) diff --git a/worlds/pokemon_emerald/archipelago.json b/worlds/pokemon_emerald/archipelago.json index ed11b8d8cc8c..753ccb1f33d0 100644 --- a/worlds/pokemon_emerald/archipelago.json +++ b/worlds/pokemon_emerald/archipelago.json @@ -1,6 +1,6 @@ { "game": "Pokemon Emerald", - "world_version": "2.4.1", + "world_version": "2.5.0", "minimum_ap_version": "0.6.1", "authors": ["Zunawe"] } diff --git a/worlds/pokemon_emerald/pokemon.py b/worlds/pokemon_emerald/pokemon.py index 73af6c465840..76285d11dab8 100644 --- a/worlds/pokemon_emerald/pokemon.py +++ b/worlds/pokemon_emerald/pokemon.py @@ -376,10 +376,10 @@ def randomize_wild_encounters(world: "PokemonEmeraldWorld") -> None: # Actually create the new list of slots and encounter table new_slots: List[int] = [] - if encounter_type in enabled_encounters: - world.allowed_dexsanity_species.update(table.slots) for species_id in table.slots: new_slots.append(species_old_to_new_map[species_id]) + if encounter_type in enabled_encounters: + world.allowed_dexsanity_species.update(new_slots) new_encounters[encounter_type] = EncounterTableData(new_slots, table.address) diff --git a/worlds/pokemon_emerald/rules.py b/worlds/pokemon_emerald/rules.py index eeadb8bea21a..30ebf72e4d63 100644 --- a/worlds/pokemon_emerald/rules.py +++ b/worlds/pokemon_emerald/rules.py @@ -1559,7 +1559,7 @@ def get_location(location: str): # Legendary hunt prevents Latios from being a wild spawn so the roamer # can be tracked, and also guarantees that the roamer is a Latios. if world.options.goal == Goal.option_legendary_hunt and \ - data.constants["SPECIES_LATIOS"] not in world.blacklisted_wilds: + data.constants["SPECIES_LATIOS"] in world.allowed_dexsanity_species: set_rule( get_location(f"Pokedex - Latios"), lambda state: state.has("EVENT_ENCOUNTER_LATIOS", world.player) diff --git a/worlds/satisfactory/__init__.py b/worlds/satisfactory/__init__.py index fc5c6f2edffe..06c56eabe5a4 100644 --- a/worlds/satisfactory/__init__.py +++ b/worlds/satisfactory/__init__.py @@ -88,16 +88,19 @@ def create_items(self) -> None: self.items.build_item_pool(self.random, precollected_items, number_of_locations) def set_rules(self) -> None: - resource_sink_goal: bool = "AWESOME Sink Points (total)" in self.options.goal_selection \ - or "AWESOME Sink Points (per minute)" in self.options.goal_selection - required_parts = set(self.game_logic.space_elevator_phases[self.options.final_elevator_phase.value - 1].keys()) + required_buildings = set() + + if "Space Elevator Phase" in self.options.goal_selection: + required_buildings.add("Space Elevator") - if resource_sink_goal: - required_parts.union(self.game_logic.buildings["AWESOME Sink"].inputs) + if "AWESOME Sink Points (total)" in self.options.goal_selection \ + or "AWESOME Sink Points (per minute)" in self.options.goal_selection: + required_buildings.add("AWESOME Sink") self.multiworld.completion_condition[self.player] = \ - lambda state: self.state_logic.can_produce_all(state, required_parts) + lambda state: self.state_logic.can_produce_all(state, required_parts) \ + and self.state_logic.can_build_all(state, required_buildings) def collect(self, state: CollectionState, item: Item) -> bool: change = super().collect(state, item) @@ -244,14 +247,14 @@ def extend_hint_information(self, _: dict[int, dict[int, str]]): or self.options.awesome_logic_placement.value == Placement.starting_inventory: locations_visible_from_start.update(range(1338700, 1338709)) # ids of shop locations 1 to 10 - location_names_with_useful_items: Iterable[str] = [ - location.name - for location in self.get_locations() - if location.address in locations_visible_from_start and location.item \ - and location.item.flags & (ItemClassification.progression | ItemClassification.useful) > 0 - ] + location_names_with_useful_items: Iterable[str] = [ + location.name + for location in self.get_locations() + if location.address in locations_visible_from_start and location.item \ + and location.item.flags & (ItemClassification.progression | ItemClassification.useful) > 0 + ] - self.options.start_location_hints.value.update(location_names_with_useful_items) + self.options.start_location_hints.value.update(location_names_with_useful_items) def push_precollected_by_name(self, item_name: str) -> None: item = self.create_item(item_name) diff --git a/worlds/sc2/docs/setup_en.md b/worlds/sc2/docs/setup_en.md index d6f9f6cda518..e70c15d28fda 100644 --- a/worlds/sc2/docs/setup_en.md +++ b/worlds/sc2/docs/setup_en.md @@ -156,15 +156,17 @@ This page includes all data associated with all games. ## How do I join a MultiWorld game? -1. Run ArchipelagoStarcraft2Client.exe. +1. Run ArchipelagoLauncher.exe. - macOS users should instead follow the instructions found at ["Running in macOS"](#running-in-macos) for this step only. -2. In the Archipelago tab, type `/connect [server IP]`. +2. Search for the Starcraft 2 Client in the launcher to open the game-specific client + - Alternatively, steps 1 and 2 can be combined by providing the `"Starcraft 2 Client"` launch argument to the launcher. +3. In the Archipelago tab, type `/connect [server IP]`. - If you're running through the website, the server IP should be displayed near the top of the room page. - The server IP may also be typed into the top bar, and then clicking "Connect" -3. Type your slot name from your YAML when prompted. -4. If the server has a password, enter that when prompted. -5. Once connected, switch to the 'StarCraft 2 Launcher' tab in the client. There, you can see all the missions in your +4. Type your slot name from your YAML when prompted. +5. If the server has a password, enter that when prompted. +6. Once connected, switch to the 'StarCraft 2 Launcher' tab in the client. There, you can see all the missions in your world. Unreachable missions will have greyed-out text. Completed missions (all locations collected) will have white text. @@ -173,7 +175,22 @@ Mission buttons will have a color corresponding to the faction you play as in th Click on an available mission to start it. -## The game isn't launching when I try to start a mission. +## Troubleshooting + +### I can't connect to my seed. + +Rooms on the Archipelago website go to sleep after two hours of inactivity; reload or refresh the room page +to start them back up. +When restarting the room, the connection port may change (the numbers after "archipelago.gg:"), +make sure that is accurate. +Your slot name should be displayed on the room page as well; make sure that exactly matches the slot name you +type into your client, and note that it is case-sensitive. + +If none of these things solve the problem, visit the [Discord](https://discord.com/invite/8Z65BR2) and check +the #software-announcements channel to see if there's a listed outage, or visit the #starcraft-2 channel for +tech support. + +### The game isn't launching when I try to start a mission. Usually, this is caused by the mod files not being downloaded. Make sure you have run `/download_data` in the Archipelago tab before playing. @@ -183,12 +200,12 @@ Make sure that you are running an up-to-date version of the client. Check the [Archipelago Releases Page](https://github.com/ArchipelagoMW/Archipelago/releases) to look up what the latest version is (RC releases are not necessary; that stands for "Release Candidate"). -If these things are in order, check the log file for issues (stored at `[Archipelago Directory]/logs/Starcraft2Client.txt`). +If these things are in order, check the log file for issues (stored at `[Archipelago Directory]/logs/SC2Client_.txt`). If you can't figure out the log file, visit our [Discord's](https://discord.com/invite/8Z65BR2) tech-support channel for help. Please include a specific description of what's going wrong and attach your log file to your message. -## My keyboard shortcuts profile is not available when I play *StarCraft 2 Archipelago*. +### My keyboard shortcuts profile is not available when I play *StarCraft 2 Archipelago*. For your keyboard shortcuts profile to work in Archipelago, you need to copy your shortcuts file from `Documents/StarCraft II/Accounts/######/Hotkeys` to `Documents/StarCraft II/Hotkeys`. diff --git a/worlds/sc2/locations.py b/worlds/sc2/locations.py index 318d52f85611..ddc188bad903 100644 --- a/worlds/sc2/locations.py +++ b/worlds/sc2/locations.py @@ -249,7 +249,6 @@ def get_locations(world: Optional["SC2World"]) -> Tuple[LocationData, ...]: LocationType.VICTORY, lambda state: ( logic.terran_common_unit(state) - and logic.terran_defense_rating(state, True) >= 2 and (adv_tactics or logic.terran_basic_anti_air(state)) ), ), @@ -271,10 +270,7 @@ def get_locations(world: Optional["SC2World"]) -> Tuple[LocationData, ...]: "Third Group Rescued", SC2WOL_LOC_ID_OFFSET + 303, LocationType.VANILLA, - lambda state: ( - logic.terran_common_unit(state) - and logic.terran_defense_rating(state, True) >= 2 - ), + logic.terran_common_unit, ), make_location_data( SC2Mission.ZERO_HOUR.mission_name, @@ -320,20 +316,14 @@ def get_locations(world: Optional["SC2World"]) -> Tuple[LocationData, ...]: "Hold Just a Little Longer", SC2WOL_LOC_ID_OFFSET + 309, LocationType.EXTRA, - lambda state: ( - logic.terran_common_unit(state) - and logic.terran_defense_rating(state, True) >= 2 - ), + logic.terran_common_unit, ), make_location_data( SC2Mission.ZERO_HOUR.mission_name, "Cavalry's on the Way", SC2WOL_LOC_ID_OFFSET + 310, LocationType.EXTRA, - lambda state: ( - logic.terran_common_unit(state) - and logic.terran_defense_rating(state, True) >= 2 - ), + logic.terran_common_unit, ), make_location_data( SC2Mission.EVACUATION.mission_name, diff --git a/worlds/sc2/pool_filter.py b/worlds/sc2/pool_filter.py index 0ffa08e010c2..abecf8264cca 100644 --- a/worlds/sc2/pool_filter.py +++ b/worlds/sc2/pool_filter.py @@ -182,7 +182,7 @@ def attempt_removal( del self.logical_inventory[item.name] item.filter_flags |= remove_flag return "" - + def remove_child_items( parent_item: StarcraftItem, remove_flag: ItemFilterFlags = ItemFilterFlags.FilterExcluded, @@ -247,13 +247,13 @@ def request_minimum_items(group: List[StarcraftItem], requested_minimum) -> None # Limit the maximum number of upgrades if max_upgrades_per_unit != -1: - for group_name, group_items in group_to_item.items(): - self.world.random.shuffle(group_to_item[group]) + for group_items in group_to_item.values(): + self.world.random.shuffle(group_items) cull_items_over_maximum(group_items, max_upgrades_per_unit) - + # Requesting minimum upgrades for items that have already been locked/placed when minimum required if min_upgrades_per_unit != -1: - for group_name, group_items in group_to_item.items(): + for group_items in group_to_item.values(): self.world.random.shuffle(group_items) request_minimum_items(group_items, min_upgrades_per_unit) @@ -349,7 +349,7 @@ def item_included(item: StarcraftItem) -> bool: ItemFilterFlags.Removed not in item.filter_flags and ((ItemFilterFlags.Unexcludable|ItemFilterFlags.Excluded) & item.filter_flags) != ItemFilterFlags.Excluded ) - + # Actually remove culled items; we won't re-add them inventory = [ item for item in inventory @@ -373,7 +373,7 @@ def item_included(item: StarcraftItem) -> bool: item for item in cullable_items if not ((ItemFilterFlags.Removed|ItemFilterFlags.Uncullable) & item.filter_flags) ] - + # Handle too many requested if current_inventory_size - start_inventory_size > inventory_size - filler_amount: for item in inventory: @@ -414,7 +414,7 @@ def item_included(item: StarcraftItem) -> bool: removable_transport_hooks = [item for item in inventory_transport_hooks if not (ItemFilterFlags.Unexcludable & item.filter_flags)] if len(inventory_transport_hooks) > 1 and removable_transport_hooks: inventory.remove(removable_transport_hooks[0]) - + # Weapon/Armour upgrades def exclude_wa(prefix: str) -> List[StarcraftItem]: return [ @@ -439,7 +439,7 @@ def exclude_wa(prefix: str) -> List[StarcraftItem]: inventory = exclude_wa(item_names.PROTOSS_GROUND_UPGRADE_PREFIX) if used_item_names.isdisjoint(item_groups.protoss_air_wa): inventory = exclude_wa(item_names.PROTOSS_AIR_UPGRADE_PREFIX) - + # Part 4: Last-ditch effort to reduce inventory size; upgrades can go in start inventory current_inventory_size = len(inventory) precollect_items = current_inventory_size - inventory_size - start_inventory_size - filler_amount @@ -453,7 +453,7 @@ def exclude_wa(prefix: str) -> List[StarcraftItem]: for item in promotable[:precollect_items]: item.filter_flags |= ItemFilterFlags.StartInventory start_inventory_size += 1 - + assert current_inventory_size - start_inventory_size <= inventory_size - filler_amount, ( f"Couldn't reduce inventory to fit. target={inventory_size}, poolsize={current_inventory_size}, " f"start_inventory={starcraft_item}, filler_amount={filler_amount}" diff --git a/worlds/sc2/regions.py b/worlds/sc2/regions.py index 4b02d294d1ba..299fcde3dbee 100644 --- a/worlds/sc2/regions.py +++ b/worlds/sc2/regions.py @@ -129,7 +129,7 @@ def adjust_mission_pools(world: 'SC2World', pools: SC2MOGenMissionPools): if grant_story_tech == GrantStoryTech.option_grant: # Additional starter mission if player is granted story tech pools.move_mission(SC2Mission.ENEMY_WITHIN, Difficulty.EASY, Difficulty.STARTER) - pools.move_mission(SC2Mission.THE_ESCAPE, Difficulty.MEDIUM, Difficulty.STARTER) + pools.move_mission(SC2Mission.THE_ESCAPE, Difficulty.EASY, Difficulty.STARTER) pools.move_mission(SC2Mission.IN_THE_ENEMY_S_SHADOW, Difficulty.MEDIUM, Difficulty.STARTER) if not war_council_nerfs or grant_story_tech == GrantStoryTech.option_grant: pools.move_mission(SC2Mission.TEMPLAR_S_RETURN, Difficulty.MEDIUM, Difficulty.STARTER) diff --git a/worlds/sc2/rules.py b/worlds/sc2/rules.py index 28a8804e5ed8..8d55c6a4e529 100644 --- a/worlds/sc2/rules.py +++ b/worlds/sc2/rules.py @@ -1660,11 +1660,11 @@ def zealot_sentry_slayer_start(self, state: CollectionState) -> bool: Created mainly for engine of destruction start, but works for other missions with no-build starts. """ return state.has_any(( - item_names.ZEALOT_WHIRLWIND, - item_names.SENTRY_DOUBLE_SHIELD_RECHARGE, - item_names.SLAYER_PHASE_BLINK, - item_names.STALKER_INSTIGATOR_SLAYER_DISINTEGRATING_PARTICLES, - item_names.STALKER_INSTIGATOR_SLAYER_PARTICLE_REFLECTION, + item_names.ZEALOT_WHIRLWIND, + item_names.SENTRY_DOUBLE_SHIELD_RECHARGE, + item_names.SLAYER_PHASE_BLINK, + item_names.STALKER_INSTIGATOR_SLAYER_DISINTEGRATING_PARTICLES, + item_names.STALKER_INSTIGATOR_SLAYER_PARTICLE_REFLECTION, ), self.player) # Mission-specific rules diff --git a/worlds/sc2/test/slow_tests.py b/worlds/sc2/test/slow_tests.py new file mode 100644 index 000000000000..90b6e7a98205 --- /dev/null +++ b/worlds/sc2/test/slow_tests.py @@ -0,0 +1,52 @@ +""" +Slow-running tests that are run infrequently. +Run this file explicitly with `python3 -m unittest worlds.sc2.test.slow_tests` +""" +from .test_base import Sc2SetupTestBase + +from Fill import FillError +from .. import mission_tables, options + + +class LargeTests(Sc2SetupTestBase): + def test_any_starter_mission_works(self) -> None: + base_options = { + options.OPTION_NAME[options.SelectedRaces]: list(options.SelectedRaces.valid_keys), + options.OPTION_NAME[options.RequiredTactics]: options.RequiredTactics.option_standard, + options.OPTION_NAME[options.MissionOrder]: options.MissionOrder.option_custom, + options.OPTION_NAME[options.ExcludeOverpoweredItems]: True, + # options.OPTION_NAME[options.ExtraLocations]: options.ExtraLocations.option_disabled, + options.OPTION_NAME[options.VanillaLocations]: options.VanillaLocations.option_disabled, + } + missions_to_check = [ + mission for mission in mission_tables.SC2Mission + if mission.pool == mission_tables.MissionPools.STARTER + ] + failed_missions: list[tuple[mission_tables.SC2Mission, int]] = [] + NUM_ATTEMPTS = 3 + for mission in missions_to_check: + for attempt in range(NUM_ATTEMPTS): + mission_options = base_options | { + options.OPTION_NAME[options.CustomMissionOrder]: { + "Test Campaign": { + "Test Layout": { + "type": "hopscotch", + "size": 25, + "goal": True, + "missions": [ + {"index": 0, "mission_pool": [mission.mission_name]} + ] + } + } + } + } + try: + self.generate_world(mission_options) + self.fill_after_generation() + assert self.multiworld.worlds[1].custom_mission_order.get_starting_missions()[0] == mission + except FillError as ex: + failed_missions.append((mission, self.multiworld.seed)) + if failed_missions: + for failed_mission in failed_missions: + print(failed_mission) + self.assertFalse(failed_missions) diff --git a/worlds/sc2/test/test_base.py b/worlds/sc2/test/test_base.py index f0f778dc798c..f6aaaddabac3 100644 --- a/worlds/sc2/test/test_base.py +++ b/worlds/sc2/test/test_base.py @@ -1,4 +1,4 @@ -from typing import * +from typing import Any, cast import unittest import random from argparse import Namespace @@ -6,18 +6,11 @@ from Generate import get_seed_name from worlds import AutoWorld from test.general import gen_steps, call_all +from Fill import distribute_items_restrictive -from test.bases import WorldTestBase from .. import SC2World, SC2Campaign -from .. import client from .. import options -class Sc2TestBase(WorldTestBase): - game = client.SC2Context.game - world: SC2World - player: ClassVar[int] = 1 - skip_long_tests: bool = True - class Sc2SetupTestBase(unittest.TestCase): """ @@ -37,10 +30,11 @@ class Sc2SetupTestBase(unittest.TestCase): PROTOSS_CAMPAIGNS = { 'enabled_campaigns': {SC2Campaign.PROPHECY.campaign_name, SC2Campaign.PROLOGUE.campaign_name, SC2Campaign.LOTV.campaign_name,} } - seed: Optional[int] = None + seed: int | None = None game = SC2World.game player = 1 - def generate_world(self, options: Dict[str, Any]) -> None: + + def generate_world(self, options: dict[str, Any]) -> None: self.multiworld = MultiWorld(1) self.multiworld.game[self.player] = self.game self.multiworld.player_name = {self.player: "Tester"} @@ -63,3 +57,11 @@ def generate_world(self, options: Dict[str, Any]) -> None: except Exception as ex: ex.add_note(f"Seed: {self.multiworld.seed}") raise + + def fill_after_generation(self) -> None: + assert self.multiworld + try: + distribute_items_restrictive(self.multiworld) + except Exception as ex: + ex.add_note(f"Seed: {self.multiworld.seed}") + raise diff --git a/worlds/sc2/test/test_generation.py b/worlds/sc2/test/test_generation.py index 329cd593e1a8..708606f7bcde 100644 --- a/worlds/sc2/test/test_generation.py +++ b/worlds/sc2/test/test_generation.py @@ -1,20 +1,24 @@ """ Unit tests for world generation """ -from typing import * - +from typing import Any from .test_base import Sc2SetupTestBase -from .. import mission_groups, mission_tables, options, locations, SC2Mission, SC2Campaign, SC2Race, unreleased_items, \ - RequiredTactics +from .. import ( + mission_groups, mission_tables, options, locations, + SC2Mission, SC2Campaign, SC2Race, unreleased_items, + RequiredTactics, +) from ..item import item_groups, item_tables, item_names from .. import get_all_missions, get_random_first_mission -from ..options import EnabledCampaigns, NovaGhostOfAChanceVariant, MissionOrder, ExcludeOverpoweredItems, \ - VanillaItemsOnly, MaximumCampaignSize +from ..options import ( + EnabledCampaigns, NovaGhostOfAChanceVariant, MissionOrder, ExcludeOverpoweredItems, + VanillaItemsOnly, MaximumCampaignSize, +) class TestItemFiltering(Sc2SetupTestBase): - def test_explicit_locks_excludes_interact_and_set_flags(self): + def test_explicit_locks_excludes_interact_and_set_flags(self) -> None: world_options = { **self.ALL_CAMPAIGNS, 'locked_items': { @@ -46,7 +50,7 @@ def test_explicit_locks_excludes_interact_and_set_flags(self): regen_biosteel_items = [x for x in itempool if x == item_names.PROGRESSIVE_REGENERATIVE_BIO_STEEL] self.assertEqual(len(regen_biosteel_items), 2) - def test_unexcludes_cancel_out_excludes(self): + def test_unexcludes_cancel_out_excludes(self) -> None: world_options = { 'grant_story_tech': options.GrantStoryTech.option_grant, 'excluded_items': { @@ -121,7 +125,7 @@ def test_exclude_2_beats_unexclude_1(self) -> None: itempool = [item.name for item in self.multiworld.itempool] self.assertNotIn(item_names.MARINE, itempool) - def test_excluding_groups_excludes_all_items_in_group(self): + def test_excluding_groups_excludes_all_items_in_group(self) -> None: world_options = { 'excluded_items': { item_groups.ItemGroupNames.BARRACKS_UNITS.lower(): -1, @@ -133,7 +137,7 @@ def test_excluding_groups_excludes_all_items_in_group(self): for item_name in item_groups.barracks_units: self.assertNotIn(item_name, itempool) - def test_excluding_mission_groups_excludes_all_missions_in_group(self): + def test_excluding_mission_groups_excludes_all_missions_in_group(self) -> None: world_options = { **self.ZERG_CAMPAIGNS, 'enable_race_swap': options.EnableRaceSwapVariants.option_shuffle_all, @@ -164,7 +168,7 @@ def test_excluding_campaigns_excludes_campaign_specific_items(self) -> None: self.assertNotEqual(item_data.type, item_tables.TerranItemType.Nova_Gear) self.assertNotEqual(item_name, item_names.NOVA_PROGRESSIVE_STEALTH_SUIT_MODULE) - def test_starter_unit_populates_start_inventory(self): + def test_starter_unit_populates_start_inventory(self) -> None: world_options = { 'enabled_campaigns': { SC2Campaign.WOL.campaign_name, @@ -308,7 +312,7 @@ def test_vanilla_items_only_excludes_terran_progressives(self) -> None: self.generate_world(world_options) world_items = [(item.name, item_tables.item_table[item.name]) for item in self.multiworld.itempool] self.assertTrue(world_items) - occurrences: Dict[str, int] = {} + occurrences: dict[str, int] = {} for item_name, _ in world_items: if item_name in item_groups.terran_progressive_items: if item_name in item_groups.nova_equipment: @@ -528,7 +532,7 @@ def test_deprecated_orbital_command_not_present(self) -> None: Orbital command got replaced. The item is still there for backwards compatibility. It shouldn't be generated. """ - world_options = {} + world_options: dict[str, Any] = {} self.generate_world(world_options) itempool = [item.name for item in self.multiworld.itempool] @@ -595,7 +599,7 @@ def test_disabling_speedrun_locations_removes_them_from_the_pool(self) -> None: self.assertIn(speedrun_location_name, all_location_names) self.assertNotIn(speedrun_location_name, world_location_names) - def test_nco_and_wol_picks_correct_starting_mission(self): + def test_nco_and_wol_picks_correct_starting_mission(self) -> None: world_options = { 'mission_order': MissionOrder.option_vanilla, 'enabled_campaigns': { @@ -606,7 +610,7 @@ def test_nco_and_wol_picks_correct_starting_mission(self): self.generate_world(world_options) self.assertEqual(get_random_first_mission(self.world, self.world.custom_mission_order), mission_tables.SC2Mission.LIBERATION_DAY) - def test_excluding_mission_short_name_excludes_all_variants_of_mission(self): + def test_excluding_mission_short_name_excludes_all_variants_of_mission(self) -> None: world_options = { 'excluded_missions': [ mission_tables.SC2Mission.ZERO_HOUR.mission_name.split(" (")[0] @@ -625,7 +629,7 @@ def test_excluding_mission_short_name_excludes_all_variants_of_mission(self): self.assertNotIn(mission_tables.SC2Mission.ZERO_HOUR_Z, missions) self.assertNotIn(mission_tables.SC2Mission.ZERO_HOUR_P, missions) - def test_excluding_mission_variant_excludes_just_that_variant(self): + def test_excluding_mission_variant_excludes_just_that_variant(self) -> None: world_options = { 'excluded_missions': [ mission_tables.SC2Mission.ZERO_HOUR.mission_name @@ -644,7 +648,7 @@ def test_excluding_mission_variant_excludes_just_that_variant(self): self.assertIn(mission_tables.SC2Mission.ZERO_HOUR_Z, missions) self.assertIn(mission_tables.SC2Mission.ZERO_HOUR_P, missions) - def test_weapon_armor_upgrades(self): + def test_weapon_armor_upgrades(self) -> None: world_options = { # Vanilla WoL with all missions 'mission_order': options.MissionOrder.option_vanilla, @@ -682,7 +686,7 @@ def test_weapon_armor_upgrades(self): self.assertGreaterEqual(len(vehicle_weapon_items), 3) self.assertEqual(len(other_bundle_items), 0) - def test_weapon_armor_upgrades_with_bundles(self): + def test_weapon_armor_upgrades_with_bundles(self) -> None: world_options = { # Vanilla WoL with all missions 'mission_order': options.MissionOrder.option_vanilla, @@ -720,7 +724,7 @@ def test_weapon_armor_upgrades_with_bundles(self): self.assertGreaterEqual(len(vehicle_upgrade_items), 3) self.assertEqual(len(other_bundle_items), 0) - def test_weapon_armor_upgrades_all_in_air(self): + def test_weapon_armor_upgrades_all_in_air(self) -> None: world_options = { # Vanilla WoL with all missions 'mission_order': options.MissionOrder.option_vanilla, @@ -753,7 +757,7 @@ def test_weapon_armor_upgrades_all_in_air(self): self.assertGreaterEqual(len(vehicle_weapon_items), 3) self.assertGreaterEqual(len(ship_weapon_items), 3) - def test_weapon_armor_upgrades_generic_upgrade_missions(self): + def test_weapon_armor_upgrades_generic_upgrade_missions(self) -> None: """ Tests the case when there aren't enough missions in order to get required weapon/armor upgrades for logic requirements. @@ -782,7 +786,7 @@ def test_weapon_armor_upgrades_generic_upgrade_missions(self): # Under standard tactics you need to place L3 upgrades for available unit classes self.assertEqual(len(upgrade_items), 3) - def test_weapon_armor_upgrades_generic_upgrade_missions_no_logic(self): + def test_weapon_armor_upgrades_generic_upgrade_missions_no_logic(self) -> None: """ Tests the case when there aren't enough missions in order to get required weapon/armor upgrades for logic requirements. @@ -813,7 +817,7 @@ def test_weapon_armor_upgrades_generic_upgrade_missions_no_logic(self): # No logic won't take the fallback to trigger self.assertEqual(len(upgrade_items), 0) - def test_weapon_armor_upgrades_generic_upgrade_missions_no_countermeasure_needed(self): + def test_weapon_armor_upgrades_generic_upgrade_missions_no_countermeasure_needed(self) -> None: world_options = { # Vanilla WoL with all missions 'mission_order': options.MissionOrder.option_vanilla, @@ -837,7 +841,7 @@ def test_weapon_armor_upgrades_generic_upgrade_missions_no_countermeasure_needed # No additional starting inventory item placement is needed self.assertEqual(len(upgrade_items), 0) - def test_kerrigan_levels_per_mission_triggering_pre_fill(self): + def test_kerrigan_levels_per_mission_triggering_pre_fill(self) -> None: world_options = { **self.ALL_CAMPAIGNS, 'mission_order': options.MissionOrder.option_custom, @@ -878,7 +882,7 @@ def test_kerrigan_levels_per_mission_triggering_pre_fill(self): self.assertGreater(len(kerrigan_1_stacks), 0) - def test_kerrigan_levels_per_mission_and_generic_upgrades_both_triggering_pre_fill(self): + def test_kerrigan_levels_per_mission_and_generic_upgrades_both_triggering_pre_fill(self) -> None: world_options = { **self.ALL_CAMPAIGNS, 'mission_order': options.MissionOrder.option_custom, @@ -925,7 +929,7 @@ def test_kerrigan_levels_per_mission_and_generic_upgrades_both_triggering_pre_fi self.assertNotIn(item_names.KERRIGAN_LEVELS_70, itempool) self.assertNotIn(item_names.KERRIGAN_LEVELS_70, starting_inventory) - def test_locking_required_items(self): + def test_locking_required_items(self) -> None: world_options = { **self.ALL_CAMPAIGNS, 'mission_order': options.MissionOrder.option_custom, @@ -962,7 +966,7 @@ def test_locking_required_items(self): self.assertIn(item_names.KERRIGAN_MEND, itempool) - def test_fully_balanced_mission_races(self): + def test_fully_balanced_mission_races(self) -> None: """ Tests whether fully balanced mission race balancing actually is fully balanced. """ @@ -1080,7 +1084,7 @@ def test_weapon_armor_upgrade_items_capped_by_max_upgrade_level(self) -> None: self.generate_world(world_options) itempool = [item.name for item in self.multiworld.itempool] - upgrade_item_counts: Dict[str, int] = {} + upgrade_item_counts: dict[str, int] = {} for item_name in itempool: if item_tables.item_table[item_name].type in ( item_tables.TerranItemType.Upgrade, @@ -1252,7 +1256,7 @@ def test_unreleased_item_quantity(self) -> None: self.generate_world(world_options) itempool = [item.name for item in self.multiworld.itempool] - items_to_check: List[str] = unreleased_items + items_to_check: list[str] = unreleased_items for item in items_to_check: self.assertNotIn(item, itempool) @@ -1273,7 +1277,7 @@ def test_unreleased_item_quantity_locked(self) -> None: self.generate_world(world_options) itempool = [item.name for item in self.multiworld.itempool] - items_to_check: List[str] = unreleased_items + items_to_check: list[str] = unreleased_items for item in items_to_check: self.assertIn(item, itempool) diff --git a/worlds/sc2/test/test_regions.py b/worlds/sc2/test/test_regions.py index 880a02f97374..5d9870d8941b 100644 --- a/worlds/sc2/test/test_regions.py +++ b/worlds/sc2/test/test_regions.py @@ -1,9 +1,10 @@ import unittest -from .test_base import Sc2TestBase +from .test_base import Sc2SetupTestBase from .. import mission_tables, SC2Campaign from .. import options from ..mission_order.layout_types import Grid + class TestGridsizes(unittest.TestCase): def test_grid_sizes_meet_specs(self): self.assertTupleEqual((1, 2, 0), Grid.get_grid_dimensions(2)) @@ -24,17 +25,17 @@ def test_grid_sizes_meet_specs(self): self.assertTupleEqual((5, 7, 2), Grid.get_grid_dimensions(33)) -class TestGridGeneration(Sc2TestBase): - options = { - "mission_order": options.MissionOrder.option_grid, - "excluded_missions": [mission_tables.SC2Mission.ZERO_HOUR.mission_name,], - "enabled_campaigns": { - SC2Campaign.WOL.campaign_name, - SC2Campaign.PROPHECY.campaign_name, - } - } - +class TestGridGeneration(Sc2SetupTestBase): def test_size_matches_exclusions(self): + world_options = { + options.OPTION_NAME[options.MissionOrder]: options.MissionOrder.option_grid, + options.OPTION_NAME[options.ExcludedMissions]: [mission_tables.SC2Mission.ZERO_HOUR.mission_name], + options.OPTION_NAME[options.EnabledCampaigns]: { + SC2Campaign.WOL.campaign_name, + SC2Campaign.PROPHECY.campaign_name, + } + } + self.generate_world(world_options) self.assertNotIn(mission_tables.SC2Mission.ZERO_HOUR.mission_name, self.multiworld.regions) # WoL has 29 missions. -1 for Zero Hour being excluded, +1 for the automatically-added menu location self.assertEqual(len(self.multiworld.regions), 29) diff --git a/worlds/smw/Client.py b/worlds/smw/Client.py index 85524eb7ad41..ef576a873869 100644 --- a/worlds/smw/Client.py +++ b/worlds/smw/Client.py @@ -132,7 +132,7 @@ def on_package(self, ctx: SNIClient, cmd: str, args: dict[str, Any]) -> None: self.instance_id = time.time() source_name = args["data"]["source"] - if "TrapLink" in ctx.tags and "TrapLink" in args["tags"] and source_name != ctx.slot_info[ctx.slot].name: + if "TrapLink" in ctx.tags and "TrapLink" in args["tags"] and source_name != ctx.player_names[ctx.slot]: trap_name: str = args["data"]["trap_name"] if trap_name not in trap_name_to_value: # We don't know how to handle this trap, ignore it diff --git a/worlds/stardew_valley/data/bundles_data/meme_bundles.py b/worlds/stardew_valley/data/bundles_data/meme_bundles.py index c3abdca129e8..a9b7f8c7daf1 100644 --- a/worlds/stardew_valley/data/bundles_data/meme_bundles.py +++ b/worlds/stardew_valley/data/bundles_data/meme_bundles.py @@ -257,7 +257,7 @@ red_fish_items = [red_mullet, red_snapper, lava_eel, crimsonfish] blue_fish_items = [anchovy, tuna, sardine, bream, squid, ice_pip, albacore, blue_discus, midnight_squid, spook_fish, glacierfish] other_fish = [pufferfish, largemouth_bass, smallmouth_bass, rainbow_trout, walleye, perch, carp, catfish, pike, sunfish, herring, eel, octopus, sea_cucumber, - super_cucumber, ghostfish, stonefish, sandfish, scorpion_carp, flounder, midnight_carp, tigerseye, bullhead, tilapia, chub, dorado, shad, + super_cucumber, ghostfish, stonefish, sandfish, scorpion_carp, flounder, midnight_carp, bullhead, tilapia, chub, dorado, shad, tiger_trout, lingcod, halibut, slimejack, stingray, goby, blobfish, angler, legend, mutant_carp] dr_seuss_items = [other_fish, [fish.as_amount(2) for fish in other_fish], red_fish_items, blue_fish_items] dr_seuss_bundle = FixedPriceDeepBundleTemplate(CCRoom.crafts_room, MemeBundleName.dr_seuss, dr_seuss_items, 4, 4) diff --git a/worlds/stardew_valley/data/locations.csv b/worlds/stardew_valley/data/locations.csv index 9c6cffad519a..8c4a521d7705 100644 --- a/worlds/stardew_valley/data/locations.csv +++ b/worlds/stardew_valley/data/locations.csv @@ -438,6 +438,8 @@ id,region,name,tags,content_packs 906,Traveling Cart Sunday,Traveling Merchant Sunday Item 6,"TRAVELING_MERCHANT", 907,Traveling Cart Sunday,Traveling Merchant Sunday Item 7,"TRAVELING_MERCHANT", 908,Traveling Cart Sunday,Traveling Merchant Sunday Item 8,"TRAVELING_MERCHANT", +909,Traveling Cart Sunday,Traveling Merchant Sunday Item 9,"TRAVELING_MERCHANT", +910,Traveling Cart Sunday,Traveling Merchant Sunday Item 10,"TRAVELING_MERCHANT", 911,Traveling Cart Monday,Traveling Merchant Monday Item 1,"MANDATORY,TRAVELING_MERCHANT", 912,Traveling Cart Monday,Traveling Merchant Monday Item 2,"TRAVELING_MERCHANT", 913,Traveling Cart Monday,Traveling Merchant Monday Item 3,"TRAVELING_MERCHANT", @@ -446,6 +448,8 @@ id,region,name,tags,content_packs 916,Traveling Cart Monday,Traveling Merchant Monday Item 6,"TRAVELING_MERCHANT", 917,Traveling Cart Monday,Traveling Merchant Monday Item 7,"TRAVELING_MERCHANT", 918,Traveling Cart Monday,Traveling Merchant Monday Item 8,"TRAVELING_MERCHANT", +919,Traveling Cart Monday,Traveling Merchant Monday Item 9,"TRAVELING_MERCHANT", +920,Traveling Cart Monday,Traveling Merchant Monday Item 10,"TRAVELING_MERCHANT", 921,Traveling Cart Tuesday,Traveling Merchant Tuesday Item 1,"MANDATORY,TRAVELING_MERCHANT", 922,Traveling Cart Tuesday,Traveling Merchant Tuesday Item 2,"TRAVELING_MERCHANT", 923,Traveling Cart Tuesday,Traveling Merchant Tuesday Item 3,"TRAVELING_MERCHANT", @@ -454,6 +458,8 @@ id,region,name,tags,content_packs 926,Traveling Cart Tuesday,Traveling Merchant Tuesday Item 6,"TRAVELING_MERCHANT", 927,Traveling Cart Tuesday,Traveling Merchant Tuesday Item 7,"TRAVELING_MERCHANT", 928,Traveling Cart Tuesday,Traveling Merchant Tuesday Item 8,"TRAVELING_MERCHANT", +929,Traveling Cart Tuesday,Traveling Merchant Tuesday Item 9,"TRAVELING_MERCHANT", +930,Traveling Cart Tuesday,Traveling Merchant Tuesday Item 10,"TRAVELING_MERCHANT", 931,Traveling Cart Wednesday,Traveling Merchant Wednesday Item 1,"MANDATORY,TRAVELING_MERCHANT", 932,Traveling Cart Wednesday,Traveling Merchant Wednesday Item 2,"TRAVELING_MERCHANT", 933,Traveling Cart Wednesday,Traveling Merchant Wednesday Item 3,"TRAVELING_MERCHANT", @@ -462,6 +468,8 @@ id,region,name,tags,content_packs 936,Traveling Cart Wednesday,Traveling Merchant Wednesday Item 6,"TRAVELING_MERCHANT", 937,Traveling Cart Wednesday,Traveling Merchant Wednesday Item 7,"TRAVELING_MERCHANT", 938,Traveling Cart Wednesday,Traveling Merchant Wednesday Item 8,"TRAVELING_MERCHANT", +939,Traveling Cart Wednesday,Traveling Merchant Wednesday Item 9,"TRAVELING_MERCHANT", +940,Traveling Cart Wednesday,Traveling Merchant Wednesday Item 10,"TRAVELING_MERCHANT", 941,Traveling Cart Thursday,Traveling Merchant Thursday Item 1,"MANDATORY,TRAVELING_MERCHANT", 942,Traveling Cart Thursday,Traveling Merchant Thursday Item 2,"TRAVELING_MERCHANT", 943,Traveling Cart Thursday,Traveling Merchant Thursday Item 3,"TRAVELING_MERCHANT", @@ -470,6 +478,8 @@ id,region,name,tags,content_packs 946,Traveling Cart Thursday,Traveling Merchant Thursday Item 6,"TRAVELING_MERCHANT", 947,Traveling Cart Thursday,Traveling Merchant Thursday Item 7,"TRAVELING_MERCHANT", 948,Traveling Cart Thursday,Traveling Merchant Thursday Item 8,"TRAVELING_MERCHANT", +949,Traveling Cart Thursday,Traveling Merchant Thursday Item 9,"TRAVELING_MERCHANT", +950,Traveling Cart Thursday,Traveling Merchant Thursday Item 10,"TRAVELING_MERCHANT", 951,Traveling Cart Friday,Traveling Merchant Friday Item 1,"MANDATORY,TRAVELING_MERCHANT", 952,Traveling Cart Friday,Traveling Merchant Friday Item 2,"TRAVELING_MERCHANT", 953,Traveling Cart Friday,Traveling Merchant Friday Item 3,"TRAVELING_MERCHANT", @@ -478,6 +488,8 @@ id,region,name,tags,content_packs 956,Traveling Cart Friday,Traveling Merchant Friday Item 6,"TRAVELING_MERCHANT", 957,Traveling Cart Friday,Traveling Merchant Friday Item 7,"TRAVELING_MERCHANT", 958,Traveling Cart Friday,Traveling Merchant Friday Item 8,"TRAVELING_MERCHANT", +959,Traveling Cart Friday,Traveling Merchant Friday Item 9,"TRAVELING_MERCHANT", +960,Traveling Cart Friday,Traveling Merchant Friday Item 10,"TRAVELING_MERCHANT", 961,Traveling Cart Saturday,Traveling Merchant Saturday Item 1,"MANDATORY,TRAVELING_MERCHANT", 962,Traveling Cart Saturday,Traveling Merchant Saturday Item 2,"TRAVELING_MERCHANT", 963,Traveling Cart Saturday,Traveling Merchant Saturday Item 3,"TRAVELING_MERCHANT", @@ -486,6 +498,8 @@ id,region,name,tags,content_packs 966,Traveling Cart Saturday,Traveling Merchant Saturday Item 6,"TRAVELING_MERCHANT", 967,Traveling Cart Saturday,Traveling Merchant Saturday Item 7,"TRAVELING_MERCHANT", 968,Traveling Cart Saturday,Traveling Merchant Saturday Item 8,"TRAVELING_MERCHANT", +969,Traveling Cart Saturday,Traveling Merchant Saturday Item 9,"TRAVELING_MERCHANT", +970,Traveling Cart Saturday,Traveling Merchant Saturday Item 10,"TRAVELING_MERCHANT", 1001,Fishing,Fishsanity: Carp,FISHSANITY, 1002,Fishing,Fishsanity: Herring,FISHSANITY, 1003,Fishing,Fishsanity: Smallmouth Bass,FISHSANITY, @@ -1182,7 +1196,7 @@ id,region,name,tags,content_packs 2104,Fishing,Biome Balance,SPECIAL_ORDER_BOARD, 2105,Haley's House,Rock Rejuvenation,SPECIAL_ORDER_BOARD, 2106,Alex's House,Gifts for George,SPECIAL_ORDER_BOARD, -2107,Museum,Fragments of the past,"GINGER_ISLAND,SPECIAL_ORDER_BOARD", +2107,Museum,Fragments of the past,"SPECIAL_ORDER_BOARD", 2108,Saloon,Gus' Famous Omelet,SPECIAL_ORDER_BOARD, 2109,Farm,Crop Order,SPECIAL_ORDER_BOARD, 2110,Railroad,Community Cleanup,SPECIAL_ORDER_BOARD, @@ -2227,7 +2241,7 @@ id,region,name,tags,content_packs 3530,Farm,Craft Cookout Kit,"CRAFTSANITY,CRAFTSANITY_CRAFT", 3531,Farm,Craft Fish Smoker,"CRAFTSANITY,CRAFTSANITY_CRAFT", 3532,Farm,Craft Dehydrator,"CRAFTSANITY,CRAFTSANITY_CRAFT", -3533,Farm,Craft Blue Grass Starter,"CRAFTSANITY,CRAFTSANITY_CRAFT,GINGER_ISLAND", +3533,Farm,Craft Blue Grass Starter,"CRAFTSANITY,CRAFTSANITY_CRAFT,GINGER_ISLAND,REQUIRES_QI_ORDERS", 3534,Farm,Craft Mystic Tree Seed,"CRAFTSANITY,CRAFTSANITY_CRAFT,REQUIRES_MASTERIES", 3535,Farm,Craft Sonar Bobber,"CRAFTSANITY,CRAFTSANITY_CRAFT", 3536,Farm,Craft Challenge Bait,"CRAFTSANITY,CRAFTSANITY_CRAFT,REQUIRES_MASTERIES", diff --git a/worlds/stardew_valley/locations.py b/worlds/stardew_valley/locations.py index 613698ac1bfe..a817022e3d5d 100644 --- a/worlds/stardew_valley/locations.py +++ b/worlds/stardew_valley/locations.py @@ -1,6 +1,7 @@ import csv import enum import logging +import math from dataclasses import dataclass from random import Random from typing import Optional, Dict, Protocol, List, Iterable @@ -16,7 +17,7 @@ from .options import ArcadeMachineLocations, SpecialOrderLocations, Museumsanity, \ FestivalLocations, ElevatorProgression, BackpackProgression, FarmType from .options import StardewValleyOptions, Craftsanity, Chefsanity, Cooksanity, Shipsanity, Monstersanity -from .options.options import BackpackSize, Moviesanity, Eatsanity, IncludeEndgameLocations, Friendsanity +from .options.options import BackpackSize, Moviesanity, Eatsanity, IncludeEndgameLocations, Friendsanity, Fishsanity, SkillProgression, Cropsanity from .strings.ap_names.ap_option_names import WalnutsanityOptionName, SecretsanityOptionName, EatsanityOptionName, ChefsanityOptionName, StartWithoutOptionName from .strings.backpack_tiers import Backpack from .strings.goal_names import Goal @@ -665,19 +666,48 @@ def extend_endgame_locations(randomized_locations: List[LocationData], options: def extend_filler_locations(randomized_locations: List[LocationData], options: StardewValleyOptions, content: StardewContent): days = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"] - i = 1 - while len(randomized_locations) < 90: - location_name = f"Traveling Merchant Sunday Item {i}" - while any(location.name == location_name for location in randomized_locations): - i += 1 - location_name = f"Traveling Merchant Sunday Item {i}" + number_locations_to_add_per_day = 0 + min_number_locations = 90 # Under 90 locations we can run out of rooms for the mandatory core items + if len(randomized_locations) < min_number_locations: + number_locations_to_add = min_number_locations - len(randomized_locations) + number_locations_to_add_per_day += math.ceil(number_locations_to_add / 7) + + # These settings generate a lot of empty locations, so they can absorb a lot of items + filler_heavy_settings = [options.fishsanity != Fishsanity.option_none, + options.shipsanity != Shipsanity.option_none, + options.cooksanity != Cooksanity.option_none, + options.craftsanity != Craftsanity.option_none, + len(options.eatsanity.value) > 0, + options.museumsanity == Museumsanity.option_all, + options.quest_locations.value >= 0, + options.bundle_per_room >= 2] + # These settings generate orphan items and can cause too many items, if enabled without a complementary of the filler heavy settings + orphan_settings = [len(options.chefsanity.value) > 0, + options.friendsanity != Friendsanity.option_none, + options.skill_progression == SkillProgression.option_progressive_with_masteries, + options.cropsanity != Cropsanity.option_disabled, + len(options.start_without.value) > 0, + options.bundle_per_room <= -1, + options.bundle_per_room <= -2] + + enabled_filler_heavy_settings = len([val for val in filler_heavy_settings if val]) + enabled_orphan_settings = len([val for val in orphan_settings if val]) + if enabled_orphan_settings > enabled_filler_heavy_settings: + number_locations_to_add_per_day += enabled_orphan_settings - enabled_filler_heavy_settings + + if number_locations_to_add_per_day <= 0: + return + + existing_traveling_merchant_locations = [location.name for location in randomized_locations if location.name.startswith("Traveling Merchant Sunday Item ")] + start_num_to_add = len(existing_traveling_merchant_locations) + 1 + + for i in range(start_num_to_add, start_num_to_add+number_locations_to_add_per_day): logger.debug(f"Player too few locations, adding Traveling Merchant Items #{i}") for day in days: location_name = f"Traveling Merchant {day} Item {i}" randomized_locations.append(location_table[location_name]) - def create_locations(location_collector: StardewLocationCollector, bundle_rooms: List[BundleRoom], trash_bear_requests: Dict[str, List[str]], diff --git a/worlds/stardew_valley/logic/logic.py b/worlds/stardew_valley/logic/logic.py index 37260d1494ad..47b51ce7a627 100644 --- a/worlds/stardew_valley/logic/logic.py +++ b/worlds/stardew_valley/logic/logic.py @@ -297,7 +297,6 @@ def __init__(self, player: int, options: StardewValleyOptions, content: StardewC Material.stone: self.ability.can_mine_stone(), Material.wood: self.ability.can_chop_trees(), Meal.ice_cream: (self.season.has(Season.summer) & self.money.can_spend_at(Region.town, 250)) | self.money.can_spend_at(Region.oasis, 240), - Meal.strange_bun: self.relationship.has_hearts(NPC.shane, 7) & self.has(Ingredient.wheat_flour) & self.has(Fish.periwinkle) & self.has(ArtisanGood.void_mayonnaise), MetalBar.copper: self.can_smelt(Ore.copper), MetalBar.gold: self.can_smelt(Ore.gold), MetalBar.iridium: self.can_smelt(Ore.iridium), @@ -313,7 +312,7 @@ def __init__(self, player: int, options: StardewValleyOptions, content: StardewC RetainingSoil.basic: self.money.can_spend_at(Region.pierre_store, 100), RetainingSoil.quality: self.time.has_year_two & self.money.can_spend_at(Region.pierre_store, 150), SpecialItem.lucky_purple_shorts: self.special_items.has_purple_shorts(), - SpecialItem.trimmed_purple_shorts: self.has(SpecialItem.lucky_purple_shorts) & self.has(Machine.sewing_machine), + SpecialItem.trimmed_purple_shorts: self.has(SpecialItem.lucky_purple_shorts) & self.has(MetalBar.gold) & self.has(Machine.sewing_machine), SpecialItem.far_away_stone: self.special_items.has_far_away_stone(), SpecialItem.solid_gold_lewis: self.special_items.has_solid_gold_lewis(), SpecialItem.advanced_tv_remote: self.special_items.has_advanced_tv_remote(), diff --git a/worlds/stardew_valley/test/TestNumberLocations.py b/worlds/stardew_valley/test/TestNumberLocations.py index b21488733bb1..50b158d282ca 100644 --- a/worlds/stardew_valley/test/TestNumberLocations.py +++ b/worlds/stardew_valley/test/TestNumberLocations.py @@ -7,6 +7,13 @@ from ..items.item_data import FILLER_GROUPS +def get_real_item_count(multiworld): + number_items = len([item for item in multiworld.itempool + if all(filler_group not in item_table[item.name].groups for filler_group in FILLER_GROUPS) and Group.TRAP not in item_table[ + item.name].groups and (item.classification & ItemClassification.progression)]) + return number_items + + class TestLocationGeneration(SVTestBase): def test_all_location_created_are_in_location_table(self): @@ -20,8 +27,7 @@ class TestMinLocationAndMaxItem(SVTestBase): def test_minimal_location_maximal_items_still_valid(self): valid_locations = self.get_real_locations() number_locations = len(valid_locations) - number_items = len([item for item in self.multiworld.itempool - if all(filler_group not in item_table[item.name].groups for filler_group in FILLER_GROUPS) and Group.TRAP not in item_table[item.name].groups]) + number_items = get_real_item_count(self.multiworld) print(f"Stardew Valley - Minimum Locations: {number_locations}, Maximum Items: {number_items} [ISLAND EXCLUDED]") self.assertGreaterEqual(number_locations, number_items) @@ -32,8 +38,7 @@ class TestMinLocationAndMaxItemWithIsland(SVTestBase): def test_minimal_location_maximal_items_with_island_still_valid(self): valid_locations = self.get_real_locations() number_locations = len(valid_locations) - number_items = len([item for item in self.multiworld.itempool - if all(filler_group not in item_table[item.name].groups for filler_group in FILLER_GROUPS) and Group.TRAP not in item_table[item.name].groups and (item.classification & ItemClassification.progression)]) + number_items = get_real_item_count(self.multiworld) print(f"Stardew Valley - Minimum Locations: {number_locations}, Maximum Items: {number_items} [ISLAND INCLUDED]") self.assertGreaterEqual(number_locations, number_items) @@ -99,3 +104,5 @@ def test_allsanity_with_mods_has_at_least_locations(self): f"\n\tPlease update test_allsanity_with_mods_has_at_least_locations" f"\n\t\tExpected: {expected_locations}" f"\n\t\tActual: {number_locations}") + + diff --git a/worlds/stardew_valley/test/long/TestNumberLocationsLong.py b/worlds/stardew_valley/test/long/TestNumberLocationsLong.py new file mode 100644 index 000000000000..9a46547aca1a --- /dev/null +++ b/worlds/stardew_valley/test/long/TestNumberLocationsLong.py @@ -0,0 +1,62 @@ +import unittest + +from BaseClasses import ItemClassification +from ..assertion import get_all_location_names +from ..bases import skip_long_tests, SVTestCase, solo_multiworld +from ..options.presets import setting_mins_and_maxes, allsanity_no_mods_7_x_x, get_minsanity_options, default_7_x_x +from ...items import Group, item_table +from ...items.item_data import FILLER_GROUPS + +if skip_long_tests(): + raise unittest.SkipTest("Long tests disabled") + + +def get_real_item_count(multiworld): + number_items = len([item for item in multiworld.itempool + if all(filler_group not in item_table[item.name].groups for filler_group in FILLER_GROUPS) and Group.TRAP not in item_table[ + item.name].groups and (item.classification & ItemClassification.progression)]) + return number_items + + +class TestCountsPerSetting(SVTestCase): + + def test_items_locations_counts_per_setting_with_ginger_island(self): + option_mins_and_maxes = setting_mins_and_maxes() + + for name in option_mins_and_maxes: + values = option_mins_and_maxes[name] + if not isinstance(values, list): + continue + with self.subTest(f"{name}"): + highest_variance_items = -1 + highest_variance_locations = -1 + for preset in [allsanity_no_mods_7_x_x, default_7_x_x, get_minsanity_options]: + lowest_items = 9999 + lowest_locations = 9999 + highest_items = -1 + highest_locations = -1 + for value in values: + world_options = preset() + world_options[name] = value + with solo_multiworld(world_options, world_caching=False) as (multiworld, _): + num_locations = len([loc for loc in get_all_location_names(multiworld) if not loc.startswith("Traveling Merchant")]) + num_items = get_real_item_count(multiworld) + if num_items > highest_items: + highest_items = num_items + if num_items < lowest_items: + lowest_items = num_items + if num_locations > highest_locations: + highest_locations = num_locations + if num_locations < lowest_locations: + lowest_locations = num_locations + + variance_items = highest_items - lowest_items + variance_locations = highest_locations - lowest_locations + if variance_locations > highest_variance_locations: + highest_variance_locations = variance_locations + if variance_items > highest_variance_items: + highest_variance_items = variance_items + if highest_variance_locations > highest_variance_items: + print(f"Options `{name}` can create up to {highest_variance_locations - highest_variance_items} filler ({highest_variance_locations} locations and up to {highest_variance_items} items)") + if highest_variance_locations < highest_variance_items: + print(f"Options `{name}` can create up to {highest_variance_items - highest_variance_locations} orphan ({highest_variance_locations} locations and up to {highest_variance_items} items)") \ No newline at end of file diff --git a/worlds/stardew_valley/test/options/presets.py b/worlds/stardew_valley/test/options/presets.py index 92aab191dec9..71ad32bb2012 100644 --- a/worlds/stardew_valley/test/options/presets.py +++ b/worlds/stardew_valley/test/options/presets.py @@ -292,3 +292,48 @@ def minimal_locations_maximal_items_with_island(): min_max_options = minimal_locations_maximal_items() min_max_options.update({options.ExcludeGingerIsland.internal_name: options.ExcludeGingerIsland.option_false}) return min_max_options + + +def setting_mins_and_maxes(): + low_orphan_options = { + options.ArcadeMachineLocations.internal_name: [options.ArcadeMachineLocations.option_disabled, options.ArcadeMachineLocations.option_full_shuffling], + options.BackpackProgression.internal_name: [options.BackpackProgression.option_vanilla, options.BackpackProgression.option_progressive], + options.BackpackSize.internal_name: [options.BackpackSize.option_1, options.BackpackSize.option_12], + options.Booksanity.internal_name: [options.Booksanity.option_none, options.Booksanity.option_power_skill, options.Booksanity.option_power, options.Booksanity.option_all], + options.BuildingProgression.internal_name: options.BuildingProgression.option_vanilla_cheap, + options.BundlePerRoom.internal_name: [options.BundlePerRoom.option_two_fewer, options.BundlePerRoom.option_four_extra], + options.BundlePrice.internal_name: options.BundlePrice.option_normal, + options.BundleRandomization.internal_name: options.BundleRandomization.option_remixed, + options.Chefsanity.internal_name: [options.Chefsanity.preset_none, options.Chefsanity.preset_all], + options.Cooksanity.internal_name: [options.Cooksanity.option_none, options.Cooksanity.option_all], + options.Craftsanity.internal_name: [options.Craftsanity.option_none, options.Craftsanity.option_all], + options.Cropsanity.internal_name: [options.Cropsanity.option_disabled, options.Cropsanity.option_enabled], + options.Eatsanity.internal_name: [options.Eatsanity.preset_none, options.Eatsanity.preset_all], + options.ElevatorProgression.internal_name: [options.ElevatorProgression.option_vanilla, options.ElevatorProgression.option_progressive], + options.EnabledFillerBuffs.internal_name: options.EnabledFillerBuffs.preset_all, + options.EntranceRandomization.internal_name: options.EntranceRandomization.option_disabled, + options.ExcludeGingerIsland.internal_name: [options.ExcludeGingerIsland.option_false, options.ExcludeGingerIsland.option_true], + options.FarmType.internal_name: [options.FarmType.option_standard, options.FarmType.option_meadowlands], + options.FestivalLocations.internal_name: [options.FestivalLocations.option_disabled, options.FestivalLocations.option_hard], + options.Fishsanity.internal_name: [options.Fishsanity.option_none, options.Fishsanity.option_all], + options.Friendsanity.internal_name: [options.Friendsanity.option_none, options.Friendsanity.option_all_with_marriage], + options.FriendsanityHeartSize.internal_name: [1, 8], + options.Goal.internal_name: options.Goal.option_allsanity, + options.IncludeEndgameLocations.internal_name: [options.IncludeEndgameLocations.option_false, options.IncludeEndgameLocations.option_true], + options.Mods.internal_name: frozenset(), + options.Monstersanity.internal_name: [options.Monstersanity.option_none, options.Monstersanity.option_one_per_monster], + options.Moviesanity.internal_name: [options.Moviesanity.option_none, options.Moviesanity.option_all_movies_and_all_loved_snacks], + options.Museumsanity.internal_name: [options.Museumsanity.option_none, options.Museumsanity.option_all], + options.NumberOfMovementBuffs.internal_name: [0, 12], + options.QuestLocations.internal_name: [-1, 56], + options.SeasonRandomization.internal_name: [options.SeasonRandomization.option_disabled, options.SeasonRandomization.option_randomized_not_winter], + options.Secretsanity.internal_name: [options.Secretsanity.preset_none, options.Secretsanity.preset_all], + options.Shipsanity.internal_name: [options.Shipsanity.option_none, options.Shipsanity.option_everything], + options.SkillProgression.internal_name: [options.SkillProgression.option_vanilla, options.SkillProgression.option_progressive_with_masteries], + options.SpecialOrderLocations.internal_name: [options.SpecialOrderLocations.option_vanilla, options.SpecialOrderLocations.option_board_qi], + options.StartWithout.internal_name: [options.StartWithout.preset_none, options.StartWithout.preset_all], + options.ToolProgression.internal_name: [options.ToolProgression.option_vanilla, options.ToolProgression.option_progressive], + options.TrapDifficulty.internal_name: options.TrapDifficulty.option_medium, + options.Walnutsanity.internal_name: [options.Walnutsanity.preset_none, options.Walnutsanity.preset_all], + } + return low_orphan_options diff --git a/worlds/witness/player_logic.py b/worlds/witness/player_logic.py index aed6d3da66bb..b24434732ffc 100644 --- a/worlds/witness/player_logic.py +++ b/worlds/witness/player_logic.py @@ -527,7 +527,7 @@ def handle_panelhunt_postgame(self, world: "WitnessWorld") -> List[List[str]]: if chal_lasers > 7: postgame_adjustments.append([ "Requirement Changes:", - "0xFFF00 - 11 Lasers - True", + "0xFFF00 - 11 Lasers + Redirect - True", ]) if disable_challenge_lasers: @@ -640,7 +640,7 @@ def make_options_adjustments(self, world: "WitnessWorld") -> None: if chal_lasers <= 7 or mnt_lasers > 7: adjustment_linesets_in_order.append([ "Requirement Changes:", - "0xFFF00 - 11 Lasers - True", + "0xFFF00 - 11 Lasers + Redirect - True", ]) if world.options.disable_non_randomized_puzzles: diff --git a/worlds/witness/test/test_lasers.py b/worlds/witness/test/test_lasers.py index 5681757161e7..4a71c0d433be 100644 --- a/worlds/witness/test/test_lasers.py +++ b/worlds/witness/test/test_lasers.py @@ -216,3 +216,32 @@ def test_doors_to_elevator_paths(self) -> None: } self.assert_can_beat_with_minimally(exact_requirement) + + +class LongBoxNeedsAllLasersWhenBoxIsRotated(WitnessTestBase): + options = { + "puzzle_randomization": "sigma_expert", + "shuffle_symbols": True, + "shuffle_doors": "mixed", + "door_groupings": "off", + "shuffle_boat": True, + "shuffle_lasers": "anywhere", + "disable_non_randomized_puzzles": False, + "shuffle_discarded_panels": True, + "shuffle_vault_boxes": True, + "obelisk_keys": True, + "shuffle_EPs": "individual", + "EP_difficulty": "eclipse", + "shuffle_postgame": False, + "victory_condition": "elevator", + "mountain_lasers": 11, + "challenge_lasers": 11, + "early_caves": "off", + "elevators_come_to_you": {"Quarry Elevator"}, + } + + run_default_tests = False + + def test_long_box_needs_all_lasers_when_box_is_rotated(self): + long_box_location = self.world.get_location("Mountaintop Box Long Solved") + self.assert_dependency_on_event_item(long_box_location, "+1 Laser (Redirected)")