diff --git a/.github/pyright-config.json b/.github/pyright-config.json
index fba044da06..c5432dbf3c 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 862a050c51..79c4f983a4 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 772a6c0be3..8ed0c3523c 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 3abbb5f644..5751dce857 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 610f6d7477..1a39afa11d 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 0061dd15b0..231fb59dc5 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 1675c942bd..341735e5dd 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 7f81e57507..21e1a24b88 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 ac84207062..64f51af4a2 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 2ccdad8d11..4a876bf98e 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 b08b389005..cfffa6cc4a 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/Generate.py b/Generate.py
index c2f266c6b3..267107c96f 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 1d0ca8b086..118e19b02f 100644
--- a/Launcher.py
+++ b/Launcher.py
@@ -30,8 +30,8 @@ if __name__ == "__main__":
import settings
import Utils
-from Utils import (init_logging, is_frozen, is_linux, is_macos, is_windows, local_path, messagebox, open_filename,
- user_path, is_mobile)
+from Utils import (env_cleared_lib_path, init_logging, is_frozen, is_linux, is_macos, is_windows, local_path,
+ messagebox, open_filename, user_path, is_mobile)
if __name__ == "__main__":
init_logging('Launcher')
@@ -53,10 +53,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():
@@ -107,10 +104,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}")
@@ -203,22 +197,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:
@@ -431,12 +435,17 @@ def run_gui(launch_components: list[Component], args: Any) -> None:
@staticmethod
def component_action(button):
- MDSnackbar(MDSnackbarText(text="Opening in a new window..."), y=dp(24), pos_hint={"center_x": 0.5},
- size_hint_x=0.5).open()
+ 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 94ca8ba7ac..30833993e1 100644
--- a/OptionsCreator.py
+++ b/OptionsCreator.py
@@ -384,10 +384,11 @@ class OptionsCreator(ThemedApp):
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 4cc582f2c6..30d5173809 100644
--- a/Utils.py
+++ b/Utils.py
@@ -22,7 +22,7 @@ from datetime import datetime, timezone
from settings import Settings, get_settings
from time import sleep
-from typing import BinaryIO, Coroutine, Optional, Set, Dict, Any, Union, TypeGuard
+from typing import BinaryIO, Coroutine, Mapping, Optional, Set, Dict, Any, Union, TypeGuard
from yaml import load, load_all, dump
from pathspec import PathSpec, GitIgnoreSpec
from typing_extensions import deprecated
@@ -239,10 +239,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)
@@ -348,6 +345,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:
@@ -372,11 +372,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 {}
@@ -766,6 +761,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")
@@ -778,10 +786,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/requirements.txt b/WebHostLib/requirements.txt
index c9a923680a..fd194f2232 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/tutorialLanding.html b/WebHostLib/templates/tutorialLanding.html
index a96da883b6..ac7f24f95a 100644
--- a/WebHostLib/templates/tutorialLanding.html
+++ b/WebHostLib/templates/tutorialLanding.html
@@ -20,11 +20,7 @@
{% 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/rule builder.md b/docs/rule builder.md
index 4f9102a2ba..c3a8fcb6c4 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 4420778ac0..b633746d56 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@365aa9b
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 0000000000..1e5def6b44
--- /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 77a89c96c2..07c0607c1f 100644
--- a/rule_builder/rules.py
+++ b/rule_builder/rules.py
@@ -7,6 +7,7 @@ from typing_extensions import TypeVar, dataclass_transform, override
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 @@ class Rule(Generic[TWorld]):
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 @@ class Rule(Generic[TWorld]):
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"""
@@ -688,24 +704,24 @@ class Filtered(WrapperRule[TWorld], game="Archipelago"):
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 @@ class HasAny(Rule[TWorld], game="Archipelago"):
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 @@ class HasAllCounts(Rule[TWorld], game="Archipelago"):
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 @@ class HasAllCounts(Rule[TWorld], game="Archipelago"):
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 @@ class HasAnyCount(Rule[TWorld], game="Archipelago"):
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 @@ class HasFromList(Rule[TWorld], game="Archipelago"):
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 @@ class HasFromList(Rule[TWorld], game="Archipelago"):
@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 @@ class HasFromListUnique(Rule[TWorld], game="Archipelago"):
@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 @@ class HasFromListUnique(Rule[TWorld], game="Archipelago"):
@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 @@ class HasGroup(Rule[TWorld], game="Archipelago"):
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 @@ class HasGroupUnique(Rule[TWorld], game="Archipelago"):
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 ebef3880fc..3c40eab59e 100644
--- a/setup.py
+++ b/setup.py
@@ -657,7 +657,7 @@ cx_Freeze.setup(
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_rule_builder.py b/test/general/test_rule_builder.py
index 81003dcd87..85e239175d 100644
--- a/test/general/test_rule_builder.py
+++ b/test/general/test_rule_builder.py
@@ -6,8 +6,9 @@ from typing_extensions import override
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"
@@ -659,14 +668,15 @@ class TestRules(RuleBuilderTestCase):
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))
@@ -763,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)],
@@ -771,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,
),
@@ -811,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",
@@ -848,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",
@@ -923,7 +945,7 @@ class TestSerialization(RuleBuilderTestCase):
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):
@@ -1342,3 +1364,32 @@ class TestExplain(RuleBuilderTestCase):
"& 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 8a96da2e63..8eccc7e5f3 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/custom_views.py b/worlds/apquest/client/custom_views.py
index 4c1d28c732..026aa1fc8d 100644
--- a/worlds/apquest/client/custom_views.py
+++ b/worlds/apquest/client/custom_views.py
@@ -77,7 +77,7 @@ class APQuestGrid(GridLayout):
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
diff --git a/worlds/apquest/client/graphics.py b/worlds/apquest/client/graphics.py
index 535bf84ee8..0e31218c6f 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 f4c4ab769d..1e1999bff2 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 @@ class SoundManager:
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 @@ class SoundManager:
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 @@ class SoundManager:
# 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 @@ class SoundManager:
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/aquaria/Items.py b/worlds/aquaria/Items.py
index 3365c1fa59..b510b24738 100644
--- a/worlds/aquaria/Items.py
+++ b/worlds/aquaria/Items.py
@@ -271,7 +271,7 @@ item_table = {
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 @@ four_gods_excludes = [ItemNames.ANEMONE, ItemNames.ARNASSI_STATUE, ItemNames.BIG
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 781d06e09f..b32f9119ec 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 2997f21d04..395d349154 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 ceb758669d..4483cf0238 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/requirements.txt b/worlds/factorio/requirements.txt
index 8d68440166..b2b3804a50 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/docs/mac_en.md b/worlds/generic/docs/mac_en.md
index 4db48d2abd..eaaeb13c15 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/lufia2ac/__init__.py b/worlds/lufia2ac/__init__.py
index 96de24a4b6..d5b104dde4 100644
--- a/worlds/lufia2ac/__init__.py
+++ b/worlds/lufia2ac/__init__.py
@@ -5,11 +5,11 @@ from enum import IntFlag
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 54685f91a0..4ccb2eb4c9 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 7f17232cfb..bc1fc6aa99 100644
--- a/worlds/messenger/rules.py
+++ b/worlds/messenger/rules.py
@@ -192,7 +192,7 @@ class MessengerRules:
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/musedash/MuseDashCollection.py b/worlds/musedash/MuseDashCollection.py
index 4c23a5c64f..472b4c95a1 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 86486a4929..6cb294bd98 100644
--- a/worlds/musedash/MuseDashData.py
+++ b/worlds/musedash/MuseDashData.py
@@ -696,11 +696,20 @@ SONG_DATA: Dict[str, SongData] = {
"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 239d640e68..55767cf04b 100644
--- a/worlds/musedash/__init__.py
+++ b/worlds/musedash/__init__.py
@@ -124,7 +124,8 @@ class MuseDashWorld(World):
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 dea7846b4f..0580d85e77 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 27798243a5..f41e807171 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 @@ class DifficultyRanges(MuseDashTestBase):
# 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/__init__.py b/worlds/pokemon_emerald/__init__.py
index 10abed539f..fb683df90d 100644
--- a/worlds/pokemon_emerald/__init__.py
+++ b/worlds/pokemon_emerald/__init__.py
@@ -263,6 +263,14 @@ class PokemonEmeraldWorld(World):
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/pokemon.py b/worlds/pokemon_emerald/pokemon.py
index 73af6c4658..76285d11da 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 eeadb8bea2..30ebf72e4d 100644
--- a/worlds/pokemon_emerald/rules.py
+++ b/worlds/pokemon_emerald/rules.py
@@ -1559,7 +1559,7 @@ def set_rules(world: "PokemonEmeraldWorld") -> None:
# 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 fc5c6f2edf..06c56eabe5 100644
--- a/worlds/satisfactory/__init__.py
+++ b/worlds/satisfactory/__init__.py
@@ -88,16 +88,19 @@ class SatisfactoryWorld(World):
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 resource_sink_goal:
- required_parts.union(self.game_logic.buildings["AWESOME Sink"].inputs)
+ if "Space Elevator Phase" in self.options.goal_selection:
+ required_buildings.add("Space Elevator")
+
+ 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 @@ class SatisfactoryWorld(World):
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 d6f9f6cda5..e70c15d28f 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 318d52f856..ddc188bad9 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 0ffa08e010..abecf8264c 100644
--- a/worlds/sc2/pool_filter.py
+++ b/worlds/sc2/pool_filter.py
@@ -182,7 +182,7 @@ class ValidInventory:
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 @@ class ValidInventory:
# 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 @@ class ValidInventory:
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 @@ class ValidInventory:
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 @@ class ValidInventory:
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 @@ class ValidInventory:
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 @@ class ValidInventory:
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 4b02d294d1..299fcde3db 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 28a8804e5e..8d55c6a4e5 100644
--- a/worlds/sc2/rules.py
+++ b/worlds/sc2/rules.py
@@ -1660,11 +1660,11 @@ class SC2Logic:
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 0000000000..90b6e7a982
--- /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 f0f778dc79..f6aaaddaba 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 BaseClasses import MultiWorld, CollectionState, PlandoOptions
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 @@ class Sc2SetupTestBase(unittest.TestCase):
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 329cd593e1..708606f7bc 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 @@ class TestItemFiltering(Sc2SetupTestBase):
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 @@ class TestItemFiltering(Sc2SetupTestBase):
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 @@ class TestItemFiltering(Sc2SetupTestBase):
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 @@ class TestItemFiltering(Sc2SetupTestBase):
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 @@ class TestItemFiltering(Sc2SetupTestBase):
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 @@ class TestItemFiltering(Sc2SetupTestBase):
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 @@ class TestItemFiltering(Sc2SetupTestBase):
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 @@ class TestItemFiltering(Sc2SetupTestBase):
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 @@ class TestItemFiltering(Sc2SetupTestBase):
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 @@ class TestItemFiltering(Sc2SetupTestBase):
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 @@ class TestItemFiltering(Sc2SetupTestBase):
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 @@ class TestItemFiltering(Sc2SetupTestBase):
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 @@ class TestItemFiltering(Sc2SetupTestBase):
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 @@ class TestItemFiltering(Sc2SetupTestBase):
# 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 @@ class TestItemFiltering(Sc2SetupTestBase):
# 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 @@ class TestItemFiltering(Sc2SetupTestBase):
# 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 @@ class TestItemFiltering(Sc2SetupTestBase):
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 @@ class TestItemFiltering(Sc2SetupTestBase):
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 @@ class TestItemFiltering(Sc2SetupTestBase):
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 @@ class TestItemFiltering(Sc2SetupTestBase):
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 @@ class TestItemFiltering(Sc2SetupTestBase):
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 @@ class TestItemFiltering(Sc2SetupTestBase):
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 880a02f973..5d9870d894 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 @@ class TestGridsizes(unittest.TestCase):
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 85524eb7ad..ef576a8738 100644
--- a/worlds/smw/Client.py
+++ b/worlds/smw/Client.py
@@ -132,7 +132,7 @@ class SMWSNIClient(SNIClient):
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 c3abdca129..a9b7f8c7da 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 @@ algorerhythm_bundle = BundleTemplate(CCRoom.bulletin_board, MemeBundleName.algor
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 9c6cffad51..8c4a521d77 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 613698ac1b..a817022e3d 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 .mods.mod_data import ModNames
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 37260d1494..47b51ce7a6 100644
--- a/worlds/stardew_valley/logic/logic.py
+++ b/worlds/stardew_valley/logic/logic.py
@@ -297,7 +297,6 @@ class StardewLogic(ReceivedLogicMixin, HasLogicMixin, RegionLogicMixin, Travelin
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 @@ class StardewLogic(ReceivedLogicMixin, HasLogicMixin, RegionLogicMixin, Travelin
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 b21488733b..50b158d282 100644
--- a/worlds/stardew_valley/test/TestNumberLocations.py
+++ b/worlds/stardew_valley/test/TestNumberLocations.py
@@ -7,6 +7,13 @@ from ..items import Group, item_table
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 @@ class TestAllSanityWithModsSettingsHasAllExpectedLocations(SVTestBase):
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 0000000000..9a46547aca
--- /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 92aab191de..71ad32bb20 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 aed6d3da66..b24434732f 100644
--- a/worlds/witness/player_logic.py
+++ b/worlds/witness/player_logic.py
@@ -527,7 +527,7 @@ class WitnessPlayerLogic:
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 @@ class WitnessPlayerLogic:
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 5681757161..4a71c0d433 100644
--- a/worlds/witness/test/test_lasers.py
+++ b/worlds/witness/test/test_lasers.py
@@ -216,3 +216,32 @@ class TestDoorsRequiredToWinElevator(WitnessTestBase):
}
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)")