Compare commits

..

1 Commits

Author SHA1 Message Date
Berserker
1529c8938c WebHost: add document for other games and tools, wiki etc. 2026-03-15 23:32:31 +01:00
89 changed files with 518 additions and 1448 deletions

View File

@@ -3,7 +3,6 @@
"../BizHawkClient.py", "../BizHawkClient.py",
"../Patch.py", "../Patch.py",
"../rule_builder/cached_world.py", "../rule_builder/cached_world.py",
"../rule_builder/field_resolvers.py",
"../rule_builder/options.py", "../rule_builder/options.py",
"../rule_builder/rules.py", "../rule_builder/rules.py",
"../test/param.py", "../test/param.py",

View File

@@ -14,8 +14,6 @@ env:
BEFORE: ${{ github.event.before }} BEFORE: ${{ github.event.before }}
AFTER: ${{ github.event.after }} AFTER: ${{ github.event.after }}
permissions: {}
jobs: jobs:
flake8-or-mypy: flake8-or-mypy:
strategy: strategy:
@@ -27,7 +25,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v6.0.2 - uses: actions/checkout@v4
- name: "Determine modified files (pull_request)" - name: "Determine modified files (pull_request)"
if: github.event_name == 'pull_request' if: github.event_name == 'pull_request'
@@ -52,7 +50,7 @@ jobs:
run: | run: |
echo "diff=." >> $GITHUB_ENV echo "diff=." >> $GITHUB_ENV
- uses: actions/setup-python@v6.2.0 - uses: actions/setup-python@v5
if: env.diff != '' if: env.diff != ''
with: with:
python-version: '3.11' python-version: '3.11'

View File

@@ -41,9 +41,9 @@ jobs:
runs-on: windows-latest runs-on: windows-latest
steps: steps:
# - copy code below to release.yml - # - copy code below to release.yml -
- uses: actions/checkout@v6.0.2 - uses: actions/checkout@v4
- name: Install python - name: Install python
uses: actions/setup-python@v6.2.0 uses: actions/setup-python@v5
with: with:
python-version: '~3.12.7' python-version: '~3.12.7'
check-latest: true check-latest: true
@@ -82,7 +82,7 @@ jobs:
# - copy code above to release.yml - # - copy code above to release.yml -
- name: Attest Build - name: Attest Build
if: ${{ github.event_name == 'workflow_dispatch' }} if: ${{ github.event_name == 'workflow_dispatch' }}
uses: actions/attest@v4.1.0 uses: actions/attest-build-provenance@v2
with: with:
subject-path: | subject-path: |
build/exe.*/ArchipelagoLauncher.exe build/exe.*/ArchipelagoLauncher.exe
@@ -110,17 +110,18 @@ jobs:
cp Players/Templates/VVVVVV.yaml Players/ cp Players/Templates/VVVVVV.yaml Players/
timeout 30 ./ArchipelagoGenerate timeout 30 ./ArchipelagoGenerate
- name: Store 7z - name: Store 7z
uses: actions/upload-artifact@v7.0.0 uses: actions/upload-artifact@v4
with: with:
name: ${{ env.ZIP_NAME }}
path: dist/${{ env.ZIP_NAME }} path: dist/${{ env.ZIP_NAME }}
archive: false compression-level: 0 # .7z is incompressible by zip
if-no-files-found: error if-no-files-found: error
retention-days: 7 # keep for 7 days, should be enough retention-days: 7 # keep for 7 days, should be enough
- name: Store Setup - name: Store Setup
uses: actions/upload-artifact@v7.0.0 uses: actions/upload-artifact@v4
with: with:
name: ${{ env.SETUP_NAME }}
path: setups/${{ env.SETUP_NAME }} path: setups/${{ env.SETUP_NAME }}
archive: false
if-no-files-found: error if-no-files-found: error
retention-days: 7 # keep for 7 days, should be enough retention-days: 7 # keep for 7 days, should be enough
@@ -128,14 +129,14 @@ jobs:
runs-on: ubuntu-22.04 runs-on: ubuntu-22.04
steps: steps:
# - copy code below to release.yml - # - copy code below to release.yml -
- uses: actions/checkout@v6.0.2 - uses: actions/checkout@v4
- name: Install base dependencies - name: Install base dependencies
run: | run: |
sudo apt update sudo apt update
sudo apt -y install build-essential p7zip xz-utils wget libglib2.0-0 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 sudo apt -y install python3-gi libgirepository1.0-dev # should pull dependencies for gi installation below
- name: Get a recent python - name: Get a recent python
uses: actions/setup-python@v6.2.0 uses: actions/setup-python@v5
with: with:
python-version: '~3.12.7' python-version: '~3.12.7'
check-latest: true check-latest: true
@@ -172,7 +173,7 @@ jobs:
# - copy code above to release.yml - # - copy code above to release.yml -
- name: Attest Build - name: Attest Build
if: ${{ github.event_name == 'workflow_dispatch' }} if: ${{ github.event_name == 'workflow_dispatch' }}
uses: actions/attest@v4.1.0 uses: actions/attest-build-provenance@v2
with: with:
subject-path: | subject-path: |
build/exe.*/ArchipelagoLauncher build/exe.*/ArchipelagoLauncher
@@ -203,17 +204,17 @@ jobs:
cp Players/Templates/VVVVVV.yaml Players/ cp Players/Templates/VVVVVV.yaml Players/
timeout 30 ./ArchipelagoGenerate timeout 30 ./ArchipelagoGenerate
- name: Store AppImage - name: Store AppImage
uses: actions/upload-artifact@v7.0.0 uses: actions/upload-artifact@v4
with: with:
name: ${{ env.APPIMAGE_NAME }}
path: dist/${{ 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 if-no-files-found: error
retention-days: 7 retention-days: 7
- name: Store .tar.gz - name: Store .tar.gz
uses: actions/upload-artifact@v7.0.0 uses: actions/upload-artifact@v4
with: with:
name: ${{ env.TAR_NAME }}
path: dist/${{ env.TAR_NAME }} path: dist/${{ env.TAR_NAME }}
archive: false compression-level: 0 # .gz is incompressible by zip
if-no-files-found: error if-no-files-found: error
retention-days: 7 retention-days: 7

View File

@@ -17,26 +17,17 @@ on:
paths: paths:
- '**.py' - '**.py'
- '**.js' - '**.js'
- '.github/workflows/*.yml' - '.github/workflows/codeql-analysis.yml'
- '.github/workflows/*.yaml'
- '**/action.yml'
- '**/action.yaml'
pull_request: pull_request:
# The branches below must be a subset of the branches above # The branches below must be a subset of the branches above
branches: [ main ] branches: [ main ]
paths: paths:
- '**.py' - '**.py'
- '**.js' - '**.js'
- '.github/workflows/*.yml' - '.github/workflows/codeql-analysis.yml'
- '.github/workflows/*.yaml'
- '**/action.yml'
- '**/action.yaml'
schedule: schedule:
- cron: '44 8 * * 1' - cron: '44 8 * * 1'
permissions:
security-events: write
jobs: jobs:
analyze: analyze:
name: Analyze name: Analyze
@@ -45,17 +36,18 @@ jobs:
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
language: [ 'javascript', 'python', 'actions' ] language: [ 'javascript', 'python' ]
# CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ]
# Learn more: # 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 # 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: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v6.0.2 uses: actions/checkout@v4
# Initializes the CodeQL tools for scanning. # Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL - name: Initialize CodeQL
uses: github/codeql-action/init@v4.35.1 uses: github/codeql-action/init@v3
with: with:
languages: ${{ matrix.language }} languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file. # If you wish to specify custom queries, you can do so here or in a config file.
@@ -66,7 +58,7 @@ jobs:
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java). # 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) # If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild - name: Autobuild
uses: github/codeql-action/autobuild@v4.35.1 uses: github/codeql-action/autobuild@v3
# Command-line programs to run using the OS shell. # Command-line programs to run using the OS shell.
# 📚 https://git.io/JvXDl # 📚 https://git.io/JvXDl
@@ -80,4 +72,4 @@ jobs:
# make release # make release
- name: Perform CodeQL Analysis - name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v4.35.1 uses: github/codeql-action/analyze@v3

View File

@@ -24,8 +24,6 @@ on:
- '**/CMakeLists.txt' - '**/CMakeLists.txt'
- '.github/workflows/ctest.yml' - '.github/workflows/ctest.yml'
permissions: {}
jobs: jobs:
ctest: ctest:
runs-on: ${{ matrix.os }} runs-on: ${{ matrix.os }}
@@ -37,7 +35,7 @@ jobs:
os: [ubuntu-latest, windows-latest] os: [ubuntu-latest, windows-latest]
steps: steps:
- uses: actions/checkout@v6.0.2 - uses: actions/checkout@v4
- uses: ilammy/msvc-dev-cmd@0b201ec74fa43914dc39ae48a89fd1d8cb592756 - uses: ilammy/msvc-dev-cmd@0b201ec74fa43914dc39ae48a89fd1d8cb592756
if: startsWith(matrix.os,'windows') if: startsWith(matrix.os,'windows')
- uses: Bacondish2023/setup-googletest@49065d1f7a6d21f6134864dd65980fe5dbe06c73 - uses: Bacondish2023/setup-googletest@49065d1f7a6d21f6134864dd65980fe5dbe06c73

View File

@@ -19,8 +19,6 @@ on:
env: env:
REGISTRY: ghcr.io REGISTRY: ghcr.io
permissions: {}
jobs: jobs:
prepare: prepare:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -31,7 +29,7 @@ jobs:
package-name: ${{ steps.package.outputs.name }} package-name: ${{ steps.package.outputs.name }}
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v6.0.2 uses: actions/checkout@v4
- name: Set lowercase image name - name: Set lowercase image name
id: image id: image
@@ -45,7 +43,7 @@ jobs:
- name: Extract metadata - name: Extract metadata
id: meta id: meta
uses: docker/metadata-action@v6.0.0 uses: docker/metadata-action@v5
with: with:
images: ${{ env.REGISTRY }}/${{ steps.image.outputs.name }} images: ${{ env.REGISTRY }}/${{ steps.image.outputs.name }}
tags: | tags: |
@@ -94,13 +92,13 @@ jobs:
cache-scope: arm64 cache-scope: arm64
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v6.0.2 uses: actions/checkout@v4
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 uses: docker/setup-buildx-action@v3
- name: Log in to GitHub Container Registry - name: Log in to GitHub Container Registry
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0 uses: docker/login-action@v3
with: with:
registry: ${{ env.REGISTRY }} registry: ${{ env.REGISTRY }}
username: ${{ github.actor }} username: ${{ github.actor }}
@@ -117,7 +115,7 @@ jobs:
echo "tags=$(IFS=','; echo "${suffixed[*]}")" >> $GITHUB_OUTPUT echo "tags=$(IFS=','; echo "${suffixed[*]}")" >> $GITHUB_OUTPUT
- name: Build and push Docker image - name: Build and push Docker image
uses: docker/build-push-action@v7.0.0 uses: docker/build-push-action@v5
with: with:
context: . context: .
file: ./Dockerfile file: ./Dockerfile
@@ -137,7 +135,7 @@ jobs:
packages: write packages: write
steps: steps:
- name: Log in to GitHub Container Registry - name: Log in to GitHub Container Registry
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0 uses: docker/login-action@v3
with: with:
registry: ${{ env.REGISTRY }} registry: ${{ env.REGISTRY }}
username: ${{ github.actor }} username: ${{ github.actor }}

View File

@@ -14,7 +14,7 @@ jobs:
name: 'Apply content-based labels' name: 'Apply content-based labels'
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/labeler@v6.0.1 - uses: actions/labeler@v5
with: with:
sync-labels: false sync-labels: false
peer_review: peer_review:

View File

@@ -48,9 +48,9 @@ jobs:
shell: bash shell: bash
run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV
# - code below copied from build.yml - # - code below copied from build.yml -
- uses: actions/checkout@v6.0.2 - uses: actions/checkout@v4
- name: Install python - name: Install python
uses: actions/setup-python@v6.2.0 uses: actions/setup-python@v5
with: with:
python-version: '~3.12.7' python-version: '~3.12.7'
check-latest: true check-latest: true
@@ -88,7 +88,7 @@ jobs:
echo "SETUP_NAME=$SETUP_NAME" >> $Env:GITHUB_ENV echo "SETUP_NAME=$SETUP_NAME" >> $Env:GITHUB_ENV
# - code above copied from build.yml - # - code above copied from build.yml -
- name: Attest Build - name: Attest Build
uses: actions/attest@v4.1.0 uses: actions/attest-build-provenance@v2
with: with:
subject-path: | subject-path: |
build/exe.*/ArchipelagoLauncher.exe build/exe.*/ArchipelagoLauncher.exe
@@ -114,14 +114,14 @@ jobs:
- name: Set env - name: Set env
run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV
# - code below copied from build.yml - # - code below copied from build.yml -
- uses: actions/checkout@v6.0.2 - uses: actions/checkout@v4
- name: Install base dependencies - name: Install base dependencies
run: | run: |
sudo apt update sudo apt update
sudo apt -y install build-essential p7zip xz-utils wget libglib2.0-0 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 sudo apt -y install python3-gi libgirepository1.0-dev # should pull dependencies for gi installation below
- name: Get a recent python - name: Get a recent python
uses: actions/setup-python@v6.2.0 uses: actions/setup-python@v5
with: with:
python-version: '~3.12.7' python-version: '~3.12.7'
check-latest: true check-latest: true
@@ -157,7 +157,7 @@ jobs:
echo "TAR_NAME=$TAR_NAME" >> $GITHUB_ENV echo "TAR_NAME=$TAR_NAME" >> $GITHUB_ENV
# - code above copied from build.yml - # - code above copied from build.yml -
- name: Attest Build - name: Attest Build
uses: actions/attest@v4.1.0 uses: actions/attest-build-provenance@v2
with: with:
subject-path: | subject-path: |
build/exe.*/ArchipelagoLauncher build/exe.*/ArchipelagoLauncher

View File

@@ -28,14 +28,12 @@ on:
- 'requirements.txt' - 'requirements.txt'
- '.github/workflows/scan-build.yml' - '.github/workflows/scan-build.yml'
permissions: {}
jobs: jobs:
scan-build: scan-build:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v6.0.2 - uses: actions/checkout@v4
with: with:
submodules: recursive submodules: recursive
- name: Install newer Clang - name: Install newer Clang
@@ -47,7 +45,7 @@ jobs:
run: | run: |
sudo apt install clang-tools-19 sudo apt install clang-tools-19
- name: Get a recent python - name: Get a recent python
uses: actions/setup-python@v6.2.0 uses: actions/setup-python@v5
with: with:
python-version: '3.11' python-version: '3.11'
- name: Install dependencies - name: Install dependencies
@@ -61,9 +59,7 @@ jobs:
scan-build-19 --status-bugs -o scan-build-reports -disable-checker deadcode.DeadStores python setup.py build -y scan-build-19 --status-bugs -o scan-build-reports -disable-checker deadcode.DeadStores python setup.py build -y
- name: Store report - name: Store report
if: failure() if: failure()
uses: actions/upload-artifact@v7.0.0 uses: actions/upload-artifact@v4
with: with:
name: scan-build-reports name: scan-build-reports
path: scan-build-reports path: scan-build-reports
compression-level: 9 # highly compressible
if-no-files-found: error

View File

@@ -14,15 +14,13 @@ on:
- ".github/workflows/strict-type-check.yml" - ".github/workflows/strict-type-check.yml"
- "**.pyi" - "**.pyi"
permissions: {}
jobs: jobs:
pyright: pyright:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v6.0.2 - uses: actions/checkout@v4
- uses: actions/setup-python@v6.2.0 - uses: actions/setup-python@v5
with: with:
python-version: "3.11" python-version: "3.11"

View File

@@ -29,8 +29,6 @@ on:
- '!.github/workflows/**' - '!.github/workflows/**'
- '.github/workflows/unittests.yml' - '.github/workflows/unittests.yml'
permissions: {}
jobs: jobs:
unit: unit:
runs-on: ${{ matrix.os }} runs-on: ${{ matrix.os }}
@@ -53,9 +51,9 @@ jobs:
os: macos-latest os: macos-latest
steps: steps:
- uses: actions/checkout@v6.0.2 - uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python.version }} - name: Set up Python ${{ matrix.python.version }}
uses: actions/setup-python@v6.2.0 uses: actions/setup-python@v5
with: with:
python-version: ${{ matrix.python.version }} python-version: ${{ matrix.python.version }}
- name: Install dependencies - name: Install dependencies
@@ -80,9 +78,9 @@ jobs:
- {version: '3.13'} # current - {version: '3.13'} # current
steps: steps:
- uses: actions/checkout@v6.0.2 - uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python.version }} - name: Set up Python ${{ matrix.python.version }}
uses: actions/setup-python@v6.2.0 uses: actions/setup-python@v5
with: with:
python-version: ${{ matrix.python.version }} python-version: ${{ matrix.python.version }}
- name: Install dependencies - name: Install dependencies

View File

@@ -87,7 +87,6 @@ def main(args=None) -> tuple[argparse.Namespace, int]:
seed = get_seed(args.seed) seed = get_seed(args.seed)
if __name__ == "__main__":
Utils.init_logging(f"Generate_{seed}", loglevel=args.log_level, add_timestamp=args.log_time) Utils.init_logging(f"Generate_{seed}", loglevel=args.log_level, add_timestamp=args.log_time)
random.seed(seed) random.seed(seed)
seed_name = get_seed_name(random) seed_name = get_seed_name(random)

View File

@@ -29,8 +29,8 @@ if __name__ == "__main__":
import settings import settings
import Utils import Utils
from Utils import (env_cleared_lib_path, init_logging, is_frozen, is_linux, is_macos, is_windows, local_path, from Utils import (init_logging, is_frozen, is_linux, is_macos, is_windows, local_path, messagebox, open_filename,
messagebox, open_filename, user_path) user_path)
if __name__ == "__main__": if __name__ == "__main__":
init_logging('Launcher') init_logging('Launcher')
@@ -52,7 +52,10 @@ def open_host_yaml():
webbrowser.open(file) webbrowser.open(file)
return return
env = env_cleared_lib_path() 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
subprocess.Popen([exe, file], env=env) subprocess.Popen([exe, file], env=env)
def open_patch(): def open_patch():
@@ -103,7 +106,10 @@ def open_folder(folder_path):
return return
if exe: if exe:
env = env_cleared_lib_path() 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
subprocess.Popen([exe, folder_path], env=env) subprocess.Popen([exe, folder_path], env=env)
else: else:
logging.warning(f"No file browser available to open {folder_path}") logging.warning(f"No file browser available to open {folder_path}")
@@ -196,32 +202,22 @@ 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 return [sys.executable, local_path(f"{component.script_name}.py")] if component.script_name else None
def launch(exe: Sequence[str], in_terminal: bool = False) -> bool: def launch(exe, in_terminal=False):
"""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 in_terminal:
if is_windows: if is_windows:
# intentionally using a window title with a space so it gets quoted and treated as a title # 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) subprocess.Popen(["start", "Running Archipelago", *exe], shell=True)
return True return
elif is_linux: elif is_linux:
terminal = which("x-terminal-emulator") or which("konsole") or which("gnome-terminal") or which("xterm") terminal = which('x-terminal-emulator') or which('gnome-terminal') or which('xterm')
if terminal: if terminal:
# Clear LD_LIB_PATH during terminal startup, but set it again when running command in case it's needed subprocess.Popen([terminal, '-e', shlex.join(exe)])
ld_lib_path = os.environ.get("LD_LIBRARY_PATH") return
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: elif is_macos:
terminal = [which("open"), "-W", "-a", "Terminal.app"] terminal = [which('open'), '-W', '-a', 'Terminal.app']
subprocess.Popen([*terminal, *exe]) subprocess.Popen([*terminal, *exe])
return True return
subprocess.Popen(exe) subprocess.Popen(exe)
return False
def create_shortcut(button: Any, component: Component) -> None: def create_shortcut(button: Any, component: Component) -> None:
@@ -410,17 +406,12 @@ def run_gui(launch_components: list[Component], args: Any) -> None:
@staticmethod @staticmethod
def component_action(button): def component_action(button):
open_text = "Opening in a new window..." MDSnackbar(MDSnackbarText(text="Opening in a new window..."), y=dp(24), pos_hint={"center_x": 0.5},
size_hint_x=0.5).open()
if button.component.func: 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() button.component.func()
else: else:
# if launch returns False, it started the process in background (not in a new terminal) launch(get_exe(button.component), button.component.cli)
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: 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. """ """ When a patch file is dropped into the window, run the associated component. """

View File

@@ -384,11 +384,10 @@ class OptionsCreator(ThemedApp):
def create_free_text(self, option: typing.Type[FreeText] | typing.Type[TextChoice], name: str): def create_free_text(self, option: typing.Type[FreeText] | typing.Type[TextChoice], name: str):
text = VisualFreeText(option=option, name=name) text = VisualFreeText(option=option, name=name)
def set_value(instance, value): def set_value(instance):
self.options[name] = value self.options[name] = instance.text
text.bind(text=set_value) text.bind(on_text_validate=set_value)
self.options[name] = option.default
return text return text
def create_choice(self, option: typing.Type[Choice], name: str): def create_choice(self, option: typing.Type[Choice], name: str):

View File

@@ -22,7 +22,7 @@ from datetime import datetime, timezone
from settings import Settings, get_settings from settings import Settings, get_settings
from time import sleep from time import sleep
from typing import BinaryIO, Coroutine, Mapping, Optional, Set, Dict, Any, Union, TypeGuard from typing import BinaryIO, Coroutine, Optional, Set, Dict, Any, Union, TypeGuard
from yaml import load, load_all, dump from yaml import load, load_all, dump
from pathspec import PathSpec, GitIgnoreSpec from pathspec import PathSpec, GitIgnoreSpec
from typing_extensions import deprecated from typing_extensions import deprecated
@@ -236,7 +236,10 @@ 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")) 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." assert open_command, "Didn't find program for open_file! Please report this together with system details."
env = env_cleared_lib_path() 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
subprocess.call([open_command, filename], env=env) subprocess.call([open_command, filename], env=env)
@@ -342,9 +345,6 @@ def persistent_load() -> Dict[str, Dict[str, Any]]:
try: try:
with open(path, "r") as f: with open(path, "r") as f:
storage = unsafe_parse_yaml(f.read()) 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: except Exception as e:
logging.debug(f"Could not read store: {e}") logging.debug(f"Could not read store: {e}")
if storage is None: if storage is None:
@@ -369,6 +369,11 @@ def load_data_package_for_checksum(game: str, checksum: typing.Optional[str]) ->
except Exception as e: except Exception as e:
logging.debug(f"Could not load data package: {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 # cache does not match
return {} return {}
@@ -753,19 +758,6 @@ def is_kivy_running() -> bool:
return False 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: def _mp_open_filename(res: "multiprocessing.Queue[typing.Optional[str]]", *args: Any) -> None:
if is_kivy_running(): if is_kivy_running():
raise RuntimeError("kivy should not be running in multiprocess") raise RuntimeError("kivy should not be running in multiprocess")
@@ -778,7 +770,10 @@ def _mp_save_filename(res: "multiprocessing.Queue[typing.Optional[str]]", *args:
res.put(save_filename(*args)) res.put(save_filename(*args))
def _run_for_stdout(*args: str): def _run_for_stdout(*args: str):
env = env_cleared_lib_path() 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
return subprocess.run(args, capture_output=True, text=True, env=env).stdout.split("\n", 1)[0] or None return subprocess.run(args, capture_output=True, text=True, env=env).stdout.split("\n", 1)[0] or None

View File

@@ -110,14 +110,13 @@ if __name__ == "__main__":
logging.exception(e) logging.exception(e)
logging.warning("Could not update LttP sprites.") logging.warning("Could not update LttP sprites.")
app = get_app() app = get_app()
from worlds import AutoWorldRegister, network_data_package from worlds import AutoWorldRegister
# Update to only valid WebHost worlds # Update to only valid WebHost worlds
invalid_worlds = {name for name, world in AutoWorldRegister.world_types.items() invalid_worlds = {name for name, world in AutoWorldRegister.world_types.items()
if not hasattr(world.web, "tutorials")} if not hasattr(world.web, "tutorials")}
if invalid_worlds: if invalid_worlds:
logging.error(f"Following worlds not loaded as they are invalid for WebHost: {invalid_worlds}") logging.error(f"Following worlds not loaded as they are invalid for WebHost: {invalid_worlds}")
AutoWorldRegister.world_types = {k: v for k, v in AutoWorldRegister.world_types.items() if k not in invalid_worlds} AutoWorldRegister.world_types = {k: v for k, v in AutoWorldRegister.world_types.items() if k not in invalid_worlds}
network_data_package["games"] = {k: v for k, v in network_data_package["games"].items() if k not in invalid_worlds}
create_options_files() create_options_files()
copy_tutorials_files_to_static() copy_tutorials_files_to_static()
if app.config["SELFLAUNCH"]: if app.config["SELFLAUNCH"]:

View File

@@ -1,14 +1,14 @@
flask==3.1.3 flask>=3.1.1
werkzeug==3.1.6 werkzeug>=3.1.3
pony==0.7.19; python_version <= '3.12' pony>=0.7.19; python_version <= '3.12'
pony @ git+https://github.com/black-sliver/pony@7feb1221953b7fa4a6735466bf21a8b4d35e33ba#0.7.19; python_version >= '3.13' pony @ git+https://github.com/black-sliver/pony@7feb1221953b7fa4a6735466bf21a8b4d35e33ba#0.7.19; python_version >= '3.13'
waitress==3.0.2 waitress>=3.0.2
Flask-Caching==2.3.1 Flask-Caching>=2.3.0
Flask-Compress==1.18 # pkg_resources can't resolve the "backports.zstd" dependency of >1.18, breaking ModuleUpdate.py Flask-Compress==1.18 # pkg_resources can't resolve the "backports.zstd" dependency of >1.18, breaking ModuleUpdate.py
Flask-Limiter==4.1.1 Flask-Limiter>=3.12
Flask-Cors==6.0.2 Flask-Cors>=6.0.2
bokeh==3.8.2 bokeh>=3.6.3
markupsafe==3.0.3 markupsafe>=3.0.2
setproctitle==1.3.7 setproctitle>=1.3.5
mistune==3.2.0 mistune>=3.1.3
docutils==0.22.4 docutils>=0.22.2

View File

@@ -33,9 +33,7 @@
<h1>Currently Supported Games</h1> <h1>Currently Supported Games</h1>
<p>Below are the games that are currently included with the Archipelago software. To play a game that is not on <p>Below are the games that are currently included with the Archipelago software. To play a game that is not on
this page, please refer to the <a href="/tutorial/Archipelago/setup/en#playing-with-custom-worlds">playing with this page, please refer to the <a href="/tutorial/Archipelago/setup/en#playing-with-custom-worlds">playing with
custom worlds</a> section of the setup guide and the custom worlds</a> section of the setup guide.</p>
<a href="{{ url_for("tutorial", game="Archipelago", file="other_en") }}">other games and tools guide</a>
to find more.</p>
<div class="js-only"> <div class="js-only">
<label for="game-search">Search for your game below!</label><br /> <label for="game-search">Search for your game below!</label><br />
<div class="page-controls"> <div class="page-controls">

View File

@@ -20,7 +20,11 @@
{% for file_name, file_data in tutorial_data.files.items() %} {% for file_name, file_data in tutorial_data.files.items() %}
<li> <li>
<a href="{{ url_for("tutorial", game=world_name, file=file_name) }}">{{ file_data.language }}</a> <a href="{{ url_for("tutorial", game=world_name, file=file_name) }}">{{ file_data.language }}</a>
by {{ file_data.authors | join(", ") }} by
{% for author in file_data.authors %}
{{ author }}
{% if not loop.last %}, {% endif %}
{% endfor %}
</li> </li>
{% endfor %} {% endfor %}
</ul> </ul>

View File

@@ -19,6 +19,8 @@
# NewSoupVi is acting maintainer, but world belongs to core with the exception of the music # NewSoupVi is acting maintainer, but world belongs to core with the exception of the music
/worlds/apquest/ @NewSoupVi /worlds/apquest/ @NewSoupVi
# Sudoku (APSudoku)
/worlds/apsudoku/ @EmilyV99
# Aquaria # Aquaria
/worlds/aquaria/ @tioui /worlds/aquaria/ @tioui

View File

@@ -129,42 +129,6 @@ common_rule_only_on_easy = common_rule & easy_filter
common_rule_skipped_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 ## Enabling caching
The rule builder provides a `CachedRuleBuilderWorld` base class for your `World` class that enables caching on your rules. The rule builder provides a `CachedRuleBuilderWorld` base class for your `World` class that enables caching on your rules.

View File

@@ -1,21 +1,21 @@
colorama==0.4.6 colorama>=0.4.6
websockets==13.1 # ,<14 websockets>=13.0.1,<14
PyYAML==6.0.3 PyYAML>=6.0.3
jellyfish==1.2.1 jellyfish>=1.2.1
jinja2==3.1.6 jinja2>=3.1.6
schema==0.7.8 schema>=0.7.8
kivy==2.3.1 kivy>=2.3.1
bsdiff4==1.2.6 bsdiff4>=1.2.6
platformdirs==4.9.4 platformdirs>=4.5.0
certifi==2026.2.25 certifi>=2025.11.12
cython==3.2.4 cython>=3.2.1
cymem==2.0.13 cymem>=2.0.13
orjson==3.11.7 orjson>=3.11.4
typing_extensions==4.15.0 typing_extensions>=4.15.0
pyshortcuts==1.9.7 pyshortcuts>=1.9.6
pathspec==1.0.4 pathspec>=0.12.1
kivymd @ git+https://github.com/kivymd/KivyMD@5ff9d0d kivymd @ git+https://github.com/kivymd/KivyMD@5ff9d0d
kivymd>=2.0.1.dev0 kivymd>=2.0.1.dev0
# Legacy world dependencies that custom worlds rely on # Legacy world dependencies that custom worlds rely on
Pymem==1.14.0 Pymem>=1.13.0

View File

@@ -1,162 +0,0 @@
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
}

View File

@@ -7,7 +7,6 @@ from typing_extensions import TypeVar, dataclass_transform, override
from BaseClasses import CollectionState from BaseClasses import CollectionState
from NetUtils import JSONMessagePart from NetUtils import JSONMessagePart
from .field_resolvers import FieldResolver, FieldResolverRegister, resolve_field
from .options import OptionFilter from .options import OptionFilter
if TYPE_CHECKING: if TYPE_CHECKING:
@@ -109,14 +108,11 @@ class Rule(Generic[TWorld]):
def to_dict(self) -> dict[str, Any]: def to_dict(self) -> dict[str, Any]:
"""Returns a JSON compatible dict representation of this rule""" """Returns a JSON compatible dict representation of this rule"""
args = {} args = {
for field in dataclasses.fields(self): field.name: getattr(self, field.name, None)
if field.name in ("options", "filtered_resolution"): for field in dataclasses.fields(self)
continue if field.name not in ("options", "filtered_resolution")
value = getattr(self, field.name, None) }
if isinstance(value, FieldResolver):
value = value.to_dict()
args[field.name] = value
return { return {
"rule": self.__class__.__qualname__, "rule": self.__class__.__qualname__,
"options": [o.to_dict() for o in self.options], "options": [o.to_dict() for o in self.options],
@@ -128,19 +124,7 @@ class Rule(Generic[TWorld]):
def from_dict(cls, data: Mapping[str, Any], world_cls: "type[World]") -> Self: 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""" """Returns a new instance of this rule from a serialized dict representation"""
options = OptionFilter.multiple_from_dict(data.get("options", ())) options = OptionFilter.multiple_from_dict(data.get("options", ()))
args = cls._parse_field_resolvers(data.get("args", {}), world_cls.game) return cls(**data.get("args", {}), options=options, filtered_resolution=data.get("filtered_resolution", False))
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]": 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""" """Combines two rules or a rule and an option filter into an And rule"""
@@ -543,7 +527,7 @@ class Or(NestedRule[TWorld], game="Archipelago"):
items[item] = 1 items[item] = 1
elif isinstance(child, HasAnyCount.Resolved): elif isinstance(child, HasAnyCount.Resolved):
for item, count in child.item_counts: for item, count in child.item_counts:
if item not in items or count < items[item]: if item not in items or items[item] < count:
items[item] = count items[item] = count
else: else:
clauses.append(child) clauses.append(child)
@@ -704,24 +688,24 @@ class Filtered(WrapperRule[TWorld], game="Archipelago"):
class Has(Rule[TWorld], game="Archipelago"): class Has(Rule[TWorld], game="Archipelago"):
"""A rule that checks if the player has at least `count` of a given item""" """A rule that checks if the player has at least `count` of a given item"""
item_name: str | FieldResolver item_name: str
"""The item to check for""" """The item to check for"""
count: int | FieldResolver = 1 count: int = 1
"""The count the player is required to have""" """The count the player is required to have"""
@override @override
def _instantiate(self, world: TWorld) -> Rule.Resolved: def _instantiate(self, world: TWorld) -> Rule.Resolved:
return self.Resolved( return self.Resolved(
resolve_field(self.item_name, world, str), self.item_name,
count=resolve_field(self.count, world, int), self.count,
player=world.player, player=world.player,
caching_enabled=getattr(world, "rule_caching_enabled", False), caching_enabled=getattr(world, "rule_caching_enabled", False),
) )
@override @override
def __str__(self) -> str: def __str__(self) -> str:
count = f", count={self.count}" if isinstance(self.count, FieldResolver) or self.count > 1 else "" count = f", count={self.count}" if self.count > 1 else ""
options = f", options={self.options}" if self.options else "" options = f", options={self.options}" if self.options else ""
return f"{self.__class__.__name__}({self.item_name}{count}{options})" return f"{self.__class__.__name__}({self.item_name}{count}{options})"
@@ -1007,7 +991,7 @@ class HasAny(Rule[TWorld], game="Archipelago"):
class HasAllCounts(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""" """A rule that checks if the player has all of the specified counts of the given items"""
item_counts: Mapping[str, int | FieldResolver] item_counts: dict[str, int]
"""A mapping of item name to count to check for""" """A mapping of item name to count to check for"""
@override @override
@@ -1018,30 +1002,12 @@ class HasAllCounts(Rule[TWorld], game="Archipelago"):
if len(self.item_counts) == 1: if len(self.item_counts) == 1:
item = next(iter(self.item_counts)) item = next(iter(self.item_counts))
return Has(item, self.item_counts[item]).resolve(world) 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( return self.Resolved(
item_counts, tuple(self.item_counts.items()),
player=world.player, player=world.player,
caching_enabled=getattr(world, "rule_caching_enabled", False), 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 @override
def __str__(self) -> str: def __str__(self) -> str:
items = ", ".join([f"{item} x{count}" for item, count in self.item_counts.items()]) items = ", ".join([f"{item} x{count}" for item, count in self.item_counts.items()])
@@ -1130,7 +1096,7 @@ class HasAllCounts(Rule[TWorld], game="Archipelago"):
class HasAnyCount(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""" """A rule that checks if the player has any of the specified counts of the given items"""
item_counts: Mapping[str, int | FieldResolver] item_counts: dict[str, int]
"""A mapping of item name to count to check for""" """A mapping of item name to count to check for"""
@override @override
@@ -1141,30 +1107,12 @@ class HasAnyCount(Rule[TWorld], game="Archipelago"):
if len(self.item_counts) == 1: if len(self.item_counts) == 1:
item = next(iter(self.item_counts)) item = next(iter(self.item_counts))
return Has(item, self.item_counts[item]).resolve(world) 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( return self.Resolved(
item_counts, tuple(self.item_counts.items()),
player=world.player, player=world.player,
caching_enabled=getattr(world, "rule_caching_enabled", False), 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 @override
def __str__(self) -> str: def __str__(self) -> str:
items = ", ".join([f"{item} x{count}" for item, count in self.item_counts.items()]) items = ", ".join([f"{item} x{count}" for item, count in self.item_counts.items()])
@@ -1256,13 +1204,13 @@ class HasFromList(Rule[TWorld], game="Archipelago"):
item_names: tuple[str, ...] item_names: tuple[str, ...]
"""A tuple of item names to check for""" """A tuple of item names to check for"""
count: int | FieldResolver = 1 count: int = 1
"""The number of items the player needs to have""" """The number of items the player needs to have"""
def __init__( def __init__(
self, self,
*item_names: str, *item_names: str,
count: int | FieldResolver = 1, count: int = 1,
options: Iterable[OptionFilter] = (), options: Iterable[OptionFilter] = (),
filtered_resolution: bool = False, filtered_resolution: bool = False,
) -> None: ) -> None:
@@ -1279,7 +1227,7 @@ class HasFromList(Rule[TWorld], game="Archipelago"):
return Has(self.item_names[0], self.count).resolve(world) return Has(self.item_names[0], self.count).resolve(world)
return self.Resolved( return self.Resolved(
self.item_names, self.item_names,
count=resolve_field(self.count, world, int), self.count,
player=world.player, player=world.player,
caching_enabled=getattr(world, "rule_caching_enabled", False), caching_enabled=getattr(world, "rule_caching_enabled", False),
) )
@@ -1287,7 +1235,7 @@ class HasFromList(Rule[TWorld], game="Archipelago"):
@override @override
@classmethod @classmethod
def from_dict(cls, data: Mapping[str, Any], world_cls: "type[World]") -> Self: def from_dict(cls, data: Mapping[str, Any], world_cls: "type[World]") -> Self:
args = cls._parse_field_resolvers(data.get("args", {}), world_cls.game) args = {**data.get("args", {})}
item_names = args.pop("item_names", ()) item_names = args.pop("item_names", ())
options = OptionFilter.multiple_from_dict(data.get("options", ())) options = OptionFilter.multiple_from_dict(data.get("options", ()))
return cls(*item_names, **args, options=options, filtered_resolution=data.get("filtered_resolution", False)) return cls(*item_names, **args, options=options, filtered_resolution=data.get("filtered_resolution", False))
@@ -1390,13 +1338,13 @@ class HasFromListUnique(Rule[TWorld], game="Archipelago"):
item_names: tuple[str, ...] item_names: tuple[str, ...]
"""A tuple of item names to check for""" """A tuple of item names to check for"""
count: int | FieldResolver = 1 count: int = 1
"""The number of items the player needs to have""" """The number of items the player needs to have"""
def __init__( def __init__(
self, self,
*item_names: str, *item_names: str,
count: int | FieldResolver = 1, count: int = 1,
options: Iterable[OptionFilter] = (), options: Iterable[OptionFilter] = (),
filtered_resolution: bool = False, filtered_resolution: bool = False,
) -> None: ) -> None:
@@ -1406,15 +1354,14 @@ class HasFromListUnique(Rule[TWorld], game="Archipelago"):
@override @override
def _instantiate(self, world: TWorld) -> Rule.Resolved: def _instantiate(self, world: TWorld) -> Rule.Resolved:
count = resolve_field(self.count, world, int) if len(self.item_names) == 0 or len(self.item_names) < self.count:
if len(self.item_names) == 0 or len(self.item_names) < count:
# match state.has_from_list_unique # match state.has_from_list_unique
return False_().resolve(world) return False_().resolve(world)
if len(self.item_names) == 1: if len(self.item_names) == 1:
return Has(self.item_names[0]).resolve(world) return Has(self.item_names[0]).resolve(world)
return self.Resolved( return self.Resolved(
self.item_names, self.item_names,
count, self.count,
player=world.player, player=world.player,
caching_enabled=getattr(world, "rule_caching_enabled", False), caching_enabled=getattr(world, "rule_caching_enabled", False),
) )
@@ -1422,7 +1369,7 @@ class HasFromListUnique(Rule[TWorld], game="Archipelago"):
@override @override
@classmethod @classmethod
def from_dict(cls, data: Mapping[str, Any], world_cls: "type[World]") -> Self: def from_dict(cls, data: Mapping[str, Any], world_cls: "type[World]") -> Self:
args = cls._parse_field_resolvers(data.get("args", {}), world_cls.game) args = {**data.get("args", {})}
item_names = args.pop("item_names", ()) item_names = args.pop("item_names", ())
options = OptionFilter.multiple_from_dict(data.get("options", ())) options = OptionFilter.multiple_from_dict(data.get("options", ()))
return cls(*item_names, **args, options=options, filtered_resolution=data.get("filtered_resolution", False)) return cls(*item_names, **args, options=options, filtered_resolution=data.get("filtered_resolution", False))
@@ -1521,7 +1468,7 @@ class HasGroup(Rule[TWorld], game="Archipelago"):
item_name_group: str item_name_group: str
"""The name of the item group containing the items""" """The name of the item group containing the items"""
count: int | FieldResolver = 1 count: int = 1
"""The number of items the player needs to have""" """The number of items the player needs to have"""
@override @override
@@ -1530,14 +1477,14 @@ class HasGroup(Rule[TWorld], game="Archipelago"):
return self.Resolved( return self.Resolved(
self.item_name_group, self.item_name_group,
item_names, item_names,
count=resolve_field(self.count, world, int), self.count,
player=world.player, player=world.player,
caching_enabled=getattr(world, "rule_caching_enabled", False), caching_enabled=getattr(world, "rule_caching_enabled", False),
) )
@override @override
def __str__(self) -> str: def __str__(self) -> str:
count = f", count={self.count}" if isinstance(self.count, FieldResolver) or self.count > 1 else "" count = f", count={self.count}" if self.count > 1 else ""
options = f", options={self.options}" if self.options else "" options = f", options={self.options}" if self.options else ""
return f"{self.__class__.__name__}({self.item_name_group}{count}{options})" return f"{self.__class__.__name__}({self.item_name_group}{count}{options})"
@@ -1595,7 +1542,7 @@ class HasGroupUnique(Rule[TWorld], game="Archipelago"):
item_name_group: str item_name_group: str
"""The name of the item group containing the items""" """The name of the item group containing the items"""
count: int | FieldResolver = 1 count: int = 1
"""The number of items the player needs to have""" """The number of items the player needs to have"""
@override @override
@@ -1604,14 +1551,14 @@ class HasGroupUnique(Rule[TWorld], game="Archipelago"):
return self.Resolved( return self.Resolved(
self.item_name_group, self.item_name_group,
item_names, item_names,
count=resolve_field(self.count, world, int), self.count,
player=world.player, player=world.player,
caching_enabled=getattr(world, "rule_caching_enabled", False), caching_enabled=getattr(world, "rule_caching_enabled", False),
) )
@override @override
def __str__(self) -> str: def __str__(self) -> str:
count = f", count={self.count}" if isinstance(self.count, FieldResolver) or self.count > 1 else "" count = f", count={self.count}" if self.count > 1 else ""
options = f", options={self.options}" if self.options else "" options = f", options={self.options}" if self.options else ""
return f"{self.__class__.__name__}({self.item_name_group}{count}{options})" return f"{self.__class__.__name__}({self.item_name_group}{count}{options})"

View File

@@ -71,6 +71,7 @@ non_apworlds: set[str] = {
"Ocarina of Time", "Ocarina of Time",
"Overcooked! 2", "Overcooked! 2",
"Raft", "Raft",
"Sudoku",
"Super Mario 64", "Super Mario 64",
"VVVVVV", "VVVVVV",
"Wargroove", "Wargroove",
@@ -657,7 +658,7 @@ cx_Freeze.setup(
options={ options={
"build_exe": { "build_exe": {
"packages": ["worlds", "kivy", "cymem", "websockets", "kivymd"], "packages": ["worlds", "kivy", "cymem", "websockets", "kivymd"],
"includes": ["rule_builder.cached_world"], "includes": [],
"excludes": ["numpy", "Cython", "PySide2", "PIL", "excludes": ["numpy", "Cython", "PySide2", "PIL",
"pandas"], "pandas"],
"zip_includes": [], "zip_includes": [],

View File

@@ -11,7 +11,7 @@ class TestImplemented(unittest.TestCase):
def test_completion_condition(self): def test_completion_condition(self):
"""Ensure a completion condition is set that has requirements.""" """Ensure a completion condition is set that has requirements."""
for game_name, world_type in AutoWorldRegister.world_types.items(): for game_name, world_type in AutoWorldRegister.world_types.items():
if not world_type.hidden: if not world_type.hidden and game_name not in {"Sudoku"}:
with self.subTest(game_name): with self.subTest(game_name):
multiworld = setup_solo_multiworld(world_type) multiworld = setup_solo_multiworld(world_type)
self.assertFalse(multiworld.completion_condition[1](multiworld.state)) self.assertFalse(multiworld.completion_condition[1](multiworld.state))
@@ -59,7 +59,7 @@ class TestImplemented(unittest.TestCase):
def test_prefill_items(self): def test_prefill_items(self):
"""Test that every world can reach every location from allstate before pre_fill.""" """Test that every world can reach every location from allstate before pre_fill."""
for gamename, world_type in AutoWorldRegister.world_types.items(): for gamename, world_type in AutoWorldRegister.world_types.items():
if gamename not in ("Archipelago", "Final Fantasy", "Test Game"): if gamename not in ("Archipelago", "Sudoku", "Final Fantasy", "Test Game"):
with self.subTest(gamename): with self.subTest(gamename):
multiworld = setup_solo_multiworld(world_type, ("generate_early", "create_regions", "create_items", multiworld = setup_solo_multiworld(world_type, ("generate_early", "create_regions", "create_items",
"set_rules", "connect_entrances", "generate_basic")) "set_rules", "connect_entrances", "generate_basic"))

View File

@@ -109,7 +109,7 @@ class TestOptions(unittest.TestCase):
def test_option_set_keys_random(self): def test_option_set_keys_random(self):
"""Tests that option sets do not contain 'random' and its variants as valid keys""" """Tests that option sets do not contain 'random' and its variants as valid keys"""
for game_name, world_type in AutoWorldRegister.world_types.items(): for game_name, world_type in AutoWorldRegister.world_types.items():
if game_name not in ("Archipelago", "Super Metroid"): if game_name not in ("Archipelago", "Sudoku", "Super Metroid"):
for option_key, option in world_type.options_dataclass.type_hints.items(): for option_key, option in world_type.options_dataclass.type_hints.items():
if issubclass(option, OptionSet): if issubclass(option, OptionSet):
with self.subTest(game=game_name, option=option_key): with self.subTest(game=game_name, option=option_key):

View File

@@ -6,9 +6,8 @@ from typing_extensions import override
from BaseClasses import CollectionState, Item, ItemClassification, Location, MultiWorld, Region from BaseClasses import CollectionState, Item, ItemClassification, Location, MultiWorld, Region
from NetUtils import JSONMessagePart from NetUtils import JSONMessagePart
from Options import Choice, FreeText, Option, OptionSet, PerGameCommonOptions, Range, Toggle from Options import Choice, FreeText, Option, OptionSet, PerGameCommonOptions, Toggle
from rule_builder.cached_world import CachedRuleBuilderWorld 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.options import Operator, OptionFilter
from rule_builder.rules import ( from rule_builder.rules import (
And, And,
@@ -60,20 +59,12 @@ class SetOption(OptionSet):
valid_keys: ClassVar[set[str]] = {"one", "two", "three"} # pyright: ignore[reportIncompatibleVariableOverride] 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 @dataclass
class RuleBuilderOptions(PerGameCommonOptions): class RuleBuilderOptions(PerGameCommonOptions):
toggle_option: ToggleOption toggle_option: ToggleOption
choice_option: ChoiceOption choice_option: ChoiceOption
text_option: FreeTextOption text_option: FreeTextOption
set_option: SetOption set_option: SetOption
range_option: RangeOption
GAME_NAME = "Rule Builder Test Game" GAME_NAME = "Rule Builder Test Game"
@@ -242,14 +233,6 @@ class CachedRuleBuilderTestCase(RuleBuilderTestCase):
Or(Has("A"), HasAny("B", "C"), HasAnyCount({"D": 1, "E": 1})), Or(Has("A"), HasAny("B", "C"), HasAnyCount({"D": 1, "E": 1})),
HasAny.Resolved(("A", "B", "C", "D", "E"), player=1), HasAny.Resolved(("A", "B", "C", "D", "E"), player=1),
), ),
(
And(HasAllCounts({"A": 1, "B": 2}), HasAllCounts({"A": 2, "B": 2})),
HasAllCounts.Resolved((("A", 2), ("B", 2)), player=1),
),
(
Or(HasAnyCount({"A": 1, "B": 2}), HasAnyCount({"A": 2, "B": 2})),
HasAnyCount.Resolved((("A", 1), ("B", 2)), player=1),
),
) )
) )
class TestSimplify(RuleBuilderTestCase): class TestSimplify(RuleBuilderTestCase):
@@ -668,15 +651,14 @@ class TestRules(RuleBuilderTestCase):
self.assertFalse(resolved_rule(self.state)) self.assertFalse(resolved_rule(self.state))
def test_has_any_count(self) -> None: def test_has_any_count(self) -> None:
item_counts: dict[str, int | FieldResolver] = {"Item 1": 1, "Item 2": 2} item_counts = {"Item 1": 1, "Item 2": 2}
rule = HasAnyCount(item_counts) rule = HasAnyCount(item_counts)
resolved_rule = rule.resolve(self.world) resolved_rule = rule.resolve(self.world)
self.world.register_rule_dependencies(resolved_rule) self.world.register_rule_dependencies(resolved_rule)
for item_name, count in item_counts.items(): for item_name, count in item_counts.items():
item = self.world.create_item(item_name) item = self.world.create_item(item_name)
num_items = resolve_field(count, self.world, int) for _ in range(count):
for _ in range(num_items):
self.assertFalse(resolved_rule(self.state)) self.assertFalse(resolved_rule(self.state))
self.state.collect(item) self.state.collect(item)
self.assertTrue(resolved_rule(self.state)) self.assertTrue(resolved_rule(self.state))
@@ -773,7 +755,7 @@ class TestSerialization(RuleBuilderTestCase):
rule: ClassVar[Rule[Any]] = And( rule: ClassVar[Rule[Any]] = And(
Or( Or(
Has("i1", count=FromOption(RangeOption)), Has("i1", count=4),
HasFromList("i2", "i3", "i4", count=2), HasFromList("i2", "i3", "i4", count=2),
HasAnyCount({"i5": 2, "i6": 3}), HasAnyCount({"i5": 2, "i6": 3}),
options=[OptionFilter(ToggleOption, 0)], options=[OptionFilter(ToggleOption, 0)],
@@ -781,7 +763,7 @@ class TestSerialization(RuleBuilderTestCase):
Or( Or(
HasAll("i7", "i8"), HasAll("i7", "i8"),
HasAllCounts( HasAllCounts(
{"i9": 1, "i10": FromWorldAttr("instance_data.i10_count")}, {"i9": 1, "i10": 5},
options=[OptionFilter(ToggleOption, 1, operator="ne")], options=[OptionFilter(ToggleOption, 1, operator="ne")],
filtered_resolution=True, filtered_resolution=True,
), ),
@@ -821,14 +803,7 @@ class TestSerialization(RuleBuilderTestCase):
"rule": "Has", "rule": "Has",
"options": [], "options": [],
"filtered_resolution": False, "filtered_resolution": False,
"args": { "args": {"item_name": "i1", "count": 4},
"item_name": "i1",
"count": {
"resolver": "FromOption",
"option": "test.general.test_rule_builder.RangeOption",
"field": "value",
},
},
}, },
{ {
"rule": "HasFromList", "rule": "HasFromList",
@@ -865,12 +840,7 @@ class TestSerialization(RuleBuilderTestCase):
}, },
], ],
"filtered_resolution": True, "filtered_resolution": True,
"args": { "args": {"item_counts": {"i9": 1, "i10": 5}},
"item_counts": {
"i9": 1,
"i10": {"resolver": "FromWorldAttr", "name": "instance_data.i10_count"},
}
},
}, },
{ {
"rule": "CanReachRegion", "rule": "CanReachRegion",
@@ -945,7 +915,7 @@ class TestSerialization(RuleBuilderTestCase):
multiworld = setup_solo_multiworld(self.world_cls, steps=(), seed=0) multiworld = setup_solo_multiworld(self.world_cls, steps=(), seed=0)
world = multiworld.worlds[1] world = multiworld.worlds[1]
deserialized_rule = world.rule_from_dict(self.rule_dict) deserialized_rule = world.rule_from_dict(self.rule_dict)
self.assertEqual(deserialized_rule, self.rule, f"\n{deserialized_rule}\n{self.rule}") self.assertEqual(deserialized_rule, self.rule, str(deserialized_rule))
class TestExplain(RuleBuilderTestCase): class TestExplain(RuleBuilderTestCase):
@@ -1364,32 +1334,3 @@ class TestExplain(RuleBuilderTestCase):
"& False)", "& False)",
) )
assert str(self.resolved_rule) == " ".join(expected) 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}")

View File

@@ -269,9 +269,8 @@ if not is_frozen():
from Launcher import open_folder from Launcher import open_folder
import argparse import argparse
parser = argparse.ArgumentParser(prog="Build APWorlds", description="Build script for APWorlds") parser = argparse.ArgumentParser("Build script for APWorlds")
parser.add_argument("worlds", type=str, default=(), nargs="*", help="names of APWorlds to build") parser.add_argument("worlds", type=str, default=(), nargs="*", help="Names of APWorlds to build.")
parser.add_argument("--skip_open_folder", action="store_true", help="don't open the output build folder")
args = parser.parse_args(launch_args) args = parser.parse_args(launch_args)
if args.worlds: if args.worlds:
@@ -321,8 +320,6 @@ if not is_frozen():
zf.write(pathlib.Path(world_directory, file), pathlib.Path(file_name, file)) zf.write(pathlib.Path(world_directory, file), pathlib.Path(file_name, file))
zf.writestr(apworld.manifest_path, json.dumps(manifest)) zf.writestr(apworld.manifest_path, json.dumps(manifest))
if not args.skip_open_folder:
open_folder(apworlds_folder) open_folder(apworlds_folder)
components.append(Component("Build APWorlds", func=_build_apworlds, cli=True, components.append(Component("Build APWorlds", func=_build_apworlds, cli=True,

View File

@@ -1,2 +1,2 @@
maseya-z3pr==1.0.0rc1 maseya-z3pr>=1.0.0rc1
xxtea==3.7.0 xxtea>=3.0.0

View File

@@ -30,10 +30,7 @@
C to fire available Confetti Cannons C to fire available Confetti Cannons
Number Keys + Backspace for Math Trap\n Number Keys + Backspace for Math Trap\n
[b]Click to move also works![/b] Rebinding controls might be added in the future :)"""
Click/tap Confetti Cannon to fire it
Submit Math Trap solution in the command line at the bottom"""
<VolumeSliderView>: <VolumeSliderView>:
orientation: "horizontal" orientation: "horizontal"

View File

@@ -4,9 +4,8 @@ from argparse import Namespace
from enum import Enum from enum import Enum
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
from CommonClient import ClientCommandProcessor, CommonContext, logger, server_loop from CommonClient import CommonContext, gui_enabled, logger, server_loop
from NetUtils import ClientStatus from NetUtils import ClientStatus
from Utils import gui_enabled
from ..game.events import ConfettiFired, LocationClearedEvent, MathProblemSolved, MathProblemStarted, VictoryEvent from ..game.events import ConfettiFired, LocationClearedEvent, MathProblemSolved, MathProblemStarted, VictoryEvent
from ..game.game import Game from ..game.game import Game
@@ -42,16 +41,6 @@ class ConnectionStatus(Enum):
GAME_RUNNING = 3 GAME_RUNNING = 3
class APQuestClientCommandProcessor(ClientCommandProcessor):
ctx: "APQuestContext"
def default(self, raw: str) -> None:
if self.ctx.external_math_trap_input(raw):
return
super().default(raw)
class APQuestContext(CommonContext): class APQuestContext(CommonContext):
game = "APQuest" game = "APQuest"
items_handling = 0b111 # full remote items_handling = 0b111 # full remote
@@ -76,7 +65,6 @@ class APQuestContext(CommonContext):
delay_intro_song: bool delay_intro_song: bool
ui: APQuestManager ui: APQuestManager
command_processor = APQuestClientCommandProcessor
def __init__( def __init__(
self, server_address: str | None = None, password: str | None = None, delay_intro_song: bool = False self, server_address: str | None = None, password: str | None = None, delay_intro_song: bool = False
@@ -184,6 +172,7 @@ class APQuestContext(CommonContext):
assert self.ap_quest_game is not None assert self.ap_quest_game is not None
self.ap_quest_game.gameboard.fill_remote_location_content(remote_item_graphic_overrides) self.ap_quest_game.gameboard.fill_remote_location_content(remote_item_graphic_overrides)
self.render() self.render()
self.ui.game_view.bind_keyboard()
self.connection_status = ConnectionStatus.GAME_RUNNING self.connection_status = ConnectionStatus.GAME_RUNNING
self.ui.game_started() self.ui.game_started()
@@ -255,59 +244,6 @@ class APQuestContext(CommonContext):
self.ap_quest_game.input(input_key) self.ap_quest_game.input(input_key)
self.render() self.render()
def queue_auto_move(self, target_x: int, target_y: int) -> None:
if self.ap_quest_game is None:
return
if not self.ap_quest_game.gameboard.ready:
return
if not self.ui.game_view.focused > 1: # Must already be in focus
return
self.ap_quest_game.queue_auto_move(target_x, target_y)
self.ui.start_auto_move()
def do_auto_move_and_rerender(self) -> None:
if self.ap_quest_game is None:
return
if not self.ap_quest_game.gameboard.ready:
return
changed = self.ap_quest_game.do_auto_move()
if changed:
self.render()
def confetti_and_rerender(self) -> None:
# Used by tap mode
if self.ap_quest_game is None:
return
if not self.ap_quest_game.gameboard.ready:
return
if self.ap_quest_game.attempt_fire_confetti_cannon():
self.render()
def external_math_trap_input(self, raw: str) -> bool:
if self.ap_quest_game is None:
return False
if not self.ap_quest_game.gameboard.ready:
return False
if not self.ap_quest_game.active_math_problem:
return False
raw = raw.strip()
if not raw:
return False
if not raw.isnumeric():
return False
self.ap_quest_game.math_problem_replace([int(digit) for digit in raw])
if not self.ap_quest_game.active_math_problem:
self.ui.game_view.force_focus()
self.render()
return True
def make_gui(self) -> "type[kvui.GameManager]": def make_gui(self) -> "type[kvui.GameManager]":
self.load_kv() self.load_kv()
return APQuestManager return APQuestManager

View File

@@ -4,26 +4,29 @@ from math import sqrt
from random import choice, random from random import choice, random
from typing import Any from typing import Any
from kivy.core.window import Window from kivy.core.window import Keyboard, Window
from kivy.graphics import Color, Triangle from kivy.graphics import Color, Triangle
from kivy.graphics.instructions import Canvas from kivy.graphics.instructions import Canvas
from kivy.uix.behaviors import ButtonBehavior from kivy.input import MotionEvent
from kivy.uix.boxlayout import BoxLayout from kivy.uix.boxlayout import BoxLayout
from kivy.uix.gridlayout import GridLayout from kivy.uix.gridlayout import GridLayout
from kivy.uix.image import Image
from kivy.uix.widget import Widget
from kivymd.uix.recycleview import MDRecycleView from kivymd.uix.recycleview import MDRecycleView
from CommonClient import logger from CommonClient import logger
from ..game.inputs import Input from ..game.inputs import Input
INPUT_MAP_STR = {
INPUT_MAP = {
"up": Input.UP,
"w": Input.UP, "w": Input.UP,
"down": Input.DOWN,
"s": Input.DOWN, "s": Input.DOWN,
"right": Input.RIGHT,
"d": Input.RIGHT, "d": Input.RIGHT,
"left": Input.LEFT,
"a": Input.LEFT, "a": Input.LEFT,
" ": Input.ACTION, "spacebar": Input.ACTION,
"c": Input.CONFETTI, "c": Input.CONFETTI,
"0": Input.ZERO, "0": Input.ZERO,
"1": Input.ONE, "1": Input.ONE,
@@ -35,52 +38,38 @@ INPUT_MAP_STR = {
"7": Input.SEVEN, "7": Input.SEVEN,
"8": Input.EIGHT, "8": Input.EIGHT,
"9": Input.NINE, "9": Input.NINE,
} "backspace": Input.BACKSPACE,
INPUT_MAP_SPECIAL_INT = {
# Arrow Keys and Backspace
273: Input.UP,
274: Input.DOWN,
275: Input.RIGHT,
276: Input.LEFT,
8: Input.BACKSPACE,
} }
class APQuestGameView(MDRecycleView): class APQuestGameView(MDRecycleView):
focused: int = 1 _keyboard: Keyboard | None = None
input_function: Callable[[Input], None] input_function: Callable[[Input], None]
def __init__(self, input_function: Callable[[Input], None], **kwargs: Any) -> None: def __init__(self, input_function: Callable[[Input], None], **kwargs: Any) -> None:
super().__init__(**kwargs) super().__init__(**kwargs)
self.input_function = input_function self.input_function = input_function
Window.bind(on_key_down=self._on_keyboard_down) self.bind_keyboard()
Window.bind(on_touch_down=self.check_focus)
self.opacity = 0.5
def check_focus(self, _, touch, *args, **kwargs) -> None: def on_touch_down(self, touch: MotionEvent) -> None:
if self.parent.collide_point(*touch.pos): self.bind_keyboard()
self.focused += 1
self.opacity = 1 def bind_keyboard(self) -> None:
if self._keyboard is not None:
return return
self._keyboard = Window.request_keyboard(self._keyboard_closed, self)
self._keyboard.bind(on_key_down=self._on_keyboard_down)
self.focused = 0 def _keyboard_closed(self) -> None:
self.opacity = 0.5 if self._keyboard is None:
return
self._keyboard.unbind(on_key_down=self._on_keyboard_down)
self._keyboard = None
def force_focus(self) -> None: def _on_keyboard_down(self, _: Any, keycode: tuple[int, str], _1: Any, _2: Any) -> bool:
Window.release_keyboard() if keycode[1] in INPUT_MAP:
self.focused = 1 self.input_function(INPUT_MAP[keycode[1]])
self.opacity = 1 return True
def _on_keyboard_down(self, _: Any, keycode_int: int, _2: Any, keycode: str, _4: Any) -> bool:
if not self.focused:
return False
if keycode in INPUT_MAP_STR:
self.input_function(INPUT_MAP_STR[keycode])
elif keycode_int in INPUT_MAP_SPECIAL_INT:
self.input_function(INPUT_MAP_SPECIAL_INT[keycode_int])
return False
class APQuestGrid(GridLayout): class APQuestGrid(GridLayout):
@@ -88,7 +77,7 @@ class APQuestGrid(GridLayout):
parent_width, parent_height = self.parent.size parent_width, parent_height = self.parent.size
self_width_according_to_parent_height = parent_height * 12 / 11 self_width_according_to_parent_height = parent_height * 12 / 11
self_height_according_to_parent_width = parent_width * 11 / 12 self_height_according_to_parent_width = parent_height * 11 / 12
if self_width_according_to_parent_height > parent_width: if self_width_according_to_parent_height > parent_width:
self.size = parent_width, self_height_according_to_parent_width self.size = parent_width, self_height_according_to_parent_width
@@ -214,23 +203,13 @@ class Confetti:
return True return True
class ConfettiView(Widget): class ConfettiView(MDRecycleView):
confetti: list[Confetti] confetti: list[Confetti]
def __init__(self, **kwargs: Any) -> None: def __init__(self, **kwargs: Any) -> None:
super().__init__(**kwargs) super().__init__(**kwargs)
self.confetti = [] self.confetti = []
# Don't eat tap events for the game grid under the confetti view
def on_touch_down(self, touch) -> bool:
return False
def on_touch_move(self, touch) -> bool:
return False
def on_touch_up(self, touch) -> bool:
return False
def check_resize(self, _: int, _1: int) -> None: def check_resize(self, _: int, _1: int) -> None:
parent_width, parent_height = self.parent.size parent_width, parent_height = self.parent.size
@@ -275,32 +254,3 @@ class VolumeSliderView(BoxLayout):
class APQuestControlsView(BoxLayout): class APQuestControlsView(BoxLayout):
pass pass
class TapImage(ButtonBehavior, Image):
callback: Callable[[], None]
def __init__(self, callback: Callable[[], None], **kwargs) -> None:
self.callback = callback
super().__init__(**kwargs)
def on_release(self) -> bool:
self.callback()
return True
class TapIfConfettiCannonImage(ButtonBehavior, Image):
callback: Callable[[], None]
is_confetti_cannon: bool = False
def __init__(self, callback: Callable[[], None], **kwargs: dict[str, Any]) -> None:
self.callback = callback
super().__init__(**kwargs)
def on_release(self) -> bool:
if self.is_confetti_cannon:
self.callback()
return True

View File

@@ -6,7 +6,6 @@ from kvui import GameManager, MDNavigationItemBase
# isort: on # isort: on
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
from kivy._clock import ClockEvent
from kivy.clock import Clock from kivy.clock import Clock
from kivy.uix.gridlayout import GridLayout from kivy.uix.gridlayout import GridLayout
from kivy.uix.image import Image from kivy.uix.image import Image
@@ -14,16 +13,7 @@ from kivy.uix.layout import Layout
from kivymd.uix.recycleview import MDRecycleView from kivymd.uix.recycleview import MDRecycleView
from ..game.game import Game from ..game.game import Game
from ..game.graphics import Graphic from .custom_views import APQuestControlsView, APQuestGameView, APQuestGrid, ConfettiView, VolumeSliderView
from .custom_views import (
APQuestControlsView,
APQuestGameView,
APQuestGrid,
ConfettiView,
TapIfConfettiCannonImage,
TapImage,
VolumeSliderView,
)
from .graphics import PlayerSprite, get_texture from .graphics import PlayerSprite, get_texture
from .sounds import SoundManager from .sounds import SoundManager
@@ -38,17 +28,15 @@ class APQuestManager(GameManager):
lower_game_grid: GridLayout lower_game_grid: GridLayout
upper_game_grid: GridLayout upper_game_grid: GridLayout
game_view: MDRecycleView | None = None game_view: MDRecycleView
game_view_tab: MDNavigationItemBase game_view_tab: MDNavigationItemBase
sound_manager: SoundManager sound_manager: SoundManager
bottom_image_grid: list[list[Image]] bottom_image_grid: list[list[Image]]
top_image_grid: list[list[TapImage]] top_image_grid: list[list[Image]]
confetti_view: ConfettiView confetti_view: ConfettiView
move_event: ClockEvent | None
bottom_grid_is_grass: bool bottom_grid_is_grass: bool
def __init__(self, *args: Any, **kwargs: Any) -> None: def __init__(self, *args: Any, **kwargs: Any) -> None:
@@ -57,7 +45,6 @@ class APQuestManager(GameManager):
self.sound_manager.allow_intro_to_play = not self.ctx.delay_intro_song self.sound_manager.allow_intro_to_play = not self.ctx.delay_intro_song
self.top_image_grid = [] self.top_image_grid = []
self.bottom_image_grid = [] self.bottom_image_grid = []
self.move_event = None
self.bottom_grid_is_grass = False self.bottom_grid_is_grass = False
def allow_intro_song(self) -> None: def allow_intro_song(self) -> None:
@@ -84,12 +71,10 @@ class APQuestManager(GameManager):
def game_started(self) -> None: def game_started(self) -> None:
self.switch_to_game_tab() self.switch_to_game_tab()
if self.game_view is not None:
self.game_view.force_focus()
self.sound_manager.game_started = True self.sound_manager.game_started = True
def render(self, game: Game, player_sprite: PlayerSprite) -> None: def render(self, game: Game, player_sprite: PlayerSprite) -> None:
self.setup_game_grid_if_not_setup(game) self.setup_game_grid_if_not_setup(game.gameboard.size)
# This calls game.render(), which needs to happen to update the state of math traps # This calls game.render(), which needs to happen to update the state of math traps
self.render_gameboard(game, player_sprite) self.render_gameboard(game, player_sprite)
@@ -119,8 +104,6 @@ class APQuestManager(GameManager):
for item_graphic, image_row in zip(rendered_item_column, self.top_image_grid, strict=False): for item_graphic, image_row in zip(rendered_item_column, self.top_image_grid, strict=False):
image = image_row[-1] image = image_row[-1]
image.is_confetti_cannon = item_graphic == Graphic.CONFETTI_CANNON
texture = get_texture(item_graphic) texture = get_texture(item_graphic)
if texture is None: if texture is None:
image.opacity = 0 image.opacity = 0
@@ -153,25 +136,23 @@ class APQuestManager(GameManager):
self.bottom_grid_is_grass = grass self.bottom_grid_is_grass = grass
def setup_game_grid_if_not_setup(self, game: Game) -> None: def setup_game_grid_if_not_setup(self, size: tuple[int, int]) -> None:
if self.upper_game_grid.children: if self.upper_game_grid.children:
return return
self.top_image_grid = [] self.top_image_grid = []
self.bottom_image_grid = [] self.bottom_image_grid = []
size = game.gameboard.size for _row in range(size[1]):
for row in range(size[1]):
self.top_image_grid.append([]) self.top_image_grid.append([])
self.bottom_image_grid.append([]) self.bottom_image_grid.append([])
for column in range(size[0]): for _column in range(size[0]):
bottom_image = Image(fit_mode="fill", color=(0.3, 0.3, 0.3)) bottom_image = Image(fit_mode="fill", color=(0.3, 0.3, 0.3))
self.lower_game_grid.add_widget(bottom_image) self.lower_game_grid.add_widget(bottom_image)
self.bottom_image_grid[-1].append(bottom_image) self.bottom_image_grid[-1].append(bottom_image)
top_image = TapImage(lambda y=row, x=column: self.ctx.queue_auto_move(x, y), fit_mode="fill") top_image = Image(fit_mode="fill")
self.upper_game_grid.add_widget(top_image) self.upper_game_grid.add_widget(top_image)
self.top_image_grid[-1].append(top_image) self.top_image_grid[-1].append(top_image)
@@ -179,19 +160,11 @@ class APQuestManager(GameManager):
image = Image(fit_mode="fill", color=(0.3, 0.3, 0.3)) image = Image(fit_mode="fill", color=(0.3, 0.3, 0.3))
self.lower_game_grid.add_widget(image) self.lower_game_grid.add_widget(image)
image2 = TapIfConfettiCannonImage(lambda: self.ctx.confetti_and_rerender(), fit_mode="fill", opacity=0) image2 = Image(fit_mode="fill", opacity=0)
self.upper_game_grid.add_widget(image2) self.upper_game_grid.add_widget(image2)
self.top_image_grid[-1].append(image2) self.top_image_grid[-1].append(image2)
def start_auto_move(self) -> None:
if self.move_event is not None:
self.move_event.cancel()
self.ctx.do_auto_move_and_rerender()
self.move_event = Clock.schedule_interval(lambda _: self.ctx.do_auto_move_and_rerender(), 0.10)
def build(self) -> Layout: def build(self) -> Layout:
container = super().build() container = super().build()

View File

@@ -1,10 +1,10 @@
import pkgutil import pkgutil
from collections.abc import Buffer
from enum import Enum from enum import Enum
from io import BytesIO from io import BytesIO
from typing import Literal, NamedTuple, Protocol, cast from typing import Literal, NamedTuple, Protocol, cast
from kivy.uix.image import CoreImage from kivy.uix.image import CoreImage
from typing_extensions import Buffer
from CommonClient import logger from CommonClient import logger

View File

@@ -1,12 +1,12 @@
import asyncio import asyncio
import pkgutil import pkgutil
from asyncio import Task from asyncio import Task
from collections.abc import Buffer
from pathlib import Path from pathlib import Path
from typing import cast from typing import cast
from kivy import Config from kivy import Config
from kivy.core.audio import Sound, SoundLoader from kivy.core.audio import Sound, SoundLoader
from typing_extensions import Buffer
from CommonClient import logger from CommonClient import logger
@@ -85,7 +85,7 @@ class SoundManager:
def ensure_config(self) -> None: def ensure_config(self) -> None:
Config.adddefaultsection("APQuest") Config.adddefaultsection("APQuest")
Config.setdefault("APQuest", "volume", 30) Config.setdefault("APQuest", "volume", 50)
self.set_volume_percentage(Config.getint("APQuest", "volume")) self.set_volume_percentage(Config.getint("APQuest", "volume"))
async def sound_manager_loop(self) -> None: async def sound_manager_loop(self) -> None:
@@ -149,7 +149,6 @@ class SoundManager:
continue continue
if sound_name == audio_filename: if sound_name == audio_filename:
sound.volume = self.volume_percentage / 100
sound.play() sound.play()
self.update_background_music() self.update_background_music()
higher_priority_sound_is_playing = True higher_priority_sound_is_playing = True
@@ -214,7 +213,6 @@ class SoundManager:
# It ends up feeling better if this just always continues playing quietly after being started. # 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. # 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": if self.game_started and song.state == "stop":
song.volume = self.current_background_music_volume * self.volume_percentage / 100
song.play() song.play()
song.seek(0) song.seek(0)
continue continue
@@ -230,7 +228,6 @@ class SoundManager:
if self.current_background_music_volume != 0: if self.current_background_music_volume != 0:
if song.state == "stop": if song.state == "stop":
song.volume = self.current_background_music_volume * self.volume_percentage / 100
song.play() song.play()
song.seek(0) song.seek(0)

View File

@@ -6,11 +6,6 @@
- Die [APQuest-apworld](https://github.com/NewSoupVi/Archipelago/releases), - Die [APQuest-apworld](https://github.com/NewSoupVi/Archipelago/releases),
falls diese nicht mit deiner Version von Archipelago gebündelt ist. falls diese nicht mit deiner Version von Archipelago gebündelt ist.
## Optionale Software
- [APQuest AP Tracker](https://github.com/palex00/ap-quest-tracker/releases/latest), zur Verwendung mit
[PopTracker](https://github.com/black-sliver/PopTracker/releases)
## Wie man spielt ## Wie man spielt
Zuerst brauchst du einen Raum, mit dem du dich verbinden kannst. Zuerst brauchst du einen Raum, mit dem du dich verbinden kannst.
@@ -46,15 +41,3 @@ Du solltest jetzt verbunden sein und kannst APQuest spielen.
Der APQuest Client kann zwischen verschiedenen Slots wechseln, ohne neugestartet werden zu müssen, Der APQuest Client kann zwischen verschiedenen Slots wechseln, ohne neugestartet werden zu müssen,
Klicke einfach den "Disconnect"-Knopf. Dann verbinde dich mit dem anderen Raum / Slot. Klicke einfach den "Disconnect"-Knopf. Dann verbinde dich mit dem anderen Raum / Slot.
## Automatisches Tracken
AP Quest verfügt über einen voll funktionsfähigen, automatischen Tracker mit Karten der Spielwelt.
1. Lade [APQuest AP Tracker](https://github.com/palex00/ap-quest-tracker/releases/latest) und
[PopTracker](https://github.com/black-sliver/PopTracker/releases) herunter.
2. Lege das Tracker-Pack im Ordner „packs/“ deiner PopTracker-Installation ab.
3. Öffne PopTracker und lade das APQuest-Pack.
4. Für das automatische Tracking klick oben auf das „AP“-Symbol.
5. Gib die Serveradresse von Archipelago (die, mit der du deinen Client verbunden hast), den Slot-Namen und das Passwort ein.

View File

@@ -6,11 +6,6 @@
- [The APQuest apworld](https://github.com/NewSoupVi/Archipelago/releases), - [The APQuest apworld](https://github.com/NewSoupVi/Archipelago/releases),
if not bundled with your version of Archipelago if not bundled with your version of Archipelago
## Optional Software
- [APQuest AP Tracker](https://github.com/palex00/ap-quest-tracker/releases/latest), for use with
[PopTracker](https://github.com/black-sliver/PopTracker/releases)
## How to play ## How to play
First, you need a room to connect to. For this, you or someone you know has to generate a game. First, you need a room to connect to. For this, you or someone you know has to generate a game.
@@ -45,14 +40,3 @@ You should now be connected and able to play APQuest.
The APQuest Client can seamlessly switch rooms without restarting. The APQuest Client can seamlessly switch rooms without restarting.
Simply click the "Disconnect" button, then connect to a different slot/room. Simply click the "Disconnect" button, then connect to a different slot/room.
## Auto-Tracking
AP Quest has a fully functional map tracker that supports auto-tracking.
1. Download [APQuest AP Tracker](https://github.com/palex00/ap-quest-tracker/releases/latest) and
[PopTracker](https://github.com/black-sliver/PopTracker/releases).
2. Put the tracker pack into packs/ in your PopTracker install.
3. Open PopTracker, and load the APQuest pack.
4. For autotracking, click on the "AP" symbol at the top.
5. Enter the Archipelago server address (the one you connected your client to), slot name, and password.

View File

@@ -17,10 +17,8 @@ class Entity:
class InteractableMixin: class InteractableMixin:
auto_move_attempt_passing_through = False
@abstractmethod @abstractmethod
def interact(self, player: Player) -> bool: def interact(self, player: Player) -> None:
pass pass
@@ -91,16 +89,15 @@ class Chest(Entity, InteractableMixin, LocationMixin):
self.is_open = True self.is_open = True
self.update_solidity() self.update_solidity()
def interact(self, player: Player) -> bool: def interact(self, player: Player) -> None:
if self.has_given_content: if self.has_given_content:
return False return
if self.is_open: if self.is_open:
self.give_content(player) self.give_content(player)
return True return
self.open() self.open()
return True
def content_success(self) -> None: def content_success(self) -> None:
self.update_solidity() self.update_solidity()
@@ -138,59 +135,47 @@ class Door(Entity):
class KeyDoor(Door, InteractableMixin): class KeyDoor(Door, InteractableMixin):
auto_move_attempt_passing_through = True
closed_graphic = Graphic.KEY_DOOR closed_graphic = Graphic.KEY_DOOR
def interact(self, player: Player) -> bool: def interact(self, player: Player) -> None:
if self.is_open: if self.is_open:
return False return
if not player.has_item(Item.KEY): if not player.has_item(Item.KEY):
return False return
player.remove_item(Item.KEY) player.remove_item(Item.KEY)
self.open() self.open()
return True
class BreakableBlock(Door, InteractableMixin): class BreakableBlock(Door, InteractableMixin):
auto_move_attempt_passing_through = True
closed_graphic = Graphic.BREAKABLE_BLOCK closed_graphic = Graphic.BREAKABLE_BLOCK
def interact(self, player: Player) -> bool: def interact(self, player: Player) -> None:
if self.is_open: if self.is_open:
return False return
if not player.has_item(Item.HAMMER): if not player.has_item(Item.HAMMER):
return False return
player.remove_item(Item.HAMMER) player.remove_item(Item.HAMMER)
self.open() self.open()
return True
class Bush(Door, InteractableMixin): class Bush(Door, InteractableMixin):
auto_move_attempt_passing_through = True
closed_graphic = Graphic.BUSH closed_graphic = Graphic.BUSH
def interact(self, player: Player) -> bool: def interact(self, player: Player) -> None:
if self.is_open: if self.is_open:
return False return
if not player.has_item(Item.SWORD): if not player.has_item(Item.SWORD):
return False return
self.open() self.open()
return True
class Button(Entity, InteractableMixin): class Button(Entity, InteractableMixin):
solid = True solid = True
@@ -201,13 +186,12 @@ class Button(Entity, InteractableMixin):
def __init__(self, activates: ActivatableMixin) -> None: def __init__(self, activates: ActivatableMixin) -> None:
self.activates = activates self.activates = activates
def interact(self, player: Player) -> bool: def interact(self, player: Player) -> None:
if self.activated: if self.activated:
return False return
self.activated = True self.activated = True
self.activates.activate(player) self.activates.activate(player)
return True
@property @property
def graphic(self) -> Graphic: def graphic(self) -> Graphic:
@@ -256,9 +240,9 @@ class Enemy(Entity, InteractableMixin):
return return
self.current_health = self.max_health self.current_health = self.max_health
def interact(self, player: Player) -> bool: def interact(self, player: Player) -> None:
if self.dead: if self.dead:
return False return
if player.has_item(Item.SWORD): if player.has_item(Item.SWORD):
self.current_health = max(0, self.current_health - 1) self.current_health = max(0, self.current_health - 1)
@@ -266,10 +250,9 @@ class Enemy(Entity, InteractableMixin):
if self.current_health == 0: if self.current_health == 0:
if not self.dead: if not self.dead:
self.die() self.die()
return True return
player.damage(2) player.damage(2)
return True
@property @property
def graphic(self) -> Graphic: def graphic(self) -> Graphic:
@@ -287,15 +270,13 @@ class EnemyWithLoot(Enemy, LocationMixin):
self.dead = True self.dead = True
self.solid = not self.has_given_content self.solid = not self.has_given_content
def interact(self, player: Player) -> bool: def interact(self, player: Player) -> None:
if self.dead: if self.dead:
if not self.has_given_content: if not self.has_given_content:
self.give_content(player) self.give_content(player)
return True return
return False
super().interact(player) super().interact(player)
return True
@property @property
def graphic(self) -> Graphic: def graphic(self) -> Graphic:
@@ -322,12 +303,10 @@ class FinalBoss(Enemy):
} }
enemy_default_graphic = Graphic.BOSS_1_HEALTH enemy_default_graphic = Graphic.BOSS_1_HEALTH
def interact(self, player: Player) -> bool: def interact(self, player: Player) -> None:
dead_before = self.dead dead_before = self.dead
changed = super().interact(player) super().interact(player)
if not dead_before and self.dead: if not dead_before and self.dead:
player.victory() player.victory()
return changed

View File

@@ -23,8 +23,6 @@ class Game:
active_math_problem: MathProblem | None active_math_problem: MathProblem | None
active_math_problem_input: list[int] | None active_math_problem_input: list[int] | None
auto_target_path: list[tuple[int, int]] = []
remotely_received_items: set[tuple[int, int, int]] remotely_received_items: set[tuple[int, int, int]]
def __init__( def __init__(
@@ -34,7 +32,6 @@ class Game:
self.gameboard = create_gameboard(hard_mode, hammer_exists, extra_chest) self.gameboard = create_gameboard(hard_mode, hammer_exists, extra_chest)
self.player = Player(self.gameboard, self.queued_events.append) self.player = Player(self.gameboard, self.queued_events.append)
self.active_math_problem = None self.active_math_problem = None
self.active_math_problem_input = None
self.remotely_received_items = set() self.remotely_received_items = set()
if random_object is None: if random_object is None:
@@ -97,40 +94,29 @@ class Game:
return tuple(graphics_array) return tuple(graphics_array)
def attempt_player_movement(self, direction: Direction, cancel_auto_move: bool = True) -> bool: def attempt_player_movement(self, direction: Direction) -> None:
if cancel_auto_move:
self.cancel_auto_move()
self.player.facing = direction self.player.facing = direction
delta_x, delta_y = direction.value delta_x, delta_y = direction.value
new_x, new_y = self.player.current_x + delta_x, self.player.current_y + delta_y new_x, new_y = self.player.current_x + delta_x, self.player.current_y + delta_y
if self.gameboard.get_entity_at(new_x, new_y).solid: if not self.gameboard.get_entity_at(new_x, new_y).solid:
return False
self.player.current_x = new_x self.player.current_x = new_x
self.player.current_y = new_y self.player.current_y = new_y
return True
def attempt_interact(self) -> bool: def attempt_interact(self) -> None:
delta_x, delta_y = self.player.facing.value delta_x, delta_y = self.player.facing.value
entity_x, entity_y = self.player.current_x + delta_x, self.player.current_y + delta_y entity_x, entity_y = self.player.current_x + delta_x, self.player.current_y + delta_y
entity = self.gameboard.get_entity_at(entity_x, entity_y) entity = self.gameboard.get_entity_at(entity_x, entity_y)
if isinstance(entity, InteractableMixin): if isinstance(entity, InteractableMixin):
return entity.interact(self.player) entity.interact(self.player)
return False
def attempt_fire_confetti_cannon(self) -> bool:
if not self.player.has_item(Item.CONFETTI_CANNON):
return False
def attempt_fire_confetti_cannon(self) -> None:
if self.player.has_item(Item.CONFETTI_CANNON):
self.player.remove_item(Item.CONFETTI_CANNON) self.player.remove_item(Item.CONFETTI_CANNON)
self.queued_events.append(ConfettiFired(self.player.current_x, self.player.current_y)) self.queued_events.append(ConfettiFired(self.player.current_x, self.player.current_y))
return True
def math_problem_success(self) -> None: def math_problem_success(self) -> None:
self.active_math_problem = None self.active_math_problem = None
@@ -168,12 +154,6 @@ class Game:
self.active_math_problem_input.pop() self.active_math_problem_input.pop()
self.check_math_problem_result() self.check_math_problem_result()
def math_problem_replace(self, input: list[int]) -> None:
if self.active_math_problem_input is None:
return
self.active_math_problem_input = input[:2]
self.check_math_problem_result()
def input(self, input_key: Input) -> None: def input(self, input_key: Input) -> None:
if not self.gameboard.ready: if not self.gameboard.ready:
return return
@@ -221,47 +201,3 @@ class Game:
def force_clear_location(self, location_id: int) -> None: def force_clear_location(self, location_id: int) -> None:
location = Location(location_id) location = Location(location_id)
self.gameboard.force_clear_location(location) self.gameboard.force_clear_location(location)
def cancel_auto_move(self) -> None:
self.auto_target_path = []
def queue_auto_move(self, target_x: int, target_y: int) -> None:
self.cancel_auto_move()
path = self.gameboard.calculate_shortest_path(self.player.current_x, self.player.current_y, target_x, target_y)
self.auto_target_path = path
def do_auto_move(self) -> bool:
if not self.auto_target_path:
return False
target_x, target_y = self.auto_target_path.pop(0)
movement = target_x - self.player.current_x, target_y - self.player.current_y
direction = Direction(movement)
moved = self.attempt_player_movement(direction, cancel_auto_move=False)
if moved:
return True
# We are attempting to interact with something on the path.
# First, make the player face it.
if self.player.facing != direction:
self.player.facing = direction
self.auto_target_path.insert(0, (target_x, target_y))
return True
# If we are facing it, attempt to interact with it.
changed = self.attempt_interact()
if not changed:
self.cancel_auto_move()
return False
# If the interaction was successful, and this was the end of the path, stop
# (i.e. don't try to attack the attacked enemy over and over until it's dead)
if not self.auto_target_path:
self.cancel_auto_move()
return True
# If there is more to go, keep going along the path
self.auto_target_path.insert(0, (target_x, target_y))
return True

View File

@@ -15,7 +15,6 @@ from .entities import (
EnemyWithLoot, EnemyWithLoot,
Entity, Entity,
FinalBoss, FinalBoss,
InteractableMixin,
KeyDoor, KeyDoor,
LocationMixin, LocationMixin,
Wall, Wall,
@@ -24,7 +23,6 @@ from .generate_math_problem import MathProblem
from .graphics import DIGIT_TO_GRAPHIC, DIGIT_TO_GRAPHIC_ZERO_EMPTY, MATH_PROBLEM_TYPE_TO_GRAPHIC, Graphic from .graphics import DIGIT_TO_GRAPHIC, DIGIT_TO_GRAPHIC_ZERO_EMPTY, MATH_PROBLEM_TYPE_TO_GRAPHIC, Graphic
from .items import Item from .items import Item
from .locations import DEFAULT_CONTENT, Location from .locations import DEFAULT_CONTENT, Location
from .path_finding import find_path_or_closest
if TYPE_CHECKING: if TYPE_CHECKING:
from .player import Player from .player import Player
@@ -109,21 +107,6 @@ class Gameboard:
return tuple(graphics) return tuple(graphics)
def as_traversability_bools(self) -> tuple[tuple[bool, ...], ...]:
traversability = []
for y, row in enumerate(self.gameboard):
traversable_row = []
for x, entity in enumerate(row):
traversable_row.append(
not entity.solid
or (isinstance(entity, InteractableMixin) and entity.auto_move_attempt_passing_through)
)
traversability.append(tuple(traversable_row))
return tuple(traversability)
def render_math_problem( def render_math_problem(
self, problem: MathProblem, current_input_digits: list[int], current_input_int: int | None self, problem: MathProblem, current_input_digits: list[int], current_input_int: int | None
) -> tuple[tuple[Graphic, ...], ...]: ) -> tuple[tuple[Graphic, ...], ...]:
@@ -203,23 +186,6 @@ class Gameboard:
entity = self.remote_entity_by_location_id[location] entity = self.remote_entity_by_location_id[location]
entity.force_clear() entity.force_clear()
def calculate_shortest_path(
self, source_x: int, source_y: int, target_x: int, target_y: int
) -> list[tuple[int, int]]:
gameboard_traversability = self.as_traversability_bools()
path = find_path_or_closest(gameboard_traversability, source_x, source_y, target_x, target_y)
if not path:
return path
# If the path stops just short of target, attempt interacting with it at the end
if abs(path[-1][0] - target_x) + abs(path[-1][1] - target_y) == 1:
if isinstance(self.gameboard[target_y][target_x], InteractableMixin):
path.append((target_x, target_y))
return path[1:] # Cut off starting tile
@property @property
def ready(self) -> bool: def ready(self) -> bool:
return self.content_filled return self.content_filled

View File

@@ -6,7 +6,6 @@ from typing import NamedTuple
_random = random.Random() _random = random.Random()
class NumberChoiceConstraints(NamedTuple): class NumberChoiceConstraints(NamedTuple):
num_1_min: int num_1_min: int
num_1_max: int num_1_max: int

View File

@@ -1,84 +0,0 @@
import heapq
from collections.abc import Generator
Point = tuple[int, int]
def heuristic(a: Point, b: Point) -> int:
# Manhattan distance (good for 4-directional grids)
return abs(a[0] - b[0]) + abs(a[1] - b[1])
def reconstruct_path(came_from: dict[Point, Point], current: Point) -> list[Point]:
path = [current]
while current in came_from:
current = came_from[current]
path.append(current)
path.reverse()
return path
def find_path_or_closest(
grid: tuple[tuple[bool, ...], ...], source_x: int, source_y: int, target_x: int, target_y: int
) -> list[Point]:
start = source_x, source_y
goal = target_x, target_y
rows, cols = len(grid), len(grid[0])
def in_bounds(p: Point) -> bool:
return 0 <= p[0] < rows and 0 <= p[1] < cols
def passable(p: Point) -> bool:
return grid[p[1]][p[0]]
def neighbors(p: Point) -> Generator[Point, None, None]:
x, y = p
for dx, dy in [(-1, 0), (1, 0), (0, -1), (0, 1)]:
np = (x + dx, y + dy)
if in_bounds(np) and passable(np):
yield np
open_heap: list[tuple[int, tuple[int, int]]] = []
heapq.heappush(open_heap, (0, start))
came_from: dict[Point, Point] = {}
g_score = {start: 0}
# Track best fallback node
best_node = start
best_dist = heuristic(start, goal)
visited = set()
while open_heap:
_, current = heapq.heappop(open_heap)
if current in visited:
continue
visited.add(current)
# Check if we reached the goal
if current == goal:
return reconstruct_path(came_from, current)
# Update "closest node" fallback
dist = heuristic(current, goal)
if dist < best_dist or (dist == best_dist and g_score[current] < g_score.get(best_node, float("inf"))):
best_node = current
best_dist = dist
for neighbor in neighbors(current):
tentative_g = g_score[current] + 1 # cost is 1 per move
if tentative_g < g_score.get(neighbor, float("inf")):
came_from[neighbor] = current
g_score[neighbor] = tentative_g
f_score = tentative_g + heuristic(neighbor, goal)
heapq.heappush(open_heap, (f_score, neighbor))
# Goal not reachable → return path to closest node
if best_node is not None:
return reconstruct_path(came_from, best_node)
return []

View File

@@ -0,0 +1,34 @@
from typing import Dict
from BaseClasses import Tutorial
from ..AutoWorld import WebWorld, World
class AP_SudokuWebWorld(WebWorld):
options_page = False
theme = 'partyTime'
setup_en = Tutorial(
tutorial_name='Setup Guide',
description='A guide to playing APSudoku',
language='English',
file_name='setup_en.md',
link='setup/en',
authors=['EmilyV']
)
tutorials = [setup_en]
class AP_SudokuWorld(World):
"""
Play a little Sudoku while you're in BK mode to maybe get some useful hints
"""
game = "Sudoku"
web = AP_SudokuWebWorld()
item_name_to_id: Dict[str, int] = {}
location_name_to_id: Dict[str, int] = {}
@classmethod
def stage_assert_generate(cls, multiworld):
raise Exception("APSudoku cannot be used for generating worlds, the client can instead connect to any slot from any world")

View File

@@ -0,0 +1,15 @@
# APSudoku
## Hint Games
HintGames do not need to be added at the start of a seed, and do not create a 'slot'- instead, you connect the HintGame client to a different game's slot. By playing a HintGame, you can earn hints for the connected slot.
## What is this game?
Play Sudoku puzzles of varying difficulties, earning a hint for each puzzle correctly solved. Harder puzzles are more likely to grant a hint towards a Progression item, though otherwise what hint is granted is random.
## Where is the options page?
There is no options page; this game cannot be used in your .yamls. Instead, the client can connect to any slot in a multiworld.
By using the connected room's Admin Password on the Admin Panel tab, you can configure some settings at any time to affect the entire room. This allows disabling hints entirely, as well as altering the hint odds for each difficulty.

View File

@@ -0,0 +1,55 @@
# APSudoku Setup Guide
## Required Software
- [APSudoku](https://github.com/APSudoku/APSudoku)
## General Concept
This is a HintGame client, which can connect to any multiworld slot, allowing you to play Sudoku to unlock random hints for that slot's locations.
Does not need to be added at the start of a seed, as it does not create any slots of its own, nor does it have any YAML files.
## Installation Procedures
### Windows / Linux
Go to the latest release from the [github APSudoku Releases page](https://github.com/APSudoku/APSudoku/releases/latest). Download and extract the appropriate file for your platform.
### Web
Go to the [github pages](apsudoku.github.io) or [itch.io](https://emilyv99.itch.io/apsudoku) site, and play in the browser.
## Joining a MultiWorld Game
1. Run the APSudoku executable.
2. Under `Settings` &rarr; `Connection` at the top-right:
- Enter the server address and port number
- Enter the name of the slot you wish to connect to
- Enter the room password (optional)
- Select DeathLink related settings (optional)
- Press `Connect`
4. Under the `Sudoku` tab
- Choose puzzle difficulty
- Click `Start` to generate a puzzle
5. Try to solve the Sudoku. Click `Check` when done
- A correct solution rewards you with 1 hint for a location in the world you are connected to
- An incorrect solution has no penalty, unless DeathLink is enabled (see below)
Info:
- You can set various settings under `Settings` &rarr; `Sudoku`, and can change the colors used under `Settings` &rarr; `Theme`.
- While connected, you can view the `Console` and `Hints` tabs for standard TextClient-like features
- You can also use the `Tracking` tab to view either a basic tracker or a valid [GodotAP tracker pack](https://github.com/EmilyV99/GodotAP/blob/main/tracker_packs/GET_PACKS.md)
- While connected, the number of "unhinted" locations for your slot is shown in the upper-left of the the `Sudoku` tab. (If this reads 0, no further hints can be earned for this slot, as every locations is already hinted)
- Click the various `?` buttons for information on controls/how to play
## Admin Settings
By using the connected room's Admin Password on the Admin Panel tab, you can configure some settings at any time to affect the entire room.
- You can disable APSudoku for the entire room, preventing any hints from being granted.
- You can customize the reward weights for each difficulty, making progression hints more or less likely, and/or adding a chance to get "no hint" after a solve.
## DeathLink Support
If `DeathLink` is enabled when you click `Connect`:
- Lose a life if you check an incorrect puzzle (not an _incomplete_ puzzle- if any cells are empty, you get off with a warning), or if you quit a puzzle without solving it (including disconnecting).
- Your life count is customizable (default 0). Dying with 0 lives left kills linked players AND resets your puzzle.
- On receiving a DeathLink from another player, your puzzle resets.

View File

@@ -271,7 +271,7 @@ item_table = {
ItemNames.TRIDENT: ItemData(698031, 1, ItemType.JUNK, ItemGroup.COLLECTIBLE), # collectible_trident_head 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.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.JELLY_EGG: ItemData(698033, 1, ItemType.JUNK, ItemGroup.COLLECTIBLE), # collectible_upsidedown_seed
ItemNames.URCHIN_COSTUME: ItemData(698034, 1, ItemType.PROGRESSION, ItemGroup.COLLECTIBLE), # collectible_urchin_costume ItemNames.URCHIN_COSTUME: ItemData(698034, 1, ItemType.JUNK, ItemGroup.COLLECTIBLE), # collectible_urchin_costume
ItemNames.BABY_WALKER: ItemData(698035, 1, ItemType.JUNK, ItemGroup.COLLECTIBLE), # collectible_walker 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.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 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.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.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.GIRL_COSTUME, ItemNames.ODD_CONTAINER, ItemNames.TRIDENT, ItemNames.TURTLE_EGG,
ItemNames.JELLY_EGG, ItemNames.BABY_WALKER, ItemNames.RAINBOW_MUSHROOM, ItemNames.JELLY_EGG, ItemNames.URCHIN_COSTUME, ItemNames.BABY_WALKER,
ItemNames.RAINBOW_MUSHROOM, ItemNames.RAINBOW_MUSHROOM, ItemNames.FISH_OIL, ItemNames.RAINBOW_MUSHROOM, ItemNames.RAINBOW_MUSHROOM, ItemNames.RAINBOW_MUSHROOM,
ItemNames.LEAF_POULTICE, ItemNames.LEAF_POULTICE, ItemNames.LEAF_POULTICE, ItemNames.LEAF_POULTICE, ItemNames.LEAF_POULTICE, ItemNames.LEAF_POULTICE,
ItemNames.LEECHING_POULTICE, ItemNames.LEECHING_POULTICE, ItemNames.ARCANE_POULTICE, ItemNames.LEECHING_POULTICE, ItemNames.LEECHING_POULTICE, ItemNames.ARCANE_POULTICE,
ItemNames.ROTTEN_MEAT, ItemNames.ROTTEN_MEAT, ItemNames.ROTTEN_MEAT, ItemNames.ROTTEN_MEAT, ItemNames.ROTTEN_MEAT, ItemNames.ROTTEN_MEAT, ItemNames.ROTTEN_MEAT, ItemNames.ROTTEN_MEAT,

View File

@@ -37,7 +37,7 @@ def _has_li(state: CollectionState, player: int) -> bool:
DAMAGING_ITEMS:Iterable[str] = [ DAMAGING_ITEMS:Iterable[str] = [
ItemNames.ENERGY_FORM, ItemNames.NATURE_FORM, ItemNames.BEAST_FORM, ItemNames.ENERGY_FORM, ItemNames.NATURE_FORM, ItemNames.BEAST_FORM,
ItemNames.LI_AND_LI_SONG, ItemNames.BABY_NAUTILUS, ItemNames.BABY_PIRANHA, ItemNames.LI_AND_LI_SONG, ItemNames.BABY_NAUTILUS, ItemNames.BABY_PIRANHA,
ItemNames.BABY_BLASTER, ItemNames.URCHIN_COSTUME ItemNames.BABY_BLASTER
] ]
def _has_damaging_item(state: CollectionState, player: int, damaging_items:Iterable[str] = DAMAGING_ITEMS) -> bool: def _has_damaging_item(state: CollectionState, player: int, damaging_items:Iterable[str] = DAMAGING_ITEMS) -> bool:

View File

@@ -76,7 +76,7 @@ class AquariaWorld(World):
item_name_groups = { item_name_groups = {
"Damage": {ItemNames.ENERGY_FORM, ItemNames.NATURE_FORM, ItemNames.BEAST_FORM, "Damage": {ItemNames.ENERGY_FORM, ItemNames.NATURE_FORM, ItemNames.BEAST_FORM,
ItemNames.LI_AND_LI_SONG, ItemNames.BABY_NAUTILUS, ItemNames.BABY_PIRANHA, ItemNames.LI_AND_LI_SONG, ItemNames.BABY_NAUTILUS, ItemNames.BABY_PIRANHA,
ItemNames.BABY_BLASTER, ItemNames.URCHIN_COSTUME}, ItemNames.BABY_BLASTER},
"Light": {ItemNames.SUN_FORM, ItemNames.BABY_DUMBO} "Light": {ItemNames.SUN_FORM, ItemNames.BABY_DUMBO}
} }
"""Grouping item make it easier to find them""" """Grouping item make it easier to find them"""

View File

@@ -37,7 +37,7 @@ class FactorioWeb(WebWorld):
"English", "English",
"setup_en.md", "setup_en.md",
"setup/en", "setup/en",
["Berserker", "Farrak Kilhn"] ["Berserker, Farrak Kilhn"]
)] )]
option_groups = option_groups option_groups = option_groups

View File

@@ -130,7 +130,6 @@ end
data.raw["assembling-machine"]["assembling-machine-1"].crafting_categories = table.deepcopy(data.raw["assembling-machine"]["assembling-machine-3"].crafting_categories) data.raw["assembling-machine"]["assembling-machine-1"].crafting_categories = table.deepcopy(data.raw["assembling-machine"]["assembling-machine-3"].crafting_categories)
data.raw["assembling-machine"]["assembling-machine-2"].crafting_categories = table.deepcopy(data.raw["assembling-machine"]["assembling-machine-3"].crafting_categories) data.raw["assembling-machine"]["assembling-machine-2"].crafting_categories = table.deepcopy(data.raw["assembling-machine"]["assembling-machine-3"].crafting_categories)
data.raw["assembling-machine"]["assembling-machine-1"].fluid_boxes = table.deepcopy(data.raw["assembling-machine"]["assembling-machine-2"].fluid_boxes) data.raw["assembling-machine"]["assembling-machine-1"].fluid_boxes = table.deepcopy(data.raw["assembling-machine"]["assembling-machine-2"].fluid_boxes)
data.raw["assembling-machine"]["assembling-machine-1"].fluid_boxes_off_when_no_fluid_recipe = data.raw["assembling-machine"]["assembling-machine-2"].fluid_boxes_off_when_no_fluid_recipe
if mods["factory-levels"] then if mods["factory-levels"] then
-- Factory-Levels allows the assembling machines to get faster (and depending on settings), more productive at crafting products, the more the -- Factory-Levels allows the assembling machines to get faster (and depending on settings), more productive at crafting products, the more the
-- assembling machine crafts the product. If the machine crafts enough, it may auto-upgrade to the next tier. -- assembling machine crafts the product. If the machine crafts enough, it may auto-upgrade to the next tier.

View File

@@ -1 +1 @@
factorio-rcon-py==2.1.3 factorio-rcon-py>=2.1.2

View File

@@ -26,10 +26,10 @@ class GenericWeb(WebWorld):
'English', 'setup_en.md', 'setup/en', ['alwaysintreble']) 'English', 'setup_en.md', 'setup/en', ['alwaysintreble'])
triggers = Tutorial('Archipelago Triggers Guide', 'A guide to setting up and using triggers in your game settings.', triggers = Tutorial('Archipelago Triggers Guide', 'A guide to setting up and using triggers in your game settings.',
'English', 'triggers_en.md', 'triggers/en', ['alwaysintreble']) 'English', 'triggers_en.md', 'triggers/en', ['alwaysintreble'])
other_games = Tutorial('Other Games and Tools', other = Tutorial('Other Games and Tools',
'A guide to additional games and tools that can be used with Archipelago.', 'A guide to additional games and tools that can be used with Archipelago.',
'English', 'other_en.md', 'other/en', ['Berserker']) 'English', 'other_en.md', 'other/en', ['Berserker'])
tutorials = [setup, mac, commands, advanced_settings, triggers, plando, other_games] tutorials = [setup, mac, commands, advanced_settings, triggers, plando, other]
class GenericWorld(World): class GenericWorld(World):

View File

@@ -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. 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 ## Prerequisite Software
Here is a list of software to install and source code to download. Here is a list of software to install and source code to download.
1. Python 3.11.9 "universal2" or newer from the [macOS Python downloads page](https://www.python.org/downloads/macos/). 1. Python 3.11 "universal2" or newer from the [macOS Python downloads page](https://www.python.org/downloads/macos/).
**Python 3.14 is not supported yet.** **Python 3.14 is not supported yet.**
2. Xcode from the [macOS App Store](https://apps.apple.com/us/app/xcode/id497799835). 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). 3. The source code from the [Archipelago releases page](https://github.com/ArchipelagoMW/Archipelago/releases).

View File

@@ -1,37 +1,24 @@
# Other Games And Tools # Other Games and Tools
This page provides information and links regarding various tools that may be of use with Archipelago, including additional playable games not supported by this website. This guide provides information on additional community resources, tools, and games that function with Archipelago.
You should only download and use files from sources you trust; sources listed here are not officially vetted for safety, so use your own judgement and caution. ## Community Resources
## Discord The Archipelago community is active across several platforms where you can find support, new games, and tools.
Currently, Discord is the primary hub for Archipelago; whether it be finding people to play with, developing new game implementations, or finding new playable games. ### Discord Servers
Archipelago has two primary Discord servers for community interaction, game support, and hosting public games:
- **[Archipelago Official Discord](https://discord.gg/8Z65BR2)**: The main hub for the community, including general discussion, support, and public multiworld hosting.
- **[Archipelago After Dark Discord](https://discord.gg/fqvNCCRsu4)**: An adults-only server for 18+ and unrated content.
The [Archipelago Official Discord](https://discord.gg/8Z65BR2) is the main hub, while the [Archipelago After Dark Discord](https://discord.gg/fqvNCCRsu4) houses additional games that may be unrated or 18+ in some territories. Both servers feature an **#apworld-index** channel. These channels are repositories for "APWorlds" — additional game implementations that can be easily added to your Archipelago installation to support more games.
The `#apworld-index` channels in each of these servers contain lists of playable games which should be easily downloadable and playable with an Archipelago installation. ### Documentation
- **[Archipelago Wiki](https://archipelago.miraheze.org/)**: A community-maintained wiki.
## Wiki ## Community Tools
The community-maintained [Archipelago Wiki](https://archipelago.miraheze.org/) has information on many games as well, and acts as a great discord-free source of information. These community-developed tools are frequently used alongside Archipelago to improve the player experience.
## Hint Games
Hint Games are a special type of game which are not included as part of the multiworld generation process. Instead, they can log in to an ongoing multiworld, connecting to a slot designated for any game. Rather than earning items for other games in the multiworld, a Hint Game will allow you to earn hints for the slot you are connected to.
Hint Games can be found from sources such as the Discord and the [Hint Game Category](https://archipelago.miraheze.org/wiki/Category:Hint_games) of the wiki, as detailed above.
## Notable Tools
### Options Creator
The Options Creator is included in the Archipelago installation, and is accessible from the Archipelago Launcher. Using this simple GUI tool, you can easily create randomization options for any installed `.apworld` - perfect when using custom worlds you've installed that don't have options pages on the website.
### PopTracker ### PopTracker
**[PopTracker](https://github.com/black-sliver/PopTracker)** is a universal multi-platform tracking application designed for randomizers. It supports many Archipelago games through tracker packs, providing both manual and automatic autotracking capabilities by connecting directly to an Archipelago server.
[PopTracker](https://poptracker.github.io) is a popular tool in Randomizer communities, which many games support via custom PopTracker Packs. Many Archipelago packs include the ability to directly connect to your slot for auto-tracking capabilities. (Check each game's setup guide or Discord channel to see if it has PopTracker compatibility!)
### Universal Tracker
[Universal Tracker](https://github.com/FarisTheAncient/Archipelago/releases?q=Tracker) is a custom tracker client that uses your .yaml files from generation (as well as the .apworld files) to attempt to provide a view of what locations are currently in-logic or not, using the actual generation logic. Specific steps may need to be taken depending on the game, or the use of randomness in your yaml. Support for UT can be found in the [#universal-tracker](https://discord.com/channels/731205301247803413/1367270230635839539) channel of the Archipelago Official Discord.

View File

@@ -5,11 +5,11 @@ from enum import IntFlag
from typing import Any, ClassVar, Dict, Iterator, List, Set, Tuple, Type from typing import Any, ClassVar, Dict, Iterator, List, Set, Tuple, Type
import settings import settings
from BaseClasses import CollectionRule, Item, ItemClassification, Location, MultiWorld, Region, Tutorial from BaseClasses import Item, ItemClassification, Location, MultiWorld, Region, Tutorial
from Options import PerGameCommonOptions from Options import PerGameCommonOptions
from Utils import __version__ from Utils import __version__
from worlds.AutoWorld import WebWorld, World from worlds.AutoWorld import WebWorld, World
from worlds.generic.Rules import add_rule, set_rule from worlds.generic.Rules import add_rule, CollectionRule, set_rule
from .Client import L2ACSNIClient # noqa: F401 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 .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 from .Locations import l2ac_location_name_to_id, L2ACLocation

View File

@@ -478,7 +478,7 @@ def space_zone_2_boss(state, player):
def space_zone_2_coins(state, player, coins): def space_zone_2_coins(state, player, coins):
auto_scroll = is_auto_scroll(state, player, "Space Zone 2") auto_scroll = is_auto_scroll(state, player, "Space Zone 2")
reachable_coins = 9 reachable_coins = 12
if state.has_any(["Mushroom", "Fire Flower", "Carrot", "Space Physics"], player): if state.has_any(["Mushroom", "Fire Flower", "Carrot", "Space Physics"], player):
reachable_coins += 15 reachable_coins += 15
if state.has("Space Physics", player) or not auto_scroll: 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))): state.has("Mushroom", player) and state.has_any(["Fire Flower", "Carrot"], player))):
reachable_coins += 3 reachable_coins += 3
if state.has("Space Physics", player): if state.has("Space Physics", player):
reachable_coins += 82 reachable_coins += 79
if not auto_scroll: if not auto_scroll:
reachable_coins += 21 reachable_coins += 21
return coins <= reachable_coins return coins <= reachable_coins

View File

@@ -192,7 +192,7 @@ class MessengerRules:
or (self.has_dart(state) and self.has_wingsuit(state)), or (self.has_dart(state) and self.has_wingsuit(state)),
# Dark Cave # Dark Cave
"Dark Cave - Right -> Dark Cave - Left": "Dark Cave - Right -> Dark Cave - Left":
lambda state: state.has("Candle", self.player) and self.has_dart(state) and self.has_wingsuit(state), lambda state: state.has("Candle", self.player) and self.has_dart(state),
# Riviere Turquoise # Riviere Turquoise
"Riviere Turquoise - Waterfall Shop -> Riviere Turquoise - Flower Flight Checkpoint": "Riviere Turquoise - Waterfall Shop -> Riviere Turquoise - Flower Flight Checkpoint":
lambda state: self.has_dart(state) or ( lambda state: self.has_dart(state) or (

View File

@@ -1,6 +1,6 @@
{ {
"game": "Mega Man 3", "game": "Mega Man 3",
"authors": ["Silvris"], "authors": ["Silvris"],
"world_version": "0.1.8", "world_version": "0.1.7",
"minimum_ap_version": "0.6.4" "minimum_ap_version": "0.6.4"
} }

View File

@@ -15,7 +15,6 @@ class MuseDashCollections:
"Default Music", "Default Music",
"Budget Is Burning: Nano Core", "Budget Is Burning: Nano Core",
"Budget Is Burning Vol.1", "Budget Is Burning Vol.1",
"Wuthering Waves Pioneer Podcast",
] ]
MUSE_PLUS_DLC: str = "Muse Plus" MUSE_PLUS_DLC: str = "Muse Plus"
@@ -41,7 +40,6 @@ class MuseDashCollections:
"Heart Message feat. Aoi Tokimori Secret", "Heart Message feat. Aoi Tokimori Secret",
"Meow Rock feat. Chun Ge, Yuan Shen", "Meow Rock feat. Chun Ge, Yuan Shen",
"Stra Stella Secret", "Stra Stella Secret",
"Musepyoi Legend",
] ]
song_items = SONG_DATA song_items = SONG_DATA

View File

@@ -696,20 +696,11 @@ SONG_DATA: Dict[str, SongData] = {
"Otsukimi Koete Otsukiai": SongData(2900820, "43-70", "MD Plus Project", True, 6, 8, 10), "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), "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), "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, 7, None), "Master Bancho's Sushi Class ": SongData(2900823, "93-0", "Welcome to the Blue Hole!", False, None, None, None),
"CHAOTiC BATTLE": SongData(2900824, "94-0", "Cosmic Radio 2025", False, 7, 9, 11), "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), "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), "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), "+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), "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), "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),
} }

View File

@@ -124,8 +124,7 @@ class MuseDashWorld(World):
self.starting_songs = [s for s in start_items if s in song_items] 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.starting_songs = self.md_collection.filter_songs_to_dlc(self.starting_songs, dlc_songs)
# Sort first for deterministic iteration order. self.included_songs = [s for s in include_songs if s in song_items and s not in self.starting_songs]
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) 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. # Making sure songs chosen for goal are allowed by DLC and remove the chosen from being added to the pool.

View File

@@ -1,6 +1,6 @@
{ {
"game": "Muse Dash", "game": "Muse Dash",
"authors": ["DeamonHunter"], "authors": ["DeamonHunter"],
"world_version": "1.5.30", "world_version": "1.5.29",
"minimum_ap_version": "0.6.3" "minimum_ap_version": "0.6.3"
} }

View File

@@ -10,7 +10,6 @@ class DifficultyRanges(MuseDashTestBase):
"PeroPero in the Universe", "PeroPero in the Universe",
"umpopoff", "umpopoff",
"P E R O P E R O Brother Dance", "P E R O P E R O Brother Dance",
"Master Bancho's Sushi Class",
] ]
def test_all_difficulty_ranges(self) -> None: def test_all_difficulty_ranges(self) -> None:
@@ -79,7 +78,7 @@ class DifficultyRanges(MuseDashTestBase):
# Some songs are weird and have less than the usual 3 difficulties. # Some songs are weird and have less than the usual 3 difficulties.
# So this override is to avoid failing on these songs. # 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", "Master Bancho's Sushi Class"): if song_name in ("umpopoff", "P E R O P E R O Brother Dance"):
self.assertTrue(song.easy is None and song.hard is not None and song.master is None, 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.") f"Song '{song_name}' difficulty not set when it should be.")
else: else:

View File

@@ -1,16 +1,3 @@
# 2.5.0
### Features
- Added a new option `dexsanity_encounter_types` to enable/disable dexsanity locations based on whether they can be
found in the allowed encounters. In other words, if Bulbasaur can only be found by fishing and fishing is not enabled,
a dexsanity location will not be created for Bulbasaur.
### Fixes
- Fixed generator error if Wailord or Relicanth are blacklisted during a dexsanity seed.
- Fixed generator error if player greatly restricts allowed opponent pokemon while force fully evolved is active.
# 2.4.1 # 2.4.1
### Fixes ### Fixes

View File

@@ -263,14 +263,6 @@ class PokemonEmeraldWorld(World):
if self.options.hms == RandomizeHms.option_shuffle: if self.options.hms == RandomizeHms.option_shuffle:
self.options.local_items.value.update(self.item_name_groups["HM"]) 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: def create_regions(self) -> None:
from .regions import create_regions from .regions import create_regions
all_regions = create_regions(self) all_regions = create_regions(self)

View File

@@ -1,6 +1,6 @@
{ {
"game": "Pokemon Emerald", "game": "Pokemon Emerald",
"world_version": "2.5.0", "world_version": "2.4.1",
"minimum_ap_version": "0.6.1", "minimum_ap_version": "0.6.1",
"authors": ["Zunawe"] "authors": ["Zunawe"]
} }

View File

@@ -376,10 +376,10 @@ def randomize_wild_encounters(world: "PokemonEmeraldWorld") -> None:
# Actually create the new list of slots and encounter table # Actually create the new list of slots and encounter table
new_slots: List[int] = [] new_slots: List[int] = []
if encounter_type in enabled_encounters:
world.allowed_dexsanity_species.update(table.slots)
for species_id in table.slots: for species_id in table.slots:
new_slots.append(species_old_to_new_map[species_id]) 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) new_encounters[encounter_type] = EncounterTableData(new_slots, table.address)

View File

@@ -1559,7 +1559,7 @@ def set_rules(world: "PokemonEmeraldWorld") -> None:
# Legendary hunt prevents Latios from being a wild spawn so the roamer # Legendary hunt prevents Latios from being a wild spawn so the roamer
# can be tracked, and also guarantees that the roamer is a Latios. # can be tracked, and also guarantees that the roamer is a Latios.
if world.options.goal == Goal.option_legendary_hunt and \ if world.options.goal == Goal.option_legendary_hunt and \
data.constants["SPECIES_LATIOS"] in world.allowed_dexsanity_species: data.constants["SPECIES_LATIOS"] not in world.blacklisted_wilds:
set_rule( set_rule(
get_location(f"Pokedex - Latios"), get_location(f"Pokedex - Latios"),
lambda state: state.has("EVENT_ENCOUNTER_LATIOS", world.player) lambda state: state.has("EVENT_ENCOUNTER_LATIOS", world.player)

View File

@@ -88,19 +88,16 @@ class SatisfactoryWorld(World):
self.items.build_item_pool(self.random, precollected_items, number_of_locations) self.items.build_item_pool(self.random, precollected_items, number_of_locations)
def set_rules(self) -> None: 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_parts = set(self.game_logic.space_elevator_phases[self.options.final_elevator_phase.value - 1].keys())
required_buildings = set()
if "Space Elevator Phase" in self.options.goal_selection: if resource_sink_goal:
required_buildings.add("Space Elevator") required_parts.union(self.game_logic.buildings["AWESOME Sink"].inputs)
if "AWESOME Sink Points (total)" in self.options.goal_selection \
or "AWESOME Sink Points (per minute)" in self.options.goal_selection:
required_buildings.add("AWESOME Sink")
self.multiworld.completion_condition[self.player] = \ 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: def collect(self, state: CollectionState, item: Item) -> bool:
change = super().collect(state, item) change = super().collect(state, item)

View File

@@ -156,17 +156,15 @@ This page includes all data associated with all games.
## How do I join a MultiWorld game? ## How do I join a MultiWorld game?
1. Run ArchipelagoLauncher.exe. 1. Run ArchipelagoStarcraft2Client.exe.
- macOS users should instead follow the instructions found at ["Running in macOS"](#running-in-macos) for this step - macOS users should instead follow the instructions found at ["Running in macOS"](#running-in-macos) for this step
only. only.
2. Search for the Starcraft 2 Client in the launcher to open the game-specific client 2. In the Archipelago tab, type `/connect [server IP]`.
- 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. - 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" - The server IP may also be typed into the top bar, and then clicking "Connect"
4. Type your slot name from your YAML when prompted. 3. Type your slot name from your YAML when prompted.
5. If the server has a password, enter that when prompted. 4. 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 5. Once connected, switch to the 'StarCraft 2 Launcher' tab in the client. There, you can see all the missions in your
world. world.
Unreachable missions will have greyed-out text. Completed missions (all locations collected) will have white text. Unreachable missions will have greyed-out text. Completed missions (all locations collected) will have white text.
@@ -175,22 +173,7 @@ Mission buttons will have a color corresponding to the faction you play as in th
Click on an available mission to start it. Click on an available mission to start it.
## Troubleshooting ## The game isn't launching when I try to start a mission.
### 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. Usually, this is caused by the mod files not being downloaded.
Make sure you have run `/download_data` in the Archipelago tab before playing. Make sure you have run `/download_data` in the Archipelago tab before playing.
@@ -200,12 +183,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 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"). 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/SC2Client_<date>.txt`). If these things are in order, check the log file for issues (stored at `[Archipelago Directory]/logs/Starcraft2Client.txt`).
If you can't figure out the log file, visit our [Discord's](https://discord.com/invite/8Z65BR2) tech-support channel If you can't figure out the log file, visit our [Discord's](https://discord.com/invite/8Z65BR2) tech-support channel
for help. for help.
Please include a specific description of what's going wrong and attach your log file to your message. 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 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`. `Documents/StarCraft II/Accounts/######/Hotkeys` to `Documents/StarCraft II/Hotkeys`.

View File

@@ -249,6 +249,7 @@ def get_locations(world: Optional["SC2World"]) -> Tuple[LocationData, ...]:
LocationType.VICTORY, LocationType.VICTORY,
lambda state: ( lambda state: (
logic.terran_common_unit(state) logic.terran_common_unit(state)
and logic.terran_defense_rating(state, True) >= 2
and (adv_tactics or logic.terran_basic_anti_air(state)) and (adv_tactics or logic.terran_basic_anti_air(state))
), ),
), ),
@@ -270,7 +271,10 @@ def get_locations(world: Optional["SC2World"]) -> Tuple[LocationData, ...]:
"Third Group Rescued", "Third Group Rescued",
SC2WOL_LOC_ID_OFFSET + 303, SC2WOL_LOC_ID_OFFSET + 303,
LocationType.VANILLA, LocationType.VANILLA,
logic.terran_common_unit, lambda state: (
logic.terran_common_unit(state)
and logic.terran_defense_rating(state, True) >= 2
),
), ),
make_location_data( make_location_data(
SC2Mission.ZERO_HOUR.mission_name, SC2Mission.ZERO_HOUR.mission_name,
@@ -316,14 +320,20 @@ def get_locations(world: Optional["SC2World"]) -> Tuple[LocationData, ...]:
"Hold Just a Little Longer", "Hold Just a Little Longer",
SC2WOL_LOC_ID_OFFSET + 309, SC2WOL_LOC_ID_OFFSET + 309,
LocationType.EXTRA, LocationType.EXTRA,
logic.terran_common_unit, lambda state: (
logic.terran_common_unit(state)
and logic.terran_defense_rating(state, True) >= 2
),
), ),
make_location_data( make_location_data(
SC2Mission.ZERO_HOUR.mission_name, SC2Mission.ZERO_HOUR.mission_name,
"Cavalry's on the Way", "Cavalry's on the Way",
SC2WOL_LOC_ID_OFFSET + 310, SC2WOL_LOC_ID_OFFSET + 310,
LocationType.EXTRA, LocationType.EXTRA,
logic.terran_common_unit, lambda state: (
logic.terran_common_unit(state)
and logic.terran_defense_rating(state, True) >= 2
),
), ),
make_location_data( make_location_data(
SC2Mission.EVACUATION.mission_name, SC2Mission.EVACUATION.mission_name,

View File

@@ -247,13 +247,13 @@ class ValidInventory:
# Limit the maximum number of upgrades # Limit the maximum number of upgrades
if max_upgrades_per_unit != -1: if max_upgrades_per_unit != -1:
for group_items in group_to_item.values(): for group_name, group_items in group_to_item.items():
self.world.random.shuffle(group_items) self.world.random.shuffle(group_to_item[group])
cull_items_over_maximum(group_items, max_upgrades_per_unit) cull_items_over_maximum(group_items, max_upgrades_per_unit)
# Requesting minimum upgrades for items that have already been locked/placed when minimum required # Requesting minimum upgrades for items that have already been locked/placed when minimum required
if min_upgrades_per_unit != -1: if min_upgrades_per_unit != -1:
for group_items in group_to_item.values(): for group_name, group_items in group_to_item.items():
self.world.random.shuffle(group_items) self.world.random.shuffle(group_items)
request_minimum_items(group_items, min_upgrades_per_unit) request_minimum_items(group_items, min_upgrades_per_unit)

View File

@@ -129,7 +129,7 @@ def adjust_mission_pools(world: 'SC2World', pools: SC2MOGenMissionPools):
if grant_story_tech == GrantStoryTech.option_grant: if grant_story_tech == GrantStoryTech.option_grant:
# Additional starter mission if player is granted story tech # Additional starter mission if player is granted story tech
pools.move_mission(SC2Mission.ENEMY_WITHIN, Difficulty.EASY, Difficulty.STARTER) pools.move_mission(SC2Mission.ENEMY_WITHIN, Difficulty.EASY, Difficulty.STARTER)
pools.move_mission(SC2Mission.THE_ESCAPE, Difficulty.EASY, Difficulty.STARTER) pools.move_mission(SC2Mission.THE_ESCAPE, Difficulty.MEDIUM, Difficulty.STARTER)
pools.move_mission(SC2Mission.IN_THE_ENEMY_S_SHADOW, Difficulty.MEDIUM, 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: if not war_council_nerfs or grant_story_tech == GrantStoryTech.option_grant:
pools.move_mission(SC2Mission.TEMPLAR_S_RETURN, Difficulty.MEDIUM, Difficulty.STARTER) pools.move_mission(SC2Mission.TEMPLAR_S_RETURN, Difficulty.MEDIUM, Difficulty.STARTER)

View File

@@ -1,52 +0,0 @@
"""
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)

View File

@@ -1,4 +1,4 @@
from typing import Any, cast from typing import *
import unittest import unittest
import random import random
from argparse import Namespace from argparse import Namespace
@@ -6,11 +6,18 @@ from BaseClasses import MultiWorld, CollectionState, PlandoOptions
from Generate import get_seed_name from Generate import get_seed_name
from worlds import AutoWorld from worlds import AutoWorld
from test.general import gen_steps, call_all 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 SC2World, SC2Campaign
from .. import client
from .. import options 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): class Sc2SetupTestBase(unittest.TestCase):
""" """
@@ -30,11 +37,10 @@ class Sc2SetupTestBase(unittest.TestCase):
PROTOSS_CAMPAIGNS = { PROTOSS_CAMPAIGNS = {
'enabled_campaigns': {SC2Campaign.PROPHECY.campaign_name, SC2Campaign.PROLOGUE.campaign_name, SC2Campaign.LOTV.campaign_name,} 'enabled_campaigns': {SC2Campaign.PROPHECY.campaign_name, SC2Campaign.PROLOGUE.campaign_name, SC2Campaign.LOTV.campaign_name,}
} }
seed: int | None = None seed: Optional[int] = None
game = SC2World.game game = SC2World.game
player = 1 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 = MultiWorld(1)
self.multiworld.game[self.player] = self.game self.multiworld.game[self.player] = self.game
self.multiworld.player_name = {self.player: "Tester"} self.multiworld.player_name = {self.player: "Tester"}
@@ -57,11 +63,3 @@ class Sc2SetupTestBase(unittest.TestCase):
except Exception as ex: except Exception as ex:
ex.add_note(f"Seed: {self.multiworld.seed}") ex.add_note(f"Seed: {self.multiworld.seed}")
raise 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

View File

@@ -1,24 +1,20 @@
""" """
Unit tests for world generation Unit tests for world generation
""" """
from typing import Any from typing import *
from .test_base import Sc2SetupTestBase from .test_base import Sc2SetupTestBase
from .. import ( from .. import mission_groups, mission_tables, options, locations, SC2Mission, SC2Campaign, SC2Race, unreleased_items, \
mission_groups, mission_tables, options, locations, RequiredTactics
SC2Mission, SC2Campaign, SC2Race, unreleased_items,
RequiredTactics,
)
from ..item import item_groups, item_tables, item_names from ..item import item_groups, item_tables, item_names
from .. import get_all_missions, get_random_first_mission from .. import get_all_missions, get_random_first_mission
from ..options import ( from ..options import EnabledCampaigns, NovaGhostOfAChanceVariant, MissionOrder, ExcludeOverpoweredItems, \
EnabledCampaigns, NovaGhostOfAChanceVariant, MissionOrder, ExcludeOverpoweredItems, VanillaItemsOnly, MaximumCampaignSize
VanillaItemsOnly, MaximumCampaignSize,
)
class TestItemFiltering(Sc2SetupTestBase): class TestItemFiltering(Sc2SetupTestBase):
def test_explicit_locks_excludes_interact_and_set_flags(self) -> None: def test_explicit_locks_excludes_interact_and_set_flags(self):
world_options = { world_options = {
**self.ALL_CAMPAIGNS, **self.ALL_CAMPAIGNS,
'locked_items': { 'locked_items': {
@@ -50,7 +46,7 @@ class TestItemFiltering(Sc2SetupTestBase):
regen_biosteel_items = [x for x in itempool if x == item_names.PROGRESSIVE_REGENERATIVE_BIO_STEEL] regen_biosteel_items = [x for x in itempool if x == item_names.PROGRESSIVE_REGENERATIVE_BIO_STEEL]
self.assertEqual(len(regen_biosteel_items), 2) self.assertEqual(len(regen_biosteel_items), 2)
def test_unexcludes_cancel_out_excludes(self) -> None: def test_unexcludes_cancel_out_excludes(self):
world_options = { world_options = {
'grant_story_tech': options.GrantStoryTech.option_grant, 'grant_story_tech': options.GrantStoryTech.option_grant,
'excluded_items': { 'excluded_items': {
@@ -125,7 +121,7 @@ class TestItemFiltering(Sc2SetupTestBase):
itempool = [item.name for item in self.multiworld.itempool] itempool = [item.name for item in self.multiworld.itempool]
self.assertNotIn(item_names.MARINE, itempool) self.assertNotIn(item_names.MARINE, itempool)
def test_excluding_groups_excludes_all_items_in_group(self) -> None: def test_excluding_groups_excludes_all_items_in_group(self):
world_options = { world_options = {
'excluded_items': { 'excluded_items': {
item_groups.ItemGroupNames.BARRACKS_UNITS.lower(): -1, item_groups.ItemGroupNames.BARRACKS_UNITS.lower(): -1,
@@ -137,7 +133,7 @@ class TestItemFiltering(Sc2SetupTestBase):
for item_name in item_groups.barracks_units: for item_name in item_groups.barracks_units:
self.assertNotIn(item_name, itempool) self.assertNotIn(item_name, itempool)
def test_excluding_mission_groups_excludes_all_missions_in_group(self) -> None: def test_excluding_mission_groups_excludes_all_missions_in_group(self):
world_options = { world_options = {
**self.ZERG_CAMPAIGNS, **self.ZERG_CAMPAIGNS,
'enable_race_swap': options.EnableRaceSwapVariants.option_shuffle_all, 'enable_race_swap': options.EnableRaceSwapVariants.option_shuffle_all,
@@ -168,7 +164,7 @@ class TestItemFiltering(Sc2SetupTestBase):
self.assertNotEqual(item_data.type, item_tables.TerranItemType.Nova_Gear) self.assertNotEqual(item_data.type, item_tables.TerranItemType.Nova_Gear)
self.assertNotEqual(item_name, item_names.NOVA_PROGRESSIVE_STEALTH_SUIT_MODULE) self.assertNotEqual(item_name, item_names.NOVA_PROGRESSIVE_STEALTH_SUIT_MODULE)
def test_starter_unit_populates_start_inventory(self) -> None: def test_starter_unit_populates_start_inventory(self):
world_options = { world_options = {
'enabled_campaigns': { 'enabled_campaigns': {
SC2Campaign.WOL.campaign_name, SC2Campaign.WOL.campaign_name,
@@ -312,7 +308,7 @@ class TestItemFiltering(Sc2SetupTestBase):
self.generate_world(world_options) self.generate_world(world_options)
world_items = [(item.name, item_tables.item_table[item.name]) for item in self.multiworld.itempool] world_items = [(item.name, item_tables.item_table[item.name]) for item in self.multiworld.itempool]
self.assertTrue(world_items) self.assertTrue(world_items)
occurrences: dict[str, int] = {} occurrences: Dict[str, int] = {}
for item_name, _ in world_items: for item_name, _ in world_items:
if item_name in item_groups.terran_progressive_items: if item_name in item_groups.terran_progressive_items:
if item_name in item_groups.nova_equipment: if item_name in item_groups.nova_equipment:
@@ -532,7 +528,7 @@ class TestItemFiltering(Sc2SetupTestBase):
Orbital command got replaced. The item is still there for backwards compatibility. Orbital command got replaced. The item is still there for backwards compatibility.
It shouldn't be generated. It shouldn't be generated.
""" """
world_options: dict[str, Any] = {} world_options = {}
self.generate_world(world_options) self.generate_world(world_options)
itempool = [item.name for item in self.multiworld.itempool] itempool = [item.name for item in self.multiworld.itempool]
@@ -599,7 +595,7 @@ class TestItemFiltering(Sc2SetupTestBase):
self.assertIn(speedrun_location_name, all_location_names) self.assertIn(speedrun_location_name, all_location_names)
self.assertNotIn(speedrun_location_name, world_location_names) self.assertNotIn(speedrun_location_name, world_location_names)
def test_nco_and_wol_picks_correct_starting_mission(self) -> None: def test_nco_and_wol_picks_correct_starting_mission(self):
world_options = { world_options = {
'mission_order': MissionOrder.option_vanilla, 'mission_order': MissionOrder.option_vanilla,
'enabled_campaigns': { 'enabled_campaigns': {
@@ -610,7 +606,7 @@ class TestItemFiltering(Sc2SetupTestBase):
self.generate_world(world_options) self.generate_world(world_options)
self.assertEqual(get_random_first_mission(self.world, self.world.custom_mission_order), mission_tables.SC2Mission.LIBERATION_DAY) 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) -> None: def test_excluding_mission_short_name_excludes_all_variants_of_mission(self):
world_options = { world_options = {
'excluded_missions': [ 'excluded_missions': [
mission_tables.SC2Mission.ZERO_HOUR.mission_name.split(" (")[0] mission_tables.SC2Mission.ZERO_HOUR.mission_name.split(" (")[0]
@@ -629,7 +625,7 @@ class TestItemFiltering(Sc2SetupTestBase):
self.assertNotIn(mission_tables.SC2Mission.ZERO_HOUR_Z, missions) self.assertNotIn(mission_tables.SC2Mission.ZERO_HOUR_Z, missions)
self.assertNotIn(mission_tables.SC2Mission.ZERO_HOUR_P, missions) self.assertNotIn(mission_tables.SC2Mission.ZERO_HOUR_P, missions)
def test_excluding_mission_variant_excludes_just_that_variant(self) -> None: def test_excluding_mission_variant_excludes_just_that_variant(self):
world_options = { world_options = {
'excluded_missions': [ 'excluded_missions': [
mission_tables.SC2Mission.ZERO_HOUR.mission_name mission_tables.SC2Mission.ZERO_HOUR.mission_name
@@ -648,7 +644,7 @@ class TestItemFiltering(Sc2SetupTestBase):
self.assertIn(mission_tables.SC2Mission.ZERO_HOUR_Z, missions) self.assertIn(mission_tables.SC2Mission.ZERO_HOUR_Z, missions)
self.assertIn(mission_tables.SC2Mission.ZERO_HOUR_P, missions) self.assertIn(mission_tables.SC2Mission.ZERO_HOUR_P, missions)
def test_weapon_armor_upgrades(self) -> None: def test_weapon_armor_upgrades(self):
world_options = { world_options = {
# Vanilla WoL with all missions # Vanilla WoL with all missions
'mission_order': options.MissionOrder.option_vanilla, 'mission_order': options.MissionOrder.option_vanilla,
@@ -686,7 +682,7 @@ class TestItemFiltering(Sc2SetupTestBase):
self.assertGreaterEqual(len(vehicle_weapon_items), 3) self.assertGreaterEqual(len(vehicle_weapon_items), 3)
self.assertEqual(len(other_bundle_items), 0) self.assertEqual(len(other_bundle_items), 0)
def test_weapon_armor_upgrades_with_bundles(self) -> None: def test_weapon_armor_upgrades_with_bundles(self):
world_options = { world_options = {
# Vanilla WoL with all missions # Vanilla WoL with all missions
'mission_order': options.MissionOrder.option_vanilla, 'mission_order': options.MissionOrder.option_vanilla,
@@ -724,7 +720,7 @@ class TestItemFiltering(Sc2SetupTestBase):
self.assertGreaterEqual(len(vehicle_upgrade_items), 3) self.assertGreaterEqual(len(vehicle_upgrade_items), 3)
self.assertEqual(len(other_bundle_items), 0) self.assertEqual(len(other_bundle_items), 0)
def test_weapon_armor_upgrades_all_in_air(self) -> None: def test_weapon_armor_upgrades_all_in_air(self):
world_options = { world_options = {
# Vanilla WoL with all missions # Vanilla WoL with all missions
'mission_order': options.MissionOrder.option_vanilla, 'mission_order': options.MissionOrder.option_vanilla,
@@ -757,7 +753,7 @@ class TestItemFiltering(Sc2SetupTestBase):
self.assertGreaterEqual(len(vehicle_weapon_items), 3) self.assertGreaterEqual(len(vehicle_weapon_items), 3)
self.assertGreaterEqual(len(ship_weapon_items), 3) self.assertGreaterEqual(len(ship_weapon_items), 3)
def test_weapon_armor_upgrades_generic_upgrade_missions(self) -> None: def test_weapon_armor_upgrades_generic_upgrade_missions(self):
""" """
Tests the case when there aren't enough missions in order to get required weapon/armor upgrades Tests the case when there aren't enough missions in order to get required weapon/armor upgrades
for logic requirements. for logic requirements.
@@ -786,7 +782,7 @@ class TestItemFiltering(Sc2SetupTestBase):
# Under standard tactics you need to place L3 upgrades for available unit classes # Under standard tactics you need to place L3 upgrades for available unit classes
self.assertEqual(len(upgrade_items), 3) self.assertEqual(len(upgrade_items), 3)
def test_weapon_armor_upgrades_generic_upgrade_missions_no_logic(self) -> None: def test_weapon_armor_upgrades_generic_upgrade_missions_no_logic(self):
""" """
Tests the case when there aren't enough missions in order to get required weapon/armor upgrades Tests the case when there aren't enough missions in order to get required weapon/armor upgrades
for logic requirements. for logic requirements.
@@ -817,7 +813,7 @@ class TestItemFiltering(Sc2SetupTestBase):
# No logic won't take the fallback to trigger # No logic won't take the fallback to trigger
self.assertEqual(len(upgrade_items), 0) self.assertEqual(len(upgrade_items), 0)
def test_weapon_armor_upgrades_generic_upgrade_missions_no_countermeasure_needed(self) -> None: def test_weapon_armor_upgrades_generic_upgrade_missions_no_countermeasure_needed(self):
world_options = { world_options = {
# Vanilla WoL with all missions # Vanilla WoL with all missions
'mission_order': options.MissionOrder.option_vanilla, 'mission_order': options.MissionOrder.option_vanilla,
@@ -841,7 +837,7 @@ class TestItemFiltering(Sc2SetupTestBase):
# No additional starting inventory item placement is needed # No additional starting inventory item placement is needed
self.assertEqual(len(upgrade_items), 0) self.assertEqual(len(upgrade_items), 0)
def test_kerrigan_levels_per_mission_triggering_pre_fill(self) -> None: def test_kerrigan_levels_per_mission_triggering_pre_fill(self):
world_options = { world_options = {
**self.ALL_CAMPAIGNS, **self.ALL_CAMPAIGNS,
'mission_order': options.MissionOrder.option_custom, 'mission_order': options.MissionOrder.option_custom,
@@ -882,7 +878,7 @@ class TestItemFiltering(Sc2SetupTestBase):
self.assertGreater(len(kerrigan_1_stacks), 0) self.assertGreater(len(kerrigan_1_stacks), 0)
def test_kerrigan_levels_per_mission_and_generic_upgrades_both_triggering_pre_fill(self) -> None: def test_kerrigan_levels_per_mission_and_generic_upgrades_both_triggering_pre_fill(self):
world_options = { world_options = {
**self.ALL_CAMPAIGNS, **self.ALL_CAMPAIGNS,
'mission_order': options.MissionOrder.option_custom, 'mission_order': options.MissionOrder.option_custom,
@@ -929,7 +925,7 @@ class TestItemFiltering(Sc2SetupTestBase):
self.assertNotIn(item_names.KERRIGAN_LEVELS_70, itempool) self.assertNotIn(item_names.KERRIGAN_LEVELS_70, itempool)
self.assertNotIn(item_names.KERRIGAN_LEVELS_70, starting_inventory) self.assertNotIn(item_names.KERRIGAN_LEVELS_70, starting_inventory)
def test_locking_required_items(self) -> None: def test_locking_required_items(self):
world_options = { world_options = {
**self.ALL_CAMPAIGNS, **self.ALL_CAMPAIGNS,
'mission_order': options.MissionOrder.option_custom, 'mission_order': options.MissionOrder.option_custom,
@@ -966,7 +962,7 @@ class TestItemFiltering(Sc2SetupTestBase):
self.assertIn(item_names.KERRIGAN_MEND, itempool) self.assertIn(item_names.KERRIGAN_MEND, itempool)
def test_fully_balanced_mission_races(self) -> None: def test_fully_balanced_mission_races(self):
""" """
Tests whether fully balanced mission race balancing actually is fully balanced. Tests whether fully balanced mission race balancing actually is fully balanced.
""" """
@@ -1084,7 +1080,7 @@ class TestItemFiltering(Sc2SetupTestBase):
self.generate_world(world_options) self.generate_world(world_options)
itempool = [item.name for item in self.multiworld.itempool] 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: for item_name in itempool:
if item_tables.item_table[item_name].type in ( if item_tables.item_table[item_name].type in (
item_tables.TerranItemType.Upgrade, item_tables.TerranItemType.Upgrade,
@@ -1256,7 +1252,7 @@ class TestItemFiltering(Sc2SetupTestBase):
self.generate_world(world_options) self.generate_world(world_options)
itempool = [item.name for item in self.multiworld.itempool] 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: for item in items_to_check:
self.assertNotIn(item, itempool) self.assertNotIn(item, itempool)
@@ -1277,7 +1273,7 @@ class TestItemFiltering(Sc2SetupTestBase):
self.generate_world(world_options) self.generate_world(world_options)
itempool = [item.name for item in self.multiworld.itempool] 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: for item in items_to_check:
self.assertIn(item, itempool) self.assertIn(item, itempool)

View File

@@ -1,10 +1,9 @@
import unittest import unittest
from .test_base import Sc2SetupTestBase from .test_base import Sc2TestBase
from .. import mission_tables, SC2Campaign from .. import mission_tables, SC2Campaign
from .. import options from .. import options
from ..mission_order.layout_types import Grid from ..mission_order.layout_types import Grid
class TestGridsizes(unittest.TestCase): class TestGridsizes(unittest.TestCase):
def test_grid_sizes_meet_specs(self): def test_grid_sizes_meet_specs(self):
self.assertTupleEqual((1, 2, 0), Grid.get_grid_dimensions(2)) self.assertTupleEqual((1, 2, 0), Grid.get_grid_dimensions(2))
@@ -25,17 +24,17 @@ class TestGridsizes(unittest.TestCase):
self.assertTupleEqual((5, 7, 2), Grid.get_grid_dimensions(33)) self.assertTupleEqual((5, 7, 2), Grid.get_grid_dimensions(33))
class TestGridGeneration(Sc2SetupTestBase): class TestGridGeneration(Sc2TestBase):
def test_size_matches_exclusions(self): options = {
world_options = { "mission_order": options.MissionOrder.option_grid,
options.OPTION_NAME[options.MissionOrder]: options.MissionOrder.option_grid, "excluded_missions": [mission_tables.SC2Mission.ZERO_HOUR.mission_name,],
options.OPTION_NAME[options.ExcludedMissions]: [mission_tables.SC2Mission.ZERO_HOUR.mission_name], "enabled_campaigns": {
options.OPTION_NAME[options.EnabledCampaigns]: {
SC2Campaign.WOL.campaign_name, SC2Campaign.WOL.campaign_name,
SC2Campaign.PROPHECY.campaign_name, SC2Campaign.PROPHECY.campaign_name,
} }
} }
self.generate_world(world_options)
def test_size_matches_exclusions(self):
self.assertNotIn(mission_tables.SC2Mission.ZERO_HOUR.mission_name, self.multiworld.regions) 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 # WoL has 29 missions. -1 for Zero Hour being excluded, +1 for the automatically-added menu location
self.assertEqual(len(self.multiworld.regions), 29) self.assertEqual(len(self.multiworld.regions), 29)

View File

@@ -132,7 +132,7 @@ class SMWSNIClient(SNIClient):
self.instance_id = time.time() self.instance_id = time.time()
source_name = args["data"]["source"] source_name = args["data"]["source"]
if "TrapLink" in ctx.tags and "TrapLink" in args["tags"] and source_name != ctx.player_names[ctx.slot]: if "TrapLink" in ctx.tags and "TrapLink" in args["tags"] and source_name != ctx.slot_info[ctx.slot].name:
trap_name: str = args["data"]["trap_name"] trap_name: str = args["data"]["trap_name"]
if trap_name not in trap_name_to_value: if trap_name not in trap_name_to_value:
# We don't know how to handle this trap, ignore it # We don't know how to handle this trap, ignore it

View File

@@ -257,7 +257,7 @@ algorerhythm_bundle = BundleTemplate(CCRoom.bulletin_board, MemeBundleName.algor
red_fish_items = [red_mullet, red_snapper, lava_eel, crimsonfish] 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] 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, 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, bullhead, tilapia, chub, dorado, shad, tiger_trout, super_cucumber, ghostfish, stonefish, sandfish, scorpion_carp, flounder, midnight_carp, tigerseye, bullhead, tilapia, chub, dorado, shad,
lingcod, halibut, slimejack, stingray, goby, blobfish, angler, legend, mutant_carp] 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_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) dr_seuss_bundle = FixedPriceDeepBundleTemplate(CCRoom.crafts_room, MemeBundleName.dr_seuss, dr_seuss_items, 4, 4)

View File

@@ -438,8 +438,6 @@ id,region,name,tags,content_packs
906,Traveling Cart Sunday,Traveling Merchant Sunday Item 6,"TRAVELING_MERCHANT", 906,Traveling Cart Sunday,Traveling Merchant Sunday Item 6,"TRAVELING_MERCHANT",
907,Traveling Cart Sunday,Traveling Merchant Sunday Item 7,"TRAVELING_MERCHANT", 907,Traveling Cart Sunday,Traveling Merchant Sunday Item 7,"TRAVELING_MERCHANT",
908,Traveling Cart Sunday,Traveling Merchant Sunday Item 8,"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", 911,Traveling Cart Monday,Traveling Merchant Monday Item 1,"MANDATORY,TRAVELING_MERCHANT",
912,Traveling Cart Monday,Traveling Merchant Monday Item 2,"TRAVELING_MERCHANT", 912,Traveling Cart Monday,Traveling Merchant Monday Item 2,"TRAVELING_MERCHANT",
913,Traveling Cart Monday,Traveling Merchant Monday Item 3,"TRAVELING_MERCHANT", 913,Traveling Cart Monday,Traveling Merchant Monday Item 3,"TRAVELING_MERCHANT",
@@ -448,8 +446,6 @@ id,region,name,tags,content_packs
916,Traveling Cart Monday,Traveling Merchant Monday Item 6,"TRAVELING_MERCHANT", 916,Traveling Cart Monday,Traveling Merchant Monday Item 6,"TRAVELING_MERCHANT",
917,Traveling Cart Monday,Traveling Merchant Monday Item 7,"TRAVELING_MERCHANT", 917,Traveling Cart Monday,Traveling Merchant Monday Item 7,"TRAVELING_MERCHANT",
918,Traveling Cart Monday,Traveling Merchant Monday Item 8,"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", 921,Traveling Cart Tuesday,Traveling Merchant Tuesday Item 1,"MANDATORY,TRAVELING_MERCHANT",
922,Traveling Cart Tuesday,Traveling Merchant Tuesday Item 2,"TRAVELING_MERCHANT", 922,Traveling Cart Tuesday,Traveling Merchant Tuesday Item 2,"TRAVELING_MERCHANT",
923,Traveling Cart Tuesday,Traveling Merchant Tuesday Item 3,"TRAVELING_MERCHANT", 923,Traveling Cart Tuesday,Traveling Merchant Tuesday Item 3,"TRAVELING_MERCHANT",
@@ -458,8 +454,6 @@ id,region,name,tags,content_packs
926,Traveling Cart Tuesday,Traveling Merchant Tuesday Item 6,"TRAVELING_MERCHANT", 926,Traveling Cart Tuesday,Traveling Merchant Tuesday Item 6,"TRAVELING_MERCHANT",
927,Traveling Cart Tuesday,Traveling Merchant Tuesday Item 7,"TRAVELING_MERCHANT", 927,Traveling Cart Tuesday,Traveling Merchant Tuesday Item 7,"TRAVELING_MERCHANT",
928,Traveling Cart Tuesday,Traveling Merchant Tuesday Item 8,"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", 931,Traveling Cart Wednesday,Traveling Merchant Wednesday Item 1,"MANDATORY,TRAVELING_MERCHANT",
932,Traveling Cart Wednesday,Traveling Merchant Wednesday Item 2,"TRAVELING_MERCHANT", 932,Traveling Cart Wednesday,Traveling Merchant Wednesday Item 2,"TRAVELING_MERCHANT",
933,Traveling Cart Wednesday,Traveling Merchant Wednesday Item 3,"TRAVELING_MERCHANT", 933,Traveling Cart Wednesday,Traveling Merchant Wednesday Item 3,"TRAVELING_MERCHANT",
@@ -468,8 +462,6 @@ id,region,name,tags,content_packs
936,Traveling Cart Wednesday,Traveling Merchant Wednesday Item 6,"TRAVELING_MERCHANT", 936,Traveling Cart Wednesday,Traveling Merchant Wednesday Item 6,"TRAVELING_MERCHANT",
937,Traveling Cart Wednesday,Traveling Merchant Wednesday Item 7,"TRAVELING_MERCHANT", 937,Traveling Cart Wednesday,Traveling Merchant Wednesday Item 7,"TRAVELING_MERCHANT",
938,Traveling Cart Wednesday,Traveling Merchant Wednesday Item 8,"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", 941,Traveling Cart Thursday,Traveling Merchant Thursday Item 1,"MANDATORY,TRAVELING_MERCHANT",
942,Traveling Cart Thursday,Traveling Merchant Thursday Item 2,"TRAVELING_MERCHANT", 942,Traveling Cart Thursday,Traveling Merchant Thursday Item 2,"TRAVELING_MERCHANT",
943,Traveling Cart Thursday,Traveling Merchant Thursday Item 3,"TRAVELING_MERCHANT", 943,Traveling Cart Thursday,Traveling Merchant Thursday Item 3,"TRAVELING_MERCHANT",
@@ -478,8 +470,6 @@ id,region,name,tags,content_packs
946,Traveling Cart Thursday,Traveling Merchant Thursday Item 6,"TRAVELING_MERCHANT", 946,Traveling Cart Thursday,Traveling Merchant Thursday Item 6,"TRAVELING_MERCHANT",
947,Traveling Cart Thursday,Traveling Merchant Thursday Item 7,"TRAVELING_MERCHANT", 947,Traveling Cart Thursday,Traveling Merchant Thursday Item 7,"TRAVELING_MERCHANT",
948,Traveling Cart Thursday,Traveling Merchant Thursday Item 8,"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", 951,Traveling Cart Friday,Traveling Merchant Friday Item 1,"MANDATORY,TRAVELING_MERCHANT",
952,Traveling Cart Friday,Traveling Merchant Friday Item 2,"TRAVELING_MERCHANT", 952,Traveling Cart Friday,Traveling Merchant Friday Item 2,"TRAVELING_MERCHANT",
953,Traveling Cart Friday,Traveling Merchant Friday Item 3,"TRAVELING_MERCHANT", 953,Traveling Cart Friday,Traveling Merchant Friday Item 3,"TRAVELING_MERCHANT",
@@ -488,8 +478,6 @@ id,region,name,tags,content_packs
956,Traveling Cart Friday,Traveling Merchant Friday Item 6,"TRAVELING_MERCHANT", 956,Traveling Cart Friday,Traveling Merchant Friday Item 6,"TRAVELING_MERCHANT",
957,Traveling Cart Friday,Traveling Merchant Friday Item 7,"TRAVELING_MERCHANT", 957,Traveling Cart Friday,Traveling Merchant Friday Item 7,"TRAVELING_MERCHANT",
958,Traveling Cart Friday,Traveling Merchant Friday Item 8,"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", 961,Traveling Cart Saturday,Traveling Merchant Saturday Item 1,"MANDATORY,TRAVELING_MERCHANT",
962,Traveling Cart Saturday,Traveling Merchant Saturday Item 2,"TRAVELING_MERCHANT", 962,Traveling Cart Saturday,Traveling Merchant Saturday Item 2,"TRAVELING_MERCHANT",
963,Traveling Cart Saturday,Traveling Merchant Saturday Item 3,"TRAVELING_MERCHANT", 963,Traveling Cart Saturday,Traveling Merchant Saturday Item 3,"TRAVELING_MERCHANT",
@@ -498,8 +486,6 @@ id,region,name,tags,content_packs
966,Traveling Cart Saturday,Traveling Merchant Saturday Item 6,"TRAVELING_MERCHANT", 966,Traveling Cart Saturday,Traveling Merchant Saturday Item 6,"TRAVELING_MERCHANT",
967,Traveling Cart Saturday,Traveling Merchant Saturday Item 7,"TRAVELING_MERCHANT", 967,Traveling Cart Saturday,Traveling Merchant Saturday Item 7,"TRAVELING_MERCHANT",
968,Traveling Cart Saturday,Traveling Merchant Saturday Item 8,"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, 1001,Fishing,Fishsanity: Carp,FISHSANITY,
1002,Fishing,Fishsanity: Herring,FISHSANITY, 1002,Fishing,Fishsanity: Herring,FISHSANITY,
1003,Fishing,Fishsanity: Smallmouth Bass,FISHSANITY, 1003,Fishing,Fishsanity: Smallmouth Bass,FISHSANITY,
@@ -1196,7 +1182,7 @@ id,region,name,tags,content_packs
2104,Fishing,Biome Balance,SPECIAL_ORDER_BOARD, 2104,Fishing,Biome Balance,SPECIAL_ORDER_BOARD,
2105,Haley's House,Rock Rejuvenation,SPECIAL_ORDER_BOARD, 2105,Haley's House,Rock Rejuvenation,SPECIAL_ORDER_BOARD,
2106,Alex's House,Gifts for George,SPECIAL_ORDER_BOARD, 2106,Alex's House,Gifts for George,SPECIAL_ORDER_BOARD,
2107,Museum,Fragments of the past,"SPECIAL_ORDER_BOARD", 2107,Museum,Fragments of the past,"GINGER_ISLAND,SPECIAL_ORDER_BOARD",
2108,Saloon,Gus' Famous Omelet,SPECIAL_ORDER_BOARD, 2108,Saloon,Gus' Famous Omelet,SPECIAL_ORDER_BOARD,
2109,Farm,Crop Order,SPECIAL_ORDER_BOARD, 2109,Farm,Crop Order,SPECIAL_ORDER_BOARD,
2110,Railroad,Community Cleanup,SPECIAL_ORDER_BOARD, 2110,Railroad,Community Cleanup,SPECIAL_ORDER_BOARD,
@@ -2241,7 +2227,7 @@ id,region,name,tags,content_packs
3530,Farm,Craft Cookout Kit,"CRAFTSANITY,CRAFTSANITY_CRAFT", 3530,Farm,Craft Cookout Kit,"CRAFTSANITY,CRAFTSANITY_CRAFT",
3531,Farm,Craft Fish Smoker,"CRAFTSANITY,CRAFTSANITY_CRAFT", 3531,Farm,Craft Fish Smoker,"CRAFTSANITY,CRAFTSANITY_CRAFT",
3532,Farm,Craft Dehydrator,"CRAFTSANITY,CRAFTSANITY_CRAFT", 3532,Farm,Craft Dehydrator,"CRAFTSANITY,CRAFTSANITY_CRAFT",
3533,Farm,Craft Blue Grass Starter,"CRAFTSANITY,CRAFTSANITY_CRAFT,GINGER_ISLAND,REQUIRES_QI_ORDERS", 3533,Farm,Craft Blue Grass Starter,"CRAFTSANITY,CRAFTSANITY_CRAFT,GINGER_ISLAND",
3534,Farm,Craft Mystic Tree Seed,"CRAFTSANITY,CRAFTSANITY_CRAFT,REQUIRES_MASTERIES", 3534,Farm,Craft Mystic Tree Seed,"CRAFTSANITY,CRAFTSANITY_CRAFT,REQUIRES_MASTERIES",
3535,Farm,Craft Sonar Bobber,"CRAFTSANITY,CRAFTSANITY_CRAFT", 3535,Farm,Craft Sonar Bobber,"CRAFTSANITY,CRAFTSANITY_CRAFT",
3536,Farm,Craft Challenge Bait,"CRAFTSANITY,CRAFTSANITY_CRAFT,REQUIRES_MASTERIES", 3536,Farm,Craft Challenge Bait,"CRAFTSANITY,CRAFTSANITY_CRAFT,REQUIRES_MASTERIES",
1 id region name tags content_packs
438 906 Traveling Cart Sunday Traveling Merchant Sunday Item 6 TRAVELING_MERCHANT
439 907 Traveling Cart Sunday Traveling Merchant Sunday Item 7 TRAVELING_MERCHANT
440 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
441 911 Traveling Cart Monday Traveling Merchant Monday Item 1 MANDATORY,TRAVELING_MERCHANT
442 912 Traveling Cart Monday Traveling Merchant Monday Item 2 TRAVELING_MERCHANT
443 913 Traveling Cart Monday Traveling Merchant Monday Item 3 TRAVELING_MERCHANT
446 916 Traveling Cart Monday Traveling Merchant Monday Item 6 TRAVELING_MERCHANT
447 917 Traveling Cart Monday Traveling Merchant Monday Item 7 TRAVELING_MERCHANT
448 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
449 921 Traveling Cart Tuesday Traveling Merchant Tuesday Item 1 MANDATORY,TRAVELING_MERCHANT
450 922 Traveling Cart Tuesday Traveling Merchant Tuesday Item 2 TRAVELING_MERCHANT
451 923 Traveling Cart Tuesday Traveling Merchant Tuesday Item 3 TRAVELING_MERCHANT
454 926 Traveling Cart Tuesday Traveling Merchant Tuesday Item 6 TRAVELING_MERCHANT
455 927 Traveling Cart Tuesday Traveling Merchant Tuesday Item 7 TRAVELING_MERCHANT
456 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
457 931 Traveling Cart Wednesday Traveling Merchant Wednesday Item 1 MANDATORY,TRAVELING_MERCHANT
458 932 Traveling Cart Wednesday Traveling Merchant Wednesday Item 2 TRAVELING_MERCHANT
459 933 Traveling Cart Wednesday Traveling Merchant Wednesday Item 3 TRAVELING_MERCHANT
462 936 Traveling Cart Wednesday Traveling Merchant Wednesday Item 6 TRAVELING_MERCHANT
463 937 Traveling Cart Wednesday Traveling Merchant Wednesday Item 7 TRAVELING_MERCHANT
464 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
465 941 Traveling Cart Thursday Traveling Merchant Thursday Item 1 MANDATORY,TRAVELING_MERCHANT
466 942 Traveling Cart Thursday Traveling Merchant Thursday Item 2 TRAVELING_MERCHANT
467 943 Traveling Cart Thursday Traveling Merchant Thursday Item 3 TRAVELING_MERCHANT
470 946 Traveling Cart Thursday Traveling Merchant Thursday Item 6 TRAVELING_MERCHANT
471 947 Traveling Cart Thursday Traveling Merchant Thursday Item 7 TRAVELING_MERCHANT
472 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
473 951 Traveling Cart Friday Traveling Merchant Friday Item 1 MANDATORY,TRAVELING_MERCHANT
474 952 Traveling Cart Friday Traveling Merchant Friday Item 2 TRAVELING_MERCHANT
475 953 Traveling Cart Friday Traveling Merchant Friday Item 3 TRAVELING_MERCHANT
478 956 Traveling Cart Friday Traveling Merchant Friday Item 6 TRAVELING_MERCHANT
479 957 Traveling Cart Friday Traveling Merchant Friday Item 7 TRAVELING_MERCHANT
480 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
481 961 Traveling Cart Saturday Traveling Merchant Saturday Item 1 MANDATORY,TRAVELING_MERCHANT
482 962 Traveling Cart Saturday Traveling Merchant Saturday Item 2 TRAVELING_MERCHANT
483 963 Traveling Cart Saturday Traveling Merchant Saturday Item 3 TRAVELING_MERCHANT
486 966 Traveling Cart Saturday Traveling Merchant Saturday Item 6 TRAVELING_MERCHANT
487 967 Traveling Cart Saturday Traveling Merchant Saturday Item 7 TRAVELING_MERCHANT
488 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
489 1001 Fishing Fishsanity: Carp FISHSANITY
490 1002 Fishing Fishsanity: Herring FISHSANITY
491 1003 Fishing Fishsanity: Smallmouth Bass FISHSANITY
1182 2104 Fishing Biome Balance SPECIAL_ORDER_BOARD
1183 2105 Haley's House Rock Rejuvenation SPECIAL_ORDER_BOARD
1184 2106 Alex's House Gifts for George SPECIAL_ORDER_BOARD
1185 2107 Museum Fragments of the past SPECIAL_ORDER_BOARD GINGER_ISLAND,SPECIAL_ORDER_BOARD
1186 2108 Saloon Gus' Famous Omelet SPECIAL_ORDER_BOARD
1187 2109 Farm Crop Order SPECIAL_ORDER_BOARD
1188 2110 Railroad Community Cleanup SPECIAL_ORDER_BOARD
2227 3530 Farm Craft Cookout Kit CRAFTSANITY,CRAFTSANITY_CRAFT
2228 3531 Farm Craft Fish Smoker CRAFTSANITY,CRAFTSANITY_CRAFT
2229 3532 Farm Craft Dehydrator CRAFTSANITY,CRAFTSANITY_CRAFT
2230 3533 Farm Craft Blue Grass Starter CRAFTSANITY,CRAFTSANITY_CRAFT,GINGER_ISLAND,REQUIRES_QI_ORDERS CRAFTSANITY,CRAFTSANITY_CRAFT,GINGER_ISLAND
2231 3534 Farm Craft Mystic Tree Seed CRAFTSANITY,CRAFTSANITY_CRAFT,REQUIRES_MASTERIES
2232 3535 Farm Craft Sonar Bobber CRAFTSANITY,CRAFTSANITY_CRAFT
2233 3536 Farm Craft Challenge Bait CRAFTSANITY,CRAFTSANITY_CRAFT,REQUIRES_MASTERIES

View File

@@ -1,7 +1,6 @@
import csv import csv
import enum import enum
import logging import logging
import math
from dataclasses import dataclass from dataclasses import dataclass
from random import Random from random import Random
from typing import Optional, Dict, Protocol, List, Iterable from typing import Optional, Dict, Protocol, List, Iterable
@@ -17,7 +16,7 @@ from .mods.mod_data import ModNames
from .options import ArcadeMachineLocations, SpecialOrderLocations, Museumsanity, \ from .options import ArcadeMachineLocations, SpecialOrderLocations, Museumsanity, \
FestivalLocations, ElevatorProgression, BackpackProgression, FarmType FestivalLocations, ElevatorProgression, BackpackProgression, FarmType
from .options import StardewValleyOptions, Craftsanity, Chefsanity, Cooksanity, Shipsanity, Monstersanity from .options import StardewValleyOptions, Craftsanity, Chefsanity, Cooksanity, Shipsanity, Monstersanity
from .options.options import BackpackSize, Moviesanity, Eatsanity, IncludeEndgameLocations, Friendsanity, Fishsanity, SkillProgression, Cropsanity from .options.options import BackpackSize, Moviesanity, Eatsanity, IncludeEndgameLocations, Friendsanity
from .strings.ap_names.ap_option_names import WalnutsanityOptionName, SecretsanityOptionName, EatsanityOptionName, ChefsanityOptionName, StartWithoutOptionName from .strings.ap_names.ap_option_names import WalnutsanityOptionName, SecretsanityOptionName, EatsanityOptionName, ChefsanityOptionName, StartWithoutOptionName
from .strings.backpack_tiers import Backpack from .strings.backpack_tiers import Backpack
from .strings.goal_names import Goal from .strings.goal_names import Goal
@@ -666,48 +665,19 @@ def extend_endgame_locations(randomized_locations: List[LocationData], options:
def extend_filler_locations(randomized_locations: List[LocationData], options: StardewValleyOptions, content: StardewContent): def extend_filler_locations(randomized_locations: List[LocationData], options: StardewValleyOptions, content: StardewContent):
days = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"] days = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]
number_locations_to_add_per_day = 0 i = 1
min_number_locations = 90 # Under 90 locations we can run out of rooms for the mandatory core items while len(randomized_locations) < 90:
if len(randomized_locations) < min_number_locations: location_name = f"Traveling Merchant Sunday Item {i}"
number_locations_to_add = min_number_locations - len(randomized_locations) while any(location.name == location_name for location in randomized_locations):
number_locations_to_add_per_day += math.ceil(number_locations_to_add / 7) i += 1
location_name = f"Traveling Merchant Sunday Item {i}"
# 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}") logger.debug(f"Player too few locations, adding Traveling Merchant Items #{i}")
for day in days: for day in days:
location_name = f"Traveling Merchant {day} Item {i}" location_name = f"Traveling Merchant {day} Item {i}"
randomized_locations.append(location_table[location_name]) randomized_locations.append(location_table[location_name])
def create_locations(location_collector: StardewLocationCollector, def create_locations(location_collector: StardewLocationCollector,
bundle_rooms: List[BundleRoom], bundle_rooms: List[BundleRoom],
trash_bear_requests: Dict[str, List[str]], trash_bear_requests: Dict[str, List[str]],

View File

@@ -297,6 +297,7 @@ class StardewLogic(ReceivedLogicMixin, HasLogicMixin, RegionLogicMixin, Travelin
Material.stone: self.ability.can_mine_stone(), Material.stone: self.ability.can_mine_stone(),
Material.wood: self.ability.can_chop_trees(), 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.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.copper: self.can_smelt(Ore.copper),
MetalBar.gold: self.can_smelt(Ore.gold), MetalBar.gold: self.can_smelt(Ore.gold),
MetalBar.iridium: self.can_smelt(Ore.iridium), MetalBar.iridium: self.can_smelt(Ore.iridium),
@@ -312,7 +313,7 @@ class StardewLogic(ReceivedLogicMixin, HasLogicMixin, RegionLogicMixin, Travelin
RetainingSoil.basic: self.money.can_spend_at(Region.pierre_store, 100), 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), 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.lucky_purple_shorts: self.special_items.has_purple_shorts(),
SpecialItem.trimmed_purple_shorts: self.has(SpecialItem.lucky_purple_shorts) & self.has(MetalBar.gold) & self.has(Machine.sewing_machine), SpecialItem.trimmed_purple_shorts: self.has(SpecialItem.lucky_purple_shorts) & self.has(Machine.sewing_machine),
SpecialItem.far_away_stone: self.special_items.has_far_away_stone(), SpecialItem.far_away_stone: self.special_items.has_far_away_stone(),
SpecialItem.solid_gold_lewis: self.special_items.has_solid_gold_lewis(), SpecialItem.solid_gold_lewis: self.special_items.has_solid_gold_lewis(),
SpecialItem.advanced_tv_remote: self.special_items.has_advanced_tv_remote(), SpecialItem.advanced_tv_remote: self.special_items.has_advanced_tv_remote(),

View File

@@ -7,13 +7,6 @@ from ..items import Group, item_table
from ..items.item_data import FILLER_GROUPS 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): class TestLocationGeneration(SVTestBase):
def test_all_location_created_are_in_location_table(self): def test_all_location_created_are_in_location_table(self):
@@ -27,7 +20,8 @@ class TestMinLocationAndMaxItem(SVTestBase):
def test_minimal_location_maximal_items_still_valid(self): def test_minimal_location_maximal_items_still_valid(self):
valid_locations = self.get_real_locations() valid_locations = self.get_real_locations()
number_locations = len(valid_locations) number_locations = len(valid_locations)
number_items = get_real_item_count(self.multiworld) 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])
print(f"Stardew Valley - Minimum Locations: {number_locations}, Maximum Items: {number_items} [ISLAND EXCLUDED]") print(f"Stardew Valley - Minimum Locations: {number_locations}, Maximum Items: {number_items} [ISLAND EXCLUDED]")
self.assertGreaterEqual(number_locations, number_items) self.assertGreaterEqual(number_locations, number_items)
@@ -38,7 +32,8 @@ class TestMinLocationAndMaxItemWithIsland(SVTestBase):
def test_minimal_location_maximal_items_with_island_still_valid(self): def test_minimal_location_maximal_items_with_island_still_valid(self):
valid_locations = self.get_real_locations() valid_locations = self.get_real_locations()
number_locations = len(valid_locations) number_locations = len(valid_locations)
number_items = get_real_item_count(self.multiworld) 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)])
print(f"Stardew Valley - Minimum Locations: {number_locations}, Maximum Items: {number_items} [ISLAND INCLUDED]") print(f"Stardew Valley - Minimum Locations: {number_locations}, Maximum Items: {number_items} [ISLAND INCLUDED]")
self.assertGreaterEqual(number_locations, number_items) self.assertGreaterEqual(number_locations, number_items)
@@ -104,5 +99,3 @@ class TestAllSanityWithModsSettingsHasAllExpectedLocations(SVTestBase):
f"\n\tPlease update test_allsanity_with_mods_has_at_least_locations" f"\n\tPlease update test_allsanity_with_mods_has_at_least_locations"
f"\n\t\tExpected: {expected_locations}" f"\n\t\tExpected: {expected_locations}"
f"\n\t\tActual: {number_locations}") f"\n\t\tActual: {number_locations}")

View File

@@ -1,62 +0,0 @@
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)")

View File

@@ -292,48 +292,3 @@ def minimal_locations_maximal_items_with_island():
min_max_options = minimal_locations_maximal_items() min_max_options = minimal_locations_maximal_items()
min_max_options.update({options.ExcludeGingerIsland.internal_name: options.ExcludeGingerIsland.option_false}) min_max_options.update({options.ExcludeGingerIsland.internal_name: options.ExcludeGingerIsland.option_false})
return min_max_options 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

View File

@@ -527,7 +527,7 @@ class WitnessPlayerLogic:
if chal_lasers > 7: if chal_lasers > 7:
postgame_adjustments.append([ postgame_adjustments.append([
"Requirement Changes:", "Requirement Changes:",
"0xFFF00 - 11 Lasers + Redirect - True", "0xFFF00 - 11 Lasers - True",
]) ])
if disable_challenge_lasers: if disable_challenge_lasers:
@@ -640,7 +640,7 @@ class WitnessPlayerLogic:
if chal_lasers <= 7 or mnt_lasers > 7: if chal_lasers <= 7 or mnt_lasers > 7:
adjustment_linesets_in_order.append([ adjustment_linesets_in_order.append([
"Requirement Changes:", "Requirement Changes:",
"0xFFF00 - 11 Lasers + Redirect - True", "0xFFF00 - 11 Lasers - True",
]) ])
if world.options.disable_non_randomized_puzzles: if world.options.disable_non_randomized_puzzles:

View File

@@ -216,32 +216,3 @@ class TestDoorsRequiredToWinElevator(WitnessTestBase):
} }
self.assert_can_beat_with_minimally(exact_requirement) 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)")