- {% for key in option.valid_keys|sort %}
+ {% for key in (option.valid_keys if option.valid_keys is ordered else option.valid_keys|sort) %}
@@ -200,13 +203,17 @@
{% endmacro %}
+{% macro RandomRow(option_name, option, extra_column=False) %}
+ {{ RangeRow(option_name, option, "Random", "random") }}
+{% endmacro %}
+
{% macro RandomRows(option_name, option, extra_column=False) %}
{% for key, value in {"Random": "random", "Random (Low)": "random-low", "Random (Middle)": "random-middle", "Random (High)": "random-high"}.items() %}
{{ RangeRow(option_name, option, key, value) }}
{% endfor %}
{% endmacro %}
-{% macro RangeRow(option_name, option, display_value, value, can_delete=False) %}
+{% macro RangeRow(option_name, option, display_value, value, can_delete=False, default_override=None) %}
|
- {% if option.default == value %}
+ {% if option.default == value or default_override == value %}
25
{% else %}
0
diff --git a/docs/CODEOWNERS b/docs/CODEOWNERS
index f54132e24aa0..10b962d49970 100644
--- a/docs/CODEOWNERS
+++ b/docs/CODEOWNERS
@@ -15,15 +15,15 @@
# A Link to the Past
/worlds/alttp/ @Berserker66
+# Sudoku (APSudoku)
+/worlds/apsudoku/ @EmilyV99
+
# Aquaria
/worlds/aquaria/ @tioui
# ArchipIDLE
/worlds/archipidle/ @LegendaryLinux
-# Sudoku (BK Sudoku)
-/worlds/bk_sudoku/ @Jarno458
-
# Blasphemous
/worlds/blasphemous/ @TRPG0
diff --git a/docs/options api.md b/docs/options api.md
index 798e97781a85..cba383232b67 100644
--- a/docs/options api.md
+++ b/docs/options api.md
@@ -86,17 +86,29 @@ class ExampleWorld(World):
```
### Option Groups
-Options may be categorized into groups for display on the WebHost. Option groups are displayed alphabetically on the
-player-options and weighted-options pages. Options without a group name are categorized into a generic "Game Options"
-group.
+Options may be categorized into groups for display on the WebHost. Option groups are displayed in the order specified
+by your world on the player-options and weighted-options pages. In the generated template files, there will be a comment
+with the group name at the beginning of each group of options. The `start_collapsed` Boolean only affects how the groups
+appear on the WebHost, with the grouping being collapsed when this is `True`.
+
+Options without a group name are categorized into a generic "Game Options" group, which is always the first group. If
+every option for your world is in a group, this group will be removed. There is also an "Items & Location Options"
+group, which is automatically created using certain specified `item_and_loc_options`. These specified options cannot be
+removed from this group.
+
+Both the "Game Options" and "Item & Location Options" groups can be overridden by creating your own groups with
+those names, letting you add options to them and change whether they start collapsed. The "Item &
+Location Options" group can also be moved to a different position in the group ordering, but "Game Options" will always
+be first, regardless of where it is in your list.
```python
from worlds.AutoWorld import WebWorld
from Options import OptionGroup
+from . import Options
class MyWorldWeb(WebWorld):
option_groups = [
- OptionGroup('Color Options', [
+ OptionGroup("Color Options", [
Options.ColorblindMode,
Options.FlashReduction,
Options.UIColors,
@@ -120,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):
@@ -132,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
@@ -145,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 b016f224dfcf..a0f4944d989f 100644
--- a/inno_setup.iss
+++ b/inno_setup.iss
@@ -87,7 +87,11 @@ Type: files; Name: "{app}\lib\worlds\_bizhawk.apworld"
Type: files; Name: "{app}\ArchipelagoLttPClient.exe"
Type: files; Name: "{app}\ArchipelagoPokemonClient.exe"
Type: files; Name: "{app}\data\lua\connector_pkmn_rb.lua"
-Type: filesandordirs; Name: "{app}\lib\worlds\rogue-legacy*"
+Type: filesandordirs; Name: "{app}\lib\worlds\rogue-legacy"
+Type: dirifempty; Name: "{app}\lib\worlds\rogue-legacy"
+Type: filesandordirs; Name: "{app}\lib\worlds\bk_sudoku"
+Type: dirifempty; Name: "{app}\lib\worlds\bk_sudoku"
+Type: files; Name: "{app}\ArchipelagoLauncher(DEBUG).exe"
Type: filesandordirs; Name: "{app}\SNI\lua*"
Type: filesandordirs; Name: "{app}\EnemizerCLI*"
#include "installdelete.iss"
@@ -209,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/setup.py b/setup.py
index 54d5118a2c50..85c0f9f7ff13 100644
--- a/setup.py
+++ b/setup.py
@@ -190,7 +190,7 @@ def resolve_icon(icon_name: str):
c = next(component for component in components if component.script_name == "Launcher")
exes.append(cx_Freeze.Executable(
script=f"{c.script_name}.py",
- target_name=f"{c.frozen_name}(DEBUG).exe",
+ target_name=f"{c.frozen_name}Debug.exe",
icon=resolve_icon(c.icon),
))
diff --git a/test/hosting/__init__.py b/test/hosting/__init__.py
new file mode 100644
index 000000000000..e69de29bb2d1
diff --git a/test/hosting/__main__.py b/test/hosting/__main__.py
new file mode 100644
index 000000000000..6640c637b5bd
--- /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 000000000000..b805bb6a2638
--- /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 000000000000..356cbcca25a0
--- /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 000000000000..c3eaac87cc08
--- /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 000000000000..e1e31ae466c4
--- /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 000000000000..e083e027fee1
--- /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 000000000000..e69de29bb2d1
diff --git a/test/options/test_option_classes.py b/test/options/test_option_classes.py
new file mode 100644
index 000000000000..8e2c4702c380
--- /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/test/webhost/test_option_presets.py b/test/webhost/test_option_presets.py
index 0c88b6c2ee6f..b0af8a871183 100644
--- a/test/webhost/test_option_presets.py
+++ b/test/webhost/test_option_presets.py
@@ -1,7 +1,7 @@
import unittest
from worlds import AutoWorldRegister
-from Options import Choice, NamedRange, Toggle, Range
+from Options import ItemDict, NamedRange, NumericOption, OptionList, OptionSet
class TestOptionPresets(unittest.TestCase):
@@ -14,7 +14,7 @@ def test_option_presets_have_valid_options(self):
with self.subTest(game=game_name, preset=preset_name, option=option_name):
try:
option = world_type.options_dataclass.type_hints[option_name].from_any(option_value)
- supported_types = [Choice, Toggle, Range, NamedRange]
+ supported_types = [NumericOption, OptionSet, OptionList, ItemDict]
if not any([issubclass(option.__class__, t) for t in supported_types]):
self.fail(f"'{option_name}' in preset '{preset_name}' for game '{game_name}' "
f"is not a supported type for webhost. "
diff --git a/typings/kivy/core/window.pyi b/typings/kivy/core/window.pyi
new file mode 100644
index 000000000000..c133b986d6c9
--- /dev/null
+++ b/typings/kivy/core/window.pyi
@@ -0,0 +1,15 @@
+from typing import Callable, ClassVar
+
+from kivy.event import EventDispatcher
+
+
+class WindowBase(EventDispatcher):
+ width: ClassVar[int] # readonly AliasProperty
+ height: ClassVar[int] # readonly AliasProperty
+
+ @staticmethod
+ def bind(**kwargs: Callable[..., None]) -> None: ...
+
+
+class Window(WindowBase):
+ ...
diff --git a/typings/kivy/event.pyi b/typings/kivy/event.pyi
new file mode 100644
index 000000000000..2e76adab0baf
--- /dev/null
+++ b/typings/kivy/event.pyi
@@ -0,0 +1,2 @@
+class EventDispatcher:
+ ...
diff --git a/worlds/LauncherComponents.py b/worlds/LauncherComponents.py
index 78ec14b4a4f5..890b41aafa63 100644
--- a/worlds/LauncherComponents.py
+++ b/worlds/LauncherComponents.py
@@ -1,8 +1,10 @@
+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 +51,10 @@ def handles_file(self, path: str):
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 +62,7 @@ def launch_subprocess(func: Callable, name: str = None):
process.start()
processes.add(process)
+
class SuffixIdentifier:
suffixes: Iterable[str]
@@ -77,6 +82,60 @@ 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)
+
+ try:
+ import zipfile
+ zipfile.ZipFile(apworld_path).open(pathlib.Path(apworld_path.name).stem + "/__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):
+ 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)
+
+ 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 +143,7 @@ def launch_textclient():
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 09f72882195e..4da9d8e87c9e 100644
--- a/worlds/__init__.py
+++ b/worlds/__init__.py
@@ -10,7 +10,11 @@
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",
diff --git a/worlds/_bizhawk/context.py b/worlds/_bizhawk/context.py
index 9fe6c9e1ffb1..234faf3b65cf 100644
--- a/worlds/_bizhawk/context.py
+++ b/worlds/_bizhawk/context.py
@@ -168,6 +168,7 @@ async def _game_watcher(ctx: BizHawkClientContext):
ctx.auth = None
ctx.username = None
ctx.client_handler = None
+ ctx.finished_game = False
await ctx.disconnect(False)
ctx.rom_hash = rom_hash
diff --git a/worlds/ahit/DeathWishRules.py b/worlds/ahit/DeathWishRules.py
index 50fafd0a4d08..1432ef5c0d75 100644
--- a/worlds/ahit/DeathWishRules.py
+++ b/worlds/ahit/DeathWishRules.py
@@ -35,7 +35,7 @@
"The Mustache Gauntlet": LocData(hookshot=True, required_hats=[HatType.DWELLER]),
- "Rift Collapse - Deep Sea": LocData(hookshot=True),
+ "Rift Collapse: Deep Sea": LocData(hookshot=True),
}
# Includes main objective requirements
@@ -55,7 +55,7 @@
"The Mustache Gauntlet": LocData(required_hats=[HatType.ICE]),
- "Rift Collapse - Deep Sea": LocData(required_hats=[HatType.DWELLER]),
+ "Rift Collapse: Deep Sea": LocData(required_hats=[HatType.DWELLER]),
}
dw_stamp_costs = {
@@ -178,9 +178,9 @@ def set_dw_rules(world: "HatInTimeWorld"):
def add_dw_rules(world: "HatInTimeWorld", loc: Location):
bonus: bool = "All Clear" in loc.name
if not bonus:
- data = dw_requirements.get(loc.name)
+ data = dw_requirements.get(loc.parent_region.name)
else:
- data = dw_bonus_requirements.get(loc.name)
+ data = dw_bonus_requirements.get(loc.parent_region.name)
if data is None:
return
diff --git a/worlds/bk_sudoku/__init__.py b/worlds/apsudoku/__init__.py
similarity index 50%
rename from worlds/bk_sudoku/__init__.py
rename to worlds/apsudoku/__init__.py
index 2c57bc7301ff..c6bd02bdc262 100644
--- a/worlds/bk_sudoku/__init__.py
+++ b/worlds/apsudoku/__init__.py
@@ -3,41 +3,32 @@
from BaseClasses import Tutorial
from ..AutoWorld import WebWorld, World
-
-class Bk_SudokuWebWorld(WebWorld):
+class AP_SudokuWebWorld(WebWorld):
options_page = "games/Sudoku/info/en"
theme = 'partyTime'
setup_en = Tutorial(
tutorial_name='Setup Guide',
- description='A guide to playing BK Sudoku',
+ description='A guide to playing APSudoku',
language='English',
file_name='setup_en.md',
link='setup/en',
- authors=['Jarno']
- )
- setup_de = Tutorial(
- tutorial_name='Setup Anleitung',
- description='Eine Anleitung um BK-Sudoku zu spielen',
- language='Deutsch',
- file_name='setup_de.md',
- link='setup/de',
- authors=['Held_der_Zeit']
+ authors=['EmilyV']
)
- tutorials = [setup_en, setup_de]
+ tutorials = [setup_en]
-
-class Bk_SudokuWorld(World):
+class AP_SudokuWorld(World):
"""
Play a little Sudoku while you're in BK mode to maybe get some useful hints
"""
game = "Sudoku"
- web = Bk_SudokuWebWorld()
+ web = AP_SudokuWebWorld()
item_name_to_id: Dict[str, int] = {}
location_name_to_id: Dict[str, int] = {}
@classmethod
def stage_assert_generate(cls, multiworld):
- raise Exception("BK Sudoku cannot be used for generating worlds, the client can instead connect to any other world")
+ raise Exception("APSudoku cannot be used for generating worlds, the client can instead connect to any slot from any world")
+
diff --git a/worlds/apsudoku/docs/en_Sudoku.md b/worlds/apsudoku/docs/en_Sudoku.md
new file mode 100644
index 000000000000..e81f773e0291
--- /dev/null
+++ b/worlds/apsudoku/docs/en_Sudoku.md
@@ -0,0 +1,13 @@
+# APSudoku
+
+## Hint Games
+
+HintGames do not need to be added at the start of a seed, and do not create a 'slot'- instead, you connect the HintGame client to a different game's slot. By playing a HintGame, you can earn hints for the connected slot.
+
+## What is this game?
+
+Play Sudoku puzzles of varying difficulties, earning a hint for each puzzle correctly solved. Harder puzzles are more likely to grant a hint towards a Progression item, though otherwise what hint is granted is random.
+
+## Where is the options page?
+
+There is no options page; this game cannot be used in your .yamls. Instead, the client can connect to any slot in a multiworld.
diff --git a/worlds/apsudoku/docs/setup_en.md b/worlds/apsudoku/docs/setup_en.md
new file mode 100644
index 000000000000..cf2c755bd837
--- /dev/null
+++ b/worlds/apsudoku/docs/setup_en.md
@@ -0,0 +1,37 @@
+# APSudoku Setup Guide
+
+## Required Software
+- [APSudoku](https://github.com/EmilyV99/APSudoku)
+- Windows (most tested on Win10)
+- Other platforms might be able to build from source themselves; and may be included in the future.
+
+## General Concept
+
+This is a HintGame client, which can connect to any multiworld slot, allowing you to play Sudoku to unlock random hints for that slot's locations.
+
+Does not need to be added at the start of a seed, as it does not create any slots of its own, nor does it have any YAML files.
+
+## Installation Procedures
+
+Go to the latest release from the [APSudoku Releases page](https://github.com/EmilyV99/APSudoku/releases). Download and extract the `APSudoku.zip` file.
+
+## Joining a MultiWorld Game
+
+1. Run APSudoku.exe
+2. Under the 'Archipelago' tab at the top-right:
+ - Enter the server url & port number
+ - Enter the name of the slot you wish to connect to
+ - Enter the room password (optional)
+ - Select DeathLink related settings (optional)
+ - Press connect
+3. Go back to the 'Sudoku' tab
+ - Click the various '?' buttons for information on how to play / control
+4. Choose puzzle difficulty
+5. Try to solve the Sudoku. Click 'Check' when done.
+
+## DeathLink Support
+
+If 'DeathLink' is enabled when you click 'Connect':
+- Lose a life if you check an incorrect puzzle (not an _incomplete_ puzzle- if any cells are empty, you get off with a warning), or quit a puzzle without solving it (including disconnecting).
+- Life count customizable (default 0). Dying with 0 lives left kills linked players AND resets your puzzle.
+- On receiving a DeathLink from another player, your puzzle resets.
diff --git a/worlds/bk_sudoku/docs/de_Sudoku.md b/worlds/bk_sudoku/docs/de_Sudoku.md
deleted file mode 100644
index abb50c5498d1..000000000000
--- a/worlds/bk_sudoku/docs/de_Sudoku.md
+++ /dev/null
@@ -1,21 +0,0 @@
-# BK-Sudoku
-
-## Was ist das für ein Spiel?
-
-BK-Sudoku ist kein typisches Archipelago-Spiel; stattdessen ist es ein gewöhnlicher Sudoku-Client der sich zu jeder
-beliebigen Multiworld verbinden kann. Einmal verbunden kannst du ein 9x9 Sudoku spielen um einen zufälligen Hinweis
-für dein Spiel zu erhalten. Es ist zwar langsam, aber es gibt dir etwas zu tun, solltest du mal nicht in der Lage sein
-weitere „Checks” zu erreichen.
-(Wer mag kann auch einfach so Sudoku spielen. Man muss nicht mit einer Multiworld verbunden sein, um ein Sudoku zu
-spielen/generieren.)
-
-## Wie werden Hinweise freigeschalten?
-
-Nach dem Lösen eines Sudokus wird für den verbundenen Slot ein zufällig ausgewählter Hinweis freigegeben, für einen
-Gegenstand der noch nicht gefunden wurde.
-
-## Wo ist die Seite für die Einstellungen?
-
-Es gibt keine Seite für die Einstellungen. Dieses Spiel kann nicht in deinen YAML-Dateien benutzt werden. Stattdessen
-kann sich der Client mit einem beliebigen Slot einer Multiworld verbinden. In dem Client selbst kann aber der
-Schwierigkeitsgrad des Sudoku ausgewählt werden.
diff --git a/worlds/bk_sudoku/docs/en_Sudoku.md b/worlds/bk_sudoku/docs/en_Sudoku.md
deleted file mode 100644
index dae5a9e3e513..000000000000
--- a/worlds/bk_sudoku/docs/en_Sudoku.md
+++ /dev/null
@@ -1,13 +0,0 @@
-# Bk Sudoku
-
-## What is this game?
-
-BK Sudoku is not a typical Archipelago game; instead, it is a generic Sudoku client that can connect to any existing multiworld. When connected, you can play Sudoku to unlock random hints for your game. While slow, it will give you something to do when you can't reach the checks in your game.
-
-## What hints are unlocked?
-
-After completing a Sudoku puzzle, the game will unlock 1 random hint for an unchecked location in the slot you are connected to.
-
-## Where is the options page?
-
-There is no options page; this game cannot be used in your .yamls. Instead, the client can connect to any slot in a multiworld.
diff --git a/worlds/bk_sudoku/docs/setup_de.md b/worlds/bk_sudoku/docs/setup_de.md
deleted file mode 100644
index 71a8e5f6245d..000000000000
--- a/worlds/bk_sudoku/docs/setup_de.md
+++ /dev/null
@@ -1,27 +0,0 @@
-# BK-Sudoku Setup Anleitung
-
-## Benötigte Software
-- [Bk-Sudoku](https://github.com/Jarno458/sudoku)
-- Windows 8 oder höher
-
-## Generelles Konzept
-
-Dies ist ein Client, der sich mit jedem beliebigen Slot einer Multiworld verbinden kann. Er lässt dich ein (9x9) Sudoku
-spielen, um zufällige Hinweise für den verbundenen Slot freizuschalten.
-
-Aufgrund des Fakts, dass der Sudoku-Client sich zu jedem beliebigen Slot verbinden kann, ist es daher nicht notwendig
-eine YAML für dieses Spiel zu generieren, da es keinen neuen Slot zur Multiworld-Session hinzufügt.
-
-## Installationsprozess
-
-Gehe zu der aktuellsten (latest) Veröffentlichung der [BK-Sudoku Releases](https://github.com/Jarno458/sudoku/releases).
-Downloade und extrahiere/entpacke die `Bk_Sudoku.zip`-Datei.
-
-## Verbinden mit einer Multiworld
-
-1. Starte `Bk_Sudoku.exe`
-2. Trage den Namen des Slots ein, mit dem du dich verbinden möchtest
-3. Trage die Server-URL und den Port ein
-4. Drücke auf Verbinden (connect)
-5. Wähle deinen Schwierigkeitsgrad
-6. Versuche das Sudoku zu Lösen
diff --git a/worlds/bk_sudoku/docs/setup_en.md b/worlds/bk_sudoku/docs/setup_en.md
deleted file mode 100644
index eda17e701bb8..000000000000
--- a/worlds/bk_sudoku/docs/setup_en.md
+++ /dev/null
@@ -1,24 +0,0 @@
-# BK Sudoku Setup Guide
-
-## Required Software
-- [Bk Sudoku](https://github.com/Jarno458/sudoku)
-- Windows 8 or higher
-
-## General Concept
-
-This is a client that can connect to any multiworld slot, and lets you play Sudoku to unlock random hints for that slot's locations.
-
-Due to the fact that the Sudoku client may connect to any slot, it is not necessary to generate a YAML for this game as it does not generate any new slots in the multiworld session.
-
-## Installation Procedures
-
-Go to the latest release on [BK Sudoku Releases](https://github.com/Jarno458/sudoku/releases). Download and extract the `Bk_Sudoku.zip` file.
-
-## Joining a MultiWorld Game
-
-1. Run Bk_Sudoku.exe
-2. Enter the name of the slot you wish to connect to
-3. Enter the server url & port number
-4. Press connect
-5. Choose difficulty
-6. Try to solve the Sudoku
diff --git a/worlds/hk/Options.py b/worlds/hk/Options.py
index f408528821cc..0ad1acff5df3 100644
--- a/worlds/hk/Options.py
+++ b/worlds/hk/Options.py
@@ -212,7 +212,7 @@ class MinimumEggPrice(Range):
Only takes effect if the EggSlotShops option is greater than 0."""
display_name = "Minimum Egg Price"
range_start = 1
- range_end = 21
+ range_end = 20
default = 1
diff --git a/worlds/hk/__init__.py b/worlds/hk/__init__.py
index fdaece8d34cd..78287305df5f 100644
--- a/worlds/hk/__init__.py
+++ b/worlds/hk/__init__.py
@@ -405,7 +405,7 @@ def _compute_weights(weights: dict, desc: str) -> typing.Dict[str, int]:
continue
if setting == CostSanity.option_shopsonly and location.basename not in multi_locations:
continue
- if location.basename in {'Grubfather', 'Seer', 'Eggshop'}:
+ if location.basename in {'Grubfather', 'Seer', 'Egg_Shop'}:
our_weights = dict(weights_geoless)
else:
our_weights = dict(weights)
diff --git a/worlds/kh2/Options.py b/worlds/kh2/Options.py
index ffe95d1d5f25..ddaf36ebcbf9 100644
--- a/worlds/kh2/Options.py
+++ b/worlds/kh2/Options.py
@@ -308,14 +308,14 @@ class CorSkipToggle(Toggle):
Full Cor Skip is also affected by this Toggle.
"""
- display_name = "CoR Skip Toggle."
+ display_name = "CoR Skip Toggle"
default = False
class CustomItemPoolQuantity(ItemDict):
"""Add more of an item into the itempool. Note: You cannot take out items from the pool."""
display_name = "Custom Item Pool"
- verify_item_name = True
+ valid_keys = default_itempool_option.keys()
default = default_itempool_option
diff --git a/worlds/kh2/__init__.py b/worlds/kh2/__init__.py
index 15cfa11c93cf..faf0bed88567 100644
--- a/worlds/kh2/__init__.py
+++ b/worlds/kh2/__init__.py
@@ -430,13 +430,13 @@ def starting_invo_verify(self):
"""
for item, value in self.options.start_inventory.value.items():
if item in ActionAbility_Table \
- or item in SupportAbility_Table or exclusion_item_table["StatUps"] \
+ or item in SupportAbility_Table or item in exclusion_item_table["StatUps"] \
or item in DonaldAbility_Table or item in GoofyAbility_Table:
# cannot have more than the quantity for abilties
if value > item_dictionary_table[item].quantity:
logging.info(
- f"{self.multiworld.get_file_safe_player_name(self.player)} cannot have more than {item_dictionary_table[item].quantity} of {item}"
- f"Changing the amount to the max amount")
+ f"{self.multiworld.get_file_safe_player_name(self.player)} cannot have more than {item_dictionary_table[item].quantity} of {item}."
+ f" Changing the amount to the max amount")
value = item_dictionary_table[item].quantity
self.item_quantity_dict[item] -= value
diff --git a/worlds/lingo/player_logic.py b/worlds/lingo/player_logic.py
index b6941f37eed1..1621620e1e14 100644
--- a/worlds/lingo/player_logic.py
+++ b/worlds/lingo/player_logic.py
@@ -18,19 +18,23 @@ class AccessRequirements:
rooms: Set[str]
doors: Set[RoomAndDoor]
colors: Set[str]
+ the_master: bool
def __init__(self):
self.rooms = set()
self.doors = set()
self.colors = set()
+ self.the_master = False
def merge(self, other: "AccessRequirements"):
self.rooms |= other.rooms
self.doors |= other.doors
self.colors |= other.colors
+ self.the_master |= other.the_master
def __str__(self):
- return f"AccessRequirements(rooms={self.rooms}, doors={self.doors}, colors={self.colors})"
+ return f"AccessRequirements(rooms={self.rooms}, doors={self.doors}, colors={self.colors})," \
+ f" the_master={self.the_master}"
class PlayerLocation(NamedTuple):
@@ -463,6 +467,9 @@ def calculate_panel_requirements(self, room: str, panel: str, world: "LingoWorld
req_panel.panel, world)
access_reqs.merge(sub_access_reqs)
+ if panel == "THE MASTER":
+ access_reqs.the_master = True
+
self.panel_reqs[room][panel] = access_reqs
return self.panel_reqs[room][panel]
@@ -502,15 +509,17 @@ def create_panel_hunt_events(self, world: "LingoWorld"):
unhindered_panels_by_color: dict[Optional[str], int] = {}
for panel_name, panel_data in room_data.items():
- # We won't count non-counting panels. THE MASTER has special access rules and is handled separately.
- if panel_data.non_counting or panel_name == "THE MASTER":
+ # We won't count non-counting panels.
+ if panel_data.non_counting:
continue
# We won't coalesce any panels that have requirements beyond colors. To simplify things for now, we will
- # only coalesce single-color panels. Chains/stacks/combo puzzles will be separate.
+ # only coalesce single-color panels. Chains/stacks/combo puzzles will be separate. THE MASTER has
+ # special access rules and is handled separately.
if len(panel_data.required_panels) > 0 or len(panel_data.required_doors) > 0\
or len(panel_data.required_rooms) > 0\
- or (world.options.shuffle_colors and len(panel_data.colors) > 1):
+ or (world.options.shuffle_colors and len(panel_data.colors) > 1)\
+ or panel_name == "THE MASTER":
self.counting_panel_reqs.setdefault(room_name, []).append(
(self.calculate_panel_requirements(room_name, panel_name, world), 1))
else:
diff --git a/worlds/lingo/regions.py b/worlds/lingo/regions.py
index 4b357db261b4..9834f04f9de7 100644
--- a/worlds/lingo/regions.py
+++ b/worlds/lingo/regions.py
@@ -49,8 +49,15 @@ def connect_entrance(regions: Dict[str, Region], source_region: Region, target_r
if door is not None:
effective_room = target_region.name if door.room is None else door.room
if door.door not in world.player_logic.item_by_door.get(effective_room, {}):
- for region in world.player_logic.calculate_door_requirements(effective_room, door.door, world).rooms:
+ access_reqs = world.player_logic.calculate_door_requirements(effective_room, door.door, world)
+ for region in access_reqs.rooms:
world.multiworld.register_indirect_condition(regions[region], connection)
+
+ # This pretty much only applies to Orange Tower Sixth Floor -> Orange Tower Basement.
+ if access_reqs.the_master:
+ for mastery_req in world.player_logic.mastery_reqs:
+ for region in mastery_req.rooms:
+ world.multiworld.register_indirect_condition(regions[region], connection)
if not pilgrimage and world.options.enable_pilgrimage and is_acceptable_pilgrimage_entrance(entrance_type, world)\
and source_region.name != "Menu":
diff --git a/worlds/lingo/rules.py b/worlds/lingo/rules.py
index 9cc11fdaea31..d91c53f05b47 100644
--- a/worlds/lingo/rules.py
+++ b/worlds/lingo/rules.py
@@ -42,12 +42,6 @@ def lingo_can_use_level_2_location(state: CollectionState, world: "LingoWorld"):
counted_panels += panel_count
if counted_panels >= world.options.level_2_requirement.value - 1:
return True
- # THE MASTER has to be handled separately, because it has special access rules.
- if state.can_reach("Orange Tower Seventh Floor", "Region", world.player)\
- and lingo_can_use_mastery_location(state, world):
- counted_panels += 1
- if counted_panels >= world.options.level_2_requirement.value - 1:
- return True
return False
@@ -65,6 +59,9 @@ def _lingo_can_satisfy_requirements(state: CollectionState, access: AccessRequir
if not state.has(color.capitalize(), world.player):
return False
+ if access.the_master and not lingo_can_use_mastery_location(state, world):
+ return False
+
return True
diff --git a/worlds/lingo/test/TestMastery.py b/worlds/lingo/test/TestMastery.py
index 3fb3c95a0208..3ebe40aa22d7 100644
--- a/worlds/lingo/test/TestMastery.py
+++ b/worlds/lingo/test/TestMastery.py
@@ -36,4 +36,21 @@ def test_requirement(self):
self.assertFalse(self.can_reach_location("Orange Tower Seventh Floor - Mastery Achievements"))
self.collect_by_name(["Green", "Gray", "Brown", "Yellow"])
- self.assertTrue(self.can_reach_location("Orange Tower Seventh Floor - Mastery Achievements"))
\ No newline at end of file
+ self.assertTrue(self.can_reach_location("Orange Tower Seventh Floor - Mastery Achievements"))
+
+
+class TestMasteryBlocksDependents(LingoTestBase):
+ options = {
+ "mastery_achievements": "24",
+ "shuffle_colors": "true",
+ "location_checks": "insanity"
+ }
+
+ def test_requirement(self):
+ self.collect_all_but("Gray")
+ self.assertFalse(self.can_reach_location("Orange Tower Basement - THE LIBRARY"))
+ self.assertFalse(self.can_reach_location("The Fearless - MASTERY"))
+
+ self.collect_by_name("Gray")
+ self.assertTrue(self.can_reach_location("Orange Tower Basement - THE LIBRARY"))
+ self.assertTrue(self.can_reach_location("The Fearless - MASTERY"))
diff --git a/worlds/musedash/MuseDashCollection.py b/worlds/musedash/MuseDashCollection.py
index 68e4ad5912bc..576a106df7cc 100644
--- a/worlds/musedash/MuseDashCollection.py
+++ b/worlds/musedash/MuseDashCollection.py
@@ -22,12 +22,15 @@ class MuseDashCollections:
]
MUSE_PLUS_DLC: str = "Muse Plus"
+
+ # Ordering matters for webhost. Order goes: Muse Plus, Time Limited Muse Plus Dlcs, Paid Dlcs
DLC: List[str] = [
- # MUSE_PLUS_DLC, # To be included when OptionSets are rendered as part of basic settings.
- # "maimai DX Limited-time Suite", # Part of Muse Plus. Goes away 31st Jan 2026.
- "Miku in Museland", # Paid DLC not included in Muse Plus
- "Rin Len's Mirrorland", # Paid DLC not included in Muse Plus
- "MSR Anthology", # Now no longer available.
+ MUSE_PLUS_DLC,
+ "CHUNITHM COURSE MUSE", # Part of Muse Plus. Goes away 22nd May 2027.
+ "maimai DX Limited-time Suite", # Part of Muse Plus. Goes away 31st Jan 2026.
+ "MSR Anthology", # Now no longer available.
+ "Miku in Museland", # Paid DLC not included in Muse Plus
+ "Rin Len's Mirrorland", # Paid DLC not included in Muse Plus
]
DIFF_OVERRIDES: List[str] = [
@@ -50,7 +53,7 @@ class MuseDashCollections:
song_items: Dict[str, SongData] = {}
song_locations: Dict[str, int] = {}
- vfx_trap_items: Dict[str, int] = {
+ trap_items: Dict[str, int] = {
"Bad Apple Trap": STARTING_CODE + 1,
"Pixelate Trap": STARTING_CODE + 2,
"Ripple Trap": STARTING_CODE + 3,
@@ -58,14 +61,16 @@ class MuseDashCollections:
"Chromatic Aberration Trap": STARTING_CODE + 5,
"Background Freeze Trap": STARTING_CODE + 6,
"Gray Scale Trap": STARTING_CODE + 7,
- "Focus Line Trap": STARTING_CODE + 10,
- }
-
- sfx_trap_items: Dict[str, int] = {
"Nyaa SFX Trap": STARTING_CODE + 8,
"Error SFX Trap": STARTING_CODE + 9,
+ "Focus Line Trap": STARTING_CODE + 10,
}
+ sfx_trap_items: List[str] = [
+ "Nyaa SFX Trap",
+ "Error SFX Trap",
+ ]
+
filler_items: Dict[str, int] = {
"Great To Perfect (10 Pack)": STARTING_CODE + 30,
"Miss To Great (5 Pack)": STARTING_CODE + 31,
@@ -78,7 +83,7 @@ class MuseDashCollections:
"Extra Life": 1,
}
- item_names_to_id: ChainMap = ChainMap({}, filler_items, sfx_trap_items, vfx_trap_items)
+ item_names_to_id: ChainMap = ChainMap({}, filler_items, trap_items)
location_names_to_id: ChainMap = ChainMap(song_locations, album_locations)
def __init__(self) -> None:
@@ -171,6 +176,9 @@ def get_songs_with_settings(self, dlc_songs: Set[str], streamer_mode_active: boo
return filtered_list
+ def filter_songs_to_dlc(self, song_list: List[str], dlc_songs: Set[str]) -> List[str]:
+ return [song for song in song_list if self.song_matches_dlc_filter(self.song_items[song], dlc_songs)]
+
def song_matches_dlc_filter(self, song: SongData, dlc_songs: Set[str]) -> bool:
if song.album in self.FREE_ALBUMS:
return True
diff --git a/worlds/musedash/Options.py b/worlds/musedash/Options.py
index 4f4f52ad2d2d..7164aa3e1362 100644
--- a/worlds/musedash/Options.py
+++ b/worlds/musedash/Options.py
@@ -1,26 +1,26 @@
-from typing import Dict
-from Options import Toggle, Option, Range, Choice, DeathLink, ItemSet, OptionSet, PerGameCommonOptions
+from Options import Toggle, Range, Choice, DeathLink, ItemSet, OptionSet, PerGameCommonOptions, OptionGroup, Removed
from dataclasses import dataclass
from .MuseDashCollection import MuseDashCollections
-class AllowJustAsPlannedDLCSongs(Toggle):
- """Whether [Muse Plus] DLC Songs, and all the albums included in it, can be chosen as randomised songs.
- Note: The [Just As Planned] DLC contains all [Muse Plus] songs."""
- display_name = "Allow [Muse Plus] DLC Songs"
-
-
class DLCMusicPacks(OptionSet):
- """Which non-[Muse Plus] DLC packs can be chosen as randomised songs."""
+ """
+ Choose which DLC Packs will be included in the pool of chooseable songs.
+
+ Note: The [Just As Planned] DLC contains all [Muse Plus] songs.
+ """
display_name = "DLC Packs"
default = {}
valid_keys = [dlc for dlc in MuseDashCollections.DLC]
class StreamerModeEnabled(Toggle):
- """In Muse Dash, an option named 'Streamer Mode' removes songs which may trigger copyright issues when streaming.
- If this is enabled, only songs available under Streamer Mode will be available for randomization."""
+ """
+ In Muse Dash, an option named 'Streamer Mode' removes songs which may trigger copyright issues when streaming.
+
+ If this is enabled, only songs available under Streamer Mode will be available for randomization.
+ """
display_name = "Streamer Mode Only Songs"
@@ -33,7 +33,8 @@ class StartingSongs(Range):
class AdditionalSongs(Range):
- """The total number of songs that will be placed in the randomization pool.
+ """
+ The total number of songs that will be placed in the randomization pool.
- This does not count any starting songs or the goal song.
- The final song count may be lower due to other settings.
"""
@@ -44,7 +45,8 @@ class AdditionalSongs(Range):
class DifficultyMode(Choice):
- """Ensures that at any chosen song has at least 1 value falling within these values.
+ """
+ Ensures that at any chosen song has at least 1 value falling within these values.
- Any: All songs are available
- Easy: 1, 2 or 3
- Medium: 4, 5
@@ -66,8 +68,11 @@ class DifficultyMode(Choice):
# Todo: Investigate options to make this non randomizable
class DifficultyModeOverrideMin(Range):
- """Ensures that 1 difficulty has at least 1 this value or higher per song.
- - Difficulty Mode must be set to Manual."""
+ """
+ Ensures that 1 difficulty has at least 1 this value or higher per song.
+
+ Note: Difficulty Mode must be set to Manual.
+ """
display_name = "Manual Difficulty Min"
range_start = 1
range_end = 11
@@ -76,8 +81,11 @@ class DifficultyModeOverrideMin(Range):
# Todo: Investigate options to make this non randomizable
class DifficultyModeOverrideMax(Range):
- """Ensures that 1 difficulty has at least 1 this value or lower per song.
- - Difficulty Mode must be set to Manual."""
+ """
+ Ensures that 1 difficulty has at least 1 this value or lower per song.
+
+ Note: Difficulty Mode must be set to Manual.
+ """
display_name = "Manual Difficulty Max"
range_start = 1
range_end = 11
@@ -85,7 +93,8 @@ class DifficultyModeOverrideMax(Range):
class GradeNeeded(Choice):
- """Completing a song will require a grade of this value or higher in order to unlock items.
+ """
+ Completing a song will require a grade of this value or higher in order to unlock items.
The grades are as follows:
- Silver S (SS): >= 95% accuracy
- Pink S (S): >= 90% accuracy
@@ -104,7 +113,9 @@ class GradeNeeded(Choice):
class MusicSheetCountPercentage(Range):
- """Controls how many music sheets are added to the pool based on the number of songs, including starting songs.
+ """
+ Controls how many music sheets are added to the pool based on the number of songs, including starting songs.
+
Higher numbers leads to more consistent game lengths, but will cause individual music sheets to be less important.
"""
range_start = 10
@@ -121,19 +132,18 @@ class MusicSheetWinCountPercentage(Range):
display_name = "Music Sheets Needed to Win"
-class TrapTypes(Choice):
- """This controls the types of traps that can be added to the pool.
+class ChosenTraps(OptionSet):
+ """
+ This controls the types of traps that can be added to the pool.
+ - Traps last the length of a song, or until you die.
- VFX Traps consist of visual effects that play over the song. (i.e. Grayscale.)
- SFX Traps consist of changing your sfx setting to one possibly more annoying sfx.
- Traps last the length of a song, or until you die.
+
Note: SFX traps are only available if [Just as Planned] DLC songs are enabled.
"""
- display_name = "Available Trap Types"
- option_None = 0
- option_VFX = 1
- option_SFX = 2
- option_All = 3
- default = 3
+ display_name = "Chosen Traps"
+ default = {}
+ valid_keys = {trap for trap in MuseDashCollections.trap_items.keys()}
class TrapCountPercentage(Range):
@@ -145,24 +155,49 @@ class TrapCountPercentage(Range):
class IncludeSongs(ItemSet):
- """Any song listed here will be guaranteed to be included as part of the seed.
- - Difficulty options will be skipped for these songs.
- - If there being too many included songs, songs will be randomly chosen without regard for difficulty.
- - If you want these songs immediately, use start_inventory instead.
+ """
+ These songs will be guaranteed to show up within the seed.
+ - You must have the DLC enabled to play these songs.
+ - Difficulty options will not affect these songs.
+ - If there are too many included songs, this will act as a whitelist ignoring song difficulty.
"""
verify_item_name = True
display_name = "Include Songs"
class ExcludeSongs(ItemSet):
- """Any song listed here will be excluded from being a part of the seed."""
+ """
+ These songs will be guaranteed to not show up within the seed.
+
+ Note: Does not affect songs within the "Include Songs" list.
+ """
verify_item_name = True
display_name = "Exclude Songs"
+md_option_groups = [
+ OptionGroup("Song Choice", [
+ DLCMusicPacks,
+ StreamerModeEnabled,
+ IncludeSongs,
+ ExcludeSongs,
+ ]),
+ OptionGroup("Difficulty", [
+ GradeNeeded,
+ DifficultyMode,
+ DifficultyModeOverrideMin,
+ DifficultyModeOverrideMax,
+ DeathLink,
+ ]),
+ OptionGroup("Traps", [
+ ChosenTraps,
+ TrapCountPercentage,
+ ]),
+]
+
+
@dataclass
class MuseDashOptions(PerGameCommonOptions):
- allow_just_as_planned_dlc_songs: AllowJustAsPlannedDLCSongs
dlc_packs: DLCMusicPacks
streamer_mode_enabled: StreamerModeEnabled
starting_song_count: StartingSongs
@@ -173,8 +208,12 @@ class MuseDashOptions(PerGameCommonOptions):
grade_needed: GradeNeeded
music_sheet_count_percentage: MusicSheetCountPercentage
music_sheet_win_count_percentage: MusicSheetWinCountPercentage
- available_trap_types: TrapTypes
+ chosen_traps: ChosenTraps
trap_count_percentage: TrapCountPercentage
death_link: DeathLink
include_songs: IncludeSongs
exclude_songs: ExcludeSongs
+
+ # Removed
+ allow_just_as_planned_dlc_songs: Removed
+ available_trap_types: Removed
diff --git a/worlds/musedash/Presets.py b/worlds/musedash/Presets.py
index 8dd8507d9b7f..fe314edbc9b5 100644
--- a/worlds/musedash/Presets.py
+++ b/worlds/musedash/Presets.py
@@ -3,7 +3,7 @@
MuseDashPresets: Dict[str, Dict[str, Any]] = {
# An option to support Short Sync games. 40 songs.
"No DLC - Short": {
- "allow_just_as_planned_dlc_songs": False,
+ "dlc_packs": [],
"starting_song_count": 5,
"additional_song_count": 34,
"music_sheet_count_percentage": 20,
@@ -11,7 +11,7 @@
},
# An option to support Short Sync games but adds variety. 40 songs.
"DLC - Short": {
- "allow_just_as_planned_dlc_songs": True,
+ "dlc_packs": ["Muse Plus"],
"starting_song_count": 5,
"additional_song_count": 34,
"music_sheet_count_percentage": 20,
@@ -19,7 +19,7 @@
},
# An option to support Longer Sync/Async games. 100 songs.
"DLC - Long": {
- "allow_just_as_planned_dlc_songs": True,
+ "dlc_packs": ["Muse Plus"],
"starting_song_count": 8,
"additional_song_count": 91,
"music_sheet_count_percentage": 20,
diff --git a/worlds/musedash/__init__.py b/worlds/musedash/__init__.py
index 1c009bfaee45..a9eacbbcf82c 100644
--- a/worlds/musedash/__init__.py
+++ b/worlds/musedash/__init__.py
@@ -1,10 +1,10 @@
from worlds.AutoWorld import World, WebWorld
-from BaseClasses import Region, Item, ItemClassification, Entrance, Tutorial
-from typing import List, ClassVar, Type
+from BaseClasses import Region, Item, ItemClassification, Tutorial
+from typing import List, ClassVar, Type, Set
from math import floor
from Options import PerGameCommonOptions
-from .Options import MuseDashOptions
+from .Options import MuseDashOptions, md_option_groups
from .Items import MuseDashSongItem, MuseDashFixedItem
from .Locations import MuseDashLocation
from .MuseDashCollection import MuseDashCollections
@@ -35,6 +35,7 @@ class MuseDashWebWorld(WebWorld):
tutorials = [setup_en, setup_es]
options_presets = MuseDashPresets
+ option_groups = md_option_groups
class MuseDashWorld(World):
@@ -72,8 +73,6 @@ class MuseDashWorld(World):
def generate_early(self):
dlc_songs = {key for key in self.options.dlc_packs.value}
- if self.options.allow_just_as_planned_dlc_songs.value:
- dlc_songs.add(self.md_collection.MUSE_PLUS_DLC)
streamer_mode = self.options.streamer_mode_enabled
(lower_diff_threshold, higher_diff_threshold) = self.get_difficulty_range()
@@ -88,7 +87,7 @@ def generate_early(self):
available_song_keys = self.md_collection.get_songs_with_settings(
dlc_songs, bool(streamer_mode.value), lower_diff_threshold, higher_diff_threshold)
- available_song_keys = self.handle_plando(available_song_keys)
+ available_song_keys = self.handle_plando(available_song_keys, dlc_songs)
count_needed_for_start = max(0, starter_song_count - len(self.starting_songs))
if len(available_song_keys) + len(self.included_songs) >= count_needed_for_start + 11:
@@ -109,7 +108,7 @@ def generate_early(self):
for song in self.starting_songs:
self.multiworld.push_precollected(self.create_item(song))
- def handle_plando(self, available_song_keys: List[str]) -> List[str]:
+ def handle_plando(self, available_song_keys: List[str], dlc_songs: Set[str]) -> List[str]:
song_items = self.md_collection.song_items
start_items = self.options.start_inventory.value.keys()
@@ -117,7 +116,9 @@ def handle_plando(self, available_song_keys: List[str]) -> List[str]:
exclude_songs = self.options.exclude_songs.value
self.starting_songs = [s for s in start_items if s in song_items]
+ self.starting_songs = self.md_collection.filter_songs_to_dlc(self.starting_songs, dlc_songs)
self.included_songs = [s for s in include_songs if s in song_items and s not in self.starting_songs]
+ self.included_songs = self.md_collection.filter_songs_to_dlc(self.included_songs, dlc_songs)
return [s for s in available_song_keys if s not in start_items
and s not in include_songs and s not in exclude_songs]
@@ -148,7 +149,7 @@ def create_song_pool(self, available_song_keys: List[str]):
self.victory_song_name = available_song_keys[chosen_song - included_song_count]
del available_song_keys[chosen_song - included_song_count]
- # Next, make sure the starting songs are fufilled
+ # Next, make sure the starting songs are fulfilled
if len(self.starting_songs) < starting_song_count:
for _ in range(len(self.starting_songs), starting_song_count):
if len(available_song_keys) > 0:
@@ -156,7 +157,7 @@ def create_song_pool(self, available_song_keys: List[str]):
else:
self.starting_songs.append(self.included_songs.pop())
- # Then attempt to fufill any remaining songs for interim songs
+ # Then attempt to fulfill any remaining songs for interim songs
if len(self.included_songs) < additional_song_count:
for _ in range(len(self.included_songs), self.options.additional_song_count):
if len(available_song_keys) <= 0:
@@ -174,11 +175,7 @@ def create_item(self, name: str) -> Item:
if filler:
return MuseDashFixedItem(name, ItemClassification.filler, filler, self.player)
- trap = self.md_collection.vfx_trap_items.get(name)
- if trap:
- return MuseDashFixedItem(name, ItemClassification.trap, trap, self.player)
-
- trap = self.md_collection.sfx_trap_items.get(name)
+ trap = self.md_collection.trap_items.get(name)
if trap:
return MuseDashFixedItem(name, ItemClassification.trap, trap, self.player)
@@ -286,17 +283,11 @@ def set_rules(self) -> None:
state.has(self.md_collection.MUSIC_SHEET_NAME, self.player, self.get_music_sheet_win_count())
def get_available_traps(self) -> List[str]:
- sfx_traps_available = self.options.allow_just_as_planned_dlc_songs.value
-
- trap_list = []
- if self.options.available_trap_types.value & 1 != 0:
- trap_list += self.md_collection.vfx_trap_items.keys()
-
- # SFX options are only available under Just as Planned DLC.
- if sfx_traps_available and self.options.available_trap_types.value & 2 != 0:
- trap_list += self.md_collection.sfx_trap_items.keys()
+ full_trap_list = self.md_collection.trap_items.keys()
+ if self.md_collection.MUSE_PLUS_DLC not in self.options.dlc_packs.value:
+ full_trap_list = [trap for trap in full_trap_list if trap not in self.md_collection.sfx_trap_items]
- return trap_list
+ return [trap for trap in full_trap_list if trap in self.options.chosen_traps.value]
def get_trap_count(self) -> int:
multiplier = self.options.trap_count_percentage.value / 100.0
diff --git a/worlds/musedash/test/TestCollection.py b/worlds/musedash/test/TestCollection.py
index 48cb69e403ad..c8c2b39acb4d 100644
--- a/worlds/musedash/test/TestCollection.py
+++ b/worlds/musedash/test/TestCollection.py
@@ -9,25 +9,26 @@ def test_all_names_are_ascii(self) -> None:
for name in collection.song_items.keys():
for c in name:
# This is taken directly from OoT. Represents the generally excepted characters.
- if (0x20 <= ord(c) < 0x7e):
+ if 0x20 <= ord(c) < 0x7e:
continue
bad_names.append(name)
break
- self.assertEqual(len(bad_names), 0, f"Muse Dash has {len(bad_names)} songs with non-ASCII characters.\n{bad_names}")
+ self.assertEqual(len(bad_names), 0,
+ f"Muse Dash has {len(bad_names)} songs with non-ASCII characters.\n{bad_names}")
def test_ids_dont_change(self) -> None:
collection = MuseDashCollections()
- itemsBefore = {name: code for name, code in collection.item_names_to_id.items()}
- locationsBefore = {name: code for name, code in collection.location_names_to_id.items()}
+ items_before = {name: code for name, code in collection.item_names_to_id.items()}
+ locations_before = {name: code for name, code in collection.location_names_to_id.items()}
collection.__init__()
- itemsAfter = {name: code for name, code in collection.item_names_to_id.items()}
- locationsAfter = {name: code for name, code in collection.location_names_to_id.items()}
+ items_after = {name: code for name, code in collection.item_names_to_id.items()}
+ locations_after = {name: code for name, code in collection.location_names_to_id.items()}
- self.assertDictEqual(itemsBefore, itemsAfter, "Item ID changed after secondary init.")
- self.assertDictEqual(locationsBefore, locationsAfter, "Location ID changed after secondary init.")
+ self.assertDictEqual(items_before, items_after, "Item ID changed after secondary init.")
+ self.assertDictEqual(locations_before, locations_after, "Location ID changed after secondary init.")
def test_free_dlc_included_in_base_songs(self) -> None:
collection = MuseDashCollections()
diff --git a/worlds/musedash/test/TestDifficultyRanges.py b/worlds/musedash/test/TestDifficultyRanges.py
index 89214d3f0f88..a9c36985afae 100644
--- a/worlds/musedash/test/TestDifficultyRanges.py
+++ b/worlds/musedash/test/TestDifficultyRanges.py
@@ -3,31 +3,31 @@
class DifficultyRanges(MuseDashTestBase):
def test_all_difficulty_ranges(self) -> None:
- muse_dash_world = self.multiworld.worlds[1]
+ muse_dash_world = self.get_world()
dlc_set = {x for x in muse_dash_world.md_collection.DLC}
difficulty_choice = muse_dash_world.options.song_difficulty_mode
difficulty_min = muse_dash_world.options.song_difficulty_min
difficulty_max = muse_dash_world.options.song_difficulty_max
- def test_range(inputRange, lower, upper):
- self.assertEqual(inputRange[0], lower)
- self.assertEqual(inputRange[1], upper)
+ def test_range(input_range, lower, upper):
+ self.assertEqual(input_range[0], lower)
+ self.assertEqual(input_range[1], upper)
- songs = muse_dash_world.md_collection.get_songs_with_settings(dlc_set, False, inputRange[0], inputRange[1])
+ songs = muse_dash_world.md_collection.get_songs_with_settings(dlc_set, False, input_range[0], input_range[1])
for songKey in songs:
song = muse_dash_world.md_collection.song_items[songKey]
- if (song.easy is not None and inputRange[0] <= song.easy <= inputRange[1]):
+ if song.easy is not None and input_range[0] <= song.easy <= input_range[1]:
continue
- if (song.hard is not None and inputRange[0] <= song.hard <= inputRange[1]):
+ if song.hard is not None and input_range[0] <= song.hard <= input_range[1]:
continue
- if (song.master is not None and inputRange[0] <= song.master <= inputRange[1]):
+ if song.master is not None and input_range[0] <= song.master <= input_range[1]:
continue
- self.fail(f"Invalid song '{songKey}' was given for range '{inputRange[0]} to {inputRange[1]}'")
+ self.fail(f"Invalid song '{songKey}' was given for range '{input_range[0]} to {input_range[1]}'")
- #auto ranges
+ # auto ranges
difficulty_choice.value = 0
test_range(muse_dash_world.get_difficulty_range(), 0, 12)
difficulty_choice.value = 1
@@ -61,7 +61,7 @@ def test_range(inputRange, lower, upper):
test_range(muse_dash_world.get_difficulty_range(), 4, 6)
def test_songs_have_difficulty(self) -> None:
- muse_dash_world = self.multiworld.worlds[1]
+ muse_dash_world = self.get_world()
for song_name in muse_dash_world.md_collection.DIFF_OVERRIDES:
song = muse_dash_world.md_collection.song_items[song_name]
@@ -73,4 +73,4 @@ def test_songs_have_difficulty(self) -> None:
f"Song '{song_name}' difficulty not set when it should be.")
else:
self.assertTrue(song.easy is not None and song.hard is not None and song.master is not None,
- f"Song '{song_name}' difficulty not set when it should be.")
+ f"Song '{song_name}' difficulty not set when it should be.")
diff --git a/worlds/musedash/test/TestPlandoSettings.py b/worlds/musedash/test/TestPlandoSettings.py
index 4b23a4afa90a..2617b7a4e02c 100644
--- a/worlds/musedash/test/TestPlandoSettings.py
+++ b/worlds/musedash/test/TestPlandoSettings.py
@@ -4,7 +4,32 @@
class TestPlandoSettings(MuseDashTestBase):
options = {
"additional_song_count": 15,
- "allow_just_as_planned_dlc_songs": True,
+ "dlc_packs": {"Muse Plus"},
+ "include_songs": [
+ "Lunatic",
+ "Out of Sense",
+ "Magic Knight Girl",
+ ]
+ }
+
+ def test_included_songs_didnt_grow_item_count(self) -> None:
+ muse_dash_world = self.get_world()
+ self.assertEqual(len(muse_dash_world.included_songs), 15, "Logical songs size grew when it shouldn't.")
+
+ def test_included_songs_plando(self) -> None:
+ muse_dash_world = self.get_world()
+ songs = muse_dash_world.included_songs.copy()
+ songs.append(muse_dash_world.victory_song_name)
+
+ self.assertIn("Lunatic", songs, "Logical songs is missing a plando song: Lunatic")
+ self.assertIn("Out of Sense", songs, "Logical songs is missing a plando song: Out of Sense")
+ self.assertIn("Magic Knight Girl", songs, "Logical songs is missing a plando song: Magic Knight Girl")
+
+
+class TestFilteredPlandoSettings(MuseDashTestBase):
+ options = {
+ "additional_song_count": 15,
+ "dlc_packs": {"MSR Anthology"},
"include_songs": [
"Operation Blade",
"Autumn Moods",
@@ -13,15 +38,15 @@ class TestPlandoSettings(MuseDashTestBase):
}
def test_included_songs_didnt_grow_item_count(self) -> None:
- muse_dash_world = self.multiworld.worlds[1]
- self.assertEqual(len(muse_dash_world.included_songs), 15,
- f"Logical songs size grew when it shouldn't. Expected 15. Got {len(muse_dash_world.included_songs)}")
+ muse_dash_world = self.get_world()
+ self.assertEqual(len(muse_dash_world.included_songs), 15, "Logical songs size grew when it shouldn't.")
- def test_included_songs_plando(self) -> None:
- muse_dash_world = self.multiworld.worlds[1]
+ # Tests for excluding included songs when the right dlc isn't enabled
+ def test_filtered_included_songs_plando(self) -> None:
+ muse_dash_world = self.get_world()
songs = muse_dash_world.included_songs.copy()
songs.append(muse_dash_world.victory_song_name)
self.assertIn("Operation Blade", songs, "Logical songs is missing a plando song: Operation Blade")
self.assertIn("Autumn Moods", songs, "Logical songs is missing a plando song: Autumn Moods")
- self.assertIn("Fireflies", songs, "Logical songs is missing a plando song: Fireflies")
\ No newline at end of file
+ self.assertNotIn("Fireflies", songs, "Logical songs has added a filtered a plando song: Fireflies")
diff --git a/worlds/musedash/test/TestTrapOption.py b/worlds/musedash/test/TestTrapOption.py
new file mode 100644
index 000000000000..ca0579c1f66c
--- /dev/null
+++ b/worlds/musedash/test/TestTrapOption.py
@@ -0,0 +1,33 @@
+from . import MuseDashTestBase
+
+
+class TestNoTraps(MuseDashTestBase):
+ def test_no_traps(self) -> None:
+ md_world = self.get_world()
+ md_world.options.chosen_traps.value.clear()
+ self.assertEqual(len(md_world.get_available_traps()), 0, "Got an available trap when we expected none.")
+
+ def test_all_traps(self) -> None:
+ md_world = self.get_world()
+ md_world.options.dlc_packs.value.add(md_world.md_collection.MUSE_PLUS_DLC)
+
+ for trap in md_world.md_collection.trap_items.keys():
+ md_world.options.chosen_traps.value.add(trap)
+
+ trap_count = len(md_world.get_available_traps())
+ true_count = len(md_world.md_collection.trap_items.keys())
+
+ self.assertEqual(trap_count, true_count, "Got a different amount of traps than what was expected.")
+
+ def test_exclude_sfx_traps(self) -> None:
+ md_world = self.get_world()
+ if "Muse Plus" in md_world.options.dlc_packs.value:
+ md_world.options.dlc_packs.value.remove("Muse Plus")
+
+ for trap in md_world.md_collection.trap_items.keys():
+ md_world.options.chosen_traps.value.add(trap)
+
+ trap_count = len(md_world.get_available_traps())
+ true_count = len(md_world.md_collection.trap_items.keys()) - len(md_world.md_collection.sfx_trap_items)
+
+ self.assertEqual(trap_count, true_count, "Got a different amount of traps than what was expected.")
diff --git a/worlds/musedash/test/TestWorstCaseSettings.py b/worlds/musedash/test/TestWorstCaseSettings.py
index eeedfa5c3a5f..fd39651d1203 100644
--- a/worlds/musedash/test/TestWorstCaseSettings.py
+++ b/worlds/musedash/test/TestWorstCaseSettings.py
@@ -4,30 +4,33 @@
# This ends up with only 25 valid songs that can be chosen.
# These tests ensure that this won't fail generation
+
class TestWorstCaseHighDifficulty(MuseDashTestBase):
options = {
"starting_song_count": 10,
- "allow_just_as_planned_dlc_songs": False,
+ "dlc_packs": [],
"streamer_mode_enabled": True,
"song_difficulty_mode": 6,
"song_difficulty_min": 11,
"song_difficulty_max": 11,
}
+
class TestWorstCaseMidDifficulty(MuseDashTestBase):
options = {
"starting_song_count": 10,
- "allow_just_as_planned_dlc_songs": False,
+ "dlc_packs": [],
"streamer_mode_enabled": True,
"song_difficulty_mode": 6,
"song_difficulty_min": 6,
"song_difficulty_max": 6,
}
+
class TestWorstCaseLowDifficulty(MuseDashTestBase):
options = {
"starting_song_count": 10,
- "allow_just_as_planned_dlc_songs": False,
+ "dlc_packs": [],
"streamer_mode_enabled": True,
"song_difficulty_mode": 6,
"song_difficulty_min": 1,
diff --git a/worlds/musedash/test/__init__.py b/worlds/musedash/test/__init__.py
index c77f9f6a06b8..ff9d988c65c2 100644
--- a/worlds/musedash/test/__init__.py
+++ b/worlds/musedash/test/__init__.py
@@ -1,5 +1,10 @@
from test.bases import WorldTestBase
-
+from .. import MuseDashWorld
+from typing import cast
class MuseDashTestBase(WorldTestBase):
game = "Muse Dash"
+
+ def get_world(self) -> MuseDashWorld:
+ return cast(MuseDashWorld, self.multiworld.worlds[1])
+
diff --git a/worlds/pokemon_emerald/CHANGELOG.md b/worlds/pokemon_emerald/CHANGELOG.md
index e967b2039b12..0437c0dae8ff 100644
--- a/worlds/pokemon_emerald/CHANGELOG.md
+++ b/worlds/pokemon_emerald/CHANGELOG.md
@@ -12,6 +12,7 @@ and won't show up in the wild. Previously they would be forced to show up exactl
- The Lilycove Wailmer now logically block you from the east. Actual game behavior is still unchanged for now.
- Water encounters in Slateport now correctly require Surf.
+- Mirage Tower can no longer be your only logical access to a species in the wild, since it can permanently disappear.
- Updated the tracker link in the setup guide.
# 2.1.1
diff --git a/worlds/pokemon_emerald/data.py b/worlds/pokemon_emerald/data.py
index e717a225561c..d89ab5febb33 100644
--- a/worlds/pokemon_emerald/data.py
+++ b/worlds/pokemon_emerald/data.py
@@ -25,13 +25,20 @@
}
"""These maps exist but don't show up in the rando or are unused, and so should be discarded"""
-POSTGAME_MAPS = {
+OUT_OF_LOGIC_MAPS = {
"MAP_DESERT_UNDERPASS",
"MAP_SAFARI_ZONE_NORTHEAST",
"MAP_SAFARI_ZONE_SOUTHEAST",
"MAP_METEOR_FALLS_STEVENS_CAVE",
+ "MAP_MIRAGE_TOWER_1F",
+ "MAP_MIRAGE_TOWER_2F",
+ "MAP_MIRAGE_TOWER_3F",
+ "MAP_MIRAGE_TOWER_4F",
}
-"""These maps have encounters and are locked behind beating the champion. Those encounter slots should be ignored for logical access to a species."""
+"""
+These maps have encounters and are locked behind beating the champion or are missable.
+Those encounter slots should be ignored for logical access to a species.
+"""
NUM_REAL_SPECIES = 386
diff --git a/worlds/pokemon_emerald/pokemon.py b/worlds/pokemon_emerald/pokemon.py
index 8aa25934af8d..c60e5e9d4f14 100644
--- a/worlds/pokemon_emerald/pokemon.py
+++ b/worlds/pokemon_emerald/pokemon.py
@@ -4,9 +4,8 @@
import functools
from typing import TYPE_CHECKING, Dict, List, Set, Optional, Tuple
-from Options import Toggle
-
-from .data import NUM_REAL_SPECIES, POSTGAME_MAPS, EncounterTableData, LearnsetMove, MiscPokemonData, SpeciesData, data
+from .data import (NUM_REAL_SPECIES, OUT_OF_LOGIC_MAPS, EncounterTableData, LearnsetMove, MiscPokemonData,
+ SpeciesData, data)
from .options import (Goal, HmCompatibility, LevelUpMoves, RandomizeAbilities, RandomizeLegendaryEncounters,
RandomizeMiscPokemon, RandomizeStarters, RandomizeTypes, RandomizeWildPokemon,
TmTutorCompatibility)
@@ -266,7 +265,8 @@ def randomize_wild_encounters(world: "PokemonEmeraldWorld") -> None:
species_old_to_new_map: Dict[int, int] = {}
for species_id in table.slots:
if species_id not in species_old_to_new_map:
- if not placed_priority_species and len(priority_species) > 0:
+ if not placed_priority_species and len(priority_species) > 0 \
+ and map_name not in OUT_OF_LOGIC_MAPS:
new_species_id = priority_species.pop()
placed_priority_species = True
else:
@@ -329,7 +329,7 @@ def randomize_wild_encounters(world: "PokemonEmeraldWorld") -> None:
new_species_id = world.random.choice(candidates).species_id
species_old_to_new_map[species_id] = new_species_id
- if world.options.dexsanity and map_data.name not in POSTGAME_MAPS:
+ if world.options.dexsanity and map_name not in OUT_OF_LOGIC_MAPS:
already_placed.add(new_species_id)
# Actually create the new list of slots and encounter table
diff --git a/worlds/pokemon_rb/items.py b/worlds/pokemon_rb/items.py
index 24cad13252b1..de29f341c6df 100644
--- a/worlds/pokemon_rb/items.py
+++ b/worlds/pokemon_rb/items.py
@@ -119,11 +119,11 @@ def __init__(self, item_id, classification, groups):
"Card Key 11F": ItemData(109, ItemClassification.progression, ["Unique", "Key Items", "Card Keys"]),
"Progressive Card Key": ItemData(110, ItemClassification.progression, ["Unique", "Key Items", "Card Keys"]),
"Sleep Trap": ItemData(111, ItemClassification.trap, ["Traps"]),
- "HM01 Cut": ItemData(196, ItemClassification.progression, ["Unique", "HMs", "Key Items"]),
- "HM02 Fly": ItemData(197, ItemClassification.progression, ["Unique", "HMs", "Key Items"]),
- "HM03 Surf": ItemData(198, ItemClassification.progression, ["Unique", "HMs", "Key Items"]),
- "HM04 Strength": ItemData(199, ItemClassification.progression, ["Unique", "HMs", "Key Items"]),
- "HM05 Flash": ItemData(200, ItemClassification.progression, ["Unique", "HMs", "Key Items"]),
+ "HM01 Cut": ItemData(196, ItemClassification.progression, ["Unique", "HMs", "HM01", "Key Items"]),
+ "HM02 Fly": ItemData(197, ItemClassification.progression, ["Unique", "HMs", "HM02", "Key Items"]),
+ "HM03 Surf": ItemData(198, ItemClassification.progression, ["Unique", "HMs", "HM03", "Key Items"]),
+ "HM04 Strength": ItemData(199, ItemClassification.progression, ["Unique", "HMs", "HM04", "Key Items"]),
+ "HM05 Flash": ItemData(200, ItemClassification.progression, ["Unique", "HMs", "HM05", "Key Items"]),
"TM01 Mega Punch": ItemData(201, ItemClassification.useful, ["Unique", "TMs"]),
"TM02 Razor Wind": ItemData(202, ItemClassification.filler, ["Unique", "TMs"]),
"TM03 Swords Dance": ItemData(203, ItemClassification.useful, ["Unique", "TMs"]),
diff --git a/worlds/ror2/__init__.py b/worlds/ror2/__init__.py
index b6a1901a8db1..7873ae54bbba 100644
--- a/worlds/ror2/__init__.py
+++ b/worlds/ror2/__init__.py
@@ -7,7 +7,7 @@
environment_sotv_orderedstages_table, environment_sotv_table, collapse_dict_list_vertical, shift_by_offset
from BaseClasses import Item, ItemClassification, Tutorial
-from .options import ItemWeights, ROR2Options
+from .options import ItemWeights, ROR2Options, ror2_option_groups
from worlds.AutoWorld import World, WebWorld
from .regions import create_explore_regions, create_classic_regions
from typing import List, Dict, Any
@@ -23,6 +23,8 @@ class RiskOfWeb(WebWorld):
["Ijwu", "Kindasneaki"]
)]
+ option_groups = ror2_option_groups
+
class RiskOfRainWorld(World):
"""
@@ -44,7 +46,7 @@ class RiskOfRainWorld(World):
}
location_name_to_id = item_pickups
- required_client_version = (0, 4, 5)
+ required_client_version = (0, 5, 0)
web = RiskOfWeb()
total_revivals: int
diff --git a/worlds/ror2/options.py b/worlds/ror2/options.py
index 066c8c8545a8..381c5942b07b 100644
--- a/worlds/ror2/options.py
+++ b/worlds/ror2/options.py
@@ -1,5 +1,5 @@
from dataclasses import dataclass
-from Options import Toggle, DefaultOnToggle, DeathLink, Range, Choice, PerGameCommonOptions
+from Options import Toggle, DefaultOnToggle, DeathLink, Range, Choice, PerGameCommonOptions, OptionGroup
# NOTE be aware that since the range of item ids that RoR2 uses is based off of the maximums of checks
@@ -350,7 +350,7 @@ class ItemPoolPresetToggle(Toggle):
class ItemWeights(Choice):
- """Set item_pool_presets to true if you want to use one of these presets.
+ """Set Use Item Weight Presets to yes if you want to use one of these presets.
Preset choices for determining the weights of the item pool.
- New is a test for a potential adjustment to the default weights.
- Uncommon puts a large number of uncommon items in the pool.
@@ -375,6 +375,44 @@ class ItemWeights(Choice):
option_void = 9
+ror2_option_groups = [
+ OptionGroup("Explore Mode Options", [
+ ChestsPerEnvironment,
+ ShrinesPerEnvironment,
+ ScavengersPerEnvironment,
+ ScannersPerEnvironment,
+ AltarsPerEnvironment,
+ RequireStages,
+ ProgressiveStages,
+ ]),
+ OptionGroup("Classic Mode Options", [
+ TotalLocations,
+ ], start_collapsed=True),
+ OptionGroup("Weighted Choices", [
+ ItemWeights,
+ ItemPoolPresetToggle,
+ WhiteScrap,
+ GreenScrap,
+ YellowScrap,
+ RedScrap,
+ CommonItem,
+ UncommonItem,
+ LegendaryItem,
+ BossItem,
+ LunarItem,
+ VoidItem,
+ Equipment,
+ Money,
+ LunarCoin,
+ Experience,
+ MountainTrap,
+ TimeWarpTrap,
+ CombatTrap,
+ TeleportTrap,
+ ]),
+]
+
+
@dataclass
class ROR2Options(PerGameCommonOptions):
goal: Goal
@@ -399,10 +437,10 @@ class ROR2Options(PerGameCommonOptions):
item_weights: ItemWeights
item_pool_presets: ItemPoolPresetToggle
# define the weights of the generated item pool.
+ white_scrap: WhiteScrap
green_scrap: GreenScrap
- red_scrap: RedScrap
yellow_scrap: YellowScrap
- white_scrap: WhiteScrap
+ red_scrap: RedScrap
common_item: CommonItem
uncommon_item: UncommonItem
legendary_item: LegendaryItem
diff --git a/worlds/ror2/regions.py b/worlds/ror2/regions.py
index 199fdccf80e8..def29b47286b 100644
--- a/worlds/ror2/regions.py
+++ b/worlds/ror2/regions.py
@@ -19,11 +19,13 @@ def create_explore_regions(ror2_world: "RiskOfRainWorld") -> None:
# Default Locations
non_dlc_regions: Dict[str, RoRRegionData] = {
"Menu": RoRRegionData(None, ["Distant Roost", "Distant Roost (2)",
- "Titanic Plains", "Titanic Plains (2)"]),
+ "Titanic Plains", "Titanic Plains (2)",
+ "Verdant Falls"]),
"Distant Roost": RoRRegionData([], ["OrderedStage_1"]),
"Distant Roost (2)": RoRRegionData([], ["OrderedStage_1"]),
"Titanic Plains": RoRRegionData([], ["OrderedStage_1"]),
"Titanic Plains (2)": RoRRegionData([], ["OrderedStage_1"]),
+ "Verdant Falls": RoRRegionData([], ["OrderedStage_1"]),
"Abandoned Aqueduct": RoRRegionData([], ["OrderedStage_2"]),
"Wetland Aspect": RoRRegionData([], ["OrderedStage_2"]),
"Rallypoint Delta": RoRRegionData([], ["OrderedStage_3"]),
diff --git a/worlds/ror2/ror2environments.py b/worlds/ror2/ror2environments.py
index d821763ef40c..61707b336241 100644
--- a/worlds/ror2/ror2environments.py
+++ b/worlds/ror2/ror2environments.py
@@ -7,6 +7,7 @@
"Distant Roost (2)": 8, # blackbeach2
"Titanic Plains": 15, # golemplains
"Titanic Plains (2)": 16, # golemplains2
+ "Verdant Falls": 28, # lakes
}
environment_vanilla_orderedstage_2_table: Dict[str, int] = {
"Abandoned Aqueduct": 17, # goolake
diff --git a/worlds/shorthike/Items.py b/worlds/shorthike/Items.py
index a240dcbc6a1f..7a5a81db9be6 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 @@ class ItemDict(TypedDict):
{"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 @@ class ItemDict(TypedDict):
{"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 c2d316c68675..319ad8f20e1b 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 @@ class LocationInfo(TypedDict):
{"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 @@ class LocationInfo(TypedDict):
{"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 1ac0ff52f974..3d9bf81a3cf8 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 73a16434219e..4a71ebd3c80a 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 470b061c4bc0..299169a40c6b 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 @@ def create_item(self, name: str) -> "ShortHikeItem":
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 @@ def create_items(self) -> None:
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 @@ def create_regions(self) -> None:
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 @@ def fill_slot_data(self) -> Dict[str, Any]:
"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/tunic/__init__.py b/worlds/tunic/__init__.py
index 9ef5800955aa..624208da3a0b 100644
--- a/worlds/tunic/__init__.py
+++ b/worlds/tunic/__init__.py
@@ -8,7 +8,7 @@
from .regions import tunic_regions
from .er_scripts import create_er_regions
from .er_data import portal_mapping
-from .options import TunicOptions, EntranceRando, tunic_option_groups, tunic_option_presets
+from .options import TunicOptions, EntranceRando, tunic_option_groups, tunic_option_presets, TunicPlandoConnections
from worlds.AutoWorld import WebWorld, World
from Options import PlandoConnection
from decimal import Decimal, ROUND_HALF_UP
@@ -43,7 +43,7 @@ class SeedGroup(TypedDict):
logic_rules: int # logic rules value
laurels_at_10_fairies: bool # laurels location value
fixed_shop: bool # fixed shop value
- plando: List[PlandoConnection] # consolidated list of plando connections for the seed group
+ plando: TunicPlandoConnections # consolidated of plando connections for the seed group
class TunicWorld(World):
@@ -96,13 +96,15 @@ def generate_early(self) -> None:
self.options.hexagon_quest.value = passthrough["hexagon_quest"]
self.options.entrance_rando.value = passthrough["entrance_rando"]
self.options.shuffle_ladders.value = passthrough["shuffle_ladders"]
+ self.options.fixed_shop.value = self.options.fixed_shop.option_false
+ self.options.laurels_location.value = self.options.laurels_location.option_anywhere
@classmethod
def stage_generate_early(cls, multiworld: MultiWorld) -> None:
tunic_worlds: Tuple[TunicWorld] = multiworld.get_game_worlds("TUNIC")
for tunic in tunic_worlds:
# if it's one of the options, then it isn't a custom seed group
- if tunic.options.entrance_rando.value in EntranceRando.options:
+ if tunic.options.entrance_rando.value in EntranceRando.options.values():
continue
group = tunic.options.entrance_rando.value
# if this is the first world in the group, set the rules equal to its rules
@@ -147,7 +149,7 @@ def stage_generate_early(cls, multiworld: MultiWorld) -> None:
f"{tunic.multiworld.get_player_name(tunic.player)}'s plando "
f"connection {cxn.entrance} <-> {cxn.exit}")
if new_cxn:
- cls.seed_groups[group]["plando"].append(cxn)
+ cls.seed_groups[group]["plando"].value.append(cxn)
def create_item(self, name: str) -> TunicItem:
item_data = item_table[name]
diff --git a/worlds/tunic/er_scripts.py b/worlds/tunic/er_scripts.py
index 7e022c9f3a0d..9d25137ba469 100644
--- a/worlds/tunic/er_scripts.py
+++ b/worlds/tunic/er_scripts.py
@@ -140,7 +140,7 @@ def pair_portals(world: "TunicWorld") -> Dict[Portal, Portal]:
waterfall_plando = False
# if it's not one of the EntranceRando options, it's a custom seed
- if world.options.entrance_rando.value not in EntranceRando.options:
+ if world.options.entrance_rando.value not in EntranceRando.options.values():
seed_group = world.seed_groups[world.options.entrance_rando.value]
logic_rules = seed_group["logic_rules"]
fixed_shop = seed_group["fixed_shop"]
@@ -162,6 +162,11 @@ def pair_portals(world: "TunicWorld") -> Dict[Portal, Portal]:
portal_map.remove(portal)
break
+ # If using Universal Tracker, restore portal_map. Could be cleaner, but it does not matter for UT even a little bit
+ if hasattr(world.multiworld, "re_gen_passthrough"):
+ if "TUNIC" in world.multiworld.re_gen_passthrough:
+ portal_map = portal_mapping.copy()
+
# create separate lists for dead ends and non-dead ends
for portal in portal_map:
dead_end_status = tunic_er_regions[portal.region].dead_end
@@ -193,7 +198,7 @@ def pair_portals(world: "TunicWorld") -> Dict[Portal, Portal]:
connected_regions.add(start_region)
connected_regions = update_reachable_regions(connected_regions, traversal_reqs, has_laurels, logic_rules)
- if world.options.entrance_rando.value in EntranceRando.options:
+ if world.options.entrance_rando.value in EntranceRando.options.values():
plando_connections = world.options.plando_connections.value
else:
plando_connections = world.seed_groups[world.options.entrance_rando.value]["plando"]
@@ -255,7 +260,7 @@ def pair_portals(world: "TunicWorld") -> Dict[Portal, Portal]:
else:
# if not both, they're both dead ends
if not portal2:
- if world.options.entrance_rando.value not in EntranceRando.options:
+ if world.options.entrance_rando.value not in EntranceRando.options.values():
raise Exception(f"Tunic ER seed group {world.options.entrance_rando.value} paired a dead "
"end to a dead end in their plando connections.")
else:
@@ -302,21 +307,21 @@ def pair_portals(world: "TunicWorld") -> Dict[Portal, Portal]:
traversal_reqs.setdefault(portal1.region, dict())[portal2.region] = []
traversal_reqs.setdefault(portal2.region, dict())[portal1.region] = []
- if portal1.region == "Zig Skip Exit" or portal2.region == "Zig Skip Exit":
- if portal1_dead_end or portal2_dead_end or \
- portal1.region == "Secret Gathering Place" or portal2.region == "Secret Gathering Place":
- if world.options.entrance_rando.value not in EntranceRando.options:
- raise Exception(f"Tunic ER seed group {world.options.entrance_rando.value} paired a dead "
- "end to a dead end in their plando connections.")
- else:
- raise Exception(f"{player_name} paired a dead end to a dead end in their "
- "plando connections.")
+ if (portal1.region == "Zig Skip Exit" and (portal2_dead_end or portal2.region == "Secret Gathering Place")
+ or portal2.region == "Zig Skip Exit" and (portal1_dead_end or portal1.region == "Secret Gathering Place")):
+ if world.options.entrance_rando.value not in EntranceRando.options.values():
+ raise Exception(f"Tunic ER seed group {world.options.entrance_rando.value} paired a dead "
+ "end to a dead end in their plando connections.")
+ else:
+ raise Exception(f"{player_name} paired a dead end to a dead end in their "
+ "plando connections.")
- if portal1.region == "Secret Gathering Place" or portal2.region == "Secret Gathering Place":
+ if (portal1.region == "Secret Gathering Place" and (portal2_dead_end or portal2.region == "Zig Skip Exit")
+ or portal2.region == "Secret Gathering Place" and (portal1_dead_end or portal1.region == "Zig Skip Exit")):
# need to make sure you didn't pair this to a dead end or zig skip
if portal1_dead_end or portal2_dead_end or \
portal1.region == "Zig Skip Exit" or portal2.region == "Zig Skip Exit":
- if world.options.entrance_rando.value not in EntranceRando.options:
+ if world.options.entrance_rando.value not in EntranceRando.options.values():
raise Exception(f"Tunic ER seed group {world.options.entrance_rando.value} paired a dead "
"end to a dead end in their plando connections.")
else:
diff --git a/worlds/tunic/options.py b/worlds/tunic/options.py
index b3b6b3b96fb0..ff9872ab4807 100644
--- a/worlds/tunic/options.py
+++ b/worlds/tunic/options.py
@@ -173,7 +173,7 @@ class ShuffleLadders(Toggle):
display_name = "Shuffle Ladders"
-class TUNICPlandoConnections(PlandoConnections):
+class TunicPlandoConnections(PlandoConnections):
entrances = {*(portal.name for portal in portal_mapping), "Shop", "Shop Portal"}
exits = {*(portal.name for portal in portal_mapping), "Shop", "Shop Portal"}
@@ -198,7 +198,7 @@ class TunicOptions(PerGameCommonOptions):
lanternless: Lanternless
maskless: Maskless
laurels_location: LaurelsLocation
- plando_connections: TUNICPlandoConnections
+ plando_connections: TunicPlandoConnections
tunic_option_groups = [
diff --git a/worlds/tunic/rules.py b/worlds/tunic/rules.py
index 12810cfa2670..0b65c8158e10 100644
--- a/worlds/tunic/rules.py
+++ b/worlds/tunic/rules.py
@@ -312,7 +312,9 @@ def set_location_rules(world: "TunicWorld", ability_unlocks: Dict[str, int]) ->
# Swamp
set_rule(multiworld.get_location("Cathedral Gauntlet - Gauntlet Reward", player),
- lambda state: state.has(laurels, player) and state.has(fire_wand, player) and has_sword(state, player))
+ lambda state: (state.has(fire_wand, player) and has_sword(state, player))
+ and (state.has(laurels, player)
+ or has_ice_grapple_logic(False, state, player, options, ability_unlocks)))
set_rule(multiworld.get_location("Swamp - [Entrance] Above Entryway", player),
lambda state: state.has(laurels, player))
set_rule(multiworld.get_location("Swamp - [South Graveyard] Upper Walkway Dash Chest", player),
diff --git a/worlds/witness/data/static_logic.py b/worlds/witness/data/static_logic.py
index bae1921f6095..ecd95ea6c0fa 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 5c5568b25661..2934308df3ec 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]:
diff --git a/worlds/zillion/requirements.txt b/worlds/zillion/requirements.txt
index 3a784846a891..ae7d9b173308 100644
--- a/worlds/zillion/requirements.txt
+++ b/worlds/zillion/requirements.txt
@@ -1,2 +1,2 @@
-zilliandomizer @ git+https://github.com/beauxq/zilliandomizer@b36a23b5a138c78732ac8efb5b5ca8b0be07dcff#0.7.0
+zilliandomizer @ git+https://github.com/beauxq/zilliandomizer@1dd2ce01c9d818caba5844529699b3ad026d6a07#0.7.1
typing-extensions>=4.7, <5
|