diff --git a/.github/workflows/unittests.yml b/.github/workflows/unittests.yml index b2530bd06c..3ad29b0077 100644 --- a/.github/workflows/unittests.yml +++ b/.github/workflows/unittests.yml @@ -24,7 +24,7 @@ on: - '.github/workflows/unittests.yml' jobs: - build: + unit: runs-on: ${{ matrix.os }} name: Test Python ${{ matrix.python.version }} ${{ matrix.os }} @@ -60,3 +60,32 @@ jobs: - name: Unittests run: | pytest -n auto + + hosting: + runs-on: ${{ matrix.os }} + name: Test hosting with ${{ matrix.python.version }} on ${{ matrix.os }} + + strategy: + matrix: + os: + - ubuntu-latest + python: + - {version: '3.11'} # current + + steps: + - uses: actions/checkout@v4 + - name: Set up Python ${{ matrix.python.version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python.version }} + - name: Install dependencies + run: | + python -m venv venv + source venv/bin/activate + python -m pip install --upgrade pip + python ModuleUpdate.py --yes --force --append "WebHostLib/requirements.txt" + - name: Test hosting + run: | + source venv/bin/activate + export PYTHONPATH=$(pwd) + python test/hosting/__main__.py diff --git a/.gitignore b/.gitignore index 022abe38fe..0bba6f1726 100644 --- a/.gitignore +++ b/.gitignore @@ -62,6 +62,7 @@ Output Logs/ /installdelete.iss /data/user.kv /datapackage +/custom_worlds # Byte-compiled / optimized / DLL files __pycache__/ diff --git a/Launcher.py b/Launcher.py index e26e4afc0f..e4b65be93a 100644 --- a/Launcher.py +++ b/Launcher.py @@ -19,7 +19,7 @@ import sys import webbrowser from os.path import isfile from shutil import which -from typing import Sequence, Union, Optional +from typing import Callable, Sequence, Union, Optional import Utils import settings @@ -160,6 +160,9 @@ def launch(exe, in_terminal=False): subprocess.Popen(exe) +refresh_components: Optional[Callable[[], None]] = None + + def run_gui(): from kvui import App, ContainerLayout, GridLayout, Button, Label, ScrollBox, Widget from kivy.core.window import Window @@ -170,11 +173,8 @@ def run_gui(): base_title: str = "Archipelago Launcher" container: ContainerLayout grid: GridLayout - - _tools = {c.display_name: c for c in components if c.type == Type.TOOL} - _clients = {c.display_name: c for c in components if c.type == Type.CLIENT} - _adjusters = {c.display_name: c for c in components if c.type == Type.ADJUSTER} - _miscs = {c.display_name: c for c in components if c.type == Type.MISC} + _tool_layout: Optional[ScrollBox] = None + _client_layout: Optional[ScrollBox] = None def __init__(self, ctx=None): self.title = self.base_title @@ -182,18 +182,7 @@ def run_gui(): self.icon = r"data/icon.png" super().__init__() - def build(self): - self.container = ContainerLayout() - self.grid = GridLayout(cols=2) - self.container.add_widget(self.grid) - self.grid.add_widget(Label(text="General", size_hint_y=None, height=40)) - self.grid.add_widget(Label(text="Clients", size_hint_y=None, height=40)) - tool_layout = ScrollBox() - tool_layout.layout.orientation = "vertical" - self.grid.add_widget(tool_layout) - client_layout = ScrollBox() - client_layout.layout.orientation = "vertical" - self.grid.add_widget(client_layout) + def _refresh_components(self) -> None: def build_button(component: Component) -> Widget: """ @@ -218,14 +207,47 @@ def run_gui(): return box_layout return button + # clear before repopulating + assert self._tool_layout and self._client_layout, "must call `build` first" + tool_children = reversed(self._tool_layout.layout.children) + for child in tool_children: + self._tool_layout.layout.remove_widget(child) + client_children = reversed(self._client_layout.layout.children) + for child in client_children: + self._client_layout.layout.remove_widget(child) + + _tools = {c.display_name: c for c in components if c.type == Type.TOOL} + _clients = {c.display_name: c for c in components if c.type == Type.CLIENT} + _adjusters = {c.display_name: c for c in components if c.type == Type.ADJUSTER} + _miscs = {c.display_name: c for c in components if c.type == Type.MISC} + for (tool, client) in itertools.zip_longest(itertools.chain( - self._tools.items(), self._miscs.items(), self._adjusters.items()), self._clients.items()): + _tools.items(), _miscs.items(), _adjusters.items() + ), _clients.items()): # column 1 if tool: - tool_layout.layout.add_widget(build_button(tool[1])) + self._tool_layout.layout.add_widget(build_button(tool[1])) # column 2 if client: - client_layout.layout.add_widget(build_button(client[1])) + self._client_layout.layout.add_widget(build_button(client[1])) + + def build(self): + self.container = ContainerLayout() + self.grid = GridLayout(cols=2) + self.container.add_widget(self.grid) + self.grid.add_widget(Label(text="General", size_hint_y=None, height=40)) + self.grid.add_widget(Label(text="Clients", size_hint_y=None, height=40)) + self._tool_layout = ScrollBox() + self._tool_layout.layout.orientation = "vertical" + self.grid.add_widget(self._tool_layout) + self._client_layout = ScrollBox() + self._client_layout.layout.orientation = "vertical" + self.grid.add_widget(self._client_layout) + + self._refresh_components() + + global refresh_components + refresh_components = self._refresh_components Window.bind(on_drop_file=self._on_drop_file) @@ -254,10 +276,17 @@ def run_gui(): Launcher().run() + # avoiding Launcher reference leak + # and don't try to do something with widgets after window closed + global refresh_components + refresh_components = None + def run_component(component: Component, *args): if component.func: component.func(*args) + if refresh_components: + refresh_components() elif component.script_name: subprocess.run([*get_exe(component.script_name), *args]) else: diff --git a/MultiServer.py b/MultiServer.py index 22375da2b3..dc5e3d21ac 100644 --- a/MultiServer.py +++ b/MultiServer.py @@ -3,6 +3,7 @@ from __future__ import annotations import argparse import asyncio import collections +import contextlib import copy import datetime import functools @@ -176,7 +177,7 @@ class Context: location_name_groups: typing.Dict[str, typing.Dict[str, typing.Set[str]]] all_item_and_group_names: typing.Dict[str, typing.Set[str]] all_location_and_group_names: typing.Dict[str, typing.Set[str]] - non_hintable_names: typing.Dict[str, typing.Set[str]] + non_hintable_names: typing.Dict[str, typing.AbstractSet[str]] spheres: typing.List[typing.Dict[int, typing.Set[int]]] """ each sphere is { player: { location_id, ... } } """ logger: logging.Logger @@ -231,7 +232,7 @@ class Context: self.embedded_blacklist = {"host", "port"} self.client_ids: typing.Dict[typing.Tuple[int, int], datetime.datetime] = {} self.auto_save_interval = 60 # in seconds - self.auto_saver_thread = None + self.auto_saver_thread: typing.Optional[threading.Thread] = None self.save_dirty = False self.tags = ['AP'] self.games: typing.Dict[int, str] = {} @@ -268,6 +269,11 @@ class Context: for world_name, world in worlds.AutoWorldRegister.world_types.items(): self.non_hintable_names[world_name] = world.hint_blacklist + for game_package in self.gamespackage.values(): + # remove groups from data sent to clients + del game_package["item_name_groups"] + del game_package["location_name_groups"] + def _init_game_data(self): for game_name, game_package in self.gamespackage.items(): if "checksum" in game_package: @@ -1926,8 +1932,6 @@ class ServerCommandProcessor(CommonCommandProcessor): def _cmd_exit(self) -> bool: """Shutdown the server""" self.ctx.server.ws_server.close() - if self.ctx.shutdown_task: - self.ctx.shutdown_task.cancel() self.ctx.exit_event.set() return True @@ -2285,7 +2289,8 @@ def parse_args() -> argparse.Namespace: async def auto_shutdown(ctx, to_cancel=None): - await asyncio.sleep(ctx.auto_shutdown) + with contextlib.suppress(asyncio.TimeoutError): + await asyncio.wait_for(ctx.exit_event.wait(), ctx.auto_shutdown) def inactivity_shutdown(): ctx.server.ws_server.close() @@ -2305,7 +2310,8 @@ async def auto_shutdown(ctx, to_cancel=None): if seconds < 0: inactivity_shutdown() else: - await asyncio.sleep(seconds) + with contextlib.suppress(asyncio.TimeoutError): + await asyncio.wait_for(ctx.exit_event.wait(), seconds) def load_server_cert(path: str, cert_key: typing.Optional[str]) -> "ssl.SSLContext": diff --git a/WebHost.py b/WebHost.py index 9b5edd322f..afacd6288e 100644 --- a/WebHost.py +++ b/WebHost.py @@ -12,6 +12,9 @@ ModuleUpdate.update() import Utils import settings +if typing.TYPE_CHECKING: + from flask import Flask + Utils.local_path.cached_path = os.path.dirname(__file__) or "." # py3.8 is not abs. remove "." when dropping 3.8 settings.no_gui = True configpath = os.path.abspath("config.yaml") @@ -19,7 +22,7 @@ if not os.path.exists(configpath): # fall back to config.yaml in home configpath = os.path.abspath(Utils.user_path('config.yaml')) -def get_app(): +def get_app() -> "Flask": from WebHostLib import register, cache, app as raw_app from WebHostLib.models import db diff --git a/WebHostLib/customserver.py b/WebHostLib/customserver.py index 3a86cb551d..9f70165b61 100644 --- a/WebHostLib/customserver.py +++ b/WebHostLib/customserver.py @@ -168,17 +168,28 @@ def get_random_port(): def get_static_server_data() -> dict: import worlds data = { - "non_hintable_names": {}, - "gamespackage": worlds.network_data_package["games"], - "item_name_groups": {world_name: world.item_name_groups for world_name, world in - worlds.AutoWorldRegister.world_types.items()}, - "location_name_groups": {world_name: world.location_name_groups for world_name, world in - worlds.AutoWorldRegister.world_types.items()}, + "non_hintable_names": { + world_name: world.hint_blacklist + for world_name, world in worlds.AutoWorldRegister.world_types.items() + }, + "gamespackage": { + world_name: { + key: value + for key, value in game_package.items() + if key not in ("item_name_groups", "location_name_groups") + } + for world_name, game_package in worlds.network_data_package["games"].items() + }, + "item_name_groups": { + world_name: world.item_name_groups + for world_name, world in worlds.AutoWorldRegister.world_types.items() + }, + "location_name_groups": { + world_name: world.location_name_groups + for world_name, world in worlds.AutoWorldRegister.world_types.items() + }, } - for world_name, world in worlds.AutoWorldRegister.world_types.items(): - data["non_hintable_names"][world_name] = world.hint_blacklist - return data @@ -266,12 +277,15 @@ def run_server_process(name: str, ponyconfig: dict, static_server_data: dict, ctx.logger.exception("Could not determine port. Likely hosting failure.") with db_session: ctx.auto_shutdown = Room.get(id=room_id).timeout + if ctx.saving: + setattr(asyncio.current_task(), "save", lambda: ctx._save(True)) ctx.shutdown_task = asyncio.create_task(auto_shutdown(ctx, [])) await ctx.shutdown_task except (KeyboardInterrupt, SystemExit): if ctx.saving: ctx._save() + setattr(asyncio.current_task(), "save", None) except Exception as e: with db_session: room = Room.get(id=room_id) @@ -281,8 +295,12 @@ def run_server_process(name: str, ponyconfig: dict, static_server_data: dict, else: if ctx.saving: ctx._save() + setattr(asyncio.current_task(), "save", None) finally: try: + ctx.save_dirty = False # make sure the saving thread does not write to DB after final wakeup + ctx.exit_event.set() # make sure the saving thread stops at some point + # NOTE: async saving should probably be an async task and could be merged with shutdown_task with (db_session): # ensure the Room does not spin up again on its own, minute of safety buffer room = Room.get(id=room_id) @@ -294,13 +312,32 @@ def run_server_process(name: str, ponyconfig: dict, static_server_data: dict, rooms_shutting_down.put(room_id) class Starter(threading.Thread): + _tasks: typing.List[asyncio.Future] + + def __init__(self): + super().__init__() + self._tasks = [] + + def _done(self, task: asyncio.Future): + self._tasks.remove(task) + task.result() + def run(self): while 1: next_room = rooms_to_run.get(block=True, timeout=None) - asyncio.run_coroutine_threadsafe(start_room(next_room), loop) + task = asyncio.run_coroutine_threadsafe(start_room(next_room), loop) + self._tasks.append(task) + task.add_done_callback(self._done) logging.info(f"Starting room {next_room} on {name}.") starter = Starter() starter.daemon = True starter.start() - loop.run_forever() + try: + loop.run_forever() + finally: + # save all tasks that want to be saved during shutdown + for task in asyncio.all_tasks(loop): + save: typing.Optional[typing.Callable[[], typing.Any]] = getattr(task, "save", None) + if save: + save() diff --git a/WebHostLib/requirements.txt b/WebHostLib/requirements.txt index 62707d78cf..3452c9d416 100644 --- a/WebHostLib/requirements.txt +++ b/WebHostLib/requirements.txt @@ -1,9 +1,10 @@ -flask>=3.0.0 +flask>=3.0.3 +werkzeug>=3.0.3 pony>=0.7.17 -waitress>=2.1.2 -Flask-Caching>=2.1.0 -Flask-Compress>=1.14 -Flask-Limiter>=3.5.0 +waitress>=3.0.0 +Flask-Caching>=2.3.0 +Flask-Compress>=1.15 +Flask-Limiter>=3.7.0 bokeh>=3.1.1; python_version <= '3.8' -bokeh>=3.3.2; python_version >= '3.9' -markupsafe>=2.1.3 +bokeh>=3.4.1; python_version >= '3.9' +markupsafe>=2.1.5 diff --git a/docs/options api.md b/docs/options api.md index aedd5d76aa..cba383232b 100644 --- a/docs/options api.md +++ b/docs/options api.md @@ -132,7 +132,8 @@ or if I need a boolean object, such as in my slot_data I can access it as: start_with_sword = bool(self.options.starting_sword.value) ``` All numeric options (i.e. Toggle, Choice, Range) can be compared to integers, strings that match their attributes, -strings that match the option attributes after "option_" is stripped, and the attributes themselves. +strings that match the option attributes after "option_" is stripped, and the attributes themselves. The option can +also be checked to see if it exists within a collection, but this will fail for a set of strings due to hashing. ```python # options.py class Logic(Choice): @@ -144,6 +145,12 @@ class Logic(Choice): alias_extra_hard = 2 crazy = 4 # won't be listed as an option and only exists as an attribute on the class +class Weapon(Choice): + option_none = 0 + option_sword = 1 + option_bow = 2 + option_hammer = 3 + # __init__.py from .options import Logic @@ -157,6 +164,16 @@ elif self.options.logic == Logic.option_extreme: do_extreme_things() elif self.options.logic == "crazy": do_insane_things() + +# check if the current option is in a collection of integers using the class attributes +if self.options.weapon in {Weapon.option_bow, Weapon.option_sword}: + do_stuff() +# in order to make a set of strings work, we have to compare against current_key +elif self.options.weapon.current_key in {"none", "hammer"}: + do_something_else() +# though it's usually better to just use a tuple instead +elif self.options.weapon in ("none", "hammer"): + do_something_else() ``` ## Generic Option Classes These options are generically available to every game automatically, but can be overridden for slightly different diff --git a/inno_setup.iss b/inno_setup.iss index 7ae90622a1..a0f4944d98 100644 --- a/inno_setup.iss +++ b/inno_setup.iss @@ -213,6 +213,11 @@ Root: HKCR; Subkey: "{#MyAppName}multidata"; ValueData: "Arc Root: HKCR; Subkey: "{#MyAppName}multidata\DefaultIcon"; ValueData: "{app}\ArchipelagoServer.exe,0"; ValueType: string; ValueName: ""; Root: HKCR; Subkey: "{#MyAppName}multidata\shell\open\command"; ValueData: """{app}\ArchipelagoServer.exe"" ""%1"""; ValueType: string; ValueName: ""; +Root: HKCR; Subkey: ".apworld"; ValueData: "{#MyAppName}worlddata"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; +Root: HKCR; Subkey: "{#MyAppName}worlddata"; ValueData: "Archipelago World Data"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; +Root: HKCR; Subkey: "{#MyAppName}worlddata\DefaultIcon"; ValueData: "{app}\ArchipelagoLauncher.exe,0"; ValueType: string; ValueName: ""; +Root: HKCR; Subkey: "{#MyAppName}worlddata\shell\open\command"; ValueData: """{app}\ArchipelagoLauncher.exe"" ""%1"""; + Root: HKCR; Subkey: "archipelago"; ValueType: "string"; ValueData: "Archipegalo Protocol"; Flags: uninsdeletekey; Root: HKCR; Subkey: "archipelago"; ValueType: "string"; ValueName: "URL Protocol"; ValueData: ""; Root: HKCR; Subkey: "archipelago\DefaultIcon"; ValueType: "string"; ValueData: "{app}\ArchipelagoTextClient.exe,0"; diff --git a/requirements.txt b/requirements.txt index d1a7b763f3..db4f544503 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,13 +2,13 @@ colorama>=0.4.6 websockets>=12.0 PyYAML>=6.0.1 jellyfish>=1.0.3 -jinja2>=3.1.3 -schema>=0.7.5 +jinja2>=3.1.4 +schema>=0.7.7 kivy>=2.3.0 bsdiff4>=1.2.4 -platformdirs>=4.1.0 -certifi>=2023.11.17 -cython>=3.0.8 +platformdirs>=4.2.2 +certifi>=2024.6.2 +cython>=3.0.10 cymem>=2.0.8 -orjson>=3.9.10 -typing_extensions>=4.7.0 +orjson>=3.10.3 +typing_extensions>=4.12.1 diff --git a/test/hosting/__init__.py b/test/hosting/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/test/hosting/__main__.py b/test/hosting/__main__.py new file mode 100644 index 0000000000..6640c637b5 --- /dev/null +++ b/test/hosting/__main__.py @@ -0,0 +1,191 @@ +# A bunch of tests to verify MultiServer and custom webhost server work as expected. +# This spawns processes and may modify your local AP, so this is not run as part of unit testing. +# Run with `python test/hosting` instead, +import logging +import traceback +from tempfile import TemporaryDirectory +from time import sleep +from typing import Any + +from test.hosting.client import Client +from test.hosting.generate import generate_local +from test.hosting.serve import ServeGame, LocalServeGame, WebHostServeGame +from test.hosting.webhost import (create_room, get_app, get_multidata_for_room, set_multidata_for_room, start_room, + stop_autohost, upload_multidata) +from test.hosting.world import copy as copy_world, delete as delete_world + +failure = False +fail_fast = True + + +def assert_true(condition: Any, msg: str = "") -> None: + global failure + if not condition: + failure = True + msg = f": {msg}" if msg else "" + raise AssertionError(f"Assertion failed{msg}") + + +def assert_equal(first: Any, second: Any, msg: str = "") -> None: + global failure + if first != second: + failure = True + msg = f": {msg}" if msg else "" + raise AssertionError(f"Assertion failed: {first} == {second}{msg}") + + +if fail_fast: + expect_true = assert_true + expect_equal = assert_equal +else: + def expect_true(condition: Any, msg: str = "") -> None: + global failure + if not condition: + failure = True + tb = "".join(traceback.format_stack()[:-1]) + msg = f": {msg}" if msg else "" + logging.error(f"Expectation failed{msg}\n{tb}") + + def expect_equal(first: Any, second: Any, msg: str = "") -> None: + global failure + if first != second: + failure = True + tb = "".join(traceback.format_stack()[:-1]) + msg = f": {msg}" if msg else "" + logging.error(f"Expectation failed {first} == {second}{msg}\n{tb}") + + +if __name__ == "__main__": + import warnings + warnings.simplefilter("ignore", ResourceWarning) + warnings.simplefilter("ignore", UserWarning) + + spacer = '=' * 80 + + with TemporaryDirectory() as tempdir: + multis = [["Clique"], ["Temp World"], ["Clique", "Temp World"]] + p1_games = [] + data_paths = [] + rooms = [] + + copy_world("Clique", "Temp World") + try: + for n, games in enumerate(multis, 1): + print(f"Generating [{n}] {', '.join(games)}") + multidata = generate_local(games, tempdir) + print(f"Generated [{n}] {', '.join(games)} as {multidata}\n") + p1_games.append(games[0]) + data_paths.append(multidata) + finally: + delete_world("Temp World") + + webapp = get_app(tempdir) + webhost_client = webapp.test_client() + for n, multidata in enumerate(data_paths, 1): + seed = upload_multidata(webhost_client, multidata) + room = create_room(webhost_client, seed) + print(f"Uploaded [{n}] {multidata} as {room}\n") + rooms.append(room) + + print("Starting autohost") + from WebHostLib.autolauncher import autohost + try: + autohost(webapp.config) + + host: ServeGame + for n, (multidata, room, game, multi_games) in enumerate(zip(data_paths, rooms, p1_games, multis), 1): + involved_games = {"Archipelago"} | set(multi_games) + for collected_items in range(3): + print(f"\nTesting [{n}] {game} in {multidata} on MultiServer with {collected_items} items collected") + with LocalServeGame(multidata) as host: + with Client(host.address, game, "Player1") as client: + local_data_packages = client.games_packages + local_collected_items = len(client.checked_locations) + if collected_items < 2: # Clique only has 2 Locations + client.collect_any() + # TODO: Ctrl+C test here as well + + for game_name in sorted(involved_games): + expect_true(game_name in local_data_packages, + f"{game_name} missing from MultiServer datap ackage") + expect_true("item_name_groups" not in local_data_packages.get(game_name, {}), + f"item_name_groups are not supposed to be in MultiServer data for {game_name}") + expect_true("location_name_groups" not in local_data_packages.get(game_name, {}), + f"location_name_groups are not supposed to be in MultiServer data for {game_name}") + for game_name in local_data_packages: + expect_true(game_name in involved_games, + f"Received unexpected extra data package for {game_name} from MultiServer") + assert_equal(local_collected_items, collected_items, + "MultiServer did not load or save correctly") + + print(f"\nTesting [{n}] {game} in {multidata} on customserver with {collected_items} items collected") + prev_host_adr: str + with WebHostServeGame(webhost_client, room) as host: + prev_host_adr = host.address + with Client(host.address, game, "Player1") as client: + web_data_packages = client.games_packages + web_collected_items = len(client.checked_locations) + if collected_items < 2: # Clique only has 2 Locations + client.collect_any() + if collected_items == 1: + sleep(1) # wait for the server to collect the item + stop_autohost(True) # simulate Ctrl+C + sleep(3) + autohost(webapp.config) # this will spin the room right up again + sleep(1) # make log less annoying + # if saving failed, the next iteration will fail below + + # verify server shut down + try: + with Client(prev_host_adr, game, "Player1") as client: + assert_true(False, "Server did not shut down") + except ConnectionError: + pass + + for game_name in sorted(involved_games): + expect_true(game_name in web_data_packages, + f"{game_name} missing from customserver data package") + expect_true("item_name_groups" not in web_data_packages.get(game_name, {}), + f"item_name_groups are not supposed to be in customserver data for {game_name}") + expect_true("location_name_groups" not in web_data_packages.get(game_name, {}), + f"location_name_groups are not supposed to be in customserver data for {game_name}") + for game_name in web_data_packages: + expect_true(game_name in involved_games, + f"Received unexpected extra data package for {game_name} from customserver") + assert_equal(web_collected_items, collected_items, + "customserver did not load or save correctly during/after " + + ("Ctrl+C" if collected_items == 2 else "/exit")) + + # compare customserver to MultiServer + expect_equal(local_data_packages, web_data_packages, + "customserver datapackage differs from MultiServer") + + sleep(5.5) # make sure all tasks actually stopped + + # raise an exception in customserver and verify the save doesn't get destroyed + # local variables room is the last room's id here + old_data = get_multidata_for_room(webhost_client, room) + print(f"Destroying multidata for {room}") + set_multidata_for_room(webhost_client, room, bytes([0])) + try: + start_room(webhost_client, room, timeout=7) + except TimeoutError: + pass + else: + assert_true(False, "Room started with destroyed multidata") + print(f"Restoring multidata for {room}") + set_multidata_for_room(webhost_client, room, old_data) + with WebHostServeGame(webhost_client, room) as host: + with Client(host.address, game, "Player1") as client: + assert_equal(len(client.checked_locations), 2, + "Save was destroyed during exception in customserver") + print("Save file is not busted 🥳") + + finally: + print("Stopping autohost") + stop_autohost(False) + + if failure: + print("Some tests failed") + exit(1) + exit(0) diff --git a/test/hosting/client.py b/test/hosting/client.py new file mode 100644 index 0000000000..b805bb6a26 --- /dev/null +++ b/test/hosting/client.py @@ -0,0 +1,110 @@ +import json +import sys +from typing import Any, Collection, Dict, Iterable, Optional +from websockets import ConnectionClosed +from websockets.sync.client import connect, ClientConnection +from threading import Thread + + +__all__ = [ + "Client" +] + + +class Client: + """Incomplete, minimalistic sync test client for AP network protocol""" + + recv_timeout = 1.0 + + host: str + game: str + slot: str + password: Optional[str] + + _ws: Optional[ClientConnection] + + games: Iterable[str] + data_package_checksums: Dict[str, Any] + games_packages: Dict[str, Any] + missing_locations: Collection[int] + checked_locations: Collection[int] + + def __init__(self, host: str, game: str, slot: str, password: Optional[str] = None) -> None: + self.host = host + self.game = game + self.slot = slot + self.password = password + self._ws = None + self.games = [] + self.data_package_checksums = {} + self.games_packages = {} + self.missing_locations = [] + self.checked_locations = [] + + def __enter__(self) -> "Client": + try: + self.connect() + except BaseException: + self.__exit__(*sys.exc_info()) + raise + return self + + def __exit__(self, exc_type, exc_val, exc_tb) -> None: # type: ignore + self.close() + + def _poll(self) -> None: + assert self._ws + try: + while True: + self._ws.recv() + except (TimeoutError, ConnectionClosed, KeyboardInterrupt, SystemExit): + pass + + def connect(self) -> None: + self._ws = connect(f"ws://{self.host}") + room_info = json.loads(self._ws.recv(self.recv_timeout))[0] + self.games = sorted(room_info["games"]) + self.data_package_checksums = room_info["datapackage_checksums"] + self._ws.send(json.dumps([{ + "cmd": "GetDataPackage", + "games": list(self.games), + }])) + data_package_msg = json.loads(self._ws.recv(self.recv_timeout))[0] + self.games_packages = data_package_msg["data"]["games"] + self._ws.send(json.dumps([{ + "cmd": "Connect", + "game": self.game, + "name": self.slot, + "password": self.password, + "uuid": "", + "version": { + "class": "Version", + "major": 0, + "minor": 4, + "build": 6, + }, + "items_handling": 0, + "tags": [], + "slot_data": False, + }])) + connect_result_msg = json.loads(self._ws.recv(self.recv_timeout))[0] + if connect_result_msg["cmd"] != "Connected": + raise ConnectionError(", ".join(connect_result_msg.get("errors", [connect_result_msg["cmd"]]))) + self.missing_locations = connect_result_msg["missing_locations"] + self.checked_locations = connect_result_msg["checked_locations"] + + def close(self) -> None: + if self._ws: + Thread(target=self._poll).start() + self._ws.close() + + def collect(self, locations: Iterable[int]) -> None: + if not self._ws: + raise ValueError("Not connected") + self._ws.send(json.dumps([{ + "cmd": "LocationChecks", + "locations": locations, + }])) + + def collect_any(self) -> None: + self.collect([next(iter(self.missing_locations))]) diff --git a/test/hosting/generate.py b/test/hosting/generate.py new file mode 100644 index 0000000000..356cbcca25 --- /dev/null +++ b/test/hosting/generate.py @@ -0,0 +1,75 @@ +import json +import sys +import warnings +from pathlib import Path +from typing import Iterable, Union, TYPE_CHECKING + +if TYPE_CHECKING: + from multiprocessing.managers import ListProxy # noqa + +__all__ = [ + "generate_local", +] + + +def _generate_local_inner(games: Iterable[str], + dest: Union[Path, str], + results: "ListProxy[Union[Path, BaseException]]") -> None: + original_argv = sys.argv + warnings.simplefilter("ignore") + try: + from tempfile import TemporaryDirectory + + if not isinstance(dest, Path): + dest = Path(dest) + + with TemporaryDirectory() as players_dir: + with TemporaryDirectory() as output_dir: + import Generate + + for n, game in enumerate(games, 1): + player_path = Path(players_dir) / f"{n}.yaml" + with open(player_path, "w", encoding="utf-8") as f: + f.write(json.dumps({ + "name": f"Player{n}", + "game": game, + game: {"hard_mode": "true"}, + "description": f"generate_local slot {n} ('Player{n}'): {game}", + })) + + # this is basically copied from test/programs/test_generate.py + # uses a reproducible seed that is different for each set of games + sys.argv = [sys.argv[0], "--seed", str(hash(tuple(games))), + "--player_files_path", players_dir, + "--outputpath", output_dir] + Generate.main() + output_files = list(Path(output_dir).glob('*.zip')) + assert len(output_files) == 1 + final_file = dest / output_files[0].name + output_files[0].rename(final_file) + results.append(final_file) + except BaseException as e: + results.append(e) + raise e + finally: + sys.argv = original_argv + + +def generate_local(games: Iterable[str], dest: Union[Path, str]) -> Path: + from multiprocessing import Manager, Process, set_start_method + + try: + set_start_method("spawn") + except RuntimeError: + pass + + manager = Manager() + results: "ListProxy[Union[Path, Exception]]" = manager.list() + + p = Process(target=_generate_local_inner, args=(games, dest, results)) + p.start() + p.join() + result = results[0] + if isinstance(result, BaseException): + raise Exception("Could not generate multiworld") from result + return result diff --git a/test/hosting/serve.py b/test/hosting/serve.py new file mode 100644 index 0000000000..c3eaac87cc --- /dev/null +++ b/test/hosting/serve.py @@ -0,0 +1,115 @@ +import sys +from pathlib import Path +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from threading import Event + from werkzeug.test import Client as FlaskClient + +__all__ = [ + "ServeGame", + "LocalServeGame", + "WebHostServeGame", +] + + +class ServeGame: + address: str + + +def _launch_multiserver(multidata: Path, ready: "Event", stop: "Event") -> None: + import os + import warnings + + original_argv = sys.argv + original_stdin = sys.stdin + warnings.simplefilter("ignore") + try: + import asyncio + from MultiServer import main, parse_args + + sys.argv = [sys.argv[0], str(multidata), "--host", "127.0.0.1"] + r, w = os.pipe() + sys.stdin = os.fdopen(r, "r") + + async def set_ready() -> None: + await asyncio.sleep(.01) # switch back to other task once more + ready.set() # server should be up, set ready state + + async def wait_stop() -> None: + await asyncio.get_event_loop().run_in_executor(None, stop.wait) + os.fdopen(w, "w").write("/exit") + + async def run() -> None: + # this will run main() until first await, then switch to set_ready() + await asyncio.gather( + main(parse_args()), + set_ready(), + wait_stop(), + ) + + asyncio.run(run()) + finally: + sys.argv = original_argv + sys.stdin = original_stdin + + +class LocalServeGame(ServeGame): + from multiprocessing import Process + + _multidata: Path + _proc: Process + _stop: "Event" + + def __init__(self, multidata: Path) -> None: + self.address = "" + self._multidata = multidata + + def __enter__(self) -> "LocalServeGame": + from multiprocessing import Manager, Process, set_start_method + + try: + set_start_method("spawn") + except RuntimeError: + pass + + manager = Manager() + ready: "Event" = manager.Event() + self._stop = manager.Event() + + self._proc = Process(target=_launch_multiserver, args=(self._multidata, ready, self._stop)) + try: + self._proc.start() + ready.wait(30) + self.address = "localhost:38281" + return self + except BaseException: + self.__exit__(*sys.exc_info()) + raise + + def __exit__(self, exc_type, exc_val, exc_tb) -> None: # type: ignore + try: + self._stop.set() + self._proc.join(30) + except TimeoutError: + self._proc.terminate() + self._proc.join() + + +class WebHostServeGame(ServeGame): + _client: "FlaskClient" + _room: str + + def __init__(self, app_client: "FlaskClient", room: str) -> None: + self.address = "" + self._client = app_client + self._room = room + + def __enter__(self) -> "WebHostServeGame": + from .webhost import start_room + self.address = start_room(self._client, self._room) + return self + + def __exit__(self, exc_type, exc_val, exc_tb) -> None: # type: ignore + from .webhost import stop_room + stop_room(self._client, self._room, timeout=30) diff --git a/test/hosting/webhost.py b/test/hosting/webhost.py new file mode 100644 index 0000000000..e1e31ae466 --- /dev/null +++ b/test/hosting/webhost.py @@ -0,0 +1,201 @@ +import re +from pathlib import Path +from typing import TYPE_CHECKING, Optional, cast + +if TYPE_CHECKING: + from flask import Flask + from werkzeug.test import Client as FlaskClient + +__all__ = [ + "get_app", + "upload_multidata", + "create_room", + "start_room", + "stop_room", + "set_room_timeout", + "get_multidata_for_room", + "set_multidata_for_room", + "stop_autohost", +] + + +def get_app(tempdir: str) -> "Flask": + from WebHostLib import app as raw_app + from WebHost import get_app + raw_app.config["PONY"] = { + "provider": "sqlite", + "filename": str(Path(tempdir) / "host.db"), + "create_db": True, + } + raw_app.config.update({ + "TESTING": True, + "HOST_ADDRESS": "localhost", + "HOSTERS": 1, + }) + return get_app() + + +def upload_multidata(app_client: "FlaskClient", multidata: Path) -> str: + response = app_client.post("/uploads", data={ + "file": multidata.open("rb"), + }) + assert response.status_code < 400, f"Upload of {multidata} failed: status {response.status_code}" + assert "Location" in response.headers, f"Upload of {multidata} failed: no redirect" + location = response.headers["Location"] + assert isinstance(location, str) + assert location.startswith("/seed/"), f"Upload of {multidata} failed: unexpected redirect" + return location[6:] + + +def create_room(app_client: "FlaskClient", seed: str, auto_start: bool = False) -> str: + response = app_client.get(f"/new_room/{seed}") + assert response.status_code < 400, f"Creating room for {seed} failed: status {response.status_code}" + assert "Location" in response.headers, f"Creating room for {seed} failed: no redirect" + location = response.headers["Location"] + assert isinstance(location, str) + assert location.startswith("/room/"), f"Creating room for {seed} failed: unexpected redirect" + room_id = location[6:] + + if not auto_start: + # by default, creating a room will auto-start it, so we update last activity here + stop_room(app_client, room_id, simulate_idle=False) + + return room_id + + +def start_room(app_client: "FlaskClient", room_id: str, timeout: float = 30) -> str: + from time import sleep + + poll_interval = .2 + + print(f"Starting room {room_id}") + no_timeout = timeout <= 0 + while no_timeout or timeout > 0: + response = app_client.get(f"/room/{room_id}") + assert response.status_code == 200, f"Starting room for {room_id} failed: status {response.status_code}" + match = re.search(r"/connect ([\w:.\-]+)", response.text) + if match: + return match[1] + timeout -= poll_interval + sleep(poll_interval) + raise TimeoutError("Room did not start") + + +def stop_room(app_client: "FlaskClient", + room_id: str, + timeout: Optional[float] = None, + simulate_idle: bool = True) -> None: + from datetime import datetime, timedelta + from time import sleep + + from pony.orm import db_session + + from WebHostLib.models import Command, Room + from WebHostLib import app + + poll_interval = 2 + + print(f"Stopping room {room_id}") + room_uuid = app.url_map.converters["suuid"].to_python(None, room_id) # type: ignore[arg-type] + + if timeout is not None: + sleep(.1) # should not be required, but other things might use threading + + with db_session: + room: Room = Room.get(id=room_uuid) + if simulate_idle: + new_last_activity = datetime.utcnow() - timedelta(seconds=room.timeout + 5) + else: + 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: + original_timeout = room.timeout + room.timeout = 1 # avoid spinning it up again + Command(room=room, commandtext="/exit") + + try: + if address and timeout is not None: + print("waiting for shutdown") + import socket + host_str, port_str = tuple(address.split(":")) + address_tuple = host_str, int(port_str) + + no_timeout = timeout <= 0 + while no_timeout or timeout > 0: + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + try: + s.connect(address_tuple) + s.close() + except ConnectionRefusedError: + return + sleep(poll_interval) + timeout -= poll_interval + + raise TimeoutError("Room did not stop") + finally: + with db_session: + room = Room.get(id=room_uuid) + room.last_port = 0 # easier to detect when the host is up this way + if address: + room.timeout = original_timeout + room.last_activity = new_last_activity + print("timeout restored") + + +def set_room_timeout(room_id: str, timeout: float) -> None: + from pony.orm import db_session + + from WebHostLib.models import Room + from WebHostLib import app + + room_uuid = app.url_map.converters["suuid"].to_python(None, room_id) # type: ignore[arg-type] + with db_session: + room: Room = Room.get(id=room_uuid) + room.timeout = timeout + + +def get_multidata_for_room(webhost_client: "FlaskClient", room_id: str) -> bytes: + from pony.orm import db_session + + from WebHostLib.models import Room + from WebHostLib import app + + room_uuid = app.url_map.converters["suuid"].to_python(None, room_id) # type: ignore[arg-type] + with db_session: + room: Room = Room.get(id=room_uuid) + return cast(bytes, room.seed.multidata) + + +def set_multidata_for_room(webhost_client: "FlaskClient", room_id: str, data: bytes) -> None: + from pony.orm import db_session + + from WebHostLib.models import Room + from WebHostLib import app + + room_uuid = app.url_map.converters["suuid"].to_python(None, room_id) # type: ignore[arg-type] + with db_session: + room: Room = Room.get(id=room_uuid) + room.seed.multidata = data + + +def stop_autohost(graceful: bool = True) -> None: + import os + import signal + + import multiprocessing + + from WebHostLib.autolauncher import stop + + stop() + proc: multiprocessing.process.BaseProcess + for proc in filter(lambda child: child.name.startswith("MultiHoster"), multiprocessing.active_children()): + if graceful and proc.pid: + os.kill(proc.pid, getattr(signal, "CTRL_C_EVENT", signal.SIGINT)) + else: + proc.kill() + try: + proc.join(30) + except TimeoutError: + proc.kill() + proc.join() diff --git a/test/hosting/world.py b/test/hosting/world.py new file mode 100644 index 0000000000..e083e027fe --- /dev/null +++ b/test/hosting/world.py @@ -0,0 +1,42 @@ +import re +import shutil +from pathlib import Path +from typing import Dict + + +__all__ = ["copy", "delete"] + + +_new_worlds: Dict[str, str] = {} + + +def copy(src: str, dst: str) -> None: + from Utils import get_file_safe_name + from worlds import AutoWorldRegister + + assert dst not in _new_worlds, "World already created" + if '"' in dst or "\\" in dst: # easier to reject than to escape + raise ValueError(f"Unsupported symbols in {dst}") + dst_folder_name = get_file_safe_name(dst.lower()) + src_cls = AutoWorldRegister.world_types[src] + src_folder = Path(src_cls.__file__).parent + worlds_folder = src_folder.parent + if (not src_cls.__file__.endswith("__init__.py") or not src_folder.is_dir() + or not (worlds_folder / "generic").is_dir()): + raise ValueError(f"Unsupported layout for copy_world from {src}") + dst_folder = worlds_folder / dst_folder_name + if dst_folder.is_dir(): + raise ValueError(f"Destination {dst_folder} already exists") + shutil.copytree(src_folder, dst_folder) + _new_worlds[dst] = str(dst_folder) + with open(dst_folder / "__init__.py", "r", encoding="utf-8-sig") as f: + contents = f.read() + contents = re.sub(r'game\s*=\s*[\'"]' + re.escape(src) + r'[\'"]', f'game = "{dst}"', contents) + with open(dst_folder / "__init__.py", "w", encoding="utf-8") as f: + f.write(contents) + + +def delete(name: str) -> None: + assert name in _new_worlds, "World not created by this script" + shutil.rmtree(_new_worlds[name]) + del _new_worlds[name] diff --git a/test/options/__init__.py b/test/options/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/test/options/test_option_classes.py b/test/options/test_option_classes.py new file mode 100644 index 0000000000..8e2c4702c3 --- /dev/null +++ b/test/options/test_option_classes.py @@ -0,0 +1,67 @@ +import unittest + +from Options import Choice, DefaultOnToggle, Toggle + + +class TestNumericOptions(unittest.TestCase): + def test_numeric_option(self) -> None: + """Tests the initialization and equivalency comparisons of the base Numeric Option class.""" + class TestChoice(Choice): + option_zero = 0 + option_one = 1 + option_two = 2 + alias_three = 1 + non_option_attr = 2 + + class TestToggle(Toggle): + pass + + class TestDefaultOnToggle(DefaultOnToggle): + pass + + with self.subTest("choice"): + choice_option_default = TestChoice.from_any(TestChoice.default) + choice_option_string = TestChoice.from_any("one") + choice_option_int = TestChoice.from_any(2) + choice_option_alias = TestChoice.from_any("three") + choice_option_attr = TestChoice.from_any(TestChoice.option_two) + + self.assertEqual(choice_option_default, TestChoice.option_zero, + "assigning default didn't match default value") + self.assertEqual(choice_option_string, "one") + self.assertEqual(choice_option_int, 2) + self.assertEqual(choice_option_alias, TestChoice.alias_three) + self.assertEqual(choice_option_attr, TestChoice.non_option_attr) + + self.assertRaises(KeyError, TestChoice.from_any, "four") + + self.assertIn(choice_option_int, [1, 2, 3]) + self.assertIn(choice_option_int, {2}) + self.assertIn(choice_option_int, (2,)) + + self.assertIn(choice_option_string, ["one", "two", "three"]) + # this fails since the hash is derived from the value + self.assertNotIn(choice_option_string, {"one"}) + self.assertIn(choice_option_string, ("one",)) + + with self.subTest("toggle"): + toggle_default = TestToggle.from_any(TestToggle.default) + toggle_string = TestToggle.from_any("false") + toggle_int = TestToggle.from_any(0) + toggle_alias = TestToggle.from_any("off") + + self.assertFalse(toggle_default) + self.assertFalse(toggle_string) + self.assertFalse(toggle_int) + self.assertFalse(toggle_alias) + + with self.subTest("on toggle"): + toggle_default = TestDefaultOnToggle.from_any(TestDefaultOnToggle.default) + toggle_string = TestDefaultOnToggle.from_any("true") + toggle_int = TestDefaultOnToggle.from_any(1) + toggle_alias = TestDefaultOnToggle.from_any("on") + + self.assertTrue(toggle_default) + self.assertTrue(toggle_string) + self.assertTrue(toggle_int) + self.assertTrue(toggle_alias) diff --git a/typings/kivy/uix/boxlayout.pyi b/typings/kivy/uix/boxlayout.pyi new file mode 100644 index 0000000000..c63d691deb --- /dev/null +++ b/typings/kivy/uix/boxlayout.pyi @@ -0,0 +1,6 @@ +from typing import Literal +from .layout import Layout + + +class BoxLayout(Layout): + orientation: Literal['horizontal', 'vertical'] diff --git a/typings/kivy/uix/layout.pyi b/typings/kivy/uix/layout.pyi index 2a418a1d8b..c27f890863 100644 --- a/typings/kivy/uix/layout.pyi +++ b/typings/kivy/uix/layout.pyi @@ -1,8 +1,14 @@ -from typing import Any +from typing import Any, Sequence + from .widget import Widget class Layout(Widget): + @property + def children(self) -> Sequence[Widget]: ... + def add_widget(self, widget: Widget) -> None: ... + def remove_widget(self, widget: Widget) -> None: ... + def do_layout(self, *largs: Any, **kwargs: Any) -> None: ... diff --git a/typings/schema/__init__.pyi b/typings/schema/__init__.pyi new file mode 100644 index 0000000000..d993ec2274 --- /dev/null +++ b/typings/schema/__init__.pyi @@ -0,0 +1,17 @@ +from typing import Any, Callable + + +class And: + def __init__(self, __type: type, __func: Callable[[Any], bool]) -> None: ... + + +class Or: + def __init__(self, *args: object) -> None: ... + + +class Schema: + def __init__(self, __x: object) -> None: ... + + +class Optional(Schema): + ... diff --git a/worlds/LauncherComponents.py b/worlds/LauncherComponents.py index 78ec14b4a4..18c1a1661e 100644 --- a/worlds/LauncherComponents.py +++ b/worlds/LauncherComponents.py @@ -1,8 +1,11 @@ +import bisect +import logging +import pathlib import weakref from enum import Enum, auto -from typing import Optional, Callable, List, Iterable +from typing import Optional, Callable, List, Iterable, Tuple -from Utils import local_path +from Utils import local_path, open_filename class Type(Enum): @@ -49,8 +52,10 @@ class Component: def __repr__(self): return f"{self.__class__.__name__}({self.display_name})" + processes = weakref.WeakSet() + def launch_subprocess(func: Callable, name: str = None): global processes import multiprocessing @@ -58,6 +63,7 @@ def launch_subprocess(func: Callable, name: str = None): process.start() processes.add(process) + class SuffixIdentifier: suffixes: Iterable[str] @@ -77,6 +83,80 @@ def launch_textclient(): launch_subprocess(CommonClient.run_as_textclient, name="TextClient") +def _install_apworld(apworld_src: str = "") -> Optional[Tuple[pathlib.Path, pathlib.Path]]: + if not apworld_src: + apworld_src = open_filename('Select APWorld file to install', (('APWorld', ('.apworld',)),)) + if not apworld_src: + # user closed menu + return + + if not apworld_src.endswith(".apworld"): + raise Exception(f"Wrong file format, looking for .apworld. File identified: {apworld_src}") + + apworld_path = pathlib.Path(apworld_src) + + module_name = pathlib.Path(apworld_path.name).stem + try: + import zipfile + zipfile.ZipFile(apworld_path).open(module_name + "/__init__.py") + except ValueError as e: + raise Exception("Archive appears invalid or damaged.") from e + except KeyError as e: + raise Exception("Archive appears to not be an apworld. (missing __init__.py)") from e + + import worlds + if worlds.user_folder is None: + raise Exception("Custom Worlds directory appears to not be writable.") + for world_source in worlds.world_sources: + if apworld_path.samefile(world_source.resolved_path): + # Note that this doesn't check if the same world is already installed. + # It only checks if the user is trying to install the apworld file + # that comes from the installation location (worlds or custom_worlds) + raise Exception(f"APWorld is already installed at {world_source.resolved_path}.") + + # TODO: run generic test suite over the apworld. + # TODO: have some kind of version system to tell from metadata if the apworld should be compatible. + + target = pathlib.Path(worlds.user_folder) / apworld_path.name + import shutil + shutil.copyfile(apworld_path, target) + + # If a module with this name is already loaded, then we can't load it now. + # TODO: We need to be able to unload a world module, + # so the user can update a world without restarting the application. + found_already_loaded = False + for loaded_world in worlds.world_sources: + loaded_name = pathlib.Path(loaded_world.path).stem + if module_name == loaded_name: + found_already_loaded = True + break + if found_already_loaded: + raise Exception(f"Installed APWorld successfully, but '{module_name}' is already loaded,\n" + "so a Launcher restart is required to use the new installation.") + world_source = worlds.WorldSource(str(target), is_zip=True) + bisect.insort(worlds.world_sources, world_source) + world_source.load() + + return apworld_path, target + + +def install_apworld(apworld_path: str = "") -> None: + try: + res = _install_apworld(apworld_path) + if res is None: + logging.info("Aborting APWorld installation.") + return + source, target = res + except Exception as e: + import Utils + Utils.messagebox(e.__class__.__name__, str(e), error=True) + logging.exception(e) + else: + import Utils + logging.info(f"Installed APWorld successfully, copied {source} to {target}.") + Utils.messagebox("Install complete.", f"Installed APWorld from {source}.") + + components: List[Component] = [ # Launcher Component('Launcher', 'Launcher', component_type=Type.HIDDEN), @@ -84,6 +164,7 @@ components: List[Component] = [ Component('Host', 'MultiServer', 'ArchipelagoServer', cli=True, file_identifier=SuffixIdentifier('.archipelago', '.zip')), Component('Generate', 'Generate', cli=True), + Component("Install APWorld", func=install_apworld, file_identifier=SuffixIdentifier(".apworld")), Component('Text Client', 'CommonClient', 'ArchipelagoTextClient', func=launch_textclient), Component('Links Awakening DX Client', 'LinksAwakeningClient', file_identifier=SuffixIdentifier('.apladx')), diff --git a/worlds/__init__.py b/worlds/__init__.py index 09f7288219..83ee96131a 100644 --- a/worlds/__init__.py +++ b/worlds/__init__.py @@ -1,16 +1,21 @@ import importlib +import logging import os import sys import warnings import zipimport import time import dataclasses -from typing import Dict, List, TypedDict, Optional +from typing import Dict, List, TypedDict from Utils import local_path, user_path local_folder = os.path.dirname(__file__) -user_folder = user_path("worlds") if user_path() != local_path() else None +user_folder = user_path("worlds") if user_path() != local_path() else user_path("custom_worlds") +try: + os.makedirs(user_folder, exist_ok=True) +except OSError: # can't access/write? + user_folder = None __all__ = { "network_data_package", @@ -44,7 +49,7 @@ class WorldSource: path: str # typically relative path from this module is_zip: bool = False relative: bool = True # relative to regular world import folder - time_taken: Optional[float] = None + time_taken: float = -1.0 def __repr__(self) -> str: return f"{self.__class__.__name__}({self.path}, is_zip={self.is_zip}, relative={self.relative})" @@ -88,7 +93,6 @@ class WorldSource: print(f"Could not load world {self}:", file=file_like) traceback.print_exc(file=file_like) file_like.seek(0) - import logging logging.exception(file_like.read()) failed_world_loads.append(os.path.basename(self.path).rsplit(".", 1)[0]) return False @@ -103,7 +107,11 @@ for folder in (folder for folder in (user_folder, local_folder) if folder): if not entry.name.startswith(("_", ".")): file_name = entry.name if relative else os.path.join(folder, entry.name) if entry.is_dir(): - world_sources.append(WorldSource(file_name, relative=relative)) + init_file_path = os.path.join(entry.path, '__init__.py') + if os.path.isfile(init_file_path): + world_sources.append(WorldSource(file_name, relative=relative)) + else: + logging.warning(f"excluding {entry.name} from world sources because it has no __init__.py") elif entry.is_file() and entry.name.endswith(".apworld"): world_sources.append(WorldSource(file_name, is_zip=True, relative=relative)) diff --git a/worlds/shorthike/Items.py b/worlds/shorthike/Items.py index a240dcbc6a..7a5a81db9b 100644 --- a/worlds/shorthike/Items.py +++ b/worlds/shorthike/Items.py @@ -10,15 +10,15 @@ class ItemDict(TypedDict): base_id = 82000 item_table: List[ItemDict] = [ - {"name": "Stick", "id": base_id + 1, "count": 8, "classification": ItemClassification.progression_skip_balancing}, + {"name": "Stick", "id": base_id + 1, "count": 0, "classification": ItemClassification.progression_skip_balancing}, {"name": "Seashell", "id": base_id + 2, "count": 23, "classification": ItemClassification.progression_skip_balancing}, {"name": "Golden Feather", "id": base_id + 3, "count": 0, "classification": ItemClassification.progression}, {"name": "Silver Feather", "id": base_id + 4, "count": 0, "classification": ItemClassification.useful}, {"name": "Bucket", "id": base_id + 5, "count": 0, "classification": ItemClassification.progression}, {"name": "Bait", "id": base_id + 6, "count": 2, "classification": ItemClassification.filler}, - {"name": "Fishing Rod", "id": base_id + 7, "count": 2, "classification": ItemClassification.progression}, + {"name": "Progressive Fishing Rod", "id": base_id + 7, "count": 2, "classification": ItemClassification.progression}, {"name": "Shovel", "id": base_id + 8, "count": 1, "classification": ItemClassification.progression}, - {"name": "Toy Shovel", "id": base_id + 9, "count": 5, "classification": ItemClassification.progression_skip_balancing}, + {"name": "Toy Shovel", "id": base_id + 9, "count": 0, "classification": ItemClassification.progression_skip_balancing}, {"name": "Compass", "id": base_id + 10, "count": 1, "classification": ItemClassification.useful}, {"name": "Medal", "id": base_id + 11, "count": 3, "classification": ItemClassification.filler}, {"name": "Shell Necklace", "id": base_id + 12, "count": 1, "classification": ItemClassification.progression}, @@ -36,7 +36,7 @@ item_table: List[ItemDict] = [ {"name": "Headband", "id": base_id + 24, "count": 1, "classification": ItemClassification.progression}, {"name": "Running Shoes", "id": base_id + 25, "count": 1, "classification": ItemClassification.useful}, {"name": "Camping Permit", "id": base_id + 26, "count": 1, "classification": ItemClassification.progression}, - {"name": "Walkie Talkie", "id": base_id + 27, "count": 1, "classification": ItemClassification.useful}, + {"name": "Walkie Talkie", "id": base_id + 27, "count": 0, "classification": ItemClassification.useful}, # Not in the item pool for now #{"name": "Boating Manual", "id": base_id + ~, "count": 1, "classification": ItemClassification.filler}, @@ -48,9 +48,9 @@ item_table: List[ItemDict] = [ {"name": "21 Coins", "id": base_id + 31, "count": 2, "classification": ItemClassification.filler}, {"name": "25 Coins", "id": base_id + 32, "count": 7, "classification": ItemClassification.filler}, {"name": "27 Coins", "id": base_id + 33, "count": 1, "classification": ItemClassification.filler}, - {"name": "32 Coins", "id": base_id + 34, "count": 1, "classification": ItemClassification.filler}, - {"name": "33 Coins", "id": base_id + 35, "count": 6, "classification": ItemClassification.filler}, - {"name": "50 Coins", "id": base_id + 36, "count": 1, "classification": ItemClassification.filler}, + {"name": "32 Coins", "id": base_id + 34, "count": 1, "classification": ItemClassification.useful}, + {"name": "33 Coins", "id": base_id + 35, "count": 6, "classification": ItemClassification.useful}, + {"name": "50 Coins", "id": base_id + 36, "count": 1, "classification": ItemClassification.useful}, # Filler item determined by settings {"name": "13 Coins", "id": base_id + 37, "count": 0, "classification": ItemClassification.filler}, diff --git a/worlds/shorthike/Locations.py b/worlds/shorthike/Locations.py index c2d316c686..319ad8f20e 100644 --- a/worlds/shorthike/Locations.py +++ b/worlds/shorthike/Locations.py @@ -5,7 +5,7 @@ class LocationInfo(TypedDict): id: int inGameId: str needsShovel: bool - purchase: bool + purchase: int minGoldenFeathers: int minGoldenFeathersEasy: int minGoldenFeathersBucket: int @@ -17,311 +17,311 @@ location_table: List[LocationInfo] = [ {"name": "Start Beach Seashell", "id": base_id + 1, "inGameId": "PickUps.3", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0}, {"name": "Beach Hut Seashell", "id": base_id + 2, "inGameId": "PickUps.2", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0}, {"name": "Beach Umbrella Seashell", "id": base_id + 3, "inGameId": "PickUps.8", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0}, {"name": "Sid Beach Mound Seashell", "id": base_id + 4, "inGameId": "PickUps.12", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0}, {"name": "Sid Beach Seashell", "id": base_id + 5, "inGameId": "PickUps.11", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0}, {"name": "Shirley's Point Beach Seashell", "id": base_id + 6, "inGameId": "PickUps.18", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0}, {"name": "Shirley's Point Rock Seashell", "id": base_id + 7, "inGameId": "PickUps.17", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0}, {"name": "Visitor's Center Beach Seashell", "id": base_id + 8, "inGameId": "PickUps.19", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0}, {"name": "West River Seashell", "id": base_id + 9, "inGameId": "PickUps.10", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 1, "minGoldenFeathersBucket": 0}, {"name": "West Riverbank Seashell", "id": base_id + 10, "inGameId": "PickUps.4", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 1, "minGoldenFeathersBucket": 0}, {"name": "Stone Tower Riverbank Seashell", "id": base_id + 11, "inGameId": "PickUps.23", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 1, "minGoldenFeathersBucket": 0}, {"name": "North Beach Seashell", "id": base_id + 12, "inGameId": "PickUps.6", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0}, {"name": "North Coast Seashell", "id": base_id + 13, "inGameId": "PickUps.7", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0}, {"name": "Boat Cliff Seashell", "id": base_id + 14, "inGameId": "PickUps.14", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0}, {"name": "Boat Isle Mound Seashell", "id": base_id + 15, "inGameId": "PickUps.22", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0}, {"name": "East Coast Seashell", "id": base_id + 16, "inGameId": "PickUps.21", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0}, {"name": "House North Beach Seashell", "id": base_id + 17, "inGameId": "PickUps.16", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0}, {"name": "Airstream Island North Seashell", "id": base_id + 18, "inGameId": "PickUps.13", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0}, {"name": "Airstream Island South Seashell", "id": base_id + 19, "inGameId": "PickUps.15", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0}, {"name": "Secret Island Beach Seashell", "id": base_id + 20, "inGameId": "PickUps.1", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0}, {"name": "Meteor Lake Seashell", "id": base_id + 126, "inGameId": "PickUps.20", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 1, "minGoldenFeathersBucket": 0}, {"name": "Good Creek Path Seashell", "id": base_id + 127, "inGameId": "PickUps.9", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 1, "minGoldenFeathersBucket": 0}, # Visitor's Center Shop {"name": "Visitor's Center Shop Golden Feather 1", "id": base_id + 21, "inGameId": "CampRangerNPC[0]", - "needsShovel": False, "purchase": True, + "needsShovel": False, "purchase": 40, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0}, {"name": "Visitor's Center Shop Golden Feather 2", "id": base_id + 22, "inGameId": "CampRangerNPC[1]", - "needsShovel": False, "purchase": True, + "needsShovel": False, "purchase": 40, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0}, {"name": "Visitor's Center Shop Hat", "id": base_id + 23, "inGameId": "CampRangerNPC[9]", - "needsShovel": False, "purchase": True, + "needsShovel": False, "purchase": 100, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0}, # Tough Bird Salesman {"name": "Tough Bird Salesman Golden Feather 1", "id": base_id + 24, "inGameId": "ToughBirdNPC (1)[0]", - "needsShovel": False, "purchase": True, + "needsShovel": False, "purchase": 100, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 1, "minGoldenFeathersBucket": 0}, {"name": "Tough Bird Salesman Golden Feather 2", "id": base_id + 25, "inGameId": "ToughBirdNPC (1)[1]", - "needsShovel": False, "purchase": True, + "needsShovel": False, "purchase": 100, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 1, "minGoldenFeathersBucket": 0}, {"name": "Tough Bird Salesman Golden Feather 3", "id": base_id + 26, "inGameId": "ToughBirdNPC (1)[2]", - "needsShovel": False, "purchase": True, + "needsShovel": False, "purchase": 100, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 1, "minGoldenFeathersBucket": 0}, {"name": "Tough Bird Salesman Golden Feather 4", "id": base_id + 27, "inGameId": "ToughBirdNPC (1)[3]", - "needsShovel": False, "purchase": True, + "needsShovel": False, "purchase": 100, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 1, "minGoldenFeathersBucket": 0}, {"name": "Tough Bird Salesman (400 Coins)", "id": base_id + 28, "inGameId": "ToughBirdNPC (1)[9]", - "needsShovel": False, "purchase": True, + "needsShovel": False, "purchase": 400, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 1, "minGoldenFeathersBucket": 0}, # Beachstickball {"name": "Beachstickball (10 Hits)", "id": base_id + 29, "inGameId": "VolleyballOpponent[0]", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0}, {"name": "Beachstickball (20 Hits)", "id": base_id + 30, "inGameId": "VolleyballOpponent[1]", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0}, {"name": "Beachstickball (30 Hits)", "id": base_id + 31, "inGameId": "VolleyballOpponent[2]", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0}, # Misc Item Locations {"name": "Shovel Kid Trade", "id": base_id + 32, "inGameId": "Frog_StandingNPC[0]", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0}, {"name": "Compass Guy", "id": base_id + 33, "inGameId": "Fox_WalkingNPC[0]", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0}, {"name": "Hawk Peak Bucket Rock", "id": base_id + 34, "inGameId": "Tools.23", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 1, "minGoldenFeathersBucket": 0}, {"name": "Orange Islands Bucket Rock", "id": base_id + 35, "inGameId": "Tools.42", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0}, {"name": "Bill the Walrus Fisherman", "id": base_id + 36, "inGameId": "SittingNPC (1)[0]", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 1, "minGoldenFeathersBucket": 0}, {"name": "Catch 3 Fish Reward", "id": base_id + 37, "inGameId": "FishBuyer[0]", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0}, {"name": "Catch All Fish Reward", "id": base_id + 38, "inGameId": "FishBuyer[1]", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 7, "minGoldenFeathersEasy": 9, "minGoldenFeathersBucket": 7}, {"name": "Permit Guy Bribe", "id": base_id + 39, "inGameId": "CamperNPC[0]", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 1, "minGoldenFeathersBucket": 0}, {"name": "Catch Fish with Permit", "id": base_id + 129, "inGameId": "Player[0]", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 1, "minGoldenFeathersBucket": 0}, {"name": "Return Camping Permit", "id": base_id + 130, "inGameId": "CamperNPC[1]", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 1, "minGoldenFeathersBucket": 0}, # Original Pickaxe Locations {"name": "Blocked Mine Pickaxe 1", "id": base_id + 40, "inGameId": "Tools.31", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0}, {"name": "Blocked Mine Pickaxe 2", "id": base_id + 41, "inGameId": "Tools.32", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0}, {"name": "Blocked Mine Pickaxe 3", "id": base_id + 42, "inGameId": "Tools.33", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0}, # Original Toy Shovel Locations {"name": "Blackwood Trail Lookout Toy Shovel", "id": base_id + 43, "inGameId": "PickUps.27", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 1, "minGoldenFeathersBucket": 0}, {"name": "Shirley's Point Beach Toy Shovel", "id": base_id + 44, "inGameId": "PickUps.30", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0}, {"name": "Visitor's Center Beach Toy Shovel", "id": base_id + 45, "inGameId": "PickUps.29", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0}, {"name": "Blackwood Trail Rock Toy Shovel", "id": base_id + 46, "inGameId": "PickUps.26", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 1, "minGoldenFeathersBucket": 0}, {"name": "Beach Hut Cliff Toy Shovel", "id": base_id + 128, "inGameId": "PickUps.28", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0}, # Original Stick Locations {"name": "Secret Island Beach Trail Stick", "id": base_id + 47, "inGameId": "PickUps.25", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0}, {"name": "Below Lighthouse Walkway Stick", "id": base_id + 48, "inGameId": "Tools.3", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 1, "minGoldenFeathersBucket": 0}, {"name": "Beach Hut Rocky Pool Sand Stick", "id": base_id + 49, "inGameId": "Tools.0", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0}, {"name": "Cliff Overlooking West River Waterfall Stick", "id": base_id + 50, "inGameId": "Tools.2", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 2, "minGoldenFeathersBucket": 0}, {"name": "Trail to Tough Bird Salesman Stick", "id": base_id + 51, "inGameId": "Tools.8", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 1, "minGoldenFeathersBucket": 0}, {"name": "North Beach Stick", "id": base_id + 52, "inGameId": "Tools.4", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0}, {"name": "Beachstickball Court Stick", "id": base_id + 53, "inGameId": "VolleyballMinigame.4", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0}, {"name": "Stick Under Sid Beach Umbrella", "id": base_id + 54, "inGameId": "Tools.1", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0}, # Boating @@ -333,377 +333,377 @@ location_table: List[LocationInfo] = [ {"name": "Boat Challenge Reward", "id": base_id + 56, "inGameId": "DeerKidBoat[0]", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0}, # Not a location for now, corresponding with the Boating Manual # {"name": "Receive Boating Manual", # "id": base_id + 133, # "inGameId": "DadDeer[1]", - # "needsShovel": False, "purchase": False, + # "needsShovel": False, "purchase": 0, # "minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0}, # Original Map Locations {"name": "Outlook Point Dog Gift", "id": base_id + 57, "inGameId": "Dog_WalkingNPC_BlueEyed[0]", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 1, "minGoldenFeathersBucket": 0}, # Original Clothes Locations {"name": "Collect 15 Seashells", "id": base_id + 58, "inGameId": "LittleKidNPCVariant (1)[0]", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0}, {"name": "Return to Shell Kid", "id": base_id + 132, "inGameId": "LittleKidNPCVariant (1)[1]", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0}, {"name": "Taylor the Turtle Headband Gift", "id": base_id + 59, "inGameId": "Turtle_WalkingNPC[0]", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 1, "minGoldenFeathersBucket": 0}, {"name": "Sue the Rabbit Shoes Reward", "id": base_id + 60, "inGameId": "Bunny_WalkingNPC (1)[0]", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 1, "minGoldenFeathersBucket": 0}, {"name": "Purchase Sunhat", "id": base_id + 61, "inGameId": "SittingNPC[0]", - "needsShovel": False, "purchase": True, + "needsShovel": False, "purchase": 100, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0}, # Original Golden Feather Locations {"name": "Blackwood Forest Golden Feather", "id": base_id + 62, "inGameId": "Feathers.3", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 1, "minGoldenFeathersBucket": 0}, {"name": "Ranger May Shell Necklace Golden Feather", "id": base_id + 63, "inGameId": "AuntMayNPC[0]", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0}, {"name": "Sand Castle Golden Feather", "id": base_id + 64, "inGameId": "SandProvince.3", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0}, {"name": "Artist Golden Feather", "id": base_id + 65, "inGameId": "StandingNPC[0]", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 1, "minGoldenFeathersBucket": 0}, {"name": "Visitor Camp Rock Golden Feather", "id": base_id + 66, "inGameId": "Feathers.8", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 1, "minGoldenFeathersBucket": 0}, {"name": "Outlook Cliff Golden Feather", "id": base_id + 67, "inGameId": "Feathers.2", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 1, "minGoldenFeathersBucket": 0}, {"name": "Meteor Lake Cliff Golden Feather", "id": base_id + 68, "inGameId": "Feathers.7", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 5, "minGoldenFeathersBucket": 0}, # Original Silver Feather Locations {"name": "Secret Island Peak", "id": base_id + 69, "inGameId": "PickUps.24", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 5, "minGoldenFeathersEasy": 7, "minGoldenFeathersBucket": 7}, {"name": "Wristwatch Trade", "id": base_id + 70, "inGameId": "Goat_StandingNPC[0]", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0}, # Golden Chests {"name": "Lighthouse Golden Chest", "id": base_id + 71, "inGameId": "Feathers.0", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 2, "minGoldenFeathersEasy": 3, "minGoldenFeathersBucket": 0}, {"name": "Outlook Golden Chest", "id": base_id + 72, "inGameId": "Feathers.6", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 1, "minGoldenFeathersBucket": 0}, {"name": "Stone Tower Golden Chest", "id": base_id + 73, "inGameId": "Feathers.5", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 1, "minGoldenFeathersBucket": 0}, {"name": "North Cliff Golden Chest", "id": base_id + 74, "inGameId": "Feathers.4", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 3, "minGoldenFeathersEasy": 10, "minGoldenFeathersBucket": 10}, # Chests {"name": "Blackwood Cliff Chest", "id": base_id + 75, "inGameId": "Coins.22", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 1, "minGoldenFeathersBucket": 0}, {"name": "White Coast Trail Chest", "id": base_id + 76, "inGameId": "Coins.6", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0}, {"name": "Sid Beach Chest", "id": base_id + 77, "inGameId": "Coins.7", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0}, {"name": "Sid Beach Buried Treasure Chest", "id": base_id + 78, "inGameId": "Coins.46", - "needsShovel": True, "purchase": False, + "needsShovel": True, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0}, {"name": "Sid Beach Cliff Chest", "id": base_id + 79, "inGameId": "Coins.9", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0}, {"name": "Visitor's Center Buried Chest", "id": base_id + 80, "inGameId": "Coins.94", - "needsShovel": True, "purchase": False, + "needsShovel": True, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0}, {"name": "Visitor's Center Hidden Chest", "id": base_id + 81, "inGameId": "Coins.42", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0}, {"name": "Shirley's Point Chest", "id": base_id + 82, "inGameId": "Coins.10", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 1, "minGoldenFeathersEasy": 2, "minGoldenFeathersBucket": 2}, {"name": "Caravan Cliff Chest", "id": base_id + 83, "inGameId": "Coins.12", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0}, {"name": "Caravan Arch Chest", "id": base_id + 84, "inGameId": "Coins.11", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0}, {"name": "King Buried Treasure Chest", "id": base_id + 85, "inGameId": "Coins.41", - "needsShovel": True, "purchase": False, + "needsShovel": True, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 1, "minGoldenFeathersBucket": 0}, {"name": "Good Creek Path Buried Chest", "id": base_id + 86, "inGameId": "Coins.48", - "needsShovel": True, "purchase": False, + "needsShovel": True, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 1, "minGoldenFeathersBucket": 0}, {"name": "Good Creek Path West Chest", "id": base_id + 87, "inGameId": "Coins.33", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 1, "minGoldenFeathersBucket": 0}, {"name": "Good Creek Path East Chest", "id": base_id + 88, "inGameId": "Coins.62", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 1, "minGoldenFeathersBucket": 0}, {"name": "West Waterfall Chest", "id": base_id + 89, "inGameId": "Coins.20", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0}, {"name": "Stone Tower West Cliff Chest", "id": base_id + 90, "inGameId": "PickUps.0", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 1, "minGoldenFeathersBucket": 0}, {"name": "Bucket Path Chest", "id": base_id + 91, "inGameId": "Coins.50", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 1, "minGoldenFeathersBucket": 0}, {"name": "Bucket Cliff Chest", "id": base_id + 92, "inGameId": "Coins.49", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 3, "minGoldenFeathersEasy": 5, "minGoldenFeathersBucket": 5}, {"name": "In Her Shadow Buried Treasure Chest", "id": base_id + 93, "inGameId": "Feathers.9", - "needsShovel": True, "purchase": False, + "needsShovel": True, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 1, "minGoldenFeathersBucket": 0}, {"name": "Meteor Lake Buried Chest", "id": base_id + 94, "inGameId": "Coins.86", - "needsShovel": True, "purchase": False, + "needsShovel": True, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 1, "minGoldenFeathersBucket": 0}, {"name": "Meteor Lake Chest", "id": base_id + 95, "inGameId": "Coins.64", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 1, "minGoldenFeathersBucket": 0}, {"name": "House North Beach Chest", "id": base_id + 96, "inGameId": "Coins.65", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0}, {"name": "East Coast Chest", "id": base_id + 97, "inGameId": "Coins.98", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0}, {"name": "Fisherman's Boat Chest 1", "id": base_id + 99, "inGameId": "Boat.0", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0}, {"name": "Fisherman's Boat Chest 2", "id": base_id + 100, "inGameId": "Boat.7", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0}, {"name": "Airstream Island Chest", "id": base_id + 101, "inGameId": "Coins.31", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0}, {"name": "West River Waterfall Head Chest", "id": base_id + 102, "inGameId": "Coins.34", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 1, "minGoldenFeathersBucket": 0}, {"name": "Old Building Chest", "id": base_id + 103, "inGameId": "Coins.104", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 1, "minGoldenFeathersBucket": 0}, {"name": "Old Building West Chest", "id": base_id + 104, "inGameId": "Coins.109", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 1, "minGoldenFeathersBucket": 0}, {"name": "Old Building East Chest", "id": base_id + 105, "inGameId": "Coins.8", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 1, "minGoldenFeathersBucket": 0}, {"name": "Hawk Peak West Chest", "id": base_id + 106, "inGameId": "Coins.21", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 3, "minGoldenFeathersEasy": 5, "minGoldenFeathersBucket": 5}, {"name": "Hawk Peak East Buried Chest", "id": base_id + 107, "inGameId": "Coins.76", - "needsShovel": True, "purchase": False, + "needsShovel": True, "purchase": 0, "minGoldenFeathers": 3, "minGoldenFeathersEasy": 5, "minGoldenFeathersBucket": 5}, {"name": "Hawk Peak Northeast Chest", "id": base_id + 108, "inGameId": "Coins.79", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 3, "minGoldenFeathersEasy": 5, "minGoldenFeathersBucket": 5}, {"name": "Northern East Coast Chest", "id": base_id + 109, "inGameId": "Coins.45", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 2, "minGoldenFeathersBucket": 0}, {"name": "North Coast Chest", "id": base_id + 110, "inGameId": "Coins.28", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 1, "minGoldenFeathersBucket": 0}, {"name": "North Coast Buried Chest", "id": base_id + 111, "inGameId": "Coins.47", - "needsShovel": True, "purchase": False, + "needsShovel": True, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0}, {"name": "Small South Island Buried Chest", "id": base_id + 112, "inGameId": "Coins.87", - "needsShovel": True, "purchase": False, + "needsShovel": True, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0}, {"name": "Secret Island Bottom Chest", "id": base_id + 113, "inGameId": "Coins.88", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0}, {"name": "Secret Island Treehouse Chest", "id": base_id + 114, "inGameId": "Coins.89", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 1, "minGoldenFeathersEasy": 1, "minGoldenFeathersBucket": 1}, {"name": "Sunhat Island Buried Chest", "id": base_id + 115, "inGameId": "Coins.112", - "needsShovel": True, "purchase": False, + "needsShovel": True, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0}, {"name": "Orange Islands South Buried Chest", "id": base_id + 116, "inGameId": "Coins.119", - "needsShovel": True, "purchase": False, + "needsShovel": True, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0}, {"name": "Orange Islands West Chest", "id": base_id + 117, "inGameId": "Coins.121", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0}, {"name": "Orange Islands North Buried Chest", "id": base_id + 118, "inGameId": "Coins.117", - "needsShovel": True, "purchase": False, + "needsShovel": True, "purchase": 0, "minGoldenFeathers": 1, "minGoldenFeathersEasy": 1, "minGoldenFeathersBucket": 0}, {"name": "Orange Islands East Chest", "id": base_id + 119, "inGameId": "Coins.120", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0}, {"name": "Orange Islands South Hidden Chest", "id": base_id + 120, "inGameId": "Coins.124", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0}, {"name": "A Stormy View Buried Treasure Chest", "id": base_id + 121, "inGameId": "Coins.113", - "needsShovel": True, "purchase": False, + "needsShovel": True, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0}, {"name": "Orange Islands Ruins Buried Chest", "id": base_id + 122, "inGameId": "Coins.118", - "needsShovel": True, "purchase": False, + "needsShovel": True, "purchase": 0, "minGoldenFeathers": 2, "minGoldenFeathersEasy": 4, "minGoldenFeathersBucket": 0}, # Race Rewards {"name": "Lighthouse Race Reward", "id": base_id + 123, "inGameId": "RaceOpponent[0]", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 2, "minGoldenFeathersEasy": 3, "minGoldenFeathersBucket": 1}, {"name": "Old Building Race Reward", "id": base_id + 124, "inGameId": "RaceOpponent[1]", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 1, "minGoldenFeathersEasy": 5, "minGoldenFeathersBucket": 0}, {"name": "Hawk Peak Race Reward", "id": base_id + 125, "inGameId": "RaceOpponent[2]", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 7, "minGoldenFeathersEasy": 9, "minGoldenFeathersBucket": 7}, {"name": "Lose Race Gift", "id": base_id + 131, "inGameId": "RaceOpponent[9]", - "needsShovel": False, "purchase": False, + "needsShovel": False, "purchase": 0, "minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0}, ] diff --git a/worlds/shorthike/Options.py b/worlds/shorthike/Options.py index 1ac0ff52f9..3d9bf81a3c 100644 --- a/worlds/shorthike/Options.py +++ b/worlds/shorthike/Options.py @@ -1,5 +1,5 @@ from dataclasses import dataclass -from Options import Choice, PerGameCommonOptions, Range, StartInventoryPool, Toggle +from Options import Choice, OptionGroup, PerGameCommonOptions, Range, StartInventoryPool, Toggle, DefaultOnToggle class Goal(Choice): """Choose the end goal. @@ -22,8 +22,10 @@ class CoinsInShops(Toggle): default = False class GoldenFeathers(Range): - """Number of Golden Feathers in the item pool. - (Note that for the Photo and Help Everyone goals, a minimum of 12 Golden Feathers is enforced)""" + """ + Number of Golden Feathers in the item pool. + (Note that for the Photo and Help Everyone goals, a minimum of 12 Golden Feathers is enforced) + """ display_name = "Golden Feathers" range_start = 0 range_end = 20 @@ -43,6 +45,20 @@ class Buckets(Range): range_end = 2 default = 2 +class Sticks(Range): + """Number of Sticks in the item pool.""" + display_name = "Sticks" + range_start = 1 + range_end = 8 + default = 8 + +class ToyShovels(Range): + """Number of Toy Shovels in the item pool.""" + display_name = "Toy Shovels" + range_start = 1 + range_end = 5 + default = 5 + class GoldenFeatherProgression(Choice): """Determines which locations are considered in logic based on the required amount of golden feathers to reach them. Easy: Locations will be considered inaccessible until the player has enough golden feathers to easily reach them. A minimum of 10 golden feathers is recommended for this setting. @@ -76,6 +92,40 @@ class FillerCoinAmount(Choice): option_50_coins = 9 default = 1 +class RandomWalkieTalkie(DefaultOnToggle): + """ + When enabled, the Walkie Talkie item will be placed into the item pool. Otherwise, it will be placed in its vanilla location. + This item usually allows the player to locate Avery around the map or restart a race. + """ + display_name = "Randomize Walkie Talkie" + +class EasierRaces(Toggle): + """When enabled, the Running Shoes will be added as a logical requirement for beating any of the races.""" + display_name = "Easier Races" + +class ShopCheckLogic(Choice): + """Determines which items will be added as logical requirements to making certain purchases in shops.""" + display_name = "Shop Check Logic" + option_nothing = 0 + option_fishing_rod = 1 + option_shovel = 2 + option_fishing_rod_and_shovel = 3 + option_golden_fishing_rod = 4 + option_golden_fishing_rod_and_shovel = 5 + default = 1 + +class MinShopCheckLogic(Choice): + """ + Determines the minimum cost of a shop item that will have the shop check logic applied to it. + If the cost of a shop item is less than this value, no items will be required to access it. + This is based on the vanilla prices of the shop item. The set cost multiplier will not affect this value. + """ + display_name = "Minimum Shop Check Logic Application" + option_40_coins = 0 + option_100_coins = 1 + option_400_coins = 2 + default = 1 + @dataclass class ShortHikeOptions(PerGameCommonOptions): start_inventory_from_pool: StartInventoryPool @@ -84,6 +134,37 @@ class ShortHikeOptions(PerGameCommonOptions): golden_feathers: GoldenFeathers silver_feathers: SilverFeathers buckets: Buckets + sticks: Sticks + toy_shovels: ToyShovels golden_feather_progression: GoldenFeatherProgression cost_multiplier: CostMultiplier filler_coin_amount: FillerCoinAmount + random_walkie_talkie: RandomWalkieTalkie + easier_races: EasierRaces + shop_check_logic: ShopCheckLogic + min_shop_check_logic: MinShopCheckLogic + +shorthike_option_groups = [ + OptionGroup("General Options", [ + Goal, + FillerCoinAmount, + RandomWalkieTalkie + ]), + OptionGroup("Logic Options", [ + GoldenFeatherProgression, + EasierRaces + ]), + OptionGroup("Item Pool Options", [ + GoldenFeathers, + SilverFeathers, + Buckets, + Sticks, + ToyShovels + ]), + OptionGroup("Shop Options", [ + CoinsInShops, + CostMultiplier, + ShopCheckLogic, + MinShopCheckLogic + ]) +] diff --git a/worlds/shorthike/Rules.py b/worlds/shorthike/Rules.py index 73a1643421..4a71ebd3c8 100644 --- a/worlds/shorthike/Rules.py +++ b/worlds/shorthike/Rules.py @@ -1,4 +1,5 @@ from worlds.generic.Rules import forbid_items_for_player, add_rule +from worlds.shorthike.Options import Goal, GoldenFeatherProgression, MinShopCheckLogic, ShopCheckLogic def create_rules(self, location_table): multiworld = self.multiworld @@ -11,11 +12,23 @@ def create_rules(self, location_table): forbid_items_for_player(multiworld.get_location(loc["name"], player), self.item_name_groups['Maps'], player) add_rule(multiworld.get_location(loc["name"], player), lambda state: state.has("Shovel", player)) + + # Shop Rules if loc["purchase"] and not options.coins_in_shops: forbid_items_for_player(multiworld.get_location(loc["name"], player), self.item_name_groups['Coins'], player) + if loc["purchase"] >= get_min_shop_logic_cost(self) and options.shop_check_logic != ShopCheckLogic.option_nothing: + if options.shop_check_logic in {ShopCheckLogic.option_fishing_rod, ShopCheckLogic.option_fishing_rod_and_shovel}: + add_rule(multiworld.get_location(loc["name"], player), + lambda state: state.has("Progressive Fishing Rod", player)) + if options.shop_check_logic in {ShopCheckLogic.option_golden_fishing_rod, ShopCheckLogic.option_golden_fishing_rod_and_shovel}: + add_rule(multiworld.get_location(loc["name"], player), + lambda state: state.has("Progressive Fishing Rod", player, 2)) + if options.shop_check_logic in {ShopCheckLogic.option_shovel, ShopCheckLogic.option_fishing_rod_and_shovel, ShopCheckLogic.option_golden_fishing_rod_and_shovel}: + add_rule(multiworld.get_location(loc["name"], player), + lambda state: state.has("Shovel", player)) # Minimum Feather Rules - if options.golden_feather_progression != 2: + if options.golden_feather_progression != GoldenFeatherProgression.option_hard: min_feathers = get_min_feathers(self, loc["minGoldenFeathers"], loc["minGoldenFeathersEasy"]) if options.buckets > 0 and loc["minGoldenFeathersBucket"] < min_feathers: @@ -32,11 +45,11 @@ def create_rules(self, location_table): # Fishing Rules add_rule(multiworld.get_location("Catch 3 Fish Reward", player), - lambda state: state.has("Fishing Rod", player)) + lambda state: state.has("Progressive Fishing Rod", player)) add_rule(multiworld.get_location("Catch Fish with Permit", player), - lambda state: state.has("Fishing Rod", player)) + lambda state: state.has("Progressive Fishing Rod", player)) add_rule(multiworld.get_location("Catch All Fish Reward", player), - lambda state: state.has("Fishing Rod", player)) + lambda state: state.has("Progressive Fishing Rod", player, 2)) # Misc Rules add_rule(multiworld.get_location("Return Camping Permit", player), @@ -59,15 +72,34 @@ def create_rules(self, location_table): lambda state: state.has("Stick", player)) add_rule(multiworld.get_location("Beachstickball (30 Hits)", player), lambda state: state.has("Stick", player)) + + # Race Rules + if options.easier_races: + add_rule(multiworld.get_location("Lighthouse Race Reward", player), + lambda state: state.has("Running Shoes", player)) + add_rule(multiworld.get_location("Old Building Race Reward", player), + lambda state: state.has("Running Shoes", player)) + add_rule(multiworld.get_location("Hawk Peak Race Reward", player), + lambda state: state.has("Running Shoes", player)) def get_min_feathers(self, min_golden_feathers, min_golden_feathers_easy): options = self.options min_feathers = min_golden_feathers - if options.golden_feather_progression == 0: + if options.golden_feather_progression == GoldenFeatherProgression.option_easy: min_feathers = min_golden_feathers_easy if min_feathers > options.golden_feathers: - if options.goal != 1 and options.goal != 3: + if options.goal not in {Goal.option_help_everyone, Goal.option_photo}: min_feathers = options.golden_feathers return min_feathers + +def get_min_shop_logic_cost(self): + options = self.options + + if options.min_shop_check_logic == MinShopCheckLogic.option_40_coins: + return 40 + elif options.min_shop_check_logic == MinShopCheckLogic.option_100_coins: + return 100 + elif options.min_shop_check_logic == MinShopCheckLogic.option_400_coins: + return 400 diff --git a/worlds/shorthike/__init__.py b/worlds/shorthike/__init__.py index 470b061c4b..299169a40c 100644 --- a/worlds/shorthike/__init__.py +++ b/worlds/shorthike/__init__.py @@ -1,12 +1,11 @@ -from collections import Counter from typing import ClassVar, Dict, Any, Type -from BaseClasses import Region, Location, Item, Tutorial +from BaseClasses import ItemClassification, Region, Location, Item, Tutorial from Options import PerGameCommonOptions from worlds.AutoWorld import World, WebWorld from .Items import item_table, group_table, base_id from .Locations import location_table from .Rules import create_rules, get_min_feathers -from .Options import ShortHikeOptions +from .Options import ShortHikeOptions, shorthike_option_groups class ShortHikeWeb(WebWorld): theme = "ocean" @@ -18,6 +17,7 @@ class ShortHikeWeb(WebWorld): "setup/en", ["Chandler"] )] + option_groups = shorthike_option_groups class ShortHikeWorld(World): """ @@ -47,9 +47,14 @@ class ShortHikeWorld(World): item_id: int = self.item_name_to_id[name] id = item_id - base_id - 1 - return ShortHikeItem(name, item_table[id]["classification"], item_id, player=self.player) + classification = item_table[id]["classification"] + if self.options.easier_races and name == "Running Shoes": + classification = ItemClassification.progression + + return ShortHikeItem(name, classification, item_id, player=self.player) def create_items(self) -> None: + itempool = [] for item in item_table: count = item["count"] @@ -57,18 +62,28 @@ class ShortHikeWorld(World): continue else: for i in range(count): - self.multiworld.itempool.append(self.create_item(item["name"])) + itempool.append(self.create_item(item["name"])) feather_count = self.options.golden_feathers if self.options.goal == 1 or self.options.goal == 3: if feather_count < 12: feather_count = 12 - junk = 45 - self.options.silver_feathers - feather_count - self.options.buckets - self.multiworld.itempool += [self.create_item(self.get_filler_item_name()) for _ in range(junk)] - self.multiworld.itempool += [self.create_item("Golden Feather") for _ in range(feather_count)] - self.multiworld.itempool += [self.create_item("Silver Feather") for _ in range(self.options.silver_feathers)] - self.multiworld.itempool += [self.create_item("Bucket") for _ in range(self.options.buckets)] + itempool += [self.create_item("Golden Feather") for _ in range(feather_count)] + itempool += [self.create_item("Silver Feather") for _ in range(self.options.silver_feathers)] + itempool += [self.create_item("Bucket") for _ in range(self.options.buckets)] + itempool += [self.create_item("Stick") for _ in range(self.options.sticks)] + itempool += [self.create_item("Toy Shovel") for _ in range(self.options.toy_shovels)] + + if self.options.random_walkie_talkie: + itempool.append(self.create_item("Walkie Talkie")) + else: + self.multiworld.get_location("Lose Race Gift", self.player).place_locked_item(self.create_item("Walkie Talkie")) + + junk = len(self.multiworld.get_unfilled_locations(self.player)) - len(itempool) + itempool += [self.create_item(self.get_filler_item_name()) for _ in range(junk)] + + self.multiworld.itempool += itempool def create_regions(self) -> None: menu_region = Region("Menu", self.player, self.multiworld) @@ -92,20 +107,23 @@ class ShortHikeWorld(World): self.multiworld.completion_condition[self.player] = lambda state: state.has("Golden Feather", self.player, 12) elif self.options.goal == "races": # Races - self.multiworld.completion_condition[self.player] = lambda state: (state.has("Golden Feather", self.player, get_min_feathers(self, 7, 9)) - or (state.has("Bucket", self.player) and state.has("Golden Feather", self.player, 7))) + self.multiworld.completion_condition[self.player] = lambda state: state.can_reach_location("Hawk Peak Race Reward", self.player) elif self.options.goal == "help_everyone": # Help Everyone - self.multiworld.completion_condition[self.player] = lambda state: (state.has("Golden Feather", self.player, 12) - and state.has("Toy Shovel", self.player) and state.has("Camping Permit", self.player) - and state.has("Motorboat Key", self.player) and state.has("Headband", self.player) - and state.has("Wristwatch", self.player) and state.has("Seashell", self.player, 15) - and state.has("Shell Necklace", self.player)) + self.multiworld.completion_condition[self.player] = lambda state: (state.can_reach_location("Collect 15 Seashells", self.player) + and state.has("Golden Feather", self.player, 12) + and state.can_reach_location("Tough Bird Salesman (400 Coins)", self.player) + and state.can_reach_location("Ranger May Shell Necklace Golden Feather", self.player) + and state.can_reach_location("Sue the Rabbit Shoes Reward", self.player) + and state.can_reach_location("Wristwatch Trade", self.player) + and state.can_reach_location("Return Camping Permit", self.player) + and state.can_reach_location("Boat Challenge Reward", self.player) + and state.can_reach_location("Shovel Kid Trade", self.player) + and state.can_reach_location("Purchase Sunhat", self.player) + and state.can_reach_location("Artist Golden Feather", self.player)) elif self.options.goal == "fishmonger": # Fishmonger - self.multiworld.completion_condition[self.player] = lambda state: (state.has("Golden Feather", self.player, get_min_feathers(self, 7, 9)) - or (state.has("Bucket", self.player) and state.has("Golden Feather", self.player, 7)) - and state.has("Fishing Rod", self.player)) + self.multiworld.completion_condition[self.player] = lambda state: state.can_reach_location("Catch All Fish Reward", self.player) def set_rules(self): create_rules(self, location_table) @@ -117,6 +135,9 @@ class ShortHikeWorld(World): "goal": int(options.goal), "logicLevel": int(options.golden_feather_progression), "costMultiplier": int(options.cost_multiplier), + "shopCheckLogic": int(options.shop_check_logic), + "minShopCheckLogic": int(options.min_shop_check_logic), + "easierRaces": bool(options.easier_races), } slot_data = { diff --git a/worlds/shorthike/docs/setup_en.md b/worlds/shorthike/docs/setup_en.md index 85d5a8f5eb..96e4d8dbbd 100644 --- a/worlds/shorthike/docs/setup_en.md +++ b/worlds/shorthike/docs/setup_en.md @@ -4,7 +4,6 @@ - A Short Hike: [Steam](https://store.steampowered.com/app/1055540/A_Short_Hike/) - The Epic Games Store or itch.io version of A Short Hike will also work. -- A Short Hike Modding Tools: [GitHub](https://github.com/BrandenEK/AShortHike.ModdingTools) - A Short Hike Randomizer: [GitHub](https://github.com/BrandenEK/AShortHike.Randomizer) ## Optional Software @@ -14,18 +13,13 @@ ## Installation -1. Open the [Modding Tools GitHub page](https://github.com/BrandenEK/AShortHike.ModdingTools/), and follow -the installation instructions. After this step, your `A Short Hike/` folder should have an empty `Modding/` subfolder. - -2. After the Modding Tools have been installed, download the -[Randomizer](https://github.com/BrandenEK/AShortHike.Randomizer/releases) zip, extract it, and move the contents -of the `Randomizer/` folder into your `Modding/` folder. After this step, your `Modding/` folder should have - `data/` and `plugins/` subfolders. +Open the [Randomizer Repository](https://github.com/BrandenEK/AShortHike.Randomizer) and follow +the installation instructions listed there. ## Connecting A Short Hike will prompt you with the server details when a new game is started or a previous one is continued. -Enter in the Server Port, Name, and Password (optional) in the popup menu that appears and hit connect. +Enter in the Server Address and Port, Name, and Password (optional) in the popup menu that appears and hit connect. ## Tracking diff --git a/worlds/tunic/er_rules.py b/worlds/tunic/er_rules.py index 08eb73a3b0..bbee212f5d 100644 --- a/worlds/tunic/er_rules.py +++ b/worlds/tunic/er_rules.py @@ -1462,8 +1462,6 @@ def set_er_location_rules(world: "TunicWorld", ability_unlocks: Dict[str, int]) # Quarry set_rule(multiworld.get_location("Quarry - [Central] Above Ladder Dash Chest", player), lambda state: state.has(laurels, player)) - set_rule(multiworld.get_location("Quarry - [West] Upper Area Bombable Wall", player), - lambda state: has_mask(state, player, options)) # Ziggurat set_rule(multiworld.get_location("Rooted Ziggurat Upper - Near Bridge Switch", player), diff --git a/worlds/tunic/rules.py b/worlds/tunic/rules.py index 0b65c8158e..e0a2c30510 100644 --- a/worlds/tunic/rules.py +++ b/worlds/tunic/rules.py @@ -304,8 +304,6 @@ def set_location_rules(world: "TunicWorld", ability_unlocks: Dict[str, int]) -> # Quarry set_rule(multiworld.get_location("Quarry - [Central] Above Ladder Dash Chest", player), lambda state: state.has(laurels, player)) - set_rule(multiworld.get_location("Quarry - [West] Upper Area Bombable Wall", player), - lambda state: has_mask(state, player, options)) # nmg - kill boss scav with orb + firecracker, or similar set_rule(multiworld.get_location("Rooted Ziggurat Lower - Hexagon Blue", player), lambda state: has_sword(state, player) or (state.has(grapple, player) and options.logic_rules)) diff --git a/worlds/witness/data/static_logic.py b/worlds/witness/data/static_logic.py index bae1921f60..ecd95ea6c0 100644 --- a/worlds/witness/data/static_logic.py +++ b/worlds/witness/data/static_logic.py @@ -1,7 +1,8 @@ from collections import defaultdict -from functools import lru_cache from typing import Dict, List, Set, Tuple +from Utils import cache_argsless + from .item_definition_classes import ( CATEGORY_NAME_MAPPINGS, DoorItemDefinition, @@ -260,17 +261,17 @@ def get_parent_progressive_item(item_name: str) -> str: return _progressive_lookup.get(item_name, item_name) -@lru_cache +@cache_argsless def get_vanilla() -> StaticWitnessLogicObj: return StaticWitnessLogicObj(get_vanilla_logic()) -@lru_cache +@cache_argsless def get_sigma_normal() -> StaticWitnessLogicObj: return StaticWitnessLogicObj(get_sigma_normal_logic()) -@lru_cache +@cache_argsless def get_sigma_expert() -> StaticWitnessLogicObj: return StaticWitnessLogicObj(get_sigma_expert_logic()) diff --git a/worlds/witness/data/utils.py b/worlds/witness/data/utils.py index 5c5568b256..2934308df3 100644 --- a/worlds/witness/data/utils.py +++ b/worlds/witness/data/utils.py @@ -1,4 +1,3 @@ -from functools import lru_cache from math import floor from pkgutil import get_data from random import random @@ -103,10 +102,15 @@ def parse_lambda(lambda_string) -> WitnessRule: return lambda_set -@lru_cache(maxsize=None) +_adjustment_file_cache = dict() + + def get_adjustment_file(adjustment_file: str) -> List[str]: - data = get_data(__name__, adjustment_file).decode("utf-8") - return [line.strip() for line in data.split("\n")] + if adjustment_file not in _adjustment_file_cache: + data = get_data(__name__, adjustment_file).decode("utf-8") + _adjustment_file_cache[adjustment_file] = [line.strip() for line in data.split("\n")] + + return _adjustment_file_cache[adjustment_file] def get_disable_unrandomized_list() -> List[str]: