Compare commits

..

3 Commits

Author SHA1 Message Date
Berserker
df76c26fbb Core: add assertion preventing building with empty platforms list 2026-03-08 21:21:39 +01:00
Berserker
9a900e29e5 Core: any platform is now None/missing key 2026-03-08 17:26:12 +01:00
Berserker
41eba5a2f6 Core: add platforms field to manifest 2026-03-05 00:38:16 +01:00
290 changed files with 6012 additions and 9578 deletions

View File

@@ -3,7 +3,6 @@
"../BizHawkClient.py",
"../Patch.py",
"../rule_builder/cached_world.py",
"../rule_builder/field_resolvers.py",
"../rule_builder/options.py",
"../rule_builder/rules.py",
"../test/param.py",
@@ -19,7 +18,6 @@
"../test/programs/test_multi_server.py",
"../test/utils/__init__.py",
"../test/webhost/test_descriptions.py",
"../test/webhost/test_suuid.py",
"../worlds/AutoSNIClient.py",
"type_check.py"
],

View File

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

View File

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

View File

@@ -17,26 +17,17 @@ on:
paths:
- '**.py'
- '**.js'
- '.github/workflows/*.yml'
- '.github/workflows/*.yaml'
- '**/action.yml'
- '**/action.yaml'
- '.github/workflows/codeql-analysis.yml'
pull_request:
# The branches below must be a subset of the branches above
branches: [ main ]
paths:
- '**.py'
- '**.js'
- '.github/workflows/*.yml'
- '.github/workflows/*.yaml'
- '**/action.yml'
- '**/action.yaml'
- '.github/workflows/codeql-analysis.yml'
schedule:
- cron: '44 8 * * 1'
permissions:
security-events: write
jobs:
analyze:
name: Analyze
@@ -45,17 +36,18 @@ jobs:
strategy:
fail-fast: false
matrix:
language: [ 'javascript', 'python', 'actions' ]
language: [ 'javascript', 'python' ]
# CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ]
# Learn more:
# https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed
steps:
- name: Checkout repository
uses: actions/checkout@v6.0.2
uses: actions/checkout@v4
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v4.35.1
uses: github/codeql-action/init@v3
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
@@ -66,7 +58,7 @@ jobs:
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@v4.35.1
uses: github/codeql-action/autobuild@v3
# Command-line programs to run using the OS shell.
# 📚 https://git.io/JvXDl
@@ -80,4 +72,4 @@ jobs:
# make release
- 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'
- '.github/workflows/ctest.yml'
permissions: {}
jobs:
ctest:
runs-on: ${{ matrix.os }}
@@ -37,7 +35,7 @@ jobs:
os: [ubuntu-latest, windows-latest]
steps:
- uses: actions/checkout@v6.0.2
- uses: actions/checkout@v4
- uses: ilammy/msvc-dev-cmd@0b201ec74fa43914dc39ae48a89fd1d8cb592756
if: startsWith(matrix.os,'windows')
- uses: Bacondish2023/setup-googletest@49065d1f7a6d21f6134864dd65980fe5dbe06c73

View File

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

View File

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

View File

@@ -29,7 +29,7 @@ jobs:
- name: Set env
run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV # tag x.y.z will become "Archipelago x.y.z"
- name: Create Release
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0
uses: softprops/action-gh-release@975c1b265e11dd76618af1c374e7981f9a6ff44a
with:
draft: true # don't publish right away, especially since windows build is added by hand
prerelease: false
@@ -48,9 +48,9 @@ jobs:
shell: bash
run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV
# - code below copied from build.yml -
- uses: actions/checkout@v6.0.2
- uses: actions/checkout@v4
- name: Install python
uses: actions/setup-python@v6.2.0
uses: actions/setup-python@v5
with:
python-version: '~3.12.7'
check-latest: true
@@ -88,7 +88,7 @@ jobs:
echo "SETUP_NAME=$SETUP_NAME" >> $Env:GITHUB_ENV
# - code above copied from build.yml -
- name: Attest Build
uses: actions/attest@v4.1.0
uses: actions/attest-build-provenance@v2
with:
subject-path: |
build/exe.*/ArchipelagoLauncher.exe
@@ -97,15 +97,13 @@ jobs:
build/exe.*/ArchipelagoServer.exe
setups/*
- name: Add to Release
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0
uses: softprops/action-gh-release@975c1b265e11dd76618af1c374e7981f9a6ff44a
with:
draft: true # see above
prerelease: false
name: Archipelago ${{ env.RELEASE_VERSION }}
files: |
setups/*
fail_on_unmatched_files: true
overwrite_files: false # Windows release is usually built by hand
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -116,14 +114,14 @@ jobs:
- name: Set env
run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV
# - code below copied from build.yml -
- uses: actions/checkout@v6.0.2
- uses: actions/checkout@v4
- name: Install base dependencies
run: |
sudo apt update
sudo apt -y install build-essential p7zip xz-utils wget libglib2.0-0
sudo apt -y install python3-gi libgirepository1.0-dev # should pull dependencies for gi installation below
- name: Get a recent python
uses: actions/setup-python@v6.2.0
uses: actions/setup-python@v5
with:
python-version: '~3.12.7'
check-latest: true
@@ -159,7 +157,7 @@ jobs:
echo "TAR_NAME=$TAR_NAME" >> $GITHUB_ENV
# - code above copied from build.yml -
- name: Attest Build
uses: actions/attest@v4.1.0
uses: actions/attest-build-provenance@v2
with:
subject-path: |
build/exe.*/ArchipelagoLauncher
@@ -167,14 +165,12 @@ jobs:
build/exe.*/ArchipelagoServer
dist/*
- name: Add to Release
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0
uses: softprops/action-gh-release@975c1b265e11dd76618af1c374e7981f9a6ff44a
with:
draft: true # see above
prerelease: false
name: Archipelago ${{ env.RELEASE_VERSION }}
files: |
dist/*
fail_on_unmatched_files: true
overwrite_files: false # should never happen; avoids accidentally changing a release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -28,14 +28,12 @@ on:
- 'requirements.txt'
- '.github/workflows/scan-build.yml'
permissions: {}
jobs:
scan-build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6.0.2
- uses: actions/checkout@v4
with:
submodules: recursive
- name: Install newer Clang
@@ -47,7 +45,7 @@ jobs:
run: |
sudo apt install clang-tools-19
- name: Get a recent python
uses: actions/setup-python@v6.2.0
uses: actions/setup-python@v5
with:
python-version: '3.11'
- 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
- name: Store report
if: failure()
uses: actions/upload-artifact@v7.0.0
uses: actions/upload-artifact@v4
with:
name: 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"
- "**.pyi"
permissions: {}
jobs:
pyright:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6.0.2
- uses: actions/checkout@v4
- uses: actions/setup-python@v6.2.0
- uses: actions/setup-python@v5
with:
python-version: "3.11"

View File

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

1
.gitignore vendored
View File

@@ -45,7 +45,6 @@ EnemizerCLI/
/SNI/
/sni-*/
/appimagetool*
/VC_redist.x64.exe
/host.yaml
/options.yaml
/config.yaml

View File

@@ -727,7 +727,6 @@ class CollectionState():
advancements: Set[Location]
path: Dict[Union[Region, Entrance], PathValue]
locations_checked: Set[Location]
"""Internal cache for Advancement Locations already checked by this CollectionState. Not for use in logic."""
stale: Dict[int, bool]
allow_partial_entrances: bool
additional_init_functions: List[Callable[[CollectionState, MultiWorld], None]] = []

View File

@@ -773,7 +773,7 @@ class CommonContext:
if len(parts) == 1:
parts = title.split(', ', 1)
if len(parts) > 1:
text = f"{parts[1]}\n\n{text}" if text else parts[1]
text = parts[1] + '\n\n' + text
title = parts[0]
# display error
self._messagebox = MessageBox(title, text, error=True)
@@ -896,8 +896,6 @@ async def server_loop(ctx: CommonContext, address: typing.Optional[str] = None)
"May not be running Archipelago on that address or port.")
except websockets.InvalidURI:
ctx.handle_connection_loss("Failed to connect to the multiworld server (invalid URI)")
except asyncio.TimeoutError:
ctx.handle_connection_loss("Failed to connect to the multiworld server. Connection timed out.")
except OSError:
ctx.handle_connection_loss("Failed to connect to the multiworld server")
except Exception:

View File

@@ -280,7 +280,6 @@ def remaining_fill(multiworld: MultiWorld,
item_to_place = itempool.pop()
spot_to_fill: typing.Optional[Location] = None
# going through locations in the same order as the provided `locations` argument
for i, location in enumerate(locations):
if location_can_fill_item(location, item_to_place):
# popping by index is faster than removing by content,

View File

@@ -40,8 +40,6 @@ def mystery_argparse(argv: list[str] | None = None) -> argparse.Namespace:
parser.add_argument('--spoiler', type=int, default=defaults.spoiler)
parser.add_argument('--outputpath', default=settings.general_options.output_path,
help="Path to output folder. Absolute or relative to cwd.") # absolute or relative to cwd
parser.add_argument('--allow_quantity', action="store_true", default=defaults.allow_quantity,
help='Allows the use of the quantity option in yamls. Default is the set value in the host.yaml.')
parser.add_argument('--race', action='store_true', default=defaults.race)
parser.add_argument('--meta_file_path', default=defaults.meta_file_path)
parser.add_argument('--log_level', default=defaults.loglevel, help='Sets log level')
@@ -89,8 +87,7 @@ def main(args=None) -> tuple[argparse.Namespace, int]:
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)
seed_name = get_seed_name(random)
@@ -125,7 +122,6 @@ def main(args=None) -> tuple[argparse.Namespace, int]:
player_id: int = 1
player_files: dict[int, str] = {}
player_errors: list[str] = []
allow_quantity = args.allow_quantity
for file in os.scandir(args.player_files_path):
fname = file.name
if file.is_file() and not fname.startswith(".") and not fname.lower().endswith(".ini") and \
@@ -137,14 +133,7 @@ def main(args=None) -> tuple[argparse.Namespace, int]:
if yaml is None:
logging.warning(f"Ignoring empty yaml document #{doc_idx + 1} in {fname}")
else:
quantity = yaml.get("quantity", 1)
if quantity <= 0:
raise ValueError("A quantity of 0 or less is invalid. Please change it to at least 1.")
if not allow_quantity and quantity > 1:
raise ValueError("Quantity greater than 1 is deactivated by host settings.")
for _ in range(quantity):
weights_for_file.append(yaml)
weights_for_file.append(yaml)
weights_cache[fname] = tuple(weights_for_file)
except Exception as e:

View File

@@ -29,8 +29,8 @@ if __name__ == "__main__":
import settings
import Utils
from Utils import (env_cleared_lib_path, init_logging, is_frozen, is_linux, is_macos, is_windows, local_path,
messagebox, open_filename, user_path)
from Utils import (init_logging, is_frozen, is_linux, is_macos, is_windows, local_path, messagebox, open_filename,
user_path)
if __name__ == "__main__":
init_logging('Launcher')
@@ -52,7 +52,10 @@ def open_host_yaml():
webbrowser.open(file)
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)
def open_patch():
@@ -103,7 +106,10 @@ def open_folder(folder_path):
return
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)
else:
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
def launch(exe: Sequence[str], in_terminal: bool = False) -> bool:
"""Runs the given command/args in `exe` in a new process.
If `in_terminal` is True, it will attempt to run in a terminal window,
and the return value will indicate whether one was found."""
def launch(exe, in_terminal=False):
if in_terminal:
if is_windows:
# intentionally using a window title with a space so it gets quoted and treated as a title
subprocess.Popen(["start", "Running Archipelago", *exe], shell=True)
return True
return
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:
# Clear LD_LIB_PATH during terminal startup, but set it again when running command in case it's needed
ld_lib_path = os.environ.get("LD_LIBRARY_PATH")
lib_path_setter = f"env LD_LIBRARY_PATH={shlex.quote(ld_lib_path)} " if ld_lib_path else ""
env = env_cleared_lib_path()
subprocess.Popen([terminal, "-e", lib_path_setter + shlex.join(exe)], env=env)
return True
subprocess.Popen([terminal, '-e', shlex.join(exe)])
return
elif is_macos:
terminal = [which("open"), "-W", "-a", "Terminal.app"]
terminal = [which('open'), '-W', '-a', 'Terminal.app']
subprocess.Popen([*terminal, *exe])
return True
return
subprocess.Popen(exe)
return False
def create_shortcut(button: Any, component: Component) -> None:
@@ -410,17 +406,12 @@ def run_gui(launch_components: list[Component], args: Any) -> None:
@staticmethod
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:
# Note: if we want to draw the Snackbar before running func, func needs to be wrapped in schedule_once
button.component.func()
else:
# if launch returns False, it started the process in background (not in a new terminal)
if not launch(get_exe(button.component), button.component.cli) and button.component.cli:
open_text = "Running in the background..."
MDSnackbar(MDSnackbarText(text=open_text), y=dp(24), pos_hint={"center_x": 0.5},
size_hint_x=0.5).open()
launch(get_exe(button.component), button.component.cli)
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. """

View File

@@ -241,8 +241,8 @@ async def gba_sync_task(ctx: MMBN3Context):
await ctx.server_auth(False)
else:
if not ctx.version_warning:
logger.warning(f"Your Lua script is version {reported_version}, expected {script_version}. "
"Please update to the latest version. "
logger.warning(f"Your Lua script is version {reported_version}, expected {script_version}."
"Please update to the latest version."
"Your connection to the Archipelago server will not be accepted.")
ctx.version_warning = True
except asyncio.TimeoutError:

View File

@@ -21,7 +21,7 @@ import time
import typing
import weakref
import zlib
from signal import SIGINT, SIGTERM, signal
from signal import SIGINT, SIGTERM
import ModuleUpdate
@@ -2633,8 +2633,8 @@ def parse_args() -> argparse.Namespace:
goal: !remaining can be used after goal completion
''')
parser.add_argument('--auto_shutdown', default=defaults["auto_shutdown"], type=int,
help="automatically shut down the server after this many seconds without new location checks. "
"0 to keep running.")
help="automatically shut down the server after this many minutes without new location checks. "
"0 to keep running. Not yet implemented.")
parser.add_argument('--use_embedded_options', action="store_true",
help='retrieve release, remaining and hint options from the multidata file,'
' instead of host.yaml')
@@ -2742,23 +2742,12 @@ async def main(args: argparse.Namespace):
ctx.shutdown_task = asyncio.create_task(auto_shutdown(ctx, [console_task]))
def stop():
try:
for remove_signal in [SIGINT, SIGTERM]:
asyncio.get_event_loop().remove_signal_handler(remove_signal)
except NotImplementedError:
pass
for remove_signal in [SIGINT, SIGTERM]:
asyncio.get_event_loop().remove_signal_handler(remove_signal)
ctx.commandprocessor._cmd_exit()
def shutdown(signum, frame):
stop()
try:
for sig in [SIGINT, SIGTERM]:
asyncio.get_event_loop().add_signal_handler(sig, stop)
except NotImplementedError:
# add_signal_handler is only implemented for UNIX platforms
for sig in [SIGINT, SIGTERM]:
signal(sig, shutdown)
for signal in [SIGINT, SIGTERM]:
asyncio.get_event_loop().add_signal_handler(signal, stop)
await ctx.exit_event.wait()
console_task.cancel()

View File

@@ -212,13 +212,6 @@ class Option(typing.Generic[T], metaclass=AssembleOptions):
else:
return cls.name_lookup[value]
def __eq__(self, other: typing.Any) -> bool:
if isinstance(other, self.__class__):
return self.value == other.value
if isinstance(other, Option):
raise TypeError(f"Can't compare {self.__class__.__name__} with {other.__class__.__name__}")
return self.value == other
def __int__(self) -> T:
return self.value
@@ -937,34 +930,13 @@ class OptionDict(Option[typing.Dict[str, typing.Any]], VerifyKeys, typing.Mappin
class OptionCounter(OptionDict):
min: int | None = None
max: int | None = None
cull_zeroes: bool = False
def __init__(self, value: dict[str, int]) -> None:
cleaned_dict = {}
invalid_value_errors = []
for key, value in value.items():
if not isinstance(value, (int, float)) or int(value) != value:
invalid_value_errors += [f"Invalid value {value} for key {key}, must be an integer."]
continue
if self.cull_zeroes and value == 0:
continue
cleaned_dict[key] = int(value)
if invalid_value_errors:
type_errors = [f"For option {self.__class__.__name__}:"] + invalid_value_errors
raise TypeError("\n".join(invalid_value_errors))
super(OptionCounter, self).__init__(collections.Counter(cleaned_dict))
super(OptionCounter, self).__init__(collections.Counter(value))
def verify(self, world: type[World], player_name: str, plando_options: PlandoOptions) -> None:
super(OptionCounter, self).verify(world, player_name, plando_options)
self.verify_values()
def verify_values(self):
range_errors = []
if self.max is not None:
@@ -987,8 +959,13 @@ class OptionCounter(OptionDict):
class ItemDict(OptionCounter):
verify_item_name = True
# Backwards compatibility: Cull 0s to make "in" checks behave the same as when this wasn't a OptionCounter
cull_zeroes = True
min = 0
def __init__(self, value: dict[str, int]) -> None:
# Backwards compatibility: Cull 0s to make "in" checks behave the same as when this wasn't a OptionCounter
value = {item_name: amount for item_name, amount in value.items() if amount != 0}
super(ItemDict, self).__init__(value)
class OptionList(Option[typing.List[typing.Any]], VerifyKeys):
@@ -1856,30 +1833,27 @@ def generate_yaml_templates(target_folder: typing.Union[str, "pathlib.Path"], ge
for game_name, world in AutoWorldRegister.world_types.items():
if not world.hidden or generate_hidden:
try:
presets = world.web.options_presets.copy()
presets.update({"": {}})
presets = world.web.options_presets.copy()
presets.update({"": {}})
option_groups = get_option_groups(world)
for name, preset in presets.items():
res = template.render(
option_groups=option_groups,
__version__=__version__,
game=game_name,
world_version=world.world_version.as_simple_string(),
yaml_dump=yaml_dump_scalar,
dictify_range=dictify_range,
cleandoc=cleandoc,
preset_name=name,
preset=preset,
)
preset_name = f" - {name}" if name else ""
with open(os.path.join(preset_folder if name else target_folder,
get_file_safe_name(game_name + preset_name) + ".yaml"),
"w", encoding="utf-8-sig") as f:
f.write(res)
except Exception as ex:
raise Exception(f"Template generation failed for world {game_name}") from ex
option_groups = get_option_groups(world)
for name, preset in presets.items():
res = template.render(
option_groups=option_groups,
__version__=__version__,
game=game_name,
world_version=world.world_version.as_simple_string(),
yaml_dump=yaml_dump_scalar,
dictify_range=dictify_range,
cleandoc=cleandoc,
preset_name=name,
preset=preset,
)
preset_name = f" - {name}" if name else ""
with open(os.path.join(preset_folder if name else target_folder,
get_file_safe_name(game_name + preset_name) + ".yaml"),
"w", encoding="utf-8-sig") as f:
f.write(res)
def dump_player_options(multiworld: MultiWorld) -> None:

View File

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

View File

@@ -24,6 +24,7 @@ Currently, the following games are supported:
* The Witness
* Sonic Adventure 2: Battle
* Starcraft 2
* Donkey Kong Country 3
* Dark Souls 3
* Super Mario World
* Pokémon Red and Blue
@@ -84,7 +85,6 @@ Currently, the following games are supported:
* APQuest
* Satisfactory
* EarthBound
* Mega Man 3
For setup and instructions check out our [tutorials page](https://archipelago.gg/tutorial/).
Downloads can be found at [Releases](https://github.com/ArchipelagoMW/Archipelago/releases), including compiled

View File

@@ -49,7 +49,7 @@ class UndertaleCommandProcessor(ClientCommandProcessor):
if isinstance(self.ctx, UndertaleContext):
os.makedirs(name=Utils.user_path("Undertale"), exist_ok=True)
tempInstall = steaminstall
if tempInstall and not os.path.isfile(os.path.join(tempInstall, "data.win")):
if not os.path.isfile(os.path.join(tempInstall, "data.win")):
tempInstall = None
if tempInstall is None:
tempInstall = "C:\\Program Files (x86)\\Steam\\steamapps\\common\\Undertale"

View File

@@ -18,11 +18,9 @@ import logging
import warnings
from argparse import Namespace
from datetime import datetime, timezone
from settings import Settings, get_settings
from time import sleep
from typing import BinaryIO, Coroutine, 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 pathspec import PathSpec, GitIgnoreSpec
from typing_extensions import deprecated
@@ -236,7 +234,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"))
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)
@@ -342,9 +343,6 @@ def persistent_load() -> Dict[str, Dict[str, Any]]:
try:
with open(path, "r") as f:
storage = unsafe_parse_yaml(f.read())
if "datapackage" in storage:
del storage["datapackage"]
logging.debug("Removed old datapackage from persistent storage")
except Exception as e:
logging.debug(f"Could not read store: {e}")
if storage is None:
@@ -369,6 +367,11 @@ def load_data_package_for_checksum(game: str, checksum: typing.Optional[str]) ->
except Exception as e:
logging.debug(f"Could not load data package: {e}")
# fall back to old cache
cache = persistent_load().get("datapackage", {}).get("games", {}).get(game, {})
if cache.get("checksum") == checksum:
return cache
# cache does not match
return {}
@@ -450,10 +453,13 @@ safe_builtins = frozenset((
class RestrictedUnpickler(pickle.Unpickler):
generic_properties_module: Optional[object]
def __init__(self, *args: Any, **kwargs: Any) -> None:
super(RestrictedUnpickler, self).__init__(*args, **kwargs)
self.options_module = importlib.import_module("Options")
self.net_utils_module = importlib.import_module("NetUtils")
self.generic_properties_module = None
def find_class(self, module: str, name: str) -> type:
if module == "builtins" and name in safe_builtins:
@@ -467,6 +473,10 @@ class RestrictedUnpickler(pickle.Unpickler):
"SlotType", "NetworkSlot", "HintStatus"}:
return getattr(self.net_utils_module, name)
# Options and Plando are unpickled by WebHost -> Generate
if module == "worlds.generic" and name == "PlandoItem":
if not self.generic_properties_module:
self.generic_properties_module = importlib.import_module("worlds.generic")
return getattr(self.generic_properties_module, name)
# pep 8 specifies that modules should have "all-lowercase names" (options, not Options)
if module.lower().endswith("options"):
if module == "Options":
@@ -746,19 +756,6 @@ def is_kivy_running() -> bool:
return False
def env_cleared_lib_path() -> Mapping[str, str]:
"""
Creates a copy of the current environment vars with the LD_LIBRARY_PATH removed if set, as this can interfere when
launching something in a subprocess.
"""
env = os.environ
if "LD_LIBRARY_PATH" in env:
env = env.copy()
del env["LD_LIBRARY_PATH"]
return env
def _mp_open_filename(res: "multiprocessing.Queue[typing.Optional[str]]", *args: Any) -> None:
if is_kivy_running():
raise RuntimeError("kivy should not be running in multiprocess")
@@ -771,7 +768,10 @@ def _mp_save_filename(res: "multiprocessing.Queue[typing.Optional[str]]", *args:
res.put(save_filename(*args))
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
@@ -1291,15 +1291,6 @@ def is_iterable_except_str(obj: object) -> TypeGuard[typing.Iterable[typing.Any]
return isinstance(obj, typing.Iterable)
def utcnow() -> datetime:
"""
Implementation of Python's datetime.utcnow() function for use after deprecation.
Needed for timezone-naive UTC datetimes stored in databases with PonyORM (upstream).
https://ponyorm.org/ponyorm-list/2014-August/000113.html
"""
return datetime.now(timezone.utc).replace(tzinfo=None)
class DaemonThreadPoolExecutor(concurrent.futures.ThreadPoolExecutor):
"""
ThreadPoolExecutor that uses daemonic threads that do not keep the program alive.

View File

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

View File

@@ -11,7 +11,6 @@ from pony.flask import Pony
from werkzeug.routing import BaseConverter
from Utils import title_sorted, get_file_safe_name
from .cli import CLI
UPLOAD_FOLDER = os.path.relpath('uploads')
LOGS_FOLDER = os.path.relpath('logs')
@@ -46,10 +45,6 @@ app.config["SELFGEN"] = True # application process is in charge of scheduling G
app.config["JOB_THRESHOLD"] = 1
# after what time in seconds should generation be aborted, freeing the queue slot. Can be set to None to disable.
app.config["JOB_TIME"] = 600
# maximum time in seconds since last activity for a room to be hosted
app.config["MAX_ROOM_TIMEOUT"] = 259200
# minimum time in days since last activity for a room to be deleted. 0 to disable.
app.config["ROOM_AUTO_DELETE"] = 0
# memory limit for generator processes in bytes
app.config["GENERATOR_MEMORY_LIMIT"] = 4294967296
@@ -69,13 +64,10 @@ app.config["ASSET_RIGHTS"] = False
cache = Cache()
Compress(app)
CLI(app)
def to_python(value: str) -> uuid.UUID:
if "=" in value or any(c.isspace() for c in value):
raise ValueError("Invalid UUID format")
return uuid.UUID(bytes=base64.urlsafe_b64decode(value + '=' * (-len(value) % 4)))
return uuid.UUID(bytes=base64.urlsafe_b64decode(value + '=='))
def to_url(value: uuid.UUID) -> str:

View File

@@ -4,14 +4,14 @@ import json
import logging
import multiprocessing
import typing
from datetime import timedelta
from datetime import timedelta, datetime
from threading import Event, Thread
from typing import Any
from uuid import UUID
from pony.orm import db_session, select, commit, PrimaryKey, desc
from pony.orm import db_session, select, commit, PrimaryKey
from Utils import restricted_loads, utcnow
from Utils import restricted_loads
from .locker import Locker, AlreadyRunningException
_stop_event = Event()
@@ -100,18 +100,13 @@ def init_generator(config: dict[str, Any]) -> None:
db.generate_mapping()
def cleanup(config: dict[str, Any]):
"""delete unowned or old user-content"""
auto_delete: int = config.get("ROOM_AUTO_DELETE", 0)
def cleanup():
"""delete unowned user-content"""
with db_session:
# >>> bool(uuid.UUID(int=0))
# True
rooms = Room.select(lambda room: room.owner == UUID(int=0)).delete(bulk=True)
seeds = Seed.select(lambda seed: seed.owner == UUID(int=0) and not seed.rooms).delete(bulk=True)
if auto_delete > 0:
cutoff = utcnow() - timedelta(days=auto_delete)
rooms += Room.select(lambda room: room.last_activity < cutoff).delete(bulk=True)
seeds += Seed.select(lambda seed: not seed.rooms and seed.creation_time < cutoff).delete(bulk=True)
slots = Slot.select(lambda slot: not slot.seed).delete(bulk=True)
# Command gets deleted by ponyorm Cascade Delete, as Room is Required
if rooms or seeds or slots:
@@ -123,7 +118,7 @@ def autohost(config: dict):
stop_event = _stop_event
try:
with Locker("autohost"):
cleanup(config)
cleanup()
hosters = []
for x in range(config["HOSTERS"]):
hoster = MultiworldInstance(config, x)
@@ -134,11 +129,10 @@ def autohost(config: dict):
with db_session:
rooms = select(
room for room in Room if
room.last_activity >= utcnow() - timedelta(
seconds=config["MAX_ROOM_TIMEOUT"])).order_by(desc(Room.last_port))
room.last_activity >= datetime.utcnow() - timedelta(days=3))
for room in rooms:
# we have to filter twice, as the per-room timeout can't currently be PonyORM transpiled.
if room.last_activity >= utcnow() - timedelta(seconds=room.timeout + 5):
if room.last_activity >= datetime.utcnow() - timedelta(seconds=room.timeout + 5):
hosters[room.id.int % len(hosters)].start_room(room.id)
except AlreadyRunningException:

View File

@@ -1,8 +0,0 @@
from flask import Flask
class CLI:
def __init__(self, app: Flask) -> None:
from .stats import stats_cli
app.cli.add_command(stats_cli)

View File

@@ -1,36 +0,0 @@
import click
from flask.cli import AppGroup
from pony.orm import raw_sql
from Utils import format_SI_prefix
stats_cli = AppGroup("stats")
@stats_cli.command("show")
def show() -> None:
from pony.orm import db_session, select
from WebHostLib.models import GameDataPackage
total_games_package_count: int = 0
total_games_package_size: int
top_10_package_sizes: list[tuple[int, str]] = []
with db_session:
data_length = raw_sql("LENGTH(data)")
data_length_desc = raw_sql("LENGTH(data) DESC")
data_length_sum = raw_sql("SUM(LENGTH(data))")
total_games_package_count = GameDataPackage.select().count()
total_games_package_size = select(data_length_sum for _ in GameDataPackage).first() # type: ignore
top_10_package_sizes = list(
select((data_length, dp.checksum) for dp in GameDataPackage) # type: ignore
.order_by(lambda _, _2: data_length_desc)
.limit(10)
)
click.echo(f"Total number of games packages: {total_games_package_count}")
click.echo(f"Total size of games packages: {format_SI_prefix(total_games_package_size, power=1024)}B")
click.echo(f"Top {len(top_10_package_sizes)} biggest games packages:")
for size, checksum in top_10_package_sizes:
click.echo(f" {checksum}: {size:>8d}")

View File

@@ -172,7 +172,7 @@ class WebHostContext(Context):
room.multisave = pickle.dumps(self.get_save())
# saving only occurs on activity, so we can "abuse" this information to mark this as last_activity
if not exit_save: # we don't want to count a shutdown as activity, which would restart the server again
room.last_activity = Utils.utcnow()
room.last_activity = datetime.datetime.utcnow()
return True
def get_save(self) -> dict:
@@ -367,7 +367,8 @@ def run_server_process(name: str, ponyconfig: dict, static_server_data: dict,
with db_session:
# ensure the Room does not spin up again on its own, minute of safety buffer
room = Room.get(id=room_id)
room.last_activity = Utils.utcnow() - datetime.timedelta(minutes=1, seconds=room.timeout)
room.last_activity = datetime.datetime.utcnow() - \
datetime.timedelta(minutes=1, seconds=room.timeout)
del room
tear_down_logging(room_id)
logging.info(f"Shutting down room {room_id} on {name}.")

View File

@@ -1,9 +1,8 @@
from datetime import timedelta
from datetime import timedelta, datetime
from flask import render_template
from pony.orm import count
from Utils import utcnow
from WebHostLib import app, cache
from .models import Room, Seed
@@ -11,6 +10,6 @@ from .models import Room, Seed
@app.route('/', methods=['GET', 'POST'])
@cache.cached(timeout=300) # cache has to appear under app route for caching to work
def landing():
rooms = count(room for room in Room if room.creation_time >= utcnow() - timedelta(days=7))
seeds = count(seed for seed in Seed if seed.creation_time >= utcnow() - timedelta(days=7))
rooms = count(room for room in Room if room.creation_time >= datetime.utcnow() - timedelta(days=7))
seeds = count(seed for seed in Seed if seed.creation_time >= datetime.utcnow() - timedelta(days=7))
return render_template("landing.html", rooms=rooms, seeds=seeds)

View File

@@ -9,12 +9,11 @@ from flask import request, redirect, url_for, render_template, Response, session
from pony.orm import count, commit, db_session
from werkzeug.utils import secure_filename
from worlds.AutoWorld import AutoWorldRegister, World
from . import app, cache
from .markdown import render_markdown
from .models import Seed, Room, Command, UUID, uuid4
from Utils import title_sorted, utcnow
from Utils import title_sorted
class WebWorldTheme(StrEnum):
DIRT = "dirt"
@@ -234,12 +233,11 @@ def host_room(room: UUID):
if room is None:
return abort(404)
now = utcnow()
now = datetime.datetime.utcnow()
# indicate that the page should reload to get the assigned port
should_refresh = (
(not room.last_port and now - room.creation_time < datetime.timedelta(seconds=3))
or room.last_activity < now - datetime.timedelta(seconds=room.timeout)
)
should_refresh = ((not room.last_port and now - room.creation_time < datetime.timedelta(seconds=3))
or room.last_activity < now - datetime.timedelta(seconds=room.timeout))
if now - room.last_activity > datetime.timedelta(minutes=1):
# we only set last_activity if needed, otherwise parallel access on /room will cause an internal server error
# due to "pony.orm.core.OptimisticCheckError: Object Room was updated outside of current transaction"

View File

@@ -2,8 +2,6 @@ from datetime import datetime
from uuid import UUID, uuid4
from pony.orm import Database, PrimaryKey, Required, Set, Optional, buffer, LongStr
from Utils import utcnow
db = Database()
STATE_QUEUED = 0
@@ -22,8 +20,8 @@ class Slot(db.Entity):
class Room(db.Entity):
id = PrimaryKey(UUID, default=uuid4)
last_activity: datetime = Required(datetime, default=lambda: utcnow(), index=True)
creation_time: datetime = Required(datetime, default=lambda: utcnow(), index=True) # index used by landing page
last_activity = Required(datetime, default=lambda: datetime.utcnow(), index=True)
creation_time = Required(datetime, default=lambda: datetime.utcnow(), index=True) # index used by landing page
owner = Required(UUID, index=True)
commands = Set('Command')
seed = Required('Seed', index=True)
@@ -40,7 +38,7 @@ class Seed(db.Entity):
rooms = Set(Room)
multidata = Required(bytes, lazy=True)
owner = Required(UUID, index=True)
creation_time: datetime = Required(datetime, default=lambda: utcnow(), index=True) # index used by landing page
creation_time = Required(datetime, default=lambda: datetime.utcnow(), index=True) # index used by landing page
slots = Set(Slot)
spoiler = Optional(LongStr, lazy=True)
meta = Required(LongStr, default=lambda: "{\"race\": false}") # additional meta information/tags

View File

@@ -1,14 +1,14 @@
flask==3.1.3
werkzeug==3.1.6
pony==0.7.19; python_version <= '3.12'
flask>=3.1.1
werkzeug>=3.1.3
pony>=0.7.19; python_version <= '3.12'
pony @ git+https://github.com/black-sliver/pony@7feb1221953b7fa4a6735466bf21a8b4d35e33ba#0.7.19; python_version >= '3.13'
waitress==3.0.2
Flask-Caching==2.3.1
waitress>=3.0.2
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-Limiter==4.1.1
Flask-Cors==6.0.2
bokeh==3.8.2
markupsafe==3.0.3
setproctitle==1.3.7
mistune==3.2.0
docutils==0.22.4
Flask-Limiter>=3.12
Flask-Cors>=6.0.2
bokeh>=3.6.3
markupsafe>=3.0.2
setproctitle>=1.3.5
mistune>=3.1.3
docutils>=0.22.2

View File

@@ -1,6 +1,6 @@
{% block footer %}
<footer id="island-footer">
<div id="copyright-notice">Copyright 2026 Archipelago</div>
<div id="copyright-notice">Copyright 2025 Archipelago</div>
<div id="links">
<a href="/sitemap">Site Map</a>
-

View File

@@ -33,9 +33,7 @@
<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
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
<a href="{{ url_for("tutorial", game="Archipelago", file="other_en") }}">other games and tools guide</a>
to find more.</p>
custom worlds</a> section of the setup guide.</p>
<div class="js-only">
<label for="game-search">Search for your game below!</label><br />
<div class="page-controls">

View File

@@ -20,7 +20,11 @@
{% for file_name, file_data in tutorial_data.files.items() %}
<li>
<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>
{% endfor %}
</ul>

View File

@@ -10,7 +10,7 @@ from werkzeug.exceptions import abort
from MultiServer import Context, get_saving_second
from NetUtils import ClientStatus, Hint, NetworkItem, NetworkSlot, SlotType
from Utils import restricted_loads, KeyedDefaultDict, utcnow
from Utils import restricted_loads, KeyedDefaultDict
from . import app, cache
from .models import GameDataPackage, Room
@@ -273,10 +273,9 @@ class TrackerData:
Does not include players who have no activity recorded.
"""
last_activity: Dict[TeamPlayer, datetime.timedelta] = {}
now = utcnow()
now = datetime.datetime.utcnow()
for (team, player), timestamp in self._multisave.get("client_activity_timers", []):
from_timestamp = datetime.datetime.fromtimestamp(timestamp, datetime.timezone.utc).replace(tzinfo=None)
last_activity[team, player] = now - from_timestamp
last_activity[team, player] = now - datetime.datetime.utcfromtimestamp(timestamp)
return last_activity

View File

@@ -41,8 +41,16 @@ http {
# server_name example.com www.example.com;
keepalive_timeout 5;
# path for static files
root /app/WebHostLib;
location / {
# checks for static file, if not found proxy to app
try_files $uri @proxy_to_app;
}
location @proxy_to_app {
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Host $http_host;
@@ -52,15 +60,5 @@ http {
proxy_pass http://app_server;
}
location /static/ {
root /app/WebHostLib/;
autoindex off;
}
location = /favicon.ico {
alias /app/WebHostLib/static/static/favicon.ico;
access_log off;
}
}
}

View File

@@ -19,6 +19,8 @@
# NewSoupVi is acting maintainer, but world belongs to core with the exception of the music
/worlds/apquest/ @NewSoupVi
# Sudoku (APSudoku)
/worlds/apsudoku/ @EmilyV99
# Aquaria
/worlds/aquaria/ @tioui
@@ -56,6 +58,9 @@
# Dark Souls III
/worlds/dark_souls_3/ @Marechal-L @nex3
# Donkey Kong Country 3
/worlds/dkc3/ @PoryGone
# DLCQuest
/worlds/dlcquest/ @axe-y @agilbert1412
@@ -129,9 +134,6 @@
# Mega Man 2
/worlds/mm2/ @Silvris
# Mega Man 3
/worlds/mm3/ @Silvris
# MegaMan Battle Network 3
/worlds/mmbn3/ @digiholic

View File

@@ -87,14 +87,12 @@ The world is your game integration for the Archipelago generator, webhost, and m
information necessary for creating the items and locations to be randomized, the logic for item placement, the
datapackage information so other game clients can recognize your game data, and documentation. Your world must be
written as a Python package to be loaded by Archipelago. This is currently done by creating a fork of the Archipelago
repository and creating a new world package in `/worlds/` (see [running from source](/docs/running%20from%20source.md)
for setup).
repository and creating a new world package in `/worlds/`.
The base World class can be found in [AutoWorld](/worlds/AutoWorld.py). Methods available for your world to call
during generation can be found in [BaseClasses](/BaseClasses.py) and [Fill](/Fill.py). Some examples and documentation
regarding the API can be found in the [world api doc](/docs/world%20api.md), and the [APQuest](/worlds/apquest/) world
is a complete world implementation that functions as an introduction to world development. Before publishing, make sure
to also check out [world maintainer.md](/docs/world%20maintainer.md).
regarding the API can be found in the [world api doc](/docs/world%20api.md). Before publishing, make sure to also
check out [world maintainer.md](/docs/world%20maintainer.md).
### Hard Requirements

View File

@@ -32,6 +32,8 @@ If the APWorld is a folder, the only required field is "game":
There are also the following optional fields:
* `minimum_ap_version` and `maximum_ap_version` - which if present will each be compared against the current
Archipelago version respectively to filter those files from being loaded.
* `platforms` - a list of strings indicating the `sys.platform`(s) the world can run on.
If empty or not set, it is assumed to be any that python itself can run on.
* `world_version` - an arbitrary version for that world in order to only load the newest valid world.
An APWorld without a world_version is always treated as older than one with a version
(**Must** use exactly the format `"major.minor.build"`, e.g. `1.0.0`)
@@ -46,8 +48,8 @@ which is the correct way to package your `.apworld` as a world developer. Do not
### "Build APWorlds" Launcher Component
In the Archipelago Launcher (on [source only](/docs/running%20from%20source.md)), there is a "Build APWorlds"
component that will package all world folders to `.apworld`, and add `archipelago.json` manifest files to them.
In the Archipelago Launcher, there is a "Build APWorlds" component that will package all world folders to `.apworld`,
and add `archipelago.json` manifest files to them.
These .apworld files will be output to `build/apworlds` (relative to the Archipelago root directory).
The `archipelago.json` file in each .apworld will automatically include the appropriate
`version` and `compatible_version`.

View File

@@ -69,6 +69,12 @@ flowchart LR
end
SNI <-- Various, depending on SNES device --> SMZ
%% Donkey Kong Country 3
subgraph Donkey Kong Country 3
DK3[SNES]
end
SNI <-- Various, depending on SNES device --> DK3
%% Super Mario World
subgraph Super Mario World
SMW[SNES]

View File

@@ -1,7 +1,6 @@
# Rule Builder
This document describes the API provided for the rule builder. Using this API provides you with with a simple interface
to define rules and the following advantages:
This document describes the API provided for the rule builder. Using this API provides you with with a simple interface to define rules and the following advantages:
- Rule classes that avoid all the common pitfalls
- Logic optimization
@@ -13,21 +12,13 @@ to define rules and the following advantages:
The rule builder consists of 3 main parts:
1. The rules, which are classes that inherit from `rule_builder.rules.Rule`. These are what you write for your logic.
They can be combined and take into account your world's options. There are a number of default rules listed below,
and you can create as many custom rules for your world as needed. When assigning the rules to a location or entrance
they must be resolved.
2. Resolved rules, which are classes that inherit from `rule_builder.rules.Rule.Resolved`. These are the optimized rules
specific to one player that are set as a location or entrance's access rule. You generally shouldn't be directly
creating these but they'll be created when assigning rules to locations or entrances. These are what power the
human-readable logic explanations.
3. The optional rule builder world subclass `CachedRuleBuilderWorld`, which is a class your world can inherit from
instead of `World`. It adds a caching system to the rules that will lazy evaluate and cache the result.
1. The rules, which are classes that inherit from `rule_builder.rules.Rule`. These are what you write for your logic. They can be combined and take into account your world's options. There are a number of default rules listed below, and you can create as many custom rules for your world as needed. When assigning the rules to a location or entrance they must be resolved.
1. Resolved rules, which are classes that inherit from `rule_builder.rules.Rule.Resolved`. These are the optimized rules specific to one player that are set as a location or entrance's access rule. You generally shouldn't be directly creating these but they'll be created when assigning rules to locations or entrances. These are what power the human-readable logic explanations.
1. The optional rule builder world subclass `CachedRuleBuilderWorld`, which is a class your world can inherit from instead of `World`. It adds a caching system to the rules that will lazy evaluate and cache the result.
## Usage
For the most part the only difference in usage is instead of writing lambdas for your logic, you write static Rule
objects. You then must use `world.set_rule` to assign the rule to a location or entrance.
For the most part the only difference in usage is instead of writing lambdas for your logic, you write static Rule objects. You then must use `world.set_rule` to assign the rule to a location or entrance.
```python
# In your world's create_regions method
@@ -49,22 +40,18 @@ The rule builder comes with a number of rules by default:
- `HasFromList`: Checks that the player has some number of given items
- `HasFromListUnique`: Checks that the player has some number of given items, ignoring duplicates of the same item
- `HasGroup`: Checks that the player has some number of items from a given item group
- `HasGroupUnique`: Checks that the player has some number of items from a given item group, ignoring duplicates of the
same item
- `HasGroupUnique`: Checks that the player has some number of items from a given item group, ignoring duplicates of the same item
- `CanReachLocation`: Checks that the player can logically reach the given location
- `CanReachRegion`: Checks that the player can logically reach the given region
- `CanReachEntrance`: Checks that the player can logically reach the given entrance
You can combine these rules together to describe the logic required for something. For example, to check if a player
either has `Movement ability` or they have both `Key 1` and `Key 2`, you can do:
You can combine these rules together to describe the logic required for something. For example, to check if a player either has `Movement ability` or they have both `Key 1` and `Key 2`, you can do:
```python
rule = Has("Movement ability") | HasAll("Key 1", "Key 2")
```
> ⚠️ Composing rules with the `and` and `or` keywords will not work. You must use the bitwise `&` and `|` operators. In
> order to catch mistakes, the rule builder will not let you do boolean operations. As a consequence, in order to check
> if a rule is defined you must use `if rule is not None`.
> ⚠️ Composing rules with the `and` and `or` keywords will not work. You must use the bitwise `&` and `|` operators. In order to catch mistakes, the rule builder will not let you do boolean operations. As a consequence, in order to check if a rule is defined you must use `if rule is not None`.
### Assigning rules
@@ -74,16 +61,13 @@ When assigning the rule you must use the `set_rule` helper to correctly resolve
self.set_rule(location_or_entrance, rule)
```
There is also a `create_entrance` helper that will resolve the rule, check if it's `False`, and if not create the
entrance and set the rule. This allows you to skip creating entrances that will never be valid. You can also specify
`force_creation=True` if you would like to create the entrance even if the rule is `False`.
There is also a `create_entrance` helper that will resolve the rule, check if it's `False`, and if not create the entrance and set the rule. This allows you to skip creating entrances that will never be valid. You can also specify `force_creation=True` if you would like to create the entrance even if the rule is `False`.
```python
self.create_entrance(from_region, to_region, rule)
```
> ⚠️ If you use a `CanReachLocation` rule on an entrance, you will either have to create the locations first, or specify
> the location's parent region name with the `parent_region_name` argument of `CanReachLocation`.
> ⚠️ If you use a `CanReachLocation` rule on an entrance, you will either have to create the locations first, or specify the location's parent region name with the `parent_region_name` argument of `CanReachLocation`.
You can also set a rule for your world's completion condition:
@@ -93,42 +77,21 @@ self.set_completion_rule(rule)
### Restricting options
Every rule allows you to specify which options it's applicable for. You can provide the argument `options` which is an
iterable of `OptionFilter` instances. When resolved, if no filters are provided or all of them pass then the rule will
resolve as normal. Otherwise, the rule will be replaced with `True` or `False` depending on what `filtered_resolution`
is set to, which defaults to `False`.
Every rule allows you to specify which options it's applicable for. You can provide the argument `options` which is an iterable of `OptionFilter` instances. Rules that pass the options check will be resolved as normal, and those that fail will be resolved as `False`.
```python
rule1 = Has(
"Fast Travel Spell",
options=[OptionFilter(RandoFastTravel, RandoFastTravel.option_true)],
)
rule2 = Has(
"Starting Party Member",
options=[OptionFilter(RandoParty, 1)], # option attributes are suggested but any value works
filtered_resolution=True,
)
```
If you want a comparison that isn't equals, you can specify with the `operator` argument. The following operators are allowed:
If you want a comparison that isn't equals, you can specify with the `operator` argument. The following operators are
allowed:
- `eq`: `==`
- `ne`: `!=`
- `gt`: `>`
- `lt`: `<`
- `ge`: `>=`
- `le`: `<=`
- `contains`: `in`
- `eq`: `option_value == filter_value`
- `ne`: `option_value != filter_value`
- `gt`: `option_value > filter_value`
- `lt`: `option_value < filter_value`
- `ge`: `option_value >= filter_value`
- `le`: `option_value <= filter_value`
- `in`: `option_value in filter_value`
- `contains`: `filter_value in option_value` (note reversed operands)
By default rules that are excluded by their options will default to `False`. If you want to default to `True` instead, you can specify `filtered_resolution=True` on your rule.
```python
rule1 = Has("Movement Ability", options=[OptionFilter(SkipsLevel, SkipsLevel.option_hard, operator="lt")])
rule2 = Has("Item", options=[OptionFilter(ChoiceOption, [1, 5], operator="in")])
```
To check if the player has received the switch item if switches are randomized, or if they can reach the switch when not
randomized:
To check if the player can reach a switch, or if they've received the switch item if switches are randomized:
```python
rule = (
@@ -152,12 +115,12 @@ If you would like to provide option filters when reusing or composing rules, you
common_rule = Has("A") | HasAny("B", "C")
...
rule = (
Filtered(common_rule, options=[OptionFilter(Opt, 0)])
| Filtered(Has("X") | CanReachRegion("Y"), options=[OptionFilter(Opt, 1)])
Filtered(common_rule, options=[OptionFilter(Opt, 0)]),
| Filtered(Has("X") | CanReachRegion("Y"), options=[OptionFilter(Opt, 1)]),
)
```
For convenience, you can also use the `&` and `|` operators to apply options to rules:
You can also use the & and | operators to apply options to rules:
```python
common_rule = Has("A")
@@ -166,73 +129,22 @@ common_rule_only_on_easy = common_rule & easy_filter
common_rule_skipped_on_easy = common_rule | easy_filter
```
Combining the above, you can easily bypass a requirement based on option choices:
```python
rule = Has("Some Upgrade") | OptionFilter(CombatDifficulty, CombatDifficulty.option_medium, operator="ge")
```
### Field resolvers
When creating rules you may sometimes need to set a field to a value that depends on the world instance. You can use a
`FieldResolver` to define how to populate that field when the rule is being resolved.
There are two build-in field resolvers:
- `FromOption`: Resolves to the value of the given option
- `FromWorldAttr`: Resolves to the value of the given world instance attribute, can specify a dotted path `a.b.c` to get
a nested attribute or dict item
```python
world.options.mcguffin_count = 5
world.precalculated_value = 99
rule = (
Has("A", count=FromOption(McguffinCount))
| HasGroup("Important items", count=FromWorldAttr("precalculated_value"))
)
# Results in Has("A", count=5) | HasGroup("Important items", count=99)
```
You can define your own resolvers by creating a class that inherits from `FieldResolver`, provides your game name, and
implements a `resolve` function:
```python
@dataclasses.dataclass(frozen=True)
class FromCustomResolution(FieldResolver, game="MyGame"):
modifier: str
@override
def resolve(self, world: "World") -> Any:
return some_math_calculation(world, self.modifier)
rule = Has("Combat Level", count=FromCustomResolution("combat"))
```
If you want to support rule serialization and your resolver contains non-serializable properties you may need to
override `to_dict` or `from_dict`.
## Enabling caching
The rule builder provides a `CachedRuleBuilderWorld` base class for your `World` class that enables caching on your
rules.
The rule builder provides a `CachedRuleBuilderWorld` base class for your `World` class that enables caching on your rules.
```python
class MyWorld(CachedRuleBuilderWorld):
game = "My Game"
```
If your world's logic is very simple and you don't have many nested rules, the caching system may have more overhead
cost than time it saves. You'll have to benchmark your own world to see if it should be enabled or not.
If your world's logic is very simple and you don't have many nested rules, the caching system may have more overhead cost than time it saves. You'll have to benchmark your own world to see if it should be enabled or not.
### Item name mapping
If you have multiple real items that map to a single logic item, add a `item_mapping` class dict to your world that maps
actual item names to real item names so the cache system knows what to invalidate.
If you have multiple real items that map to a single logic item, add a `item_mapping` class dict to your world that maps actual item names to real item names so the cache system knows what to invalidate.
For example, if you have multiple `Currency x<num>` items on locations, but your rules only check a singular logical
`Currency` item, eg `Has("Currency", 1000)`, you'll want to map each numerical currency item to the single logical
`Currency`.
For example, if you have multiple `Currency x<num>` items on locations, but your rules only check a singular logical `Currency` item, eg `Has("Currency", 1000)`, you'll want to map each numerical currency item to the single logical `Currency`.
```python
class MyWorld(CachedRuleBuilderWorld):
@@ -246,13 +158,9 @@ class MyWorld(CachedRuleBuilderWorld):
## Defining custom rules
You can create a custom rule by creating a class that inherits from `Rule` or any of the default rules. You must provide
the game name as an argument to the class. It's recommended to use the `@dataclass` decorator to reduce boilerplate, and
to also provide your world as a type argument to add correct type checking to the `_instantiate` method.
You can create a custom rule by creating a class that inherits from `Rule` or any of the default rules. You must provide the game name as an argument to the class. It's recommended to use the `@dataclass` decorator to reduce boilerplate, and to also provide your world as a type argument to add correct type checking to the `_instantiate` method.
You must provide or inherit a `Resolved` child class that defines an `_evaluate` method. This class will automatically
be converted into a frozen `dataclass`. If your world has caching enabled you may need to define one or more
dependencies functions as outlined below.
You must provide or inherit a `Resolved` child class that defines an `_evaluate` method. This class will automatically be converted into a frozen `dataclass`. If your world has caching enabled you may need to define one or more dependencies functions as outlined below.
To add a rule that checks if the user has enough mcguffins to goal, with a randomized requirement:
@@ -301,10 +209,7 @@ class ComplicatedFilter(Rule["MyWorld"], game="My Game"):
### Item dependencies
If your world inherits from `CachedRuleBuilderWorld` and there are items that when collected will affect the result of
your rule evaluation, it must define an `item_dependencies` function that returns a mapping of the item name to the id
of your rule. These dependencies will be combined to inform the caching system. It may be worthwhile to define this
function even when caching is disabled as more things may use it in the future.
If your world inherits from `CachedRuleBuilderWorld` and there are items that when collected will affect the result of your rule evaluation, it must define an `item_dependencies` function that returns a mapping of the item name to the id of your rule. These dependencies will be combined to inform the caching system. It may be worthwhile to define this function even when caching is disabled as more things may use it in the future.
```python
@dataclasses.dataclass()
@@ -321,10 +226,7 @@ All of the default `Has*` rules define this function already.
### Region dependencies
If your custom rule references other regions, it must define a `region_dependencies` function that returns a mapping of
region names to the id of your rule regardless of if your world inherits from `CachedRuleBuilderWorld`. These
dependencies will be combined to register indirect connections when you set this rule on an entrance and inform the
caching system if applicable.
If your custom rule references other regions, it must define a `region_dependencies` function that returns a mapping of region names to the id of your rule regardless of if your world inherits from `CachedRuleBuilderWorld`. These dependencies will be combined to register indirect connections when you set this rule on an entrance and inform the caching system if applicable.
```python
@dataclasses.dataclass()
@@ -341,10 +243,7 @@ The default `CanReachLocation`, `CanReachRegion`, and `CanReachEntrance` rules d
### Location dependencies
If your custom rule references other locations, it must define a `location_dependencies` function that returns a mapping
of the location name to the id of your rule regardless of if your world inherits from `CachedRuleBuilderWorld`. These
dependencies will be combined to register indirect connections when you set this rule on an entrance and inform the
caching system if applicable.
If your custom rule references other locations, it must define a `location_dependencies` function that returns a mapping of the location name to the id of your rule regardless of if your world inherits from `CachedRuleBuilderWorld`. These dependencies will be combined to register indirect connections when you set this rule on an entrance and inform the caching system if applicable.
```python
@dataclasses.dataclass()
@@ -361,10 +260,7 @@ The default `CanReachLocation` rule defines this function already.
### Entrance dependencies
If your custom rule references other entrances, it must define a `entrance_dependencies` function that returns a mapping
of the entrance name to the id of your rule regardless of if your world inherits from `CachedRuleBuilderWorld`. These
dependencies will be combined to register indirect connections when you set this rule on an entrance and inform the
caching system if applicable.
If your custom rule references other entrances, it must define a `entrance_dependencies` function that returns a mapping of the entrance name to the id of your rule regardless of if your world inherits from `CachedRuleBuilderWorld`. These dependencies will be combined to register indirect connections when you set this rule on an entrance and inform the caching system if applicable.
```python
@dataclasses.dataclass()
@@ -381,13 +277,9 @@ The default `CanReachEntrance` rule defines this function already.
### Rule explanations
Resolved rules have a default implementation for `explain_json` and `explain_str` functions. The former optionally
accepts a `CollectionState` and returns a list of `JSONMessagePart` appropriate for `print_json` in a client. It will
display a human-readable message that explains what the rule requires. The latter is similar but returns a string. It is
useful when debugging. There is also a `__str__` method defined to check what a rule is without a state.
Resolved rules have a default implementation for `explain_json` and `explain_str` functions. The former optionally accepts a `CollectionState` and returns a list of `JSONMessagePart` appropriate for `print_json` in a client. It will display a human-readable message that explains what the rule requires. The latter is similar but returns a string. It is useful when debugging. There is also a `__str__` method defined to check what a rule is without a state.
To implement a custom message with a custom rule, override the `explain_json` and/or `explain_str` method on your
`Resolved` class:
To implement a custom message with a custom rule, override the `explain_json` and/or `explain_str` method on your `Resolved` class:
```python
class MyRule(Rule, game="My Game"):
@@ -424,35 +316,22 @@ class MyRule(Rule, game="My Game"):
### Cache control
By default your custom rule will work through the cache system as any other rule if caching is enabled. There are two
class attributes on the `Resolved` class you can override to change this behavior.
By default your custom rule will work through the cache system as any other rule if caching is enabled. There are two class attributes on the `Resolved` class you can override to change this behavior.
- `force_recalculate`: Setting this to `True` will cause your custom rule to skip going through the caching system and
always recalculate when being evaluated. When a rule with this flag enabled is composed with `And` or `Or` it will
cause any parent rules to always force recalculate as well. Use this flag when it's difficult to determine when your
rule should be marked as stale.
- `skip_cache`: Setting this to `True` will also cause your custom rule to skip going through the caching system when
being evaluated. However, it will **not** affect any other rules when composed with `And` or `Or`, so it must still
define its `*_dependencies` functions as required. Use this flag when the evaluation of this rule is trivial and the
overhead of the caching system will slow it down.
- `force_recalculate`: Setting this to `True` will cause your custom rule to skip going through the caching system and always recalculate when being evaluated. When a rule with this flag enabled is composed with `And` or `Or` it will cause any parent rules to always force recalculate as well. Use this flag when it's difficult to determine when your rule should be marked as stale.
- `skip_cache`: Setting this to `True` will also cause your custom rule to skip going through the caching system when being evaluated. However, it will **not** affect any other rules when composed with `And` or `Or`, so it must still define its `*_dependencies` functions as required. Use this flag when the evaluation of this rule is trivial and the overhead of the caching system will slow it down.
### Caveats
- Ensure you are passing `caching_enabled=True` in your `_instantiate` function when creating resolved rule instances if
your world has opted into caching.
- Ensure you are passing `caching_enabled=True` in your `_instantiate` function when creating resolved rule instances if your world has opted into caching.
- Resolved rules are forced to be frozen dataclasses. They and all their attributes must be immutable and hashable.
- If your rule creates child rules ensure they are being resolved through the world rather than creating `Resolved`
instances directly.
- If your rule creates child rules ensure they are being resolved through the world rather than creating `Resolved` instances directly.
## Serialization
The rule builder is intended to be written first in Python for optimization and type safety. To facilitate exporting the
rules to a client or tracker, rules have a `to_dict` method that returns a JSON-compatible dict. Since the location and
entrance logic structure varies greatly from world to world, the actual JSON dumping is left up to the world dev.
The rule builder is intended to be written first in Python for optimization and type safety. To facilitate exporting the rules to a client or tracker, rules have a `to_dict` method that returns a JSON-compatible dict. Since the location and entrance logic structure varies greatly from world to world, the actual JSON dumping is left up to the world dev.
The dict contains a `rule` key with the name of the rule, an `options` key with the rule's list of option filters, and
an `args` key that contains any other arguments the individual rule has. For example, this is what a simple `Has` rule
would look like:
The dict contains a `rule` key with the name of the rule, an `options` key with the rule's list of option filters, and an `args` key that contains any other arguments the individual rule has. For example, this is what a simple `Has` rule would look like:
```python
{
@@ -465,8 +344,7 @@ would look like:
}
```
For `And` and `Or` rules, instead of an `args` key, they have a `children` key containing a list of their child rules in
the same serializable format:
For `And` and `Or` rules, instead of an `args` key, they have a `children` key containing a list of their child rules in the same serializable format:
```python
{
@@ -550,8 +428,7 @@ class BasicLogicRule(Rule, game="My Game"):
}
```
If your logic has been done in custom JSON first, you can define a `from_dict` class method on your rules to parse it
correctly:
If your logic has been done in custom JSON first, you can define a `from_dict` class method on your rules to parse it correctly:
```python
class BasicLogicRule(Rule, game="My Game"):
@@ -572,14 +449,10 @@ These are properties and helpers that are available to you in your world.
#### Methods
- `rule_from_dict(data)`: Create a rule instance from a deserialized dict representation
- `register_rule_builder_dependencies()`: Register all rules that depend on location or entrance access with the
inherited dependencies, gets called automatically after set_rules
- `set_rule(spot: Location | Entrance, rule: Rule)`: Resolve a rule, register its dependencies, and set it on the given
location or entrance
- `register_rule_builder_dependencies()`: Register all rules that depend on location or entrance access with the inherited dependencies, gets called automatically after set_rules
- `set_rule(spot: Location | Entrance, rule: Rule)`: Resolve a rule, register its dependencies, and set it on the given location or entrance
- `set_completion_rule(rule: Rule)`: Sets the completion condition for this world
- `create_entrance(from_region: Region, to_region: Region, rule: Rule | None, name: str | None = None, force_creation: bool = False)`:
Attempt to create an entrance from `from_region` to `to_region`, skipping creation if `rule` is defined and evaluates
to `False_()` unless force_creation is `True`
- `create_entrance(from_region: Region, to_region: Region, rule: Rule | None, name: str | None = None, force_creation: bool = False)`: Attempt to create an entrance from `from_region` to `to_region`, skipping creation if `rule` is defined and evaluates to `False_()` unless force_creation is `True`
#### CachedRuleBuilderWorld Properties
@@ -592,27 +465,18 @@ The following property is only available when inheriting from `CachedRuleBuilder
These are properties and helpers that you can use or override for custom rules.
- `_instantiate(world: World)`: Create a new resolved rule instance, override for custom rules as required
- `to_dict()`: Create a JSON-compatible dict representation of this rule, override if you want to customize your rule's
serialization
- `from_dict(data, world_cls: type[World])`: Return a new rule instance from a deserialized representation, override if
you've overridden `to_dict`
- `to_dict()`: Create a JSON-compatible dict representation of this rule, override if you want to customize your rule's serialization
- `from_dict(data, world_cls: type[World])`: Return a new rule instance from a deserialized representation, override if you've overridden `to_dict`
- `__str__()`: Basic string representation of a rule, useful for debugging
#### Resolved rule API
- `player: int`: The slot this rule is resolved for
- `_evaluate(state: CollectionState)`: Evaluate this rule against the given state, override this to define the logic for
this rule
- `item_dependencies()`: A mapping of item name to set of ids, override this if your custom rule depends on item
collection
- `region_dependencies()`: A mapping of region name to set of ids, override this if your custom rule depends on reaching
regions
- `location_dependencies()`: A mapping of location name to set of ids, override this if your custom rule depends on
reaching locations
- `entrance_dependencies()`: A mapping of entrance name to set of ids, override this if your custom rule depends on
reaching entrances
- `explain_json(state: CollectionState | None = None)`: Return a list of printJSON messages describing this rule's logic
(and if state is defined its evaluation) in a human readable way, override to explain custom rules
- `explain_str(state: CollectionState | None = None)`: Return a string describing this rule's logic (and if state is
defined its evaluation) in a human readable way, override to explain custom rules, more useful for debugging
- `_evaluate(state: CollectionState)`: Evaluate this rule against the given state, override this to define the logic for this rule
- `item_dependencies()`: A mapping of item name to set of ids, override this if your custom rule depends on item collection
- `region_dependencies()`: A mapping of region name to set of ids, override this if your custom rule depends on reaching regions
- `location_dependencies()`: A mapping of location name to set of ids, override this if your custom rule depends on reaching locations
- `entrance_dependencies()`: A mapping of entrance name to set of ids, override this if your custom rule depends on reaching entrances
- `explain_json(state: CollectionState | None = None)`: Return a list of printJSON messages describing this rule's logic (and if state is defined its evaluation) in a human readable way, override to explain custom rules
- `explain_str(state: CollectionState | None = None)`: Return a string describing this rule's logic (and if state is defined its evaluation) in a human readable way, override to explain custom rules, more useful for debugging
- `__str__()`: A string describing this rule's logic without its evaluation, override to explain custom rules

View File

@@ -131,7 +131,7 @@ Unless you configured PyCharm to use pytest as a test runner, you may get import
edit the run configuration, and set the working directory to the Archipelago directory which contains all the project files.
If you only want to run your world's defined tests, repeat the steps for the test directory within your world.
Your working directory should be the root Archipelago directory and the script should be the
Your working directory should be the directory of your world in the worlds directory and the script should be the
tests folder within your world.
You can also find the 'Archipelago Unittests' as an option in the dropdown at the top of the window

View File

@@ -108,6 +108,7 @@ Example:
```json
{
...
"Donkey Kong Country 3":"f90acedcd958213f483a6a4c238e2a3faf92165e",
"Factorio":"a699194a9589db3ebc0d821915864b422c782f44",
...
}

View File

@@ -327,11 +327,6 @@ reject the placement of an item there.
### Events (or "generation-only items/locations")
> **Warning:** If you're trying to tell the Archipelago server that the player has achieved their goal, you want to send
a [StatusUpdate packet](network%20protocol.md#statusupdate), or however [your client library](network%20protocol.md)
wraps it. Despite the popularity of "victory events" during generation, events have nothing to do with how goals are
triggered during gameplay.
An event item or location is one that only exists during multiworld generation; the server is never made aware of them.
Event locations can never be checked by the player, and event items cannot be received during play.
@@ -496,10 +491,9 @@ class MyGameWorld(World):
base_id = 1234
# instead of dynamic numbering, IDs could be part of data
# The following two dicts are required for the generation to know which items exist.
# They can be generated with arbitrary code during world load, but keep in mind that
# anything expensive (e.g. parsing non-python data files) will delay world loading.
# They can include events, but don't have to since events will be placed manually.
# The following two dicts are required for the generation to know which
# items exist. They could be generated from json or something else. They can
# include events, but don't have to since events will be placed manually.
item_name_to_id = {name: id for
id, name in enumerate(mygame_items, base_id)}
location_name_to_id = {name: id for

View File

@@ -186,20 +186,9 @@ class ERPlacementState:
self.pairings = []
self.world = world
self.coupled = coupled
self.collection_state = world.multiworld.get_all_state(False, True)
self.entrance_lookup = entrance_lookup
# Construct an 'all state', similar to MultiWorld.get_all_state(), but only for the world which is having its
# entrances randomized.
single_player_all_state = CollectionState(world.multiworld, True)
player = world.player
for item in world.multiworld.itempool:
if item.player == player:
world.collect(single_player_all_state, item)
for item in world.get_pre_fill_items():
world.collect(single_player_all_state, item)
single_player_all_state.sweep_for_advancements(world.get_locations())
self.collection_state = single_player_all_state
@property
def placed_regions(self) -> set[Region]:
return self.collection_state.reachable_regions[self.world.player]
@@ -237,7 +226,7 @@ class ERPlacementState:
copied_state.blocked_connections[self.world.player].remove(source_exit)
copied_state.blocked_connections[self.world.player].update(target_entrance.connected_region.exits)
copied_state.update_reachable_regions(self.world.player)
copied_state.sweep_for_advancements(self.world.get_locations())
copied_state.sweep_for_advancements()
# test that at there are newly reachable randomized exits that are ACTUALLY reachable
available_randomized_exits = copied_state.blocked_connections[self.world.player]
for _exit in available_randomized_exits:
@@ -413,7 +402,7 @@ def randomize_entrances(
placed_exits, paired_entrances = er_state.connect(source_exit, target_entrance)
# propagate new connections
er_state.collection_state.update_reachable_regions(world.player)
er_state.collection_state.sweep_for_advancements(world.get_locations())
er_state.collection_state.sweep_for_advancements()
if on_connect:
change = on_connect(er_state, placed_exits, paired_entrances)
if change:

View File

@@ -98,6 +98,11 @@ Root: HKCR; Subkey: "{#MyAppName}smpatch"; ValueData: "Arc
Root: HKCR; Subkey: "{#MyAppName}smpatch\DefaultIcon"; ValueData: "{app}\ArchipelagoSNIClient.exe,0"; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}smpatch\shell\open\command"; ValueData: """{app}\ArchipelagoSNIClient.exe"" ""%1"""; ValueType: string; ValueName: "";
Root: HKCR; Subkey: ".apdkc3"; ValueData: "{#MyAppName}dkc3patch"; Flags: uninsdeletevalue; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}dkc3patch"; ValueData: "Archipelago Donkey Kong Country 3 Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}dkc3patch\DefaultIcon"; ValueData: "{app}\ArchipelagoSNIClient.exe,0"; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}dkc3patch\shell\open\command"; ValueData: """{app}\ArchipelagoSNIClient.exe"" ""%1"""; ValueType: string; ValueName: "";
Root: HKCR; Subkey: ".apsmw"; ValueData: "{#MyAppName}smwpatch"; Flags: uninsdeletevalue; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}smwpatch"; ValueData: "Archipelago Super Mario World Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}smwpatch\DefaultIcon"; ValueData: "{app}\ArchipelagoSNIClient.exe,0"; ValueType: string; ValueName: "";
@@ -208,11 +213,6 @@ Root: HKCR; Subkey: "{#MyAppName}ebpatch"; ValueData: "Archi
Root: HKCR; Subkey: "{#MyAppName}ebpatch\DefaultIcon"; ValueData: "{app}\ArchipelagoSNIClient.exe,0"; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}ebpatch\shell\open\command"; ValueData: """{app}\ArchipelagoSNIClient.exe"" ""%1"""; ValueType: string; ValueName: "";
Root: HKCR; Subkey: ".apmm3"; ValueData: "{#MyAppName}mm3patch"; Flags: uninsdeletevalue; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}mm3patch"; ValueData: "Archipelago Mega Man 3 Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}mm3patch\DefaultIcon"; ValueData: "{app}\ArchipelagoBizHawkClient.exe,0"; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}mm3patch\shell\open\command"; ValueData: """{app}\ArchipelagoBizHawkClient.exe"" ""%1"""; ValueType: string; ValueName: "";
Root: HKCR; Subkey: ".archipelago"; ValueData: "{#MyAppName}multidata"; Flags: uninsdeletevalue; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}multidata"; ValueData: "Archipelago Server Data"; Flags: uninsdeletekey; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}multidata\DefaultIcon"; ValueData: "{app}\ArchipelagoServer.exe,0"; ValueType: string; ValueName: "";

View File

@@ -1,21 +1,21 @@
colorama==0.4.6
websockets==13.1 # ,<14
PyYAML==6.0.3
jellyfish==1.2.1
jinja2==3.1.6
schema==0.7.8
kivy==2.3.1
bsdiff4==1.2.6
platformdirs==4.9.4
certifi==2026.2.25
cython==3.2.4
cymem==2.0.13
orjson==3.11.7
typing_extensions==4.15.0
pyshortcuts==1.9.7
pathspec==1.0.4
colorama>=0.4.6
websockets>=13.0.1,<14
PyYAML>=6.0.3
jellyfish>=1.2.1
jinja2>=3.1.6
schema>=0.7.8
kivy>=2.3.1
bsdiff4>=1.2.6
platformdirs>=4.5.0
certifi>=2025.11.12
cython>=3.2.1
cymem>=2.0.13
orjson>=3.11.4
typing_extensions>=4.15.0
pyshortcuts>=1.9.6
pathspec>=0.12.1
kivymd @ git+https://github.com/kivymd/KivyMD@5ff9d0d
kivymd>=2.0.1.dev0
# 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 NetUtils import JSONMessagePart
from .field_resolvers import FieldResolver, FieldResolverRegister, resolve_field
from .options import OptionFilter
if TYPE_CHECKING:
@@ -36,7 +35,7 @@ def _create_hash_fn(resolved_rule_cls: "CustomRuleRegister") -> Callable[..., in
class CustomRuleRegister(type):
"""A metaclass to contain world custom rules and automatically convert resolved rules to frozen dataclasses"""
resolved_rules: ClassVar[dict["Rule.Resolved", "Rule.Resolved"]] = {}
resolved_rules: ClassVar[dict[int, "Rule.Resolved"]] = {}
"""A cached of resolved rules to turn each unique one into a singleton"""
custom_rules: ClassVar[dict[str, dict[str, type["Rule[Any]"]]]] = {}
@@ -64,9 +63,10 @@ class CustomRuleRegister(type):
@override
def __call__(cls, *args: Any, **kwds: Any) -> Any:
rule = super().__call__(*args, **kwds)
if rule in cls.resolved_rules:
return cls.resolved_rules[rule]
cls.resolved_rules[rule] = rule
rule_hash = hash(rule)
if rule_hash in cls.resolved_rules:
return cls.resolved_rules[rule_hash]
cls.resolved_rules[rule_hash] = rule
return rule
@classmethod
@@ -108,14 +108,11 @@ class Rule(Generic[TWorld]):
def to_dict(self) -> dict[str, Any]:
"""Returns a JSON compatible dict representation of this rule"""
args = {}
for field in dataclasses.fields(self):
if field.name in ("options", "filtered_resolution"):
continue
value = getattr(self, field.name, None)
if isinstance(value, FieldResolver):
value = value.to_dict()
args[field.name] = value
args = {
field.name: getattr(self, field.name, None)
for field in dataclasses.fields(self)
if field.name not in ("options", "filtered_resolution")
}
return {
"rule": self.__class__.__qualname__,
"options": [o.to_dict() for o in self.options],
@@ -127,19 +124,7 @@ class Rule(Generic[TWorld]):
def from_dict(cls, data: Mapping[str, Any], world_cls: "type[World]") -> Self:
"""Returns a new instance of this rule from a serialized dict representation"""
options = OptionFilter.multiple_from_dict(data.get("options", ()))
args = cls._parse_field_resolvers(data.get("args", {}), world_cls.game)
return cls(**args, options=options, filtered_resolution=data.get("filtered_resolution", False))
@classmethod
def _parse_field_resolvers(cls, data: Mapping[str, Any], game_name: str) -> dict[str, Any]:
result: dict[str, Any] = {}
for name, value in data.items():
if isinstance(value, dict) and "resolver" in value:
resolver_cls = FieldResolverRegister.get_resolver_cls(game_name, value["resolver"]) # pyright: ignore[reportUnknownArgumentType]
result[name] = resolver_cls.from_dict(value) # pyright: ignore[reportUnknownArgumentType]
else:
result[name] = value
return result
return cls(**data.get("args", {}), options=options, filtered_resolution=data.get("filtered_resolution", False))
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"""
@@ -542,7 +527,7 @@ class Or(NestedRule[TWorld], game="Archipelago"):
items[item] = 1
elif isinstance(child, HasAnyCount.Resolved):
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
else:
clauses.append(child)
@@ -703,24 +688,24 @@ class Filtered(WrapperRule[TWorld], game="Archipelago"):
class Has(Rule[TWorld], game="Archipelago"):
"""A rule that checks if the player has at least `count` of a given item"""
item_name: str | FieldResolver
item_name: str
"""The item to check for"""
count: int | FieldResolver = 1
count: int = 1
"""The count the player is required to have"""
@override
def _instantiate(self, world: TWorld) -> Rule.Resolved:
return self.Resolved(
resolve_field(self.item_name, world, str),
count=resolve_field(self.count, world, int),
self.item_name,
self.count,
player=world.player,
caching_enabled=getattr(world, "rule_caching_enabled", False),
)
@override
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 ""
return f"{self.__class__.__name__}({self.item_name}{count}{options})"
@@ -1006,7 +991,7 @@ class HasAny(Rule[TWorld], game="Archipelago"):
class HasAllCounts(Rule[TWorld], game="Archipelago"):
"""A rule that checks if the player has all of the specified counts of the given items"""
item_counts: Mapping[str, int | FieldResolver]
item_counts: dict[str, int]
"""A mapping of item name to count to check for"""
@override
@@ -1017,30 +1002,12 @@ class HasAllCounts(Rule[TWorld], game="Archipelago"):
if len(self.item_counts) == 1:
item = next(iter(self.item_counts))
return Has(item, self.item_counts[item]).resolve(world)
item_counts = tuple((name, resolve_field(count, world, int)) for name, count in self.item_counts.items())
return self.Resolved(
item_counts,
tuple(self.item_counts.items()),
player=world.player,
caching_enabled=getattr(world, "rule_caching_enabled", False),
)
@override
def to_dict(self) -> dict[str, Any]:
output = super().to_dict()
output["args"]["item_counts"] = {
key: value.to_dict() if isinstance(value, FieldResolver) else value
for key, value in output["args"]["item_counts"].items()
}
return output
@override
@classmethod
def from_dict(cls, data: Mapping[str, Any], world_cls: "type[World]") -> Self:
args = data.get("args", {})
item_counts = cls._parse_field_resolvers(args.get("item_counts", {}), world_cls.game)
options = OptionFilter.multiple_from_dict(data.get("options", ()))
return cls(item_counts, options=options, filtered_resolution=data.get("filtered_resolution", False))
@override
def __str__(self) -> str:
items = ", ".join([f"{item} x{count}" for item, count in self.item_counts.items()])
@@ -1129,7 +1096,7 @@ class HasAllCounts(Rule[TWorld], game="Archipelago"):
class HasAnyCount(Rule[TWorld], game="Archipelago"):
"""A rule that checks if the player has any of the specified counts of the given items"""
item_counts: Mapping[str, int | FieldResolver]
item_counts: dict[str, int]
"""A mapping of item name to count to check for"""
@override
@@ -1140,30 +1107,12 @@ class HasAnyCount(Rule[TWorld], game="Archipelago"):
if len(self.item_counts) == 1:
item = next(iter(self.item_counts))
return Has(item, self.item_counts[item]).resolve(world)
item_counts = tuple((name, resolve_field(count, world, int)) for name, count in self.item_counts.items())
return self.Resolved(
item_counts,
tuple(self.item_counts.items()),
player=world.player,
caching_enabled=getattr(world, "rule_caching_enabled", False),
)
@override
def to_dict(self) -> dict[str, Any]:
output = super().to_dict()
output["args"]["item_counts"] = {
key: value.to_dict() if isinstance(value, FieldResolver) else value
for key, value in output["args"]["item_counts"].items()
}
return output
@override
@classmethod
def from_dict(cls, data: Mapping[str, Any], world_cls: "type[World]") -> Self:
args = data.get("args", {})
item_counts = cls._parse_field_resolvers(args.get("item_counts", {}), world_cls.game)
options = OptionFilter.multiple_from_dict(data.get("options", ()))
return cls(item_counts, options=options, filtered_resolution=data.get("filtered_resolution", False))
@override
def __str__(self) -> str:
items = ", ".join([f"{item} x{count}" for item, count in self.item_counts.items()])
@@ -1255,13 +1204,13 @@ class HasFromList(Rule[TWorld], game="Archipelago"):
item_names: tuple[str, ...]
"""A tuple of item names to check for"""
count: int | FieldResolver = 1
count: int = 1
"""The number of items the player needs to have"""
def __init__(
self,
*item_names: str,
count: int | FieldResolver = 1,
count: int = 1,
options: Iterable[OptionFilter] = (),
filtered_resolution: bool = False,
) -> None:
@@ -1278,7 +1227,7 @@ class HasFromList(Rule[TWorld], game="Archipelago"):
return Has(self.item_names[0], self.count).resolve(world)
return self.Resolved(
self.item_names,
count=resolve_field(self.count, world, int),
self.count,
player=world.player,
caching_enabled=getattr(world, "rule_caching_enabled", False),
)
@@ -1286,7 +1235,7 @@ class HasFromList(Rule[TWorld], game="Archipelago"):
@override
@classmethod
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", ())
options = OptionFilter.multiple_from_dict(data.get("options", ()))
return cls(*item_names, **args, options=options, filtered_resolution=data.get("filtered_resolution", False))
@@ -1389,13 +1338,13 @@ class HasFromListUnique(Rule[TWorld], game="Archipelago"):
item_names: tuple[str, ...]
"""A tuple of item names to check for"""
count: int | FieldResolver = 1
count: int = 1
"""The number of items the player needs to have"""
def __init__(
self,
*item_names: str,
count: int | FieldResolver = 1,
count: int = 1,
options: Iterable[OptionFilter] = (),
filtered_resolution: bool = False,
) -> None:
@@ -1405,15 +1354,14 @@ class HasFromListUnique(Rule[TWorld], game="Archipelago"):
@override
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) < count:
if len(self.item_names) == 0 or len(self.item_names) < self.count:
# match state.has_from_list_unique
return False_().resolve(world)
if len(self.item_names) == 1:
return Has(self.item_names[0]).resolve(world)
return self.Resolved(
self.item_names,
count,
self.count,
player=world.player,
caching_enabled=getattr(world, "rule_caching_enabled", False),
)
@@ -1421,7 +1369,7 @@ class HasFromListUnique(Rule[TWorld], game="Archipelago"):
@override
@classmethod
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", ())
options = OptionFilter.multiple_from_dict(data.get("options", ()))
return cls(*item_names, **args, options=options, filtered_resolution=data.get("filtered_resolution", False))
@@ -1520,7 +1468,7 @@ class HasGroup(Rule[TWorld], game="Archipelago"):
item_name_group: str
"""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"""
@override
@@ -1529,14 +1477,14 @@ class HasGroup(Rule[TWorld], game="Archipelago"):
return self.Resolved(
self.item_name_group,
item_names,
count=resolve_field(self.count, world, int),
self.count,
player=world.player,
caching_enabled=getattr(world, "rule_caching_enabled", False),
)
@override
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 ""
return f"{self.__class__.__name__}({self.item_name_group}{count}{options})"
@@ -1594,7 +1542,7 @@ class HasGroupUnique(Rule[TWorld], game="Archipelago"):
item_name_group: str
"""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"""
@override
@@ -1603,14 +1551,14 @@ class HasGroupUnique(Rule[TWorld], game="Archipelago"):
return self.Resolved(
self.item_name_group,
item_names,
count=resolve_field(self.count, world, int),
self.count,
player=world.player,
caching_enabled=getattr(world, "rule_caching_enabled", False),
)
@override
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 ""
return f"{self.__class__.__name__}({self.item_name_group}{count}{options})"

View File

@@ -644,12 +644,6 @@ class GeneratorOptions(Group):
class Players(int):
"""amount of players, 0 to infer from player files"""
class AllowQuantity(Bool):
"""
allow players to set an individual quantity for their yaml settings
with 'false' any amounts from the players will be ignored and set to 1
"""
class WeightsFilePath(str):
"""
general weights file, within the stated player_files_path location
@@ -696,7 +690,6 @@ class GeneratorOptions(Group):
enemizer_path: EnemizerPath = EnemizerPath("EnemizerCLI/EnemizerCLI.Core") # + ".exe" is implied on Windows
player_files_path: PlayerFilesPath = PlayerFilesPath("Players")
players: Players = Players(0)
allow_quantity: AllowQuantity | bool = False
weights_file_path: WeightsFilePath = WeightsFilePath("weights.yaml")
meta_file_path: MetaFilePath = MetaFilePath("meta.yaml")
spoiler: Spoiler = Spoiler(3)

View File

@@ -71,6 +71,7 @@ non_apworlds: set[str] = {
"Ocarina of Time",
"Overcooked! 2",
"Raft",
"Sudoku",
"Super Mario 64",
"VVVVVV",
"Wargroove",
@@ -408,6 +409,7 @@ class BuildExeCommand(cx_Freeze.command.build_exe.build_exe):
apworld = APWorldContainer(str(zip_path))
apworld.minimum_ap_version = version_tuple
apworld.maximum_ap_version = version_tuple
apworld.platforms = [sys.platform]
apworld.game = worldtype.game
manifest.update(apworld.get_manifest())
apworld.manifest_path = f"{file_name}/archipelago.json"
@@ -657,7 +659,7 @@ cx_Freeze.setup(
options={
"build_exe": {
"packages": ["worlds", "kivy", "cymem", "websockets", "kivymd"],
"includes": ["rule_builder.cached_world"],
"includes": [],
"excludes": ["numpy", "Cython", "PySide2", "PIL",
"pandas"],
"zip_includes": [],

View File

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

View File

@@ -1,7 +1,7 @@
import unittest
from BaseClasses import PlandoOptions
from Options import Choice, TextChoice, ItemLinks, OptionSet, PlandoConnections, PlandoItems, PlandoTexts
from Options import Choice, ItemLinks, OptionSet, PlandoConnections, PlandoItems, PlandoTexts
from Utils import restricted_dumps
from worlds.AutoWorld import AutoWorldRegister
@@ -16,29 +16,6 @@ class TestOptions(unittest.TestCase):
with self.subTest(game=gamename, option=option_key):
self.assertTrue(option.__doc__)
def test_option_defaults(self):
"""Test that defaults for submitted options are valid."""
for gamename, world_type in AutoWorldRegister.world_types.items():
if not world_type.hidden:
for option_key, option in world_type.options_dataclass.type_hints.items():
with self.subTest(game=gamename, option=option_key):
if issubclass(option, TextChoice):
self.assertTrue(option.default in option.name_lookup,
f"Default value {option.default} for TextChoice option {option.__name__} in"
f" {gamename} does not resolve to a listed value!"
)
# Standard "can default generate" test
err_raised = None
try:
option.from_any(option.default)
except Exception as ex:
err_raised = ex
self.assertIsNone(err_raised,
f"Default value {option.default} for option {option.__name__} in {gamename}"
f" is not valid! Exception: {err_raised}"
)
def test_options_are_not_set_by_world(self):
"""Test that options attribute is not already set"""
for gamename, world_type in AutoWorldRegister.world_types.items():
@@ -109,7 +86,7 @@ class TestOptions(unittest.TestCase):
def test_option_set_keys_random(self):
"""Tests that option sets do not contain 'random' and its variants as valid keys"""
for game_name, world_type in AutoWorldRegister.world_types.items():
if game_name not in ("Archipelago", "Super Metroid"):
if game_name not in ("Archipelago", "Sudoku", "Super Metroid"):
for option_key, option in world_type.options_dataclass.type_hints.items():
if issubclass(option, OptionSet):
with self.subTest(game=game_name, option=option_key):

View File

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

View File

@@ -6,7 +6,6 @@ import zipfile
from pathlib import Path
from typing import TYPE_CHECKING, Iterable, Optional, cast
from Utils import utcnow
from WebHostLib import to_python
if TYPE_CHECKING:
@@ -134,7 +133,7 @@ def stop_room(app_client: "FlaskClient",
room_id: str,
timeout: Optional[float] = None,
simulate_idle: bool = True) -> None:
from datetime import timedelta
from datetime import datetime, timedelta
from time import sleep
from pony.orm import db_session
@@ -152,11 +151,10 @@ def stop_room(app_client: "FlaskClient",
with db_session:
room: Room = Room.get(id=room_uuid)
now = utcnow()
if simulate_idle:
new_last_activity = now - timedelta(seconds=room.timeout + 5)
new_last_activity = datetime.utcnow() - timedelta(seconds=room.timeout + 5)
else:
new_last_activity = now - timedelta(days=3)
new_last_activity = datetime.utcnow() - timedelta(days=3)
room.last_activity = new_last_activity
address = f"localhost:{room.last_port}" if room.last_port > 0 else None
if address:
@@ -190,7 +188,6 @@ def stop_room(app_client: "FlaskClient",
if address:
room.timeout = original_timeout
room.last_activity = new_last_activity
room.commands.clear() # make sure there is no leftover /exit
print("timeout restored")

View File

@@ -1,37 +0,0 @@
"""Verify that NetUtils' enums work correctly with all supported Python versions."""
import pickle
import unittest
from enum import Enum
from typing import Type
from NetUtils import ClientStatus, HintStatus, SlotType
from Utils import restricted_loads
class Base:
class DataEnumTest(unittest.TestCase):
type: Type[Enum]
value: Enum
def test_unpickle(self) -> None:
"""Tests that enums used in multidata or multisave can be pickled and unpickled."""
pickled = pickle.dumps(self.value)
unpickled = restricted_loads(pickled)
self.assertEqual(unpickled, self.value)
self.assertIsInstance(unpickled, self.type)
class HintStatusTest(Base.DataEnumTest):
type = HintStatus
value = HintStatus.HINT_AVOID
class ClientStatusTest(Base.DataEnumTest):
type = ClientStatus
value = ClientStatus.CLIENT_GOAL
class SlotTypeTest(Base.DataEnumTest):
type = SlotType
value = SlotType.player

View File

@@ -1,8 +1,6 @@
import unittest
from collections import Counter
from Options import Choice, DefaultOnToggle, Toggle, OptionDict, OptionError, OptionSet, OptionList, OptionCounter
from Options import Choice, DefaultOnToggle, Toggle
class TestNumericOptions(unittest.TestCase):
@@ -76,97 +74,3 @@ class TestNumericOptions(unittest.TestCase):
self.assertTrue(toggle_string)
self.assertTrue(toggle_int)
self.assertTrue(toggle_alias)
class TestContainerOptions(unittest.TestCase):
def test_option_dict(self):
class TestOptionDict(OptionDict):
valid_keys = frozenset({"A", "B", "C"})
unknown_key_init_dict = {"D": "Foo"}
test_option_dict = TestOptionDict(unknown_key_init_dict)
self.assertRaises(OptionError, test_option_dict.verify_keys)
init_dict = {"A": "foo", "B": "bar"}
test_option_dict = TestOptionDict(init_dict)
self.assertEqual(test_option_dict, init_dict) # Implicit value comparison
self.assertEqual(test_option_dict["A"], "foo")
self.assertIn("B", test_option_dict)
self.assertNotIn("C", test_option_dict)
self.assertRaises(KeyError, lambda: test_option_dict["C"])
def test_option_set(self):
class TestOptionSet(OptionSet):
valid_keys = frozenset({"A", "B", "C"})
unknown_key_init_set = {"D"}
test_option_set = TestOptionSet(unknown_key_init_set)
self.assertRaises(OptionError, test_option_set.verify_keys)
init_set = {"A", "B"}
test_option_set = TestOptionSet(init_set)
self.assertEqual(test_option_set, init_set) # Implicit value comparison
self.assertIn("B", test_option_set)
self.assertNotIn("C", test_option_set)
def test_option_list(self):
class TestOptionList(OptionList):
valid_keys = frozenset({"A", "B", "C"})
unknown_key_init_list = ["D"]
test_option_list = TestOptionList(unknown_key_init_list)
self.assertRaises(OptionError, test_option_list.verify_keys)
init_list = ["A", "B"]
test_option_list = TestOptionList(init_list)
self.assertEqual(test_option_list, init_list)
self.assertIn("B", test_option_list)
self.assertNotIn("C", test_option_list)
def test_option_counter(self):
class TestOptionCounter(OptionCounter):
valid_keys = frozenset({"A", "B", "C"})
max = 10
min = 0
unknown_key_init_dict = {"D": 5}
test_option_counter = TestOptionCounter(unknown_key_init_dict)
self.assertRaises(OptionError, test_option_counter.verify_keys)
wrong_value_type_init_dict = {"A": "B"}
self.assertRaises(TypeError, TestOptionCounter, wrong_value_type_init_dict)
violates_max_init_dict = {"A": 5, "B": 11}
test_option_counter = TestOptionCounter(violates_max_init_dict)
self.assertRaises(OptionError, test_option_counter.verify_values)
violates_min_init_dict = {"A": -1, "B": 5}
test_option_counter = TestOptionCounter(violates_min_init_dict)
self.assertRaises(OptionError, test_option_counter.verify_values)
init_dict = {"A": 0, "B": 10}
test_option_counter = TestOptionCounter(init_dict)
self.assertEqual(test_option_counter, Counter(init_dict))
self.assertIn("A", test_option_counter)
self.assertNotIn("C", test_option_counter)
self.assertEqual(test_option_counter["A"], 0)
self.assertEqual(test_option_counter["B"], 10)
self.assertEqual(test_option_counter["C"], 0)
def test_culling_option_counter(self):
class TestCullingCounter(OptionCounter):
valid_keys = frozenset({"A", "B", "C"})
cull_zeroes = True
init_dict = {"A": 0, "B": 10}
test_option_counter = TestCullingCounter(init_dict)
self.assertNotIn("A", test_option_counter)
self.assertIn("B", test_option_counter)
self.assertNotIn("C", test_option_counter)
self.assertEqual(test_option_counter["A"], 0) # It's still a Counter! cull_zeroes is about "in" checks.
self.assertEqual(test_option_counter, Counter({"B": 10}))

View File

@@ -33,9 +33,4 @@ class TestBase(unittest.TestCase):
cls.app = raw_app
def setUp(self) -> None:
from WebHostLib.models import db
from pony.orm import db_session
with db_session:
for entity in db.entities.values():
entity.select().delete(bulk=True)
self.client = self.app.test_client()

View File

@@ -1,107 +0,0 @@
from datetime import timedelta
from uuid import UUID, uuid4
from pony.orm import db_session, commit
from Utils import utcnow
from WebHostLib.autolauncher import cleanup
from WebHostLib.models import Room, Seed, Slot
from . import TestBase
class TestCleanup(TestBase):
def test_cleanup_unowned(self) -> None:
with db_session:
s1 = Seed(id=uuid4(), multidata=b"", owner=UUID(int=0))
Room(id=uuid4(), owner=UUID(int=0), seed=s1)
s2 = Seed(id=uuid4(), multidata=b"", owner=uuid4()) # Owned
Room(id=uuid4(), owner=UUID(int=0), seed=s2) # Unowned room of owned seed
Seed(id=uuid4(), multidata=b"", owner=UUID(int=0)) # Unowned seed with no rooms
commit()
cleanup({"ROOM_AUTO_DELETE": 0})
with db_session:
self.assertEqual(Room.select().count(), 0) # Both rooms were unowned
self.assertEqual(Seed.select().count(), 1) # s2 is owned
self.assertIsNotNone(Seed.get(id=s2.id))
def test_cleanup_auto_delete(self) -> None:
now = utcnow()
old_time = now - timedelta(days=10)
recent_time = now - timedelta(days=2)
with db_session:
# Case 1: Old room, owned
s1 = Seed(id=uuid4(), multidata=b"", owner=uuid4(), creation_time=old_time)
r1 = Room(id=uuid4(), owner=uuid4(), seed=s1, last_activity=old_time)
# Case 2: Recent room, owned
s2 = Seed(id=uuid4(), multidata=b"", owner=uuid4(), creation_time=old_time)
r2 = Room(id=uuid4(), owner=uuid4(), seed=s2, last_activity=recent_time)
# Case 3: Old seed, no rooms, owned
s3 = Seed(id=uuid4(), multidata=b"", owner=uuid4(), creation_time=old_time)
# Case 4: Recent seed, no rooms, owned
s4 = Seed(id=uuid4(), multidata=b"", owner=uuid4(), creation_time=recent_time)
# Case 5: Old seed with recent room (should not be deleted)
s5 = Seed(id=uuid4(), multidata=b"", owner=uuid4(), creation_time=old_time)
r5 = Room(id=uuid4(), owner=uuid4(), seed=s5, last_activity=recent_time)
commit()
# Delete items older than 5 days
cleanup({"ROOM_AUTO_DELETE": 5})
with db_session:
self.assertIsNone(Room.get(id=r1.id), "Old room should be deleted")
self.assertIsNotNone(Room.get(id=r2.id), "Recent room should NOT be deleted")
self.assertIsNone(Seed.get(id=s3.id), "Old seed without rooms should be deleted")
self.assertIsNotNone(Seed.get(id=s4.id), "Recent seed without rooms should NOT be deleted")
self.assertIsNotNone(Seed.get(id=s5.id), "Old seed with recent room should NOT be deleted")
self.assertIsNotNone(Room.get(id=r5.id), "Recent room for old seed should NOT be deleted")
# Seeds are deleted if they have NO rooms AND are old.
# After r1 is deleted, s1 has no rooms. Since it's old, it should be deleted.
self.assertIsNone(Seed.get(id=s1.id), "Old seed whose only room was deleted should be deleted")
def test_cleanup_disabled(self) -> None:
now = utcnow()
old_time = now - timedelta(days=10)
with db_session:
s1 = Seed(id=uuid4(), multidata=b"", owner=uuid4(), creation_time=old_time)
r1 = Room(id=uuid4(), owner=uuid4(), seed=s1, last_activity=old_time)
commit()
cleanup({"ROOM_AUTO_DELETE": 0})
with db_session:
self.assertIsNotNone(Room.get(id=r1.id), "Room should NOT be deleted when auto-delete is 0")
self.assertIsNotNone(Seed.get(id=s1.id), "Seed should NOT be deleted when auto-delete is 0")
def test_cleanup_slots(self) -> None:
now = utcnow()
old_time = now - timedelta(days=10)
with db_session:
s1 = Seed(id=uuid4(), multidata=b"", owner=uuid4(), creation_time=old_time)
slot1 = Slot(player_id=1, player_name="P1", seed=s1, game="TestGame")
s2 = Seed(id=uuid4(), multidata=b"", owner=uuid4(), creation_time=now)
slot2 = Slot(player_id=2, player_name="P2", seed=s2, game="TestGame")
commit()
# Delete items older than 5 days
cleanup({"ROOM_AUTO_DELETE": 5})
with db_session:
self.assertIsNone(Seed.get(id=s1.id), "Old seed should be deleted")
self.assertIsNone(Slot.get(id=slot1.id), "Slot of deleted seed should be deleted")
self.assertIsNotNone(Seed.get(id=s2.id), "Recent seed should NOT be deleted")
self.assertIsNotNone(Slot.get(id=slot2.id), "Slot of recent seed should NOT be deleted")

View File

@@ -1,76 +0,0 @@
import math
from typing import Any, Callable
from typing_extensions import override
from uuid import uuid4
from werkzeug.routing import BaseConverter
from . import TestBase
class TestSUUID(TestBase):
converter: BaseConverter
filter: Callable[[Any], str]
@override
def setUp(self) -> None:
from werkzeug.routing import Map
super().setUp()
self.converter = self.app.url_map.converters["suuid"](Map())
self.filter = self.app.jinja_env.filters["suuid"] # type: ignore # defines how we use it, not what it can be
def test_is_reversible(self) -> None:
u = uuid4()
self.assertEqual(u, self.converter.to_python(self.converter.to_url(u)))
s = "A" * 22 # uuid with all zeros
self.assertEqual(s, self.converter.to_url(self.converter.to_python(s)))
def test_uuid_length(self) -> None:
with self.assertRaises(ValueError):
self.converter.to_python("AAAA")
def test_padding(self) -> None:
self.converter.to_python("A" * 22) # check that the correct value works
with self.assertRaises(ValueError):
self.converter.to_python("A" * 22 + "==") # converter should not allow padding
def test_empty(self) -> None:
with self.assertRaises(ValueError):
self.converter.to_python("")
def test_stray_equal_signs(self) -> None:
self.converter.to_python("A" * 22) # check that the correct value works
with self.assertRaises(ValueError):
self.converter.to_python("A" * 22 + "==" + "AA") # the "==AA" should not be ignored, but error out
with self.assertRaises(ValueError):
self.converter.to_python("A" * 20 + "==" + "AA") # the final "A"s should not be appended to the first "A"s
def test_stray_whitespace(self) -> None:
s = "A" * 22
self.converter.to_python(s) # check that the correct value works
for char in " \t\r\n\v":
for pos in (0, 11, 22):
with self.subTest(char=char, pos=pos):
s_with_whitespace = s[0:pos] + char * 4 + s[pos:] # insert 4 to make padding correct
# check that the constructed s_with_whitespace is correct
self.assertEqual(len(s_with_whitespace), len(s) + 4)
self.assertEqual(s_with_whitespace[pos], char)
# s_with_whitespace should be invalid as SUUID
with self.assertRaises(ValueError):
self.converter.to_python(s_with_whitespace)
def test_filter_returns_valid_string(self) -> None:
u = uuid4()
s = self.filter(u)
self.assertIsInstance(s, str)
self.assertNotIn("=", s)
self.assertEqual(len(s), math.ceil(len(u.bytes) * 4 / 3))
def test_filter_is_same_as_converter(self) -> None:
u = uuid4()
self.assertEqual(self.filter(u), self.converter.to_url(u))
def test_filter_bad_type(self) -> None:
with self.assertRaises(Exception): # currently the type is not checked directly, so any exception is valid
self.filter(None)

View File

@@ -353,6 +353,8 @@ class World(metaclass=AutoWorldRegister):
"""path it was loaded from"""
world_version: ClassVar[Version] = Version(0, 0, 0)
"""Optional world version loaded from archipelago.json"""
platforms: ClassVar[Optional[List[str]]] = None
"""Optional platforms loaded from archipelago.json"""
def __init__(self, multiworld: "MultiWorld", player: int):
assert multiworld is not None
@@ -363,7 +365,7 @@ class World(metaclass=AutoWorldRegister):
def __getattr__(self, item: str) -> Any:
if item == "settings":
return getattr(self.__class__, item)
return self.__class__.settings
raise AttributeError
# overridable methods that get called by Main.py, sorted by execution order
@@ -512,9 +514,7 @@ class World(metaclass=AutoWorldRegister):
def get_filler_item_name(self) -> str:
"""
If core AP removes an item from your item pool, this method is called to choose a replacement item
so item count and location count remain equal.
For example: plando, item_links and start_inventory_from_pool are features that may cause this.
Called when the item pool needs to be filled with additional items to match location count.
Any returned item name must be for a "repeatable" item, i.e. one that it's okay to generate arbitrarily many of.
For most worlds this will be one or more of your filler items, but the classification of these items

View File

@@ -197,6 +197,7 @@ class APWorldContainer(APContainer):
world_version: "Version | None" = None
minimum_ap_version: "Version | None" = None
maximum_ap_version: "Version | None" = None
platforms: Optional[List[str]] = None
def read_contents(self, opened_zipfile: zipfile.ZipFile) -> Dict[str, Any]:
from Utils import tuplize_version
@@ -205,6 +206,7 @@ class APWorldContainer(APContainer):
for version_key in ("world_version", "minimum_ap_version", "maximum_ap_version"):
if version_key in manifest:
setattr(self, version_key, tuplize_version(manifest[version_key]))
self.platforms = manifest.get("platforms")
return manifest
def get_manifest(self) -> Dict[str, Any]:
@@ -215,6 +217,8 @@ class APWorldContainer(APContainer):
version = getattr(self, version_key)
if version:
manifest[version_key] = version.as_simple_string()
if self.platforms:
manifest["platforms"] = self.platforms
return manifest

View File

@@ -269,9 +269,8 @@ if not is_frozen():
from Launcher import open_folder
import argparse
parser = argparse.ArgumentParser(prog="Build APWorlds", description="Build script for APWorlds")
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")
parser = argparse.ArgumentParser("Build script for APWorlds")
parser.add_argument("worlds", type=str, default=(), nargs="*", help="Names of APWorlds to build.")
args = parser.parse_args(launch_args)
if args.worlds:
@@ -290,6 +289,12 @@ if not is_frozen():
if not worldtype:
logging.error(f"Requested APWorld \"{worldname}\" does not exist.")
continue
assert worldtype.platforms != [], (
f"World {worldname} has an empty list for platforms. "
"Use None or omit the attribute for 'any platform'."
)
file_name = os.path.split(os.path.dirname(worldtype.__file__))[1]
world_directory = os.path.join("worlds", file_name)
if os.path.isfile(os.path.join(world_directory, "archipelago.json")):
@@ -321,9 +326,7 @@ if not is_frozen():
zf.write(pathlib.Path(world_directory, file), pathlib.Path(file_name, file))
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,
description="Build APWorlds from loose-file world folders."))

View File

@@ -118,6 +118,7 @@ for world_source in world_sources:
game = manifest.get("game")
if game in AutoWorldRegister.world_types:
AutoWorldRegister.world_types[game].world_version = tuplize_version(manifest.get("world_version", "0.0.0"))
AutoWorldRegister.world_types[game].platforms = manifest.get("platforms")
if apworlds:
# encapsulation for namespace / gc purposes
@@ -165,6 +166,11 @@ if apworlds:
f"Did not load {apworld_source.path} "
f"as its maximum core version {apworld.maximum_ap_version} "
f"is lower than current core version {version_tuple}.")
elif apworld.platforms and sys.platform not in apworld.platforms:
fail_world(apworld.game,
f"Did not load {apworld_source.path} "
f"as it is not compatible with current platform {sys.platform}. "
f"Supported platforms: {', '.join(apworld.platforms)}")
else:
core_compatible.append((apworld_source, apworld))
# load highest version first
@@ -199,6 +205,8 @@ if apworlds:
# world could fail to load at this point
if apworld.world_version:
AutoWorldRegister.world_types[apworld.game].world_version = apworld.world_version
if apworld.platforms:
AutoWorldRegister.world_types[apworld.game].platforms = apworld.platforms
load_apworlds()
del load_apworlds

View File

@@ -3,10 +3,10 @@ from Options import PerGameCommonOptions
from .Locations import location_table, AdventureLocation, dragon_room_to_region
def connect(multiworld: MultiWorld, player: int, source: str, target: str, rule: callable = lambda state: True,
def connect(world: MultiWorld, player: int, source: str, target: str, rule: callable = lambda state: True,
one_way=False, name=None):
source_region = multiworld.get_region(source, player)
target_region = multiworld.get_region(target, player)
source_region = world.get_region(source, player)
target_region = world.get_region(target, player)
if name is None:
name = source + " to " + target
@@ -22,7 +22,7 @@ def connect(multiworld: MultiWorld, player: int, source: str, target: str, rule:
source_region.exits.append(connection)
connection.connect(target_region)
if not one_way:
connect(multiworld, player, target, source, rule, True)
connect(world, player, target, source, rule, True)
def create_regions(options: PerGameCommonOptions, multiworld: MultiWorld, player: int, dragon_rooms: []) -> None:

View File

@@ -3,47 +3,47 @@ from worlds.generic.Rules import add_rule, set_rule, forbid_item
def set_rules(self) -> None:
multiworld = self.multiworld
world = self.multiworld
use_bat_logic = self.options.bat_logic.value == BatLogic.option_use_logic
set_rule(multiworld.get_entrance("YellowCastlePort", self.player),
set_rule(world.get_entrance("YellowCastlePort", self.player),
lambda state: state.has("Yellow Key", self.player))
set_rule(multiworld.get_entrance("BlackCastlePort", self.player),
set_rule(world.get_entrance("BlackCastlePort", self.player),
lambda state: state.has("Black Key", self.player))
set_rule(multiworld.get_entrance("WhiteCastlePort", self.player),
set_rule(world.get_entrance("WhiteCastlePort", self.player),
lambda state: state.has("White Key", self.player))
# a future thing would be to make the bat an actual item, or at least allow it to
# be placed in a castle, which would require some additions to the rules when
# use_bat_logic is true
if not use_bat_logic:
set_rule(multiworld.get_entrance("WhiteCastleSecretPassage", self.player),
set_rule(world.get_entrance("WhiteCastleSecretPassage", self.player),
lambda state: state.has("Bridge", self.player))
set_rule(multiworld.get_entrance("WhiteCastlePeekPassage", self.player),
set_rule(world.get_entrance("WhiteCastlePeekPassage", self.player),
lambda state: state.has("Bridge", self.player) or
state.has("Magnet", self.player))
set_rule(multiworld.get_entrance("BlackCastleVaultEntrance", self.player),
set_rule(world.get_entrance("BlackCastleVaultEntrance", self.player),
lambda state: state.has("Bridge", self.player) or
state.has("Magnet", self.player))
dragon_slay_check = self.options.dragon_slay_check.value
if dragon_slay_check:
if self.difficulty_switch_b == DifficultySwitchB.option_hard_with_unlock_item:
set_rule(multiworld.get_location("Slay Yorgle", self.player),
set_rule(world.get_location("Slay Yorgle", self.player),
lambda state: state.has("Sword", self.player) and
state.has("Right Difficulty Switch", self.player))
set_rule(multiworld.get_location("Slay Grundle", self.player),
set_rule(world.get_location("Slay Grundle", self.player),
lambda state: state.has("Sword", self.player) and
state.has("Right Difficulty Switch", self.player))
set_rule(multiworld.get_location("Slay Rhindle", self.player),
set_rule(world.get_location("Slay Rhindle", self.player),
lambda state: state.has("Sword", self.player) and
state.has("Right Difficulty Switch", self.player))
else:
set_rule(multiworld.get_location("Slay Yorgle", self.player),
set_rule(world.get_location("Slay Yorgle", self.player),
lambda state: state.has("Sword", self.player))
set_rule(multiworld.get_location("Slay Grundle", self.player),
set_rule(world.get_location("Slay Grundle", self.player),
lambda state: state.has("Sword", self.player))
set_rule(multiworld.get_location("Slay Rhindle", self.player),
set_rule(world.get_location("Slay Rhindle", self.player),
lambda state: state.has("Sword", self.player))
# really this requires getting the dot item, and having another item or enemy
@@ -51,37 +51,37 @@ def set_rules(self) -> None:
# to actually make randomized, since it is invisible. May add some options
# for how that works in the distant future, but for now, just say you need
# the bridge and black key to get to it, as that simplifies things a lot
set_rule(multiworld.get_entrance("CreditsWall", self.player),
set_rule(world.get_entrance("CreditsWall", self.player),
lambda state: state.has("Bridge", self.player) and
state.has("Black Key", self.player))
if not use_bat_logic:
set_rule(multiworld.get_entrance("CreditsToFarSide", self.player),
set_rule(world.get_entrance("CreditsToFarSide", self.player),
lambda state: state.has("Magnet", self.player))
# bridge literally does not fit in this space, I think. I'll just exclude it
forbid_item(multiworld.get_location("Dungeon Vault", self.player), "Bridge", self.player)
forbid_item(world.get_location("Dungeon Vault", self.player), "Bridge", self.player)
# don't put magnet in locations that can pull in-logic items out of reach unless the bat is in play
if not use_bat_logic:
forbid_item(multiworld.get_location("Dungeon Vault", self.player), "Magnet", self.player)
forbid_item(multiworld.get_location("Red Maze Vault Entrance", self.player), "Magnet", self.player)
forbid_item(multiworld.get_location("Credits Right Side", self.player), "Magnet", self.player)
forbid_item(world.get_location("Dungeon Vault", self.player), "Magnet", self.player)
forbid_item(world.get_location("Red Maze Vault Entrance", self.player), "Magnet", self.player)
forbid_item(world.get_location("Credits Right Side", self.player), "Magnet", self.player)
# and obviously we don't want to start with the game already won
forbid_item(multiworld.get_location("Inside Yellow Castle", self.player), "Chalice", self.player)
overworld = multiworld.get_region("Overworld", self.player)
forbid_item(world.get_location("Inside Yellow Castle", self.player), "Chalice", self.player)
overworld = world.get_region("Overworld", self.player)
for loc in overworld.locations:
forbid_item(loc, "Chalice", self.player)
add_rule(multiworld.get_location("Chalice Home", self.player),
add_rule(world.get_location("Chalice Home", self.player),
lambda state: state.has("Chalice", self.player) and state.has("Yellow Key", self.player))
# multiworld.random.choice(overworld.locations).progress_type = LocationProgressType.PRIORITY
# world.random.choice(overworld.locations).progress_type = LocationProgressType.PRIORITY
# all_locations = multiworld.get_locations(self.player).copy()
# all_locations = world.get_locations(self.player).copy()
# while priority_count < get_num_items():
# loc = multiworld.random.choice(all_locations)
# loc = world.random.choice(all_locations)
# if loc.progress_type == LocationProgressType.DEFAULT:
# loc.progress_type = LocationProgressType.PRIORITY
# priority_count += 1

View File

@@ -105,8 +105,8 @@ class AdventureWorld(World):
location_name_to_id: ClassVar[Dict[str, int]] = {name: data.location_id for name, data in location_table.items()}
required_client_version: Tuple[int, int, int] = (0, 3, 9)
def __init__(self, multiworld: MultiWorld, player: int):
super().__init__(multiworld, player)
def __init__(self, world: MultiWorld, player: int):
super().__init__(world, player)
self.rom_name: Optional[bytearray] = bytearray("", "utf8" )
self.dragon_rooms: [int] = [0x14, 0x19, 0x4]
self.dragon_slay_check: Optional[int] = 0

View File

@@ -50,17 +50,16 @@ make sure ***Enable Developer Console*** is checked in Game Settings and press t
## FAQ/Common Issues
### The game is crashing on startup repeatedly!
This is a common issue on older versions of the game, caused by the game failing to interface with the Steam Workshop.
To fix it you can try the following (from least to most effort required)
- Subscribe to any random workshop mod, then unsubscribe from it
- Restart Steam
- Restart your computer
- Delete the game's config directory from the files `steamapps/common/HatinTime/HatinTimeGame/Config` then verify the game files
- Reinstall the game
### The game is not connecting when starting a new save!
For unknown reasons, the mod will randomly disable itself in the mod menu. To fix this, go to the Mods menu
(rocket icon) in-game, and re-enable the mod.
### Why do relics disappear from the stands in the Spaceship after they're completed?
This is intentional behaviour. Because of how randomizer logic works, there is no way to predict the order that
a player will place their relics. Since there are a limited amount of relic stands in the Spaceship, relics are removed
after being completed to allow for the placement of more relics without being potentially locked out.
The level that the relic set unlocked will stay unlocked.
### When I start a new save file, the intro cinematic doesn't get skipped, Hat Kid's body is missing and the mod doesn't work!
There is a bug on older versions of A Hat in Time that causes save file creation to fail to work properly
if you have too many save files. Delete them and it should fix the problem.

View File

@@ -1331,13 +1331,6 @@ def patch_rom(multiworld: MultiWorld, rom: LocalRom, player: int, enemized: bool
starting_max_arrows = 30
startingstate = CollectionState(multiworld)
has_blue_shield = False
has_red_shield = False
has_mirror_shield = False
progressive_shields = 0
has_blue_mail = False
has_red_mail = False
progressive_mail = 0
if startingstate.has('Silver Bow', player):
equip[0x340] = 1
@@ -1366,6 +1359,18 @@ def patch_rom(multiworld: MultiWorld, rom: LocalRom, player: int, enemized: bool
elif startingstate.has('Fighter Sword', player):
equip[0x359] = 1
if startingstate.has('Mirror Shield', player):
equip[0x35A] = 3
elif startingstate.has('Red Shield', player):
equip[0x35A] = 2
elif startingstate.has('Blue Shield', player):
equip[0x35A] = 1
if startingstate.has('Red Mail', player):
equip[0x35B] = 2
elif startingstate.has('Blue Mail', player):
equip[0x35B] = 1
if startingstate.has('Magic Upgrade (1/4)', player):
equip[0x37B] = 2
equip[0x36E] = 0x80
@@ -1378,6 +1383,8 @@ def patch_rom(multiworld: MultiWorld, rom: LocalRom, player: int, enemized: bool
if item.name in {'Bow', 'Silver Bow', 'Silver Arrows', 'Progressive Bow', 'Progressive Bow (Alt)',
'Titans Mitts', 'Power Glove', 'Progressive Glove',
'Golden Sword', 'Tempered Sword', 'Master Sword', 'Fighter Sword', 'Progressive Sword',
'Mirror Shield', 'Red Shield', 'Blue Shield', 'Progressive Shield',
'Red Mail', 'Blue Mail', 'Progressive Mail',
'Magic Upgrade (1/4)', 'Magic Upgrade (1/2)', 'Triforce Piece'}:
continue
@@ -1482,63 +1489,9 @@ def patch_rom(multiworld: MultiWorld, rom: LocalRom, player: int, enemized: bool
if item.name != 'Piece of Heart' or equip[0x36B] == 0:
equip[0x36C] = min(equip[0x36C] + 0x08, 0xA0)
equip[0x36D] = min(equip[0x36D] + 0x08, 0xA0)
elif item.name == 'Blue Shield':
has_blue_shield = True
continue
elif item.name == 'Red Shield':
has_red_shield = True
continue
elif item.name == 'Mirror Shield':
has_mirror_shield = True
continue
elif item.name == 'Progressive Shield':
progressive_shields += 1
continue
elif item.name == 'Blue Mail':
has_blue_mail = True
continue
elif item.name == 'Red Mail':
has_red_mail = True
continue
elif item.name == 'Progressive Mail':
progressive_mail += 1
continue
else:
raise RuntimeError(f'Unsupported item in starting equipment: {item.name}')
for _ in range(progressive_shields):
if has_mirror_shield:
continue
if has_red_shield and local_world.difficulty_requirements.progressive_shield_limit >= 3:
has_mirror_shield = True
continue
if has_blue_shield and local_world.difficulty_requirements.progressive_shield_limit >= 2:
has_red_shield = True
continue
if local_world.difficulty_requirements.progressive_shield_limit >= 1:
has_blue_shield = True
for _ in range(progressive_mail):
if has_red_mail:
continue
if has_blue_mail and local_world.difficulty_requirements.progressive_armor_limit >= 2:
has_red_mail = True
continue
if local_world.difficulty_requirements.progressive_armor_limit >= 1:
has_blue_mail = True
if has_mirror_shield:
equip[0x35A] = 3
elif has_red_shield:
equip[0x35A] = 2
elif has_blue_shield:
equip[0x35A] = 1
if has_red_mail:
equip[0x35B] = 2
elif has_blue_mail:
equip[0x35B] = 1
equip[0x343] = min(equip[0x343], starting_max_bombs)
rom.write_byte(0x180034, starting_max_bombs)
equip[0x377] = min(equip[0x377], starting_max_arrows)
@@ -1746,7 +1699,8 @@ def patch_rom(multiworld: MultiWorld, rom: LocalRom, player: int, enemized: bool
# set rom name
# 21 bytes
rom.name = bytearray(f'AP{local_world.world_version.as_simple_string().replace(".", "")[0:3]}_{player}_{multiworld.seed:11}\0', 'utf8')[:21]
from Utils import __version__
rom.name = bytearray(f'AP{__version__.replace(".", "")[0:3]}_{player}_{multiworld.seed:11}\0', 'utf8')[:21]
rom.name.extend([0] * (21 - len(rom.name)))
rom.write_bytes(0x7FC0, rom.name)

View File

@@ -1,6 +0,0 @@
{
"game": "A Link to the Past",
"minimum_ap_version": "0.6.6",
"world_version": "5.1.0",
"authors": ["Berserker"]
}

View File

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

View File

@@ -1,6 +1,6 @@
{
"game": "APQuest",
"minimum_ap_version": "0.6.7",
"world_version": "2.0.0",
"minimum_ap_version": "0.6.4",
"world_version": "1.0.1",
"authors": ["NewSoupVi"]
}

View File

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

View File

@@ -4,9 +4,8 @@ from argparse import Namespace
from enum import Enum
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 Utils import gui_enabled
from ..game.events import ConfettiFired, LocationClearedEvent, MathProblemSolved, MathProblemStarted, VictoryEvent
from ..game.game import Game
@@ -42,16 +41,6 @@ class ConnectionStatus(Enum):
GAME_RUNNING = 3
class APQuestClientCommandProcessor(ClientCommandProcessor):
ctx: "APQuestContext"
def default(self, raw: str) -> None:
if self.ctx.external_math_trap_input(raw):
return
super().default(raw)
class APQuestContext(CommonContext):
game = "APQuest"
items_handling = 0b111 # full remote
@@ -76,7 +65,6 @@ class APQuestContext(CommonContext):
delay_intro_song: bool
ui: APQuestManager
command_processor = APQuestClientCommandProcessor
def __init__(
self, server_address: str | None = None, password: str | None = None, delay_intro_song: bool = False
@@ -184,6 +172,7 @@ class APQuestContext(CommonContext):
assert self.ap_quest_game is not None
self.ap_quest_game.gameboard.fill_remote_location_content(remote_item_graphic_overrides)
self.render()
self.ui.game_view.bind_keyboard()
self.connection_status = ConnectionStatus.GAME_RUNNING
self.ui.game_started()
@@ -198,7 +187,7 @@ class APQuestContext(CommonContext):
if self.ap_quest_game is None:
raise RuntimeError("Tried to render before self.ap_quest_game was initialized.")
self.ui.render(self.ap_quest_game, self.player_sprite, self.hard_mode)
self.ui.render(self.ap_quest_game, self.player_sprite)
self.handle_game_events()
def location_checked_side_effects(self, location: int) -> None:
@@ -255,59 +244,6 @@ class APQuestContext(CommonContext):
self.ap_quest_game.input(input_key)
self.render()
def queue_auto_move(self, target_x: int, target_y: int) -> None:
if self.ap_quest_game is None:
return
if not self.ap_quest_game.gameboard.ready:
return
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]":
self.load_kv()
return APQuestManager

View File

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

View File

@@ -6,7 +6,6 @@ from kvui import GameManager, MDNavigationItemBase
# isort: on
from typing import TYPE_CHECKING, Any
from kivy._clock import ClockEvent
from kivy.clock import Clock
from kivy.uix.gridlayout import GridLayout
from kivy.uix.image import Image
@@ -14,16 +13,7 @@ from kivy.uix.layout import Layout
from kivymd.uix.recycleview import MDRecycleView
from ..game.game import Game
from ..game.graphics import Graphic
from .custom_views import (
APQuestControlsView,
APQuestGameView,
APQuestGrid,
ConfettiView,
TapIfConfettiCannonImage,
TapImage,
VolumeSliderView,
)
from .custom_views import APQuestControlsView, APQuestGameView, APQuestGrid, ConfettiView, VolumeSliderView
from .graphics import PlayerSprite, get_texture
from .sounds import SoundManager
@@ -38,17 +28,15 @@ class APQuestManager(GameManager):
lower_game_grid: GridLayout
upper_game_grid: GridLayout
game_view: MDRecycleView | None = None
game_view: MDRecycleView
game_view_tab: MDNavigationItemBase
sound_manager: SoundManager
bottom_image_grid: list[list[Image]]
top_image_grid: list[list[TapImage]]
top_image_grid: list[list[Image]]
confetti_view: ConfettiView
move_event: ClockEvent | None
bottom_grid_is_grass: bool
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.top_image_grid = []
self.bottom_image_grid = []
self.move_event = None
self.bottom_grid_is_grass = False
def allow_intro_song(self) -> None:
@@ -84,27 +71,25 @@ class APQuestManager(GameManager):
def game_started(self) -> None:
self.switch_to_game_tab()
if self.game_view is not None:
self.game_view.force_focus()
self.sound_manager.game_started = True
def render(self, game: Game, player_sprite: PlayerSprite, hard_mode: bool) -> None:
self.setup_game_grid_if_not_setup(game)
def render(self, game: Game, player_sprite: PlayerSprite) -> None:
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
self.render_gameboard(game, player_sprite, hard_mode)
self.render_gameboard(game, player_sprite)
# Only now can we check whether a math problem is active
self.render_background_game_grid(game.gameboard.size, game.active_math_problem is None)
self.sound_manager.math_trap_active = game.active_math_problem is not None
self.render_item_column(game)
def render_gameboard(self, game: Game, player_sprite: PlayerSprite, hard_mode: bool) -> None:
def render_gameboard(self, game: Game, player_sprite: PlayerSprite) -> None:
rendered_gameboard = game.render()
for gameboard_row, image_row in zip(rendered_gameboard, self.top_image_grid, strict=False):
for graphic, image in zip(gameboard_row, image_row[:11], strict=False):
texture = get_texture(graphic, player_sprite, hard_mode)
texture = get_texture(graphic, player_sprite)
if texture is None:
image.opacity = 0
@@ -119,8 +104,6 @@ class APQuestManager(GameManager):
for item_graphic, image_row in zip(rendered_item_column, self.top_image_grid, strict=False):
image = image_row[-1]
image.is_confetti_cannon = item_graphic == Graphic.CONFETTI_CANNON
texture = get_texture(item_graphic)
if texture is None:
image.opacity = 0
@@ -153,25 +136,23 @@ class APQuestManager(GameManager):
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:
return
self.top_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.bottom_image_grid.append([])
for column in range(size[0]):
for _column in range(size[0]):
bottom_image = Image(fit_mode="fill", color=(0.3, 0.3, 0.3))
self.lower_game_grid.add_widget(bottom_image)
self.bottom_image_grid[-1].append(bottom_image)
top_image = 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.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))
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.top_image_grid[-1].append(image2)
def start_auto_move(self) -> None:
if self.move_event is not None:
self.move_event.cancel()
self.ctx.do_auto_move_and_rerender()
self.move_event = Clock.schedule_interval(lambda _: self.ctx.do_auto_move_and_rerender(), 0.10)
def build(self) -> Layout:
container = super().build()

View File

@@ -1,10 +1,10 @@
import pkgutil
from collections.abc import Buffer
from enum import Enum
from io import BytesIO
from typing import Literal, NamedTuple, Protocol, cast
from kivy.uix.image import CoreImage
from typing_extensions import Buffer
from CommonClient import logger
@@ -29,7 +29,6 @@ class RelatedTexture(NamedTuple):
IMAGE_GRAPHICS: dict[Graphic, str | RelatedTexture] = {
# Inanimates
Graphic.WALL: RelatedTexture("inanimates.png", 16, 32, 16, 16),
Graphic.BREAKABLE_BLOCK: RelatedTexture("inanimates.png", 32, 32, 16, 16),
Graphic.CHEST: RelatedTexture("inanimates.png", 0, 16, 16, 16),
@@ -38,25 +37,29 @@ IMAGE_GRAPHICS: dict[Graphic, str | RelatedTexture] = {
Graphic.BUTTON_NOT_ACTIVATED: RelatedTexture("inanimates.png", 0, 0, 16, 16),
Graphic.BUTTON_ACTIVATED: RelatedTexture("inanimates.png", 16, 0, 16, 16),
Graphic.BUTTON_DOOR: RelatedTexture("inanimates.png", 32, 0, 16, 16),
# Enemies
Graphic.NORMAL_ENEMY_1_HEALTH: RelatedTexture("normal_enemy.png", 0, 0, 16, 16),
Graphic.NORMAL_ENEMY_2_HEALTH: RelatedTexture("normal_enemy.png", 16, 0, 16, 16),
Graphic.BOSS_5_HEALTH: RelatedTexture("boss.png", 16, 16, 16, 16),
Graphic.BOSS_4_HEALTH: RelatedTexture("boss.png", 0, 16, 16, 16),
Graphic.BOSS_3_HEALTH: RelatedTexture("boss.png", 32, 32, 16, 16),
Graphic.BOSS_2_HEALTH: RelatedTexture("boss.png", 16, 32, 16, 16),
Graphic.BOSS_1_HEALTH: RelatedTexture("boss.png", 0, 32, 16, 16),
# Items
Graphic.EMPTY_HEART: RelatedTexture("hearts.png", 0, 0, 16, 16),
Graphic.HEART: RelatedTexture("hearts.png", 16, 0, 16, 16),
Graphic.HALF_HEART: RelatedTexture("hearts.png", 32, 0, 16, 16),
Graphic.REMOTE_ITEM: RelatedTexture("items.png", 0, 16, 16, 16),
Graphic.CONFETTI_CANNON: RelatedTexture("items.png", 16, 16, 16, 16),
Graphic.HAMMER: RelatedTexture("items.png", 32, 16, 16, 16),
Graphic.KEY: RelatedTexture("items.png", 0, 0, 16, 16),
Graphic.SHIELD: RelatedTexture("items.png", 16, 0, 16, 16),
Graphic.SWORD: RelatedTexture("items.png", 32, 0, 16, 16),
# Numbers
Graphic.ITEMS_TEXT: "items_text.png",
Graphic.ZERO: RelatedTexture("numbers.png", 0, 16, 16, 16),
Graphic.ONE: RelatedTexture("numbers.png", 16, 16, 16, 16),
Graphic.TWO: RelatedTexture("numbers.png", 32, 16, 16, 16),
@@ -67,29 +70,26 @@ IMAGE_GRAPHICS: dict[Graphic, str | RelatedTexture] = {
Graphic.SEVEN: RelatedTexture("numbers.png", 32, 0, 16, 16),
Graphic.EIGHT: RelatedTexture("numbers.png", 48, 0, 16, 16),
Graphic.NINE: RelatedTexture("numbers.png", 64, 0, 16, 16),
# Letters
Graphic.LETTER_A: RelatedTexture("letters.png", 0, 16, 16, 16),
Graphic.LETTER_E: RelatedTexture("letters.png", 16, 16, 16, 16),
Graphic.LETTER_H: RelatedTexture("letters.png", 32, 16, 16, 16),
Graphic.LETTER_I: RelatedTexture("letters.png", 0, 0, 16, 16),
Graphic.LETTER_M: RelatedTexture("letters.png", 16, 0, 16, 16),
Graphic.LETTER_T: RelatedTexture("letters.png", 32, 0, 16, 16),
# Mathematical symbols
Graphic.DIVIDE: RelatedTexture("symbols.png", 0, 16, 16, 16),
Graphic.EQUALS: RelatedTexture("symbols.png", 16, 16, 16, 16),
Graphic.MINUS: RelatedTexture("symbols.png", 32, 16, 16, 16),
Graphic.PLUS: RelatedTexture("symbols.png", 0, 0, 16, 16),
Graphic.TIMES: RelatedTexture("symbols.png", 16, 0, 16, 16),
# Other visual-only elements
Graphic.ITEMS_TEXT: "items_text.png",
Graphic.NO: RelatedTexture("symbols.png", 32, 0, 16, 16),
Graphic.UNKNOWN: RelatedTexture("symbols.png", 32, 0, 16, 16), # Same as "No"
}
BACKGROUND_TILE = RelatedTexture("inanimates.png", 0, 32, 16, 16)
EASY_MODE_BOSS_2_HEALTH = RelatedTexture("boss.png", 16, 0, 16, 16)
class PlayerSprite(Enum):
HUMAN = 0
@@ -160,18 +160,13 @@ def get_texture_by_identifier(texture_identifier: str | RelatedTexture) -> Textu
return sub_texture
def get_texture(
graphic: Graphic | Literal["Grass"], player_sprite: PlayerSprite | None = None, hard_mode: bool = False
) -> Texture | None:
def get_texture(graphic: Graphic | Literal["Grass"], player_sprite: PlayerSprite | None = None) -> Texture | None:
if graphic == Graphic.EMPTY:
return None
if graphic == "Grass":
return get_texture_by_identifier(BACKGROUND_TILE)
if graphic == Graphic.BOSS_2_HEALTH and not hard_mode:
return get_texture_by_identifier(EASY_MODE_BOSS_2_HEALTH)
if graphic in IMAGE_GRAPHICS:
return get_texture_by_identifier(IMAGE_GRAPHICS[graphic])

View File

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

View File

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

View File

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

View File

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

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 754 B

After

Width:  |  Height:  |  Size: 580 B

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

@@ -2,16 +2,12 @@ from __future__ import annotations
from typing import TYPE_CHECKING
from rule_builder.options import OptionFilter
from rule_builder.rules import Has, HasAll, Rule
from .options import HardMode
from BaseClasses import CollectionState
from worlds.generic.Rules import add_rule, set_rule
if TYPE_CHECKING:
from .world import APQuestWorld
HAS_KEY = Has("Key") # Hmm, what could this be? A little foreshadowing perhaps? :) You'll find out if you keep reading!
def set_all_rules(world: APQuestWorld) -> None:
# In order for AP to generate an item layout that is actually possible for the player to complete,
@@ -30,46 +26,36 @@ def set_all_entrance_rules(world: APQuestWorld) -> None:
overworld_to_top_left_room = world.get_entrance("Overworld to Top Left Room")
right_room_to_final_boss_room = world.get_entrance("Right Room to Final Boss Room")
# Now, let's make some rules!
# First, let's handle the transition from the overworld to the bottom right room,
# which requires slashing a bush with the Sword.
# For this, we need a rule that says "player has a Sword".
# We can use a "Has"-type rule from the rule_builder module for this.
can_destroy_bush = Has("Sword")
# An access rule is a function. We can define this function like any other function.
# This function must accept exactly one parameter: A "CollectionState".
# A CollectionState describes the current progress of the players in the multiworld, i.e. what items they have,
# which regions they've reached, etc.
# In an access rule, we can ask whether the player has a collected a certain item.
# We can do this via the state.has(...) function.
# This function takes an item name, a player number, and an optional count parameter (more on that below)
# Since a rule only takes a CollectionState parameter, but we also need the player number in the state.has call,
# our function needs to be locally defined so that it has access to the player number from the outer scope.
# In our case, we are inside a function that has access to the "world" parameter, so we can use world.player.
def can_destroy_bush(state: CollectionState) -> bool:
return state.has("Sword", world.player)
# Now we can set our "can_destroy_bush" rule to the entrance which requires slashing a bush to clear the path.
# The easiest way to do this is by calling world.set_rule, which works for both Locations and Entrances.
world.set_rule(overworld_to_bottom_right_room, can_destroy_bush)
# Now we can set our "can_destroy_bush" rule to our entrance which requires slashing a bush to clear the path.
# One way to set rules is via the set_rule() function, which works on both Entrances and Locations.
set_rule(overworld_to_bottom_right_room, can_destroy_bush)
# Conditions can also depend on event items.
button_pressed = Has("Top Left Room Button Pressed")
world.set_rule(right_room_to_final_boss_room, button_pressed)
# Because the function has to be defined locally, most worlds prefer the lambda syntax.
set_rule(overworld_to_top_left_room, lambda state: state.has("Key", world.player))
# Conditions can depend on event items.
set_rule(right_room_to_final_boss_room, lambda state: state.has("Top Left Room Button Pressed", world.player))
# Some entrance rules may only apply if the player enabled certain options.
# In our case, if the hammer option is enabled, we need to add the Hammer requirement to the Entrance from
# Overworld to the Top Middle Room.
if world.options.hammer:
overworld_to_top_middle_room = world.get_entrance("Overworld to Top Middle Room")
can_smash_brick = Has("Hammer")
world.set_rule(overworld_to_top_middle_room, can_smash_brick)
set_rule(overworld_to_top_middle_room, lambda state: state.has("Hammer", world.player))
# So far, we've been using "Has" from the Rule Builder to make our rules.
# There is another way to make rules that you will see in a lot of older worlds.
# A rule can just be a function that takes a "state" argument and returns a bool.
# As a demonstration of what that looks like, let's do it with our final Entrance rule:
world.set_rule(overworld_to_top_left_room, lambda state: state.has("Key", world.player))
# This style is not really recommended anymore, though.
# Notice how you have to explicitly capture world.player here so that the rule applies to the correct player?
# Well, Rule Builder does this part for you, inside of world.set_rule.
# This doesn't just result in shorter code, it also means you can define rules statically (at the module level).
# APQuest opts to create its Rule objects locally, but just to show what this would look like,
# we'll re-set the "Overworld to Top Left Room" rule to a constant defined at the top of this file:
world.set_rule(overworld_to_top_left_room, HAS_KEY)
# Beyond these structural advantages,
# Rule Builder also allows the core AP code to do a lot of under-the-hood optimizations.
# Rule Builder is quite comprehensive, and even if you have really esoteric rules,
# you can make custom rules by subclassing CustomRule.
def set_all_location_rules(world: APQuestWorld) -> None:
# Location rules work no differently from Entrance rules.
@@ -81,72 +67,65 @@ def set_all_location_rules(world: APQuestWorld) -> None:
# So, we need to set requirements on the Locations themselves.
# Since combat is a bit more complicated, we'll use this chance to cover some advanced access rule concepts.
# In "set_all_entrance_rules", we had a rule for a location that doesn't always exist.
# In this case, we had to check for its existence (by checking the player's chosen options) before setting the rule.
# Other times, you may have a situation where a location can have two different rules depending on the options.
# In our case, the enemy in the right room has more health if hard mode is selected,
# so ontop of the Sword, the player will either need one more health or a Shield in hard mode.
# First, let's make our sword condition.
can_defeat_basic_enemy: Rule = Has("Sword")
# Next, we'll check whether hard mode has been chosen in the player options.
if world.options.hard_mode:
# We'll make the condition for "Has a Shield or a Health Upgrade".
# We can chain two "Has" conditions together with the | operator to make "Has Shield or has Health Upgrade".
can_withstand_a_hit = Has("Shield") | Has("Health Upgrade")
# Now, we chain this rule to our Sword rule.
# Since we want both conditions to be true, in this case, we have to chain them in an "and" way.
# For this, we can use the & operator.
can_defeat_basic_enemy = can_defeat_basic_enemy & can_withstand_a_hit
# Finally, we set our rule onto the Right Room Eney Drop location.
# Sometimes, you may want to have different rules depending on the player's chosen options.
# There is a wrong way to do this, and a right way to do this. Let's do the wrong way first.
right_room_enemy = world.get_location("Right Room Enemy Drop")
world.set_rule(right_room_enemy, can_defeat_basic_enemy)
# For the final boss, we also need to chain multiple conditions.
# First of all, you always need a Sword and a Shield.
# So far, we used the | and & operators to chain "Has" rules.
# Instead, we can also use HasAny for an or-chain of items, or HasAll for an and-chain of items.
has_sword_and_shield: Rule = HasAll("Sword", "Shield")
# DON'T DO THIS!!!!
set_rule(
right_room_enemy,
lambda state: (
state.has("Sword", world.player)
and (not world.options.hard_mode or state.has_any(("Shield", "Health Upgrade"), world.player))
),
)
# DON'T DO THIS!!!!
# In hard mode, the player also needs both Health Upgrades to survive long enough to defeat the boss.
# For this, we can use the optional "count" parameter for "Has".
has_both_health_upgrades = Has("Health Upgrade", count=2)
# Now, what's actually wrong with this? It works perfectly fine, right?
# If hard mode disabled, Sword is enough. If hard mode is enabled, we also need a Shield or a Health Upgrade.
# The access rule we just wrote does this correctly, so what's the problem?
# The problem is performance.
# Most of your world code doesn't need to be perfectly performant, since it just runs once per slot.
# However, access rules in particular are by far the hottest code path in Archipelago.
# An access rule will potentially be called thousands or even millions of times over the course of one generation.
# As a result, access rules are the one place where it's really worth putting in some effort to optimize.
# What's the performance problem here?
# Every time our access rule is called, it has to evaluate whether world.options.hard_mode is True or False.
# Wouldn't it be better if in easy mode, the access rule only checked for Sword to begin with?
# Wouldn't it also be better if in hard mode, it already knew it had to check Shield and Health Upgrade as well?
# Well, we can achieve this by doing the "if world.options.hard_mode" check outside the set_rule call,
# and instead having two *different* set_rule calls depending on which case we're in.
# Previously, we used an "if world.options.hard_mode" condition to check if we should apply the extra requirement.
# However, if you're comfortable with boolean logic, there is another way.
# OptionFilter is a rule component which isn't a "Rule" on its own, but when used in a boolean expression with
# rules, it acts like True if the option has the specified value, and acts like False otherwise.
hard_mode_is_off = OptionFilter(HardMode, False)
if world.options.hard_mode:
# If you have multiple conditions, you can obviously chain them via "or" or "and".
# However, there are also the nice helper functions "state.has_any" and "state.has_all".
set_rule(
right_room_enemy,
lambda state: (
state.has("Sword", world.player) and state.has_any(("Shield", "Health Upgrade"), world.player)
),
)
else:
set_rule(right_room_enemy, lambda state: state.has("Sword", world.player))
# So with this option-checking rule component in hand, we can write our boss condition like this:
can_defeat_final_boss = has_sword_and_shield & (hard_mode_is_off | has_both_health_upgrades)
# If you're not as comfortable with boolean logic, it might be somewhat confusing why this is correct.
# There is nothing wrong with using "if" conditions to check for options, if you find that easier to understand.
# Finally, we apply the rule to our "Final Boss Defeated" event location.
# Another way to chain multiple conditions is via the add_rule function.
# This makes the access rules a bit slower though, so it should only be used if your structure justifies it.
# In our case, it's pretty useful because hard mode and easy mode have different requirements.
final_boss = world.get_location("Final Boss Defeated")
world.set_rule(final_boss, can_defeat_final_boss)
# For the "known" requirements, it's still better to chain them using a normal "and" condition.
add_rule(final_boss, lambda state: state.has_all(("Sword", "Shield"), world.player))
if world.options.hard_mode:
# You can check for multiple copies of an item by using the optional count parameter of state.has().
add_rule(final_boss, lambda state: state.has("Health Upgrade", world.player, 2))
def set_completion_condition(world: APQuestWorld) -> None:
# Finally, we need to set a completion condition for our world, defining what the player needs to win the game.
# For this, we can use world.set_completion_rule.
# You can just set a completion condition directly like any other condition, referencing items the player receives:
world.set_completion_rule(HasAll("Sword", "Shield"))
world.multiworld.completion_condition[world.player] = lambda state: state.has_all(("Sword", "Shield"), world.player)
# In our case, we went for the Victory event design pattern (see create_events() in locations.py).
# So lets undo what we just did, and instead set the completion condition to:
world.set_completion_rule(Has("Victory"))
# One final comment about rules:
# If your world exclusively uses Rule Builder rules (like APQuest), it's worth trying CachedRuleBuilderWorld.
# CachedRuleBuilderWorld is a subclass of World that has a bunch of caching magic to make rules faster.
# Just have your world class subclass CachedRuleBuilderWorld instead of World:
# class APQuestWorld(CachedRuleBuilderWorld): ...
# This may speed up your world, or it may make it slower.
# The exact factors are complex and not well understood, but there is no harm in trying it.
# Generate a few seeds and see if there is a noticeable difference!
# If you're wondering, author has checked: APQuest is too simple to see any benefits, so we'll stick with "World".
world.multiworld.completion_condition[world.player] = lambda state: state.has("Victory", world.player)

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.TURTLE_EGG: ItemData(698032, 1, ItemType.JUNK, ItemGroup.COLLECTIBLE), # collectible_turtle_egg
ItemNames.JELLY_EGG: ItemData(698033, 1, ItemType.JUNK, ItemGroup.COLLECTIBLE), # collectible_upsidedown_seed
ItemNames.URCHIN_COSTUME: ItemData(698034, 1, ItemType.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.VEDHA_S_CURE_ALL: ItemData(698036, 1, ItemType.NORMAL, ItemGroup.RECIPE), # ingredient_Vedha'sCure-All
ItemNames.ZUUNA_S_PEROGI: ItemData(698037, 1, ItemType.NORMAL, ItemGroup.RECIPE), # ingredient_Zuuna'sperogi
@@ -384,8 +384,8 @@ four_gods_excludes = [ItemNames.ANEMONE, ItemNames.ARNASSI_STATUE, ItemNames.BIG
ItemNames.MITHALAS_BANNER, ItemNames.MITHALAS_POT, ItemNames.MUTANT_COSTUME, ItemNames.SEED_BAG,
ItemNames.KING_S_SKULL, ItemNames.SONG_PLANT_SPORE, ItemNames.STONE_HEAD, ItemNames.SUN_KEY,
ItemNames.GIRL_COSTUME, ItemNames.ODD_CONTAINER, ItemNames.TRIDENT, ItemNames.TURTLE_EGG,
ItemNames.JELLY_EGG, ItemNames.BABY_WALKER, ItemNames.RAINBOW_MUSHROOM,
ItemNames.RAINBOW_MUSHROOM, ItemNames.RAINBOW_MUSHROOM, ItemNames.FISH_OIL,
ItemNames.JELLY_EGG, ItemNames.URCHIN_COSTUME, ItemNames.BABY_WALKER,
ItemNames.RAINBOW_MUSHROOM, ItemNames.RAINBOW_MUSHROOM, ItemNames.RAINBOW_MUSHROOM,
ItemNames.LEAF_POULTICE, ItemNames.LEAF_POULTICE, ItemNames.LEAF_POULTICE,
ItemNames.LEECHING_POULTICE, ItemNames.LEECHING_POULTICE, ItemNames.ARCANE_POULTICE,
ItemNames.ROTTEN_MEAT, ItemNames.ROTTEN_MEAT, ItemNames.ROTTEN_MEAT, ItemNames.ROTTEN_MEAT,

View File

@@ -37,7 +37,7 @@ def _has_li(state: CollectionState, player: int) -> bool:
DAMAGING_ITEMS:Iterable[str] = [
ItemNames.ENERGY_FORM, ItemNames.NATURE_FORM, ItemNames.BEAST_FORM,
ItemNames.LI_AND_LI_SONG, ItemNames.BABY_NAUTILUS, ItemNames.BABY_PIRANHA,
ItemNames.BABY_BLASTER, ItemNames.URCHIN_COSTUME
ItemNames.BABY_BLASTER
]
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 = {
"Damage": {ItemNames.ENERGY_FORM, ItemNames.NATURE_FORM, ItemNames.BEAST_FORM,
ItemNames.LI_AND_LI_SONG, ItemNames.BABY_NAUTILUS, ItemNames.BABY_PIRANHA,
ItemNames.BABY_BLASTER, ItemNames.URCHIN_COSTUME},
ItemNames.BABY_BLASTER},
"Light": {ItemNames.SUN_FORM, ItemNames.BABY_DUMBO}
}
"""Grouping item make it easier to find them"""

View File

@@ -116,7 +116,7 @@ def versum_hill_rave(state: CollectionState, player: int, limit: bool, glitched:
else:
return (
graffitiL(state, player, limit, 85)
and graffitiXL(state, player, limit, 49)
and graffitiXL(state, player, limit, 48)
)
else:
return (

View File

@@ -1,5 +0,0 @@
{
"game": "Bomb Rush Cyberfunk",
"world_version": "1.0.6",
"authors": ["TRPG"]
}

View File

@@ -16,213 +16,213 @@ from .Locations import (
)
def create_regions(multiworld: MultiWorld, options: CCCharlesOptions, player: int) -> None:
menu_region = Region("Menu", player, multiworld, "Aranearum")
multiworld.regions.append(menu_region)
def create_regions(world: MultiWorld, options: CCCharlesOptions, player: int) -> None:
menu_region = Region("Menu", player, world, "Aranearum")
world.regions.append(menu_region)
start_camp_region = Region("Start Camp", player, multiworld)
start_camp_region = Region("Start Camp", player, world)
start_camp_region.add_locations(loc_start_camp, CCCharlesLocation)
multiworld.regions.append(start_camp_region)
world.regions.append(start_camp_region)
tony_tiddle_mission_region = Region("Tony Tiddle Mission", player, multiworld)
tony_tiddle_mission_region = Region("Tony Tiddle Mission", player, world)
tony_tiddle_mission_region.add_locations(loc_tony_tiddle_mission, CCCharlesLocation)
multiworld.regions.append(tony_tiddle_mission_region)
world.regions.append(tony_tiddle_mission_region)
barn_region = Region("Barn", player, multiworld)
barn_region = Region("Barn", player, world)
barn_region.add_locations(loc_barn, CCCharlesLocation)
multiworld.regions.append(barn_region)
world.regions.append(barn_region)
candice_mission_region = Region("Candice Mission", player, multiworld)
candice_mission_region = Region("Candice Mission", player, world)
candice_mission_region.add_locations(loc_candice_mission, CCCharlesLocation)
multiworld.regions.append(candice_mission_region)
world.regions.append(candice_mission_region)
tutorial_house_region = Region("Tutorial House", player, multiworld)
tutorial_house_region = Region("Tutorial House", player, world)
tutorial_house_region.add_locations(loc_tutorial_house, CCCharlesLocation)
multiworld.regions.append(tutorial_house_region)
world.regions.append(tutorial_house_region)
swamp_edges_region = Region("Swamp Edges", player, multiworld)
swamp_edges_region = Region("Swamp Edges", player, world)
swamp_edges_region.add_locations(loc_swamp_edges, CCCharlesLocation)
multiworld.regions.append(swamp_edges_region)
world.regions.append(swamp_edges_region)
swamp_mission_region = Region("Swamp Mission", player, multiworld)
swamp_mission_region = Region("Swamp Mission", player, world)
swamp_mission_region.add_locations(loc_swamp_mission, CCCharlesLocation)
multiworld.regions.append(swamp_mission_region)
world.regions.append(swamp_mission_region)
junkyard_area_region = Region("Junkyard Area", player, multiworld)
junkyard_area_region = Region("Junkyard Area", player, world)
junkyard_area_region.add_locations(loc_junkyard_area, CCCharlesLocation)
multiworld.regions.append(junkyard_area_region)
world.regions.append(junkyard_area_region)
south_house_region = Region("South House", player, multiworld)
south_house_region = Region("South House", player, world)
south_house_region.add_locations(loc_south_house, CCCharlesLocation)
multiworld.regions.append(south_house_region)
world.regions.append(south_house_region)
junkyard_shed_region = Region("Junkyard Shed", player, multiworld)
junkyard_shed_region = Region("Junkyard Shed", player, world)
junkyard_shed_region.add_locations(loc_junkyard_shed, CCCharlesLocation)
multiworld.regions.append(junkyard_shed_region)
world.regions.append(junkyard_shed_region)
military_base_region = Region("Military Base", player, multiworld)
military_base_region = Region("Military Base", player, world)
military_base_region.add_locations(loc_military_base, CCCharlesLocation)
multiworld.regions.append(military_base_region)
world.regions.append(military_base_region)
south_mine_outside_region = Region("South Mine Outside", player, multiworld)
south_mine_outside_region = Region("South Mine Outside", player, world)
south_mine_outside_region.add_locations(loc_south_mine_outside, CCCharlesLocation)
multiworld.regions.append(south_mine_outside_region)
world.regions.append(south_mine_outside_region)
south_mine_inside_region = Region("South Mine Inside", player, multiworld)
south_mine_inside_region = Region("South Mine Inside", player, world)
south_mine_inside_region.add_locations(loc_south_mine_inside, CCCharlesLocation)
multiworld.regions.append(south_mine_inside_region)
world.regions.append(south_mine_inside_region)
middle_station_region = Region("Middle Station", player, multiworld)
middle_station_region = Region("Middle Station", player, world)
middle_station_region.add_locations(loc_middle_station, CCCharlesLocation)
multiworld.regions.append(middle_station_region)
world.regions.append(middle_station_region)
canyon_region = Region("Canyon", player, multiworld)
canyon_region = Region("Canyon", player, world)
canyon_region.add_locations(loc_canyon, CCCharlesLocation)
multiworld.regions.append(canyon_region)
world.regions.append(canyon_region)
watchtower_region = Region("Watchtower", player, multiworld)
watchtower_region = Region("Watchtower", player, world)
watchtower_region.add_locations(loc_watchtower, CCCharlesLocation)
multiworld.regions.append(watchtower_region)
world.regions.append(watchtower_region)
boulder_field_region = Region("Boulder Field", player, multiworld)
boulder_field_region = Region("Boulder Field", player, world)
boulder_field_region.add_locations(loc_boulder_field, CCCharlesLocation)
multiworld.regions.append(boulder_field_region)
world.regions.append(boulder_field_region)
haunted_house_region = Region("Haunted House", player, multiworld)
haunted_house_region = Region("Haunted House", player, world)
haunted_house_region.add_locations(loc_haunted_house, CCCharlesLocation)
multiworld.regions.append(haunted_house_region)
world.regions.append(haunted_house_region)
santiago_house_region = Region("Santiago House", player, multiworld)
santiago_house_region = Region("Santiago House", player, world)
santiago_house_region.add_locations(loc_santiago_house, CCCharlesLocation)
multiworld.regions.append(santiago_house_region)
world.regions.append(santiago_house_region)
port_region = Region("Port", player, multiworld)
port_region = Region("Port", player, world)
port_region.add_locations(loc_port, CCCharlesLocation)
multiworld.regions.append(port_region)
world.regions.append(port_region)
trench_house_region = Region("Trench House", player, multiworld)
trench_house_region = Region("Trench House", player, world)
trench_house_region.add_locations(loc_trench_house, CCCharlesLocation)
multiworld.regions.append(trench_house_region)
world.regions.append(trench_house_region)
doll_woods_region = Region("Doll Woods", player, multiworld)
doll_woods_region = Region("Doll Woods", player, world)
doll_woods_region.add_locations(loc_doll_woods, CCCharlesLocation)
multiworld.regions.append(doll_woods_region)
world.regions.append(doll_woods_region)
lost_stairs_region = Region("Lost Stairs", player, multiworld)
lost_stairs_region = Region("Lost Stairs", player, world)
lost_stairs_region.add_locations(loc_lost_stairs, CCCharlesLocation)
multiworld.regions.append(lost_stairs_region)
world.regions.append(lost_stairs_region)
east_house_region = Region("East House", player, multiworld)
east_house_region = Region("East House", player, world)
east_house_region.add_locations(loc_east_house, CCCharlesLocation)
multiworld.regions.append(east_house_region)
world.regions.append(east_house_region)
rockets_testing_ground_region = Region("Rockets Testing Ground", player, multiworld)
rockets_testing_ground_region = Region("Rockets Testing Ground", player, world)
rockets_testing_ground_region.add_locations(loc_rockets_testing_ground, CCCharlesLocation)
multiworld.regions.append(rockets_testing_ground_region)
world.regions.append(rockets_testing_ground_region)
rockets_testing_bunker_region = Region("Rockets Testing Bunker", player, multiworld)
rockets_testing_bunker_region = Region("Rockets Testing Bunker", player, world)
rockets_testing_bunker_region.add_locations(loc_rockets_testing_bunker, CCCharlesLocation)
multiworld.regions.append(rockets_testing_bunker_region)
world.regions.append(rockets_testing_bunker_region)
workshop_region = Region("Workshop", player, multiworld)
workshop_region = Region("Workshop", player, world)
workshop_region.add_locations(loc_workshop, CCCharlesLocation)
multiworld.regions.append(workshop_region)
world.regions.append(workshop_region)
east_tower_region = Region("East Tower", player, multiworld)
east_tower_region = Region("East Tower", player, world)
east_tower_region.add_locations(loc_east_tower, CCCharlesLocation)
multiworld.regions.append(east_tower_region)
world.regions.append(east_tower_region)
lighthouse_region = Region("Lighthouse", player, multiworld)
lighthouse_region = Region("Lighthouse", player, world)
lighthouse_region.add_locations(loc_lighthouse, CCCharlesLocation)
multiworld.regions.append(lighthouse_region)
world.regions.append(lighthouse_region)
north_mine_outside_region = Region("North Mine Outside", player, multiworld)
north_mine_outside_region = Region("North Mine Outside", player, world)
north_mine_outside_region.add_locations(loc_north_mine_outside, CCCharlesLocation)
multiworld.regions.append(north_mine_outside_region)
world.regions.append(north_mine_outside_region)
north_mine_inside_region = Region("North Mine Inside", player, multiworld)
north_mine_inside_region = Region("North Mine Inside", player, world)
north_mine_inside_region.add_locations(loc_north_mine_inside, CCCharlesLocation)
multiworld.regions.append(north_mine_inside_region)
world.regions.append(north_mine_inside_region)
wood_bridge_region = Region("Wood Bridge", player, multiworld)
wood_bridge_region = Region("Wood Bridge", player, world)
wood_bridge_region.add_locations(loc_wood_bridge, CCCharlesLocation)
multiworld.regions.append(wood_bridge_region)
world.regions.append(wood_bridge_region)
museum_region = Region("Museum", player, multiworld)
museum_region = Region("Museum", player, world)
museum_region.add_locations(loc_museum, CCCharlesLocation)
multiworld.regions.append(museum_region)
world.regions.append(museum_region)
barbed_shelter_region = Region("Barbed Shelter", player, multiworld)
barbed_shelter_region = Region("Barbed Shelter", player, world)
barbed_shelter_region.add_locations(loc_barbed_shelter, CCCharlesLocation)
multiworld.regions.append(barbed_shelter_region)
world.regions.append(barbed_shelter_region)
west_beach_region = Region("West Beach", player, multiworld)
west_beach_region = Region("West Beach", player, world)
west_beach_region.add_locations(loc_west_beach, CCCharlesLocation)
multiworld.regions.append(west_beach_region)
world.regions.append(west_beach_region)
church_region = Region("Church", player, multiworld)
church_region = Region("Church", player, world)
church_region.add_locations(loc_church, CCCharlesLocation)
multiworld.regions.append(church_region)
world.regions.append(church_region)
west_cottage_region = Region("West Cottage", player, multiworld)
west_cottage_region = Region("West Cottage", player, world)
west_cottage_region.add_locations(loc_west_cottage, CCCharlesLocation)
multiworld.regions.append(west_cottage_region)
world.regions.append(west_cottage_region)
caravan_region = Region("Caravan", player, multiworld)
caravan_region = Region("Caravan", player, world)
caravan_region.add_locations(loc_caravan, CCCharlesLocation)
multiworld.regions.append(caravan_region)
world.regions.append(caravan_region)
trailer_cabin_region = Region("Trailer Cabin", player, multiworld)
trailer_cabin_region = Region("Trailer Cabin", player, world)
trailer_cabin_region.add_locations(loc_trailer_cabin, CCCharlesLocation)
multiworld.regions.append(trailer_cabin_region)
world.regions.append(trailer_cabin_region)
towers_region = Region("Towers", player, multiworld)
towers_region = Region("Towers", player, world)
towers_region.add_locations(loc_towers, CCCharlesLocation)
multiworld.regions.append(towers_region)
world.regions.append(towers_region)
north_beach_region = Region("North beach", player, multiworld)
north_beach_region = Region("North beach", player, world)
north_beach_region.add_locations(loc_north_beach, CCCharlesLocation)
multiworld.regions.append(north_beach_region)
world.regions.append(north_beach_region)
mine_shaft_region = Region("Mine Shaft", player, multiworld)
mine_shaft_region = Region("Mine Shaft", player, world)
mine_shaft_region.add_locations(loc_mine_shaft, CCCharlesLocation)
multiworld.regions.append(mine_shaft_region)
world.regions.append(mine_shaft_region)
mob_camp_region = Region("Mob Camp", player, multiworld)
mob_camp_region = Region("Mob Camp", player, world)
mob_camp_region.add_locations(loc_mob_camp, CCCharlesLocation)
multiworld.regions.append(mob_camp_region)
world.regions.append(mob_camp_region)
mob_camp_locked_room_region = Region("Mob Camp Locked Room", player, multiworld)
mob_camp_locked_room_region = Region("Mob Camp Locked Room", player, world)
mob_camp_locked_room_region.add_locations(loc_mob_camp_locked_room, CCCharlesLocation)
multiworld.regions.append(mob_camp_locked_room_region)
world.regions.append(mob_camp_locked_room_region)
mine_elevator_exit_region = Region("Mine Elevator Exit", player, multiworld)
mine_elevator_exit_region = Region("Mine Elevator Exit", player, world)
mine_elevator_exit_region.add_locations(loc_mine_elevator_exit, CCCharlesLocation)
multiworld.regions.append(mine_elevator_exit_region)
world.regions.append(mine_elevator_exit_region)
mountain_ruin_outside_region = Region("Mountain Ruin Outside", player, multiworld)
mountain_ruin_outside_region = Region("Mountain Ruin Outside", player, world)
mountain_ruin_outside_region.add_locations(loc_mountain_ruin_outside, CCCharlesLocation)
multiworld.regions.append(mountain_ruin_outside_region)
world.regions.append(mountain_ruin_outside_region)
mountain_ruin_inside_region = Region("Mountain Ruin Inside", player, multiworld)
mountain_ruin_inside_region = Region("Mountain Ruin Inside", player, world)
mountain_ruin_inside_region.add_locations(loc_mountain_ruin_inside, CCCharlesLocation)
multiworld.regions.append(mountain_ruin_inside_region)
world.regions.append(mountain_ruin_inside_region)
prism_temple_region = Region("Prism Temple", player, multiworld)
prism_temple_region = Region("Prism Temple", player, world)
prism_temple_region.add_locations(loc_prism_temple, CCCharlesLocation)
multiworld.regions.append(prism_temple_region)
world.regions.append(prism_temple_region)
pickle_val_region = Region("Pickle Val", player, multiworld)
pickle_val_region = Region("Pickle Val", player, world)
pickle_val_region.add_locations(loc_pickle_val, CCCharlesLocation)
multiworld.regions.append(pickle_val_region)
world.regions.append(pickle_val_region)
shrine_near_temple_region = Region("Shrine Near Temple", player, multiworld)
shrine_near_temple_region = Region("Shrine Near Temple", player, world)
shrine_near_temple_region.add_locations(loc_shrine_near_temple, CCCharlesLocation)
multiworld.regions.append(shrine_near_temple_region)
world.regions.append(shrine_near_temple_region)
morse_bunker_region = Region("Morse Bunker", player, multiworld)
morse_bunker_region = Region("Morse Bunker", player, world)
morse_bunker_region.add_locations(loc_morse_bunker, CCCharlesLocation)
multiworld.regions.append(morse_bunker_region)
world.regions.append(morse_bunker_region)
# Place "Victory" event at "Final Boss" location
loc_final_boss = CCCharlesLocation(player, "Final Boss", None, prism_temple_region)

View File

@@ -4,212 +4,212 @@ from .Options import CCCharlesOptions
# Go mode: Green Egg + Blue Egg + Red Egg + Temple Key + Bug Spray (+ Remote Explosive x8 but the base game ignores it)
def set_rules(multiworld: MultiWorld, options: CCCharlesOptions, player: int) -> None:
def set_rules(world: MultiWorld, options: CCCharlesOptions, player: int) -> None:
# Tony Tiddle
set_rule(multiworld.get_entrance("Barn Door", player),
set_rule(world.get_entrance("Barn Door", player),
lambda state: state.has("Barn Key", player))
# Candice
set_rule(multiworld.get_entrance("Tutorial House Door", player),
set_rule(world.get_entrance("Tutorial House Door", player),
lambda state: state.has("Candice's Key", player))
# Lizbeth Murkwater
set_rule(multiworld.get_location("Swamp Lizbeth Murkwater Mission End", player),
set_rule(world.get_location("Swamp Lizbeth Murkwater Mission End", player),
lambda state: state.has("Dead Fish", player))
# Daryl
set_rule(multiworld.get_location("Junkyard Area Chest Ancient Tablet", player),
set_rule(world.get_location("Junkyard Area Chest Ancient Tablet", player),
lambda state: state.has("Lockpicks", player))
set_rule(multiworld.get_location("Junkyard Area Daryl Mission End", player),
set_rule(world.get_location("Junkyard Area Daryl Mission End", player),
lambda state: state.has("Ancient Tablet", player))
# South House
set_rule(multiworld.get_location("South House Chest Scraps 1", player),
set_rule(world.get_location("South House Chest Scraps 1", player),
lambda state: state.has("Lockpicks", player))
set_rule(multiworld.get_location("South House Chest Scraps 2", player),
set_rule(world.get_location("South House Chest Scraps 2", player),
lambda state: state.has("Lockpicks", player))
set_rule(multiworld.get_location("South House Chest Scraps 3", player),
set_rule(world.get_location("South House Chest Scraps 3", player),
lambda state: state.has("Lockpicks", player))
set_rule(multiworld.get_location("South House Chest Scraps 4", player),
set_rule(world.get_location("South House Chest Scraps 4", player),
lambda state: state.has("Lockpicks", player))
set_rule(multiworld.get_location("South House Chest Scraps 5", player),
set_rule(world.get_location("South House Chest Scraps 5", player),
lambda state: state.has("Lockpicks", player))
set_rule(multiworld.get_location("South House Chest Scraps 6", player),
set_rule(world.get_location("South House Chest Scraps 6", player),
lambda state: state.has("Lockpicks", player))
# South Mine
set_rule(multiworld.get_entrance("South Mine Gate", player),
set_rule(world.get_entrance("South Mine Gate", player),
lambda state: state.has("South Mine Key", player))
set_rule(multiworld.get_location("South Mine Inside Green Paint Can", player),
set_rule(world.get_location("South Mine Inside Green Paint Can", player),
lambda state: state.has("Lockpicks", player))
# Theodore
set_rule(multiworld.get_location("Middle Station Theodore Mission End", player),
set_rule(world.get_location("Middle Station Theodore Mission End", player),
lambda state: state.has("Blue Box", player))
# Watchtower
set_rule(multiworld.get_location("Watchtower Pink Paint Can", player),
set_rule(world.get_location("Watchtower Pink Paint Can", player),
lambda state: state.has("Lockpicks", player))
# Sasha
set_rule(multiworld.get_location("Haunted House Sasha Mission End", player),
set_rule(world.get_location("Haunted House Sasha Mission End", player),
lambda state: state.has("Page Drawing", player, 8))
# Santiago
set_rule(multiworld.get_location("Port Santiago Mission End", player),
set_rule(world.get_location("Port Santiago Mission End", player),
lambda state: state.has("Journal", player))
# Trench House
set_rule(multiworld.get_location("Trench House Chest Scraps 1", player),
set_rule(world.get_location("Trench House Chest Scraps 1", player),
lambda state: state.has("Lockpicks", player))
set_rule(multiworld.get_location("Trench House Chest Scraps 2", player),
set_rule(world.get_location("Trench House Chest Scraps 2", player),
lambda state: state.has("Lockpicks", player))
set_rule(multiworld.get_location("Trench House Chest Scraps 3", player),
set_rule(world.get_location("Trench House Chest Scraps 3", player),
lambda state: state.has("Lockpicks", player))
set_rule(multiworld.get_location("Trench House Chest Scraps 4", player),
set_rule(world.get_location("Trench House Chest Scraps 4", player),
lambda state: state.has("Lockpicks", player))
set_rule(multiworld.get_location("Trench House Chest Scraps 5", player),
set_rule(world.get_location("Trench House Chest Scraps 5", player),
lambda state: state.has("Lockpicks", player))
set_rule(multiworld.get_location("Trench House Chest Scraps 6", player),
set_rule(world.get_location("Trench House Chest Scraps 6", player),
lambda state: state.has("Lockpicks", player))
# East House
set_rule(multiworld.get_location("East House Chest Scraps 1", player),
set_rule(world.get_location("East House Chest Scraps 1", player),
lambda state: state.has("Lockpicks", player))
set_rule(multiworld.get_location("East House Chest Scraps 2", player),
set_rule(world.get_location("East House Chest Scraps 2", player),
lambda state: state.has("Lockpicks", player))
set_rule(multiworld.get_location("East House Chest Scraps 3", player),
set_rule(world.get_location("East House Chest Scraps 3", player),
lambda state: state.has("Lockpicks", player))
set_rule(multiworld.get_location("East House Chest Scraps 4", player),
set_rule(world.get_location("East House Chest Scraps 4", player),
lambda state: state.has("Lockpicks", player))
set_rule(multiworld.get_location("East House Chest Scraps 5", player),
set_rule(world.get_location("East House Chest Scraps 5", player),
lambda state: state.has("Lockpicks", player))
# Rocket Testing Bunker
set_rule(multiworld.get_entrance("Stuck Bunker Door", player),
set_rule(world.get_entrance("Stuck Bunker Door", player),
lambda state: state.has("Timed Dynamite", player))
# John Smith
set_rule(multiworld.get_location("Workshop John Smith Mission End", player),
set_rule(world.get_location("Workshop John Smith Mission End", player),
lambda state: state.has("Box of Rockets", player))
# Claire
set_rule(multiworld.get_location("Lighthouse Claire Mission End", player),
set_rule(world.get_location("Lighthouse Claire Mission End", player),
lambda state: state.has("Breaker", player, 4))
# North Mine
set_rule(multiworld.get_entrance("North Mine Gate", player),
set_rule(world.get_entrance("North Mine Gate", player),
lambda state: state.has("North Mine Key", player))
set_rule(multiworld.get_location("North Mine Inside Blue Paint Can", player),
set_rule(world.get_location("North Mine Inside Blue Paint Can", player),
lambda state: state.has("Lockpicks", player))
# Paul
set_rule(multiworld.get_location("Museum Paul Mission End", player),
set_rule(world.get_location("Museum Paul Mission End", player),
lambda state: state.has("Remote Explosive x8", player))
# lambda state: state.has("Remote Explosive", player, 8)) # TODO: Add an option to split remote explosives
# West Beach
set_rule(multiworld.get_location("West Beach Chest Scraps 1", player),
set_rule(world.get_location("West Beach Chest Scraps 1", player),
lambda state: state.has("Lockpicks", player))
set_rule(multiworld.get_location("West Beach Chest Scraps 2", player),
set_rule(world.get_location("West Beach Chest Scraps 2", player),
lambda state: state.has("Lockpicks", player))
set_rule(multiworld.get_location("West Beach Chest Scraps 3", player),
set_rule(world.get_location("West Beach Chest Scraps 3", player),
lambda state: state.has("Lockpicks", player))
set_rule(multiworld.get_location("West Beach Chest Scraps 4", player),
set_rule(world.get_location("West Beach Chest Scraps 4", player),
lambda state: state.has("Lockpicks", player))
set_rule(multiworld.get_location("West Beach Chest Scraps 5", player),
set_rule(world.get_location("West Beach Chest Scraps 5", player),
lambda state: state.has("Lockpicks", player))
set_rule(multiworld.get_location("West Beach Chest Scraps 6", player),
set_rule(world.get_location("West Beach Chest Scraps 6", player),
lambda state: state.has("Lockpicks", player))
# Caravan
set_rule(multiworld.get_location("Caravan Chest Scraps 1", player),
set_rule(world.get_location("Caravan Chest Scraps 1", player),
lambda state: state.has("Lockpicks", player))
set_rule(multiworld.get_location("Caravan Chest Scraps 2", player),
set_rule(world.get_location("Caravan Chest Scraps 2", player),
lambda state: state.has("Lockpicks", player))
set_rule(multiworld.get_location("Caravan Chest Scraps 3", player),
set_rule(world.get_location("Caravan Chest Scraps 3", player),
lambda state: state.has("Lockpicks", player))
set_rule(multiworld.get_location("Caravan Chest Scraps 4", player),
set_rule(world.get_location("Caravan Chest Scraps 4", player),
lambda state: state.has("Lockpicks", player))
set_rule(multiworld.get_location("Caravan Chest Scraps 5", player),
set_rule(world.get_location("Caravan Chest Scraps 5", player),
lambda state: state.has("Lockpicks", player))
# Ronny
set_rule(multiworld.get_location("Towers Ronny Mission End", player),
set_rule(world.get_location("Towers Ronny Mission End", player),
lambda state: state.has("Employment Contracts", player))
# North Beach
set_rule(multiworld.get_location("North Beach Chest Scraps 1", player),
set_rule(world.get_location("North Beach Chest Scraps 1", player),
lambda state: state.has("Lockpicks", player))
set_rule(multiworld.get_location("North Beach Chest Scraps 2", player),
set_rule(world.get_location("North Beach Chest Scraps 2", player),
lambda state: state.has("Lockpicks", player))
set_rule(multiworld.get_location("North Beach Chest Scraps 3", player),
set_rule(world.get_location("North Beach Chest Scraps 3", player),
lambda state: state.has("Lockpicks", player))
set_rule(multiworld.get_location("North Beach Chest Scraps 4", player),
set_rule(world.get_location("North Beach Chest Scraps 4", player),
lambda state: state.has("Lockpicks", player))
# Mine Shaft
set_rule(multiworld.get_location("Mine Shaft Chest Scraps 1", player),
set_rule(world.get_location("Mine Shaft Chest Scraps 1", player),
lambda state: state.has("Lockpicks", player))
set_rule(multiworld.get_location("Mine Shaft Chest Scraps 2", player),
set_rule(world.get_location("Mine Shaft Chest Scraps 2", player),
lambda state: state.has("Lockpicks", player))
set_rule(multiworld.get_location("Mine Shaft Chest Scraps 3", player),
set_rule(world.get_location("Mine Shaft Chest Scraps 3", player),
lambda state: state.has("Lockpicks", player))
set_rule(multiworld.get_location("Mine Shaft Chest Scraps 4", player),
set_rule(world.get_location("Mine Shaft Chest Scraps 4", player),
lambda state: state.has("Lockpicks", player))
set_rule(multiworld.get_location("Mine Shaft Chest Scraps 5", player),
set_rule(world.get_location("Mine Shaft Chest Scraps 5", player),
lambda state: state.has("Lockpicks", player))
set_rule(multiworld.get_location("Mine Shaft Chest Scraps 6", player),
set_rule(world.get_location("Mine Shaft Chest Scraps 6", player),
lambda state: state.has("Lockpicks", player))
set_rule(multiworld.get_location("Mine Shaft Chest Scraps 7", player),
set_rule(world.get_location("Mine Shaft Chest Scraps 7", player),
lambda state: state.has("Lockpicks", player))
# Mob Camp
set_rule(multiworld.get_entrance("Mob Camp Locked Door", player),
set_rule(world.get_entrance("Mob Camp Locked Door", player),
lambda state: state.has("Mob Camp Key", player))
set_rule(multiworld.get_location("Mob Camp Locked Room Stolen Bob", player),
set_rule(world.get_location("Mob Camp Locked Room Stolen Bob", player),
lambda state: state.has("Broken Bob", player))
# Mountain Ruin
set_rule(multiworld.get_entrance("Mountain Ruin Gate", player),
set_rule(world.get_entrance("Mountain Ruin Gate", player),
lambda state: state.has("Mountain Ruin Key", player))
set_rule(multiworld.get_location("Mountain Ruin Inside Red Paint Can", player),
set_rule(world.get_location("Mountain Ruin Inside Red Paint Can", player),
lambda state: state.has("Lockpicks", player))
# Prism Temple
set_rule(multiworld.get_location("Prism Temple Chest Scraps 1", player),
set_rule(world.get_location("Prism Temple Chest Scraps 1", player),
lambda state: state.has("Lockpicks", player))
set_rule(multiworld.get_location("Prism Temple Chest Scraps 2", player),
set_rule(world.get_location("Prism Temple Chest Scraps 2", player),
lambda state: state.has("Lockpicks", player))
set_rule(multiworld.get_location("Prism Temple Chest Scraps 3", player),
set_rule(world.get_location("Prism Temple Chest Scraps 3", player),
lambda state: state.has("Lockpicks", player))
# Pickle Lady
set_rule(multiworld.get_location("Pickle Val Jar of Pickles", player),
set_rule(world.get_location("Pickle Val Jar of Pickles", player),
lambda state: state.has("Lockpicks", player))
set_rule(multiworld.get_location("Pickle Val Pickle Lady Mission End", player),
set_rule(world.get_location("Pickle Val Pickle Lady Mission End", player),
lambda state: state.has("Jar of Pickles", player))
# Morse Bunker
set_rule(multiworld.get_location("Morse Bunker Chest Scraps 1", player),
set_rule(world.get_location("Morse Bunker Chest Scraps 1", player),
lambda state: state.has("Lockpicks", player))
set_rule(multiworld.get_location("Morse Bunker Chest Scraps 2", player),
set_rule(world.get_location("Morse Bunker Chest Scraps 2", player),
lambda state: state.has("Lockpicks", player))
set_rule(multiworld.get_location("Morse Bunker Chest Scraps 3", player),
set_rule(world.get_location("Morse Bunker Chest Scraps 3", player),
lambda state: state.has("Lockpicks", player))
set_rule(multiworld.get_location("Morse Bunker Chest Scraps 4", player),
set_rule(world.get_location("Morse Bunker Chest Scraps 4", player),
lambda state: state.has("Lockpicks", player))
set_rule(multiworld.get_location("Morse Bunker Chest Scraps 5", player),
set_rule(world.get_location("Morse Bunker Chest Scraps 5", player),
lambda state: state.has("Lockpicks", player))
# Add rules to reach the "Go mode"
set_rule(multiworld.get_location("Final Boss", player),
set_rule(world.get_location("Final Boss", player),
lambda state: state.has("Temple Key", player)
and state.has("Green Egg", player)
and state.has("Blue Egg", player)
and state.has("Red Egg", player))
multiworld.completion_condition[player] = lambda state: state.has("Victory", player)
world.completion_condition[player] = lambda state: state.has("Victory", player)

Some files were not shown because too many files have changed in this diff Show More