diff --git a/worlds/messenger/__init__.py b/worlds/messenger/__init__.py index f4a28729f1ed..c40ca02f42f1 100644 --- a/worlds/messenger/__init__.py +++ b/worlds/messenger/__init__.py @@ -1,14 +1,33 @@ import logging -from typing import Any, Dict, List, Optional +from typing import Any, ClassVar, Dict, List, Optional, TextIO -from BaseClasses import CollectionState, Item, ItemClassification, Tutorial +from BaseClasses import CollectionState, Entrance, Item, ItemClassification, MultiWorld, Tutorial +from Options import Accessibility +from Utils import output_path +from settings import FilePath, Group from worlds.AutoWorld import WebWorld, World -from .constants import ALL_ITEMS, ALWAYS_LOCATIONS, BOSS_LOCATIONS, FILLER, NOTES, PHOBEKINS -from .options import Goal, Logic, MessengerOptions, NotesNeeded, PowerSeals -from .regions import MEGA_SHARDS, REGIONS, REGION_CONNECTIONS, SEALS +from worlds.LauncherComponents import Component, Type, components +from .client_setup import launch_game +from .connections import CONNECTIONS, RANDOMIZED_CONNECTIONS, TRANSITIONS +from .constants import ALL_ITEMS, ALWAYS_LOCATIONS, BOSS_LOCATIONS, FILLER, NOTES, PHOBEKINS, PROG_ITEMS, USEFUL_ITEMS +from .options import AvailablePortals, Goal, Logic, MessengerOptions, NotesNeeded, ShuffleTransitions +from .portals import PORTALS, add_closed_portal_reqs, disconnect_portals, shuffle_portals, validate_portals +from .regions import LEVELS, MEGA_SHARDS, LOCATIONS, REGION_CONNECTIONS from .rules import MessengerHardRules, MessengerOOBRules, MessengerRules -from .shop import FIGURINES, SHOP_ITEMS, shuffle_shop_prices -from .subclasses import MessengerItem, MessengerRegion +from .shop import FIGURINES, PROG_SHOP_ITEMS, SHOP_ITEMS, USEFUL_SHOP_ITEMS, shuffle_shop_prices +from .subclasses import MessengerEntrance, MessengerItem, MessengerRegion, MessengerShopLocation + +components.append( + Component("The Messenger", component_type=Type.CLIENT, func=launch_game)#, game_name="The Messenger", supports_uri=True) +) + + +class MessengerSettings(Group): + class GamePath(FilePath): + description = "The Messenger game executable" + is_exe = True + + game_path: GamePath = GamePath("TheMessenger.exe") class MessengerWeb(WebWorld): @@ -35,17 +54,10 @@ class MessengerWorld(World): adventure full of thrills, surprises, and humor. """ game = "The Messenger" - - item_name_groups = { - "Notes": set(NOTES), - "Keys": set(NOTES), - "Crest": {"Sun Crest", "Moon Crest"}, - "Phobe": set(PHOBEKINS), - "Phobekin": set(PHOBEKINS), - } - options_dataclass = MessengerOptions options: MessengerOptions + settings_key = "messenger_settings" + settings: ClassVar[MessengerSettings] base_offset = 0xADD_000 item_name_to_id = {item: item_id @@ -54,58 +66,144 @@ class MessengerWorld(World): for location_id, location in enumerate([ *ALWAYS_LOCATIONS, - *[seal for seals in SEALS.values() for seal in seals], *[shard for shards in MEGA_SHARDS.values() for shard in shards], *BOSS_LOCATIONS, *[f"The Shop - {shop_loc}" for shop_loc in SHOP_ITEMS], *FIGURINES, "Money Wrench", ], base_offset)} + item_name_groups = { + "Notes": set(NOTES), + "Keys": set(NOTES), + "Crest": {"Sun Crest", "Moon Crest"}, + "Phobe": set(PHOBEKINS), + "Phobekin": set(PHOBEKINS), + } + location_name_groups = { + "Notes": { + "Autumn Hills - Key of Hope", + "Searing Crags - Key of Strength", + "Underworld - Key of Chaos", + "Sunken Shrine - Key of Love", + "Elemental Skylands - Key of Symbiosis", + "Corrupted Future - Key of Courage", + }, + "Keys": { + "Autumn Hills - Key of Hope", + "Searing Crags - Key of Strength", + "Underworld - Key of Chaos", + "Sunken Shrine - Key of Love", + "Elemental Skylands - Key of Symbiosis", + "Corrupted Future - Key of Courage", + }, + "Phobe": { + "Catacombs - Necro", + "Bamboo Creek - Claustro", + "Searing Crags - Pyro", + "Cloud Ruins - Acro", + }, + "Phobekin": { + "Catacombs - Necro", + "Bamboo Creek - Claustro", + "Searing Crags - Pyro", + "Cloud Ruins - Acro", + }, + } - required_client_version = (0, 4, 2) + required_client_version = (0, 4, 3) web = MessengerWeb() total_seals: int = 0 required_seals: int = 0 + created_seals: int = 0 total_shards: int = 0 shop_prices: Dict[str, int] figurine_prices: Dict[str, int] _filler_items: List[str] + starting_portals: List[str] + plando_portals: List[str] + spoiler_portal_mapping: Dict[str, str] + portal_mapping: List[int] + transitions: List[Entrance] + reachable_locs: int = 0 def generate_early(self) -> None: if self.options.goal == Goal.option_power_seal_hunt: - self.options.shuffle_seals.value = PowerSeals.option_true self.total_seals = self.options.total_seals.value + if self.options.limited_movement: + self.options.accessibility.value = Accessibility.option_minimal + if self.options.logic_level < Logic.option_hard: + self.options.logic_level.value = Logic.option_hard + + if self.options.early_meditation: + self.multiworld.early_items[self.player]["Meditation"] = 1 + self.shop_prices, self.figurine_prices = shuffle_shop_prices(self) + starting_portals = ["Autumn Hills", "Howling Grotto", "Glacial Peak", "Riviere Turquoise", "Sunken Shrine", "Searing Crags"] + self.starting_portals = [f"{portal} Portal" + for portal in starting_portals[:3] + + self.random.sample(starting_portals[3:], k=self.options.available_portals - 3)] + # super complicated method for adding searing crags to starting portals if it wasn't chosen + # need to add a check for transition shuffle when that gets added back in + if not self.options.shuffle_portals and "Searing Crags Portal" not in self.starting_portals: + self.starting_portals.append("Searing Crags Portal") + if len(self.starting_portals) > 4: + portals_to_strip = [portal for portal in ["Riviere Turquoise Portal", "Sunken Shrine Portal"] + if portal in self.starting_portals] + self.starting_portals.remove(self.random.choice(portals_to_strip)) + + self.plando_portals = [] + self.portal_mapping = [] + self.spoiler_portal_mapping = {} + self.transitions = [] + def create_regions(self) -> None: # MessengerRegion adds itself to the multiworld - for region in [MessengerRegion(reg_name, self) for reg_name in REGIONS]: - if region.name in REGION_CONNECTIONS: - region.add_exits(REGION_CONNECTIONS[region.name]) + # create simple regions + simple_regions = [MessengerRegion(level, self) for level in LEVELS] + # create complex regions that have sub-regions + complex_regions = [MessengerRegion(f"{parent} - {reg_name}", self, parent) + for parent, sub_region in CONNECTIONS.items() + for reg_name in sub_region] + + for region in complex_regions: + region_name = region.name.replace(f"{region.parent} - ", "") + connection_data = CONNECTIONS[region.parent][region_name] + for exit_region in connection_data: + region.connect(self.multiworld.get_region(exit_region, self.player)) + + # all regions need to be created before i can do these connections so we create and connect the complex first + for region in [level for level in simple_regions if level.name in REGION_CONNECTIONS]: + region.add_exits(REGION_CONNECTIONS[region.name]) def create_items(self) -> None: # create items that are always in the item pool + main_movement_items = ["Rope Dart", "Wingsuit"] itempool: List[MessengerItem] = [ self.create_item(item) for item in self.item_name_to_id - if item not in - { - "Power Seal", *NOTES, *FIGURINES, + if "Time Shard" not in item and item not in { + "Power Seal", *NOTES, *FIGURINES, *main_movement_items, *{collected_item.name for collected_item in self.multiworld.precollected_items[self.player]}, - } and "Time Shard" not in item + } ] + if self.options.limited_movement: + itempool.append(self.create_item(self.random.choice(main_movement_items))) + else: + itempool += [self.create_item(move_item) for move_item in main_movement_items] + if self.options.goal == Goal.option_open_music_box: # make a list of all notes except those in the player's defined starting inventory, and adjust the # amount we need to put in the itempool and precollect based on that notes = [note for note in NOTES if note not in self.multiworld.precollected_items[self.player]] self.random.shuffle(notes) precollected_notes_amount = NotesNeeded.range_end - \ - self.options.notes_needed - \ - (len(NOTES) - len(notes)) + self.options.notes_needed - \ + (len(NOTES) - len(notes)) if precollected_notes_amount: for note in notes[:precollected_notes_amount]: self.multiworld.push_precollected(self.create_item(note)) @@ -116,26 +214,27 @@ def create_items(self) -> None: total_seals = min(len(self.multiworld.get_unfilled_locations(self.player)) - len(itempool), self.options.total_seals.value) if total_seals < self.total_seals: - logging.warning(f"Not enough locations for total seals setting " - f"({self.options.total_seals}). Adjusting to {total_seals}") + logging.warning( + f"Not enough locations for total seals setting " + f"({self.options.total_seals}). Adjusting to {total_seals}" + ) self.total_seals = total_seals self.required_seals = int(self.options.percent_seals_required.value / 100 * self.total_seals) seals = [self.create_item("Power Seal") for _ in range(self.total_seals)] - for i in range(self.required_seals): - seals[i].classification = ItemClassification.progression_skip_balancing itempool += seals + self.multiworld.itempool += itempool remaining_fill = len(self.multiworld.get_unfilled_locations(self.player)) - len(itempool) if remaining_fill < 10: self._filler_items = self.random.choices( - list(FILLER)[2:], - weights=list(FILLER.values())[2:], - k=remaining_fill + list(FILLER)[2:], + weights=list(FILLER.values())[2:], + k=remaining_fill ) - itempool += [self.create_filler() for _ in range(remaining_fill)] + filler = [self.create_filler() for _ in range(remaining_fill)] - self.multiworld.itempool += itempool + self.multiworld.itempool += filler def set_rules(self) -> None: logic = self.options.logic_level @@ -144,16 +243,59 @@ def set_rules(self) -> None: elif logic == Logic.option_hard: MessengerHardRules(self).set_messenger_rules() else: - MessengerOOBRules(self).set_messenger_rules() + raise ValueError(f"Somehow you have a logic option that's currently invalid." + f" {logic} for {self.multiworld.get_player_name(self.player)}") + # MessengerOOBRules(self).set_messenger_rules() + + add_closed_portal_reqs(self) + # i need portal shuffle to happen after rules exist so i can validate it + attempts = 5 + if self.options.shuffle_portals: + self.portal_mapping = [] + self.spoiler_portal_mapping = {} + for _ in range(attempts): + disconnect_portals(self) + shuffle_portals(self) + if validate_portals(self): + break + # failsafe mostly for invalid plandoed portals with no transition shuffle + else: + raise RuntimeError("Unable to generate valid portal output.") + + def write_spoiler_header(self, spoiler_handle: TextIO) -> None: + if self.options.available_portals < 6: + spoiler_handle.write(f"\nStarting Portals:\n\n") + for portal in self.starting_portals: + spoiler_handle.write(f"{portal}\n") + + spoiler = self.multiworld.spoiler + + if self.options.shuffle_portals: + # sort the portals as they appear left to right in-game + portal_info = sorted( + self.spoiler_portal_mapping.items(), + key=lambda portal: + ["Autumn Hills", "Riviere Turquoise", + "Howling Grotto", "Sunken Shrine", + "Searing Crags", "Glacial Peak"].index(portal[0])) + for portal, output in portal_info: + spoiler.set_entrance(f"{portal} Portal", output, "I can write anything I want here lmao", self.player) def fill_slot_data(self) -> Dict[str, Any]: - return { + slot_data = { "shop": {SHOP_ITEMS[item].internal_name: price for item, price in self.shop_prices.items()}, "figures": {FIGURINES[item].internal_name: price for item, price in self.figurine_prices.items()}, "max_price": self.total_shards, "required_seals": self.required_seals, + "starting_portals": self.starting_portals, + "portal_exits": self.portal_mapping, + "transitions": [[TRANSITIONS.index("Corrupted Future") if transition.name == "Artificer's Portal" + else TRANSITIONS.index(RANDOMIZED_CONNECTIONS[transition.parent_region.name]), + TRANSITIONS.index(transition.connected_region.name)] + for transition in self.transitions], **self.options.as_dict("music_box", "death_link", "logic_level"), } + return slot_data def get_filler_item_name(self) -> str: if not getattr(self, "_filler_items", None): @@ -166,15 +308,35 @@ def get_filler_item_name(self) -> str: def create_item(self, name: str) -> MessengerItem: item_id: Optional[int] = self.item_name_to_id.get(name, None) - override_prog = getattr(self, "multiworld") is not None and \ - name in {"Windmill Shuriken"} and \ - self.options.logic_level > Logic.option_normal - count = 0 + return MessengerItem( + name, + ItemClassification.progression if item_id is None else self.get_item_classification(name), + item_id, + self.player + ) + + def get_item_classification(self, name: str) -> ItemClassification: if "Time Shard " in name: count = int(name.strip("Time Shard ()")) count = count if count >= 100 else 0 self.total_shards += count - return MessengerItem(name, self.player, item_id, override_prog, count) + return ItemClassification.progression_skip_balancing if count else ItemClassification.filler + + if name == "Windmill Shuriken" and getattr(self, "multiworld", None) is not None: + return ItemClassification.progression if self.options.logic_level else ItemClassification.filler + + if name == "Power Seal": + self.created_seals += 1 + return ItemClassification.progression_skip_balancing \ + if self.required_seals >= self.created_seals else ItemClassification.filler + + if name in {*NOTES, *PROG_ITEMS, *PHOBEKINS, *PROG_SHOP_ITEMS}: + return ItemClassification.progression + + if name in {*USEFUL_ITEMS, *USEFUL_SHOP_ITEMS}: + return ItemClassification.useful + + return ItemClassification.filler def collect(self, state: "CollectionState", item: "Item") -> bool: change = super().collect(state, item) @@ -187,3 +349,25 @@ def remove(self, state: "CollectionState", item: "Item") -> bool: if change and "Time Shard" in item.name: state.prog_items[self.player]["Shards"] -= int(item.name.strip("Time Shard ()")) return change + + @classmethod + def stage_generate_output(cls, multiworld: MultiWorld, output_directory: str) -> None: + # using stage_generate_output because it doesn't increase the logged player count for players without output + # only generate output if there's a single player + if multiworld.players > 1: + return + # the messenger client calls into AP with specific args, so check the out path matches what the client sends + out_path = output_path(multiworld.get_out_file_name_base(1) + ".aptm") + if "The Messenger\\Archipelago\\output" not in out_path: + return + import orjson + data = { + "name": multiworld.get_player_name(1), + "slot_data": multiworld.worlds[1].fill_slot_data(), + "loc_data": {loc.address: {loc.item.name: [loc.item.code, loc.item.flags]} + for loc in multiworld.get_filled_locations() if loc.address}, + } + + output = orjson.dumps(data, option=orjson.OPT_NON_STR_KEYS) + with open(out_path, "wb") as f: + f.write(output) diff --git a/worlds/messenger/client_setup.py b/worlds/messenger/client_setup.py new file mode 100644 index 000000000000..9fd08e52d899 --- /dev/null +++ b/worlds/messenger/client_setup.py @@ -0,0 +1,164 @@ +import io +import logging +import os.path +import subprocess +import urllib.request +from shutil import which +from tkinter.messagebox import askyesnocancel +from typing import Any, Optional +from zipfile import ZipFile +from Utils import open_file + +import requests + +from Utils import is_windows, messagebox, tuplize_version + + +MOD_URL = "https://api.github.com/repos/alwaysintreble/TheMessengerRandomizerModAP/releases/latest" + + +def launch_game(url: Optional[str] = None) -> None: + """Check the game installation, then launch it""" + def courier_installed() -> bool: + """Check if Courier is installed""" + return os.path.exists(os.path.join(game_folder, "TheMessenger_Data", "Managed", "Assembly-CSharp.Courier.mm.dll")) + + def mod_installed() -> bool: + """Check if the mod is installed""" + return os.path.exists(os.path.join(game_folder, "Mods", "TheMessengerRandomizerAP", "courier.toml")) + + def request_data(request_url: str) -> Any: + """Fetches json response from given url""" + logging.info(f"requesting {request_url}") + response = requests.get(request_url) + if response.status_code == 200: # success + try: + data = response.json() + except requests.exceptions.JSONDecodeError: + raise RuntimeError(f"Unable to fetch data. (status code {response.status_code})") + else: + raise RuntimeError(f"Unable to fetch data. (status code {response.status_code})") + return data + + def install_courier() -> None: + """Installs latest version of Courier""" + # can't use latest since courier uses pre-release tags + courier_url = "https://api.github.com/repos/Brokemia/Courier/releases" + latest_download = request_data(courier_url)[0]["assets"][-1]["browser_download_url"] + + with urllib.request.urlopen(latest_download) as download: + with ZipFile(io.BytesIO(download.read()), "r") as zf: + for member in zf.infolist(): + zf.extract(member, path=game_folder) + + os.chdir(game_folder) + # linux and mac handling + if not is_windows: + mono_exe = which("mono") + if not mono_exe: + # steam deck support but doesn't currently work + messagebox("Failure", "Failed to install Courier", True) + raise RuntimeError("Failed to install Courier") + # # download and use mono kickstart + # # this allows steam deck support + # mono_kick_url = "https://github.com/flibitijibibo/MonoKickstart/archive/refs/heads/master.zip" + # target = os.path.join(folder, "monoKickstart") + # os.makedirs(target, exist_ok=True) + # with urllib.request.urlopen(mono_kick_url) as download: + # with ZipFile(io.BytesIO(download.read()), "r") as zf: + # for member in zf.infolist(): + # zf.extract(member, path=target) + # installer = subprocess.Popen([os.path.join(target, "precompiled"), + # os.path.join(folder, "MiniInstaller.exe")], shell=False) + # os.remove(target) + else: + installer = subprocess.Popen([mono_exe, os.path.join(game_folder, "MiniInstaller.exe")], shell=False) + else: + installer = subprocess.Popen(os.path.join(game_folder, "MiniInstaller.exe"), shell=False) + + failure = installer.wait() + if failure: + messagebox("Failure", "Failed to install Courier", True) + os.chdir(working_directory) + raise RuntimeError("Failed to install Courier") + os.chdir(working_directory) + + if courier_installed(): + messagebox("Success!", "Courier successfully installed!") + return + messagebox("Failure", "Failed to install Courier", True) + raise RuntimeError("Failed to install Courier") + + def install_mod() -> None: + """Installs latest version of the mod""" + assets = request_data(MOD_URL)["assets"] + if len(assets) == 1: + release_url = assets[0]["browser_download_url"] + else: + for asset in assets: + if "TheMessengerRandomizerAP" in asset["name"]: + release_url = asset["browser_download_url"] + break + else: + messagebox("Failure", "Failed to find latest mod download", True) + raise RuntimeError("Failed to install Mod") + + mod_folder = os.path.join(game_folder, "Mods") + os.makedirs(mod_folder, exist_ok=True) + with urllib.request.urlopen(release_url) as download: + with ZipFile(io.BytesIO(download.read()), "r") as zf: + for member in zf.infolist(): + zf.extract(member, path=mod_folder) + + messagebox("Success!", "Latest mod successfully installed!") + + def available_mod_update(latest_version: str) -> bool: + """Check if there's an available update""" + latest_version = latest_version.lstrip("v") + toml_path = os.path.join(game_folder, "Mods", "TheMessengerRandomizerAP", "courier.toml") + with open(toml_path, "r") as f: + installed_version = f.read().splitlines()[1].strip("version = \"") + + logging.info(f"Installed version: {installed_version}. Latest version: {latest_version}") + # one of the alpha builds + return "alpha" in latest_version or tuplize_version(latest_version) > tuplize_version(installed_version) + + from . import MessengerWorld + game_folder = os.path.dirname(MessengerWorld.settings.game_path) + working_directory = os.getcwd() + if not courier_installed(): + should_install = askyesnocancel("Install Courier", + "No Courier installation detected. Would you like to install now?") + if not should_install: + return + logging.info("Installing Courier") + install_courier() + if not mod_installed(): + should_install = askyesnocancel("Install Mod", + "No randomizer mod detected. Would you like to install now?") + if not should_install: + return + logging.info("Installing Mod") + install_mod() + else: + latest = request_data(MOD_URL)["tag_name"] + if available_mod_update(latest): + should_update = askyesnocancel("Update Mod", + f"New mod version detected. Would you like to update to {latest} now?") + if should_update: + logging.info("Updating mod") + install_mod() + elif should_update is None: + return + if not is_windows: + if url: + open_file(f"steam://rungameid/764790//{url}/") + else: + open_file("steam://rungameid/764790") + else: + os.chdir(game_folder) + if url: + subprocess.Popen([MessengerWorld.settings.game_path, str(url)]) + else: + subprocess.Popen(MessengerWorld.settings.game_path) + os.chdir(working_directory) diff --git a/worlds/messenger/connections.py b/worlds/messenger/connections.py new file mode 100644 index 000000000000..5e1871e287d2 --- /dev/null +++ b/worlds/messenger/connections.py @@ -0,0 +1,725 @@ +from typing import Dict, List + +CONNECTIONS: Dict[str, Dict[str, List[str]]] = { + "Ninja Village": { + "Right": [ + "Autumn Hills - Left", + "Ninja Village - Nest", + ], + "Nest": [ + "Ninja Village - Right", + ], + }, + "Autumn Hills": { + "Left": [ + "Ninja Village - Right", + "Autumn Hills - Climbing Claws Shop", + ], + "Right": [ + "Forlorn Temple - Left", + "Autumn Hills - Leaf Golem Shop", + ], + "Bottom": [ + "Catacombs - Bottom Left", + "Autumn Hills - Double Swing Checkpoint", + ], + "Portal": [ + "Tower HQ", + "Autumn Hills - Dimension Climb Shop", + ], + "Climbing Claws Shop": [ + "Autumn Hills - Left", + "Autumn Hills - Hope Path Shop", + "Autumn Hills - Lakeside Checkpoint", + "Autumn Hills - Key of Hope Checkpoint", + ], + "Hope Path Shop": [ + "Autumn Hills - Climbing Claws Shop", + "Autumn Hills - Hope Latch Checkpoint", + "Autumn Hills - Lakeside Checkpoint", + ], + "Dimension Climb Shop": [ + "Autumn Hills - Lakeside Checkpoint", + "Autumn Hills - Portal", + "Autumn Hills - Double Swing Checkpoint", + ], + "Leaf Golem Shop": [ + "Autumn Hills - Spike Ball Swing Checkpoint", + "Autumn Hills - Right", + ], + "Hope Latch Checkpoint": [ + "Autumn Hills - Hope Path Shop", + "Autumn Hills - Key of Hope Checkpoint", + ], + "Key of Hope Checkpoint": [ + "Autumn Hills - Hope Latch Checkpoint", + "Autumn Hills - Lakeside Checkpoint", + ], + "Lakeside Checkpoint": [ + "Autumn Hills - Climbing Claws Shop", + "Autumn Hills - Dimension Climb Shop", + ], + "Double Swing Checkpoint": [ + "Autumn Hills - Dimension Climb Shop", + "Autumn Hills - Spike Ball Swing Checkpoint", + "Autumn Hills - Bottom", + ], + "Spike Ball Swing Checkpoint": [ + "Autumn Hills - Double Swing Checkpoint", + "Autumn Hills - Leaf Golem Shop", + ], + }, + "Forlorn Temple": { + "Left": [ + "Autumn Hills - Right", + "Forlorn Temple - Outside Shop", + ], + "Right": [ + "Bamboo Creek - Top Left", + "Forlorn Temple - Demon King Shop", + ], + "Bottom": [ + "Catacombs - Top Left", + "Forlorn Temple - Outside Shop", + ], + "Outside Shop": [ + "Forlorn Temple - Left", + "Forlorn Temple - Bottom", + "Forlorn Temple - Entrance Shop", + ], + "Entrance Shop": [ + "Forlorn Temple - Outside Shop", + "Forlorn Temple - Sunny Day Checkpoint", + ], + "Climb Shop": [ + "Forlorn Temple - Rocket Maze Checkpoint", + "Forlorn Temple - Rocket Sunset Shop", + ], + "Rocket Sunset Shop": [ + "Forlorn Temple - Climb Shop", + "Forlorn Temple - Descent Shop", + ], + "Descent Shop": [ + "Forlorn Temple - Rocket Sunset Shop", + "Forlorn Temple - Saw Gauntlet Shop", + ], + "Saw Gauntlet Shop": [ + "Forlorn Temple - Demon King Shop", + ], + "Demon King Shop": [ + "Forlorn Temple - Saw Gauntlet Shop", + "Forlorn Temple - Right", + ], + "Sunny Day Checkpoint": [ + "Forlorn Temple - Rocket Maze Checkpoint", + ], + "Rocket Maze Checkpoint": [ + "Forlorn Temple - Sunny Day Checkpoint", + "Forlorn Temple - Climb Shop", + ], + }, + "Catacombs": { + "Top Left": [ + "Forlorn Temple - Bottom", + "Catacombs - Triple Spike Crushers Shop", + ], + "Bottom Left": [ + "Autumn Hills - Bottom", + "Catacombs - Triple Spike Crushers Shop", + "Catacombs - Death Trap Checkpoint", + ], + "Bottom": [ + "Dark Cave - Right", + "Catacombs - Dirty Pond Checkpoint", + ], + "Right": [ + "Bamboo Creek - Bottom Left", + "Catacombs - Ruxxtin Shop", + ], + "Triple Spike Crushers Shop": [ + "Catacombs - Bottom Left", + "Catacombs - Death Trap Checkpoint", + ], + "Ruxxtin Shop": [ + "Catacombs - Right", + "Catacombs - Dirty Pond Checkpoint", + ], + "Death Trap Checkpoint": [ + "Catacombs - Triple Spike Crushers Shop", + "Catacombs - Bottom Left", + "Catacombs - Dirty Pond Checkpoint", + ], + "Crusher Gauntlet Checkpoint": [ + "Catacombs - Dirty Pond Checkpoint", + ], + "Dirty Pond Checkpoint": [ + "Catacombs - Bottom", + "Catacombs - Death Trap Checkpoint", + "Catacombs - Crusher Gauntlet Checkpoint", + "Catacombs - Ruxxtin Shop", + ], + }, + "Bamboo Creek": { + "Bottom Left": [ + "Catacombs - Right", + "Bamboo Creek - Spike Crushers Shop", + ], + "Top Left": [ + "Bamboo Creek - Abandoned Shop", + "Forlorn Temple - Right", + ], + "Right": [ + "Howling Grotto - Left", + "Bamboo Creek - Time Loop Shop", + ], + "Spike Crushers Shop": [ + "Bamboo Creek - Bottom Left", + "Bamboo Creek - Abandoned Shop", + ], + "Abandoned Shop": [ + "Bamboo Creek - Spike Crushers Shop", + "Bamboo Creek - Spike Doors Checkpoint", + ], + "Time Loop Shop": [ + "Bamboo Creek - Right", + "Bamboo Creek - Spike Doors Checkpoint", + ], + "Spike Ball Pits Checkpoint": [ + "Bamboo Creek - Spike Doors Checkpoint", + ], + "Spike Doors Checkpoint": [ + "Bamboo Creek - Abandoned Shop", + "Bamboo Creek - Spike Ball Pits Checkpoint", + "Bamboo Creek - Time Loop Shop", + ], + }, + "Howling Grotto": { + "Left": [ + "Bamboo Creek - Right", + "Howling Grotto - Wingsuit Shop", + ], + "Top": [ + "Howling Grotto - Crushing Pits Shop", + "Quillshroom Marsh - Bottom Left", + ], + "Right": [ + "Howling Grotto - Emerald Golem Shop", + "Quillshroom Marsh - Top Left", + ], + "Bottom": [ + "Howling Grotto - Lost Woods Checkpoint", + "Sunken Shrine - Left", + ], + "Portal": [ + "Howling Grotto - Crushing Pits Shop", + "Tower HQ", + ], + "Wingsuit Shop": [ + "Howling Grotto - Left", + "Howling Grotto - Lost Woods Checkpoint", + ], + "Crushing Pits Shop": [ + "Howling Grotto - Lost Woods Checkpoint", + "Howling Grotto - Portal", + "Howling Grotto - Breezy Crushers Checkpoint", + "Howling Grotto - Top", + ], + "Emerald Golem Shop": [ + "Howling Grotto - Breezy Crushers Checkpoint", + "Howling Grotto - Right", + ], + "Lost Woods Checkpoint": [ + "Howling Grotto - Wingsuit Shop", + "Howling Grotto - Crushing Pits Shop", + "Howling Grotto - Bottom", + ], + "Breezy Crushers Checkpoint": [ + "Howling Grotto - Crushing Pits Shop", + "Howling Grotto - Emerald Golem Shop", + ], + }, + "Quillshroom Marsh": { + "Top Left": [ + "Howling Grotto - Right", + "Quillshroom Marsh - Seashell Checkpoint", + "Quillshroom Marsh - Spikey Window Shop", + ], + "Bottom Left": [ + "Howling Grotto - Top", + "Quillshroom Marsh - Sand Trap Shop", + "Quillshroom Marsh - Bottom Right", + ], + "Top Right": [ + "Quillshroom Marsh - Queen of Quills Shop", + "Searing Crags - Left", + ], + "Bottom Right": [ + "Quillshroom Marsh - Bottom Left", + "Quillshroom Marsh - Sand Trap Shop", + "Searing Crags - Bottom", + ], + "Spikey Window Shop": [ + "Quillshroom Marsh - Top Left", + "Quillshroom Marsh - Seashell Checkpoint", + "Quillshroom Marsh - Quicksand Checkpoint", + ], + "Sand Trap Shop": [ + "Quillshroom Marsh - Quicksand Checkpoint", + "Quillshroom Marsh - Bottom Left", + "Quillshroom Marsh - Bottom Right", + "Quillshroom Marsh - Spike Wave Checkpoint", + ], + "Queen of Quills Shop": [ + "Quillshroom Marsh - Spike Wave Checkpoint", + "Quillshroom Marsh - Top Right", + ], + "Seashell Checkpoint": [ + "Quillshroom Marsh - Top Left", + "Quillshroom Marsh - Spikey Window Shop", + ], + "Quicksand Checkpoint": [ + "Quillshroom Marsh - Spikey Window Shop", + "Quillshroom Marsh - Sand Trap Shop", + ], + "Spike Wave Checkpoint": [ + "Quillshroom Marsh - Sand Trap Shop", + "Quillshroom Marsh - Queen of Quills Shop", + ], + }, + "Searing Crags": { + "Left": [ + "Quillshroom Marsh - Top Right", + "Searing Crags - Rope Dart Shop", + ], + "Top": [ + "Searing Crags - Colossuses Shop", + "Glacial Peak - Bottom", + ], + "Bottom": [ + "Searing Crags - Portal", + "Quillshroom Marsh - Bottom Right", + ], + "Right": [ + "Searing Crags - Portal", + "Underworld - Left", + ], + "Portal": [ + "Searing Crags - Bottom", + "Searing Crags - Right", + "Searing Crags - Before Final Climb Shop", + "Searing Crags - Colossuses Shop", + "Tower HQ", + ], + "Rope Dart Shop": [ + "Searing Crags - Left", + "Searing Crags - Triple Ball Spinner Checkpoint", + ], + "Falling Rocks Shop": [ + "Searing Crags - Triple Ball Spinner Checkpoint", + "Searing Crags - Searing Mega Shard Shop", + ], + "Searing Mega Shard Shop": [ + "Searing Crags - Falling Rocks Shop", + "Searing Crags - Before Final Climb Shop", + "Searing Crags - Key of Strength Shop", + ], + "Before Final Climb Shop": [ + "Searing Crags - Raining Rocks Checkpoint", + "Searing Crags - Portal", + "Searing Crags - Colossuses Shop", + ], + "Colossuses Shop": [ + "Searing Crags - Before Final Climb Shop", + "Searing Crags - Key of Strength Shop", + "Searing Crags - Portal", + "Searing Crags - Top", + ], + "Key of Strength Shop": [ + "Searing Crags - Searing Mega Shard Shop", + ], + "Triple Ball Spinner Checkpoint": [ + "Searing Crags - Rope Dart Shop", + "Searing Crags - Falling Rocks Shop", + ], + "Raining Rocks Checkpoint": [ + "Searing Crags - Searing Mega Shard Shop", + "Searing Crags - Before Final Climb Shop", + ], + }, + "Glacial Peak": { + "Bottom": [ + "Searing Crags - Top", + "Glacial Peak - Ice Climbers' Shop", + ], + "Left": [ + "Elemental Skylands - Air Shmup", + "Glacial Peak - Projectile Spike Pit Checkpoint", + "Glacial Peak - Glacial Mega Shard Shop", + ], + "Top": [ + "Glacial Peak - Tower Entrance Shop", + "Cloud Ruins - Left", + ], + "Portal": [ + "Glacial Peak - Tower Entrance Shop", + "Tower HQ", + ], + "Ice Climbers' Shop": [ + "Glacial Peak - Bottom", + "Glacial Peak - Projectile Spike Pit Checkpoint", + ], + "Glacial Mega Shard Shop": [ + "Glacial Peak - Left", + "Glacial Peak - Air Swag Checkpoint", + ], + "Tower Entrance Shop": [ + "Glacial Peak - Top", + "Glacial Peak - Free Climbing Checkpoint", + "Glacial Peak - Portal", + ], + "Projectile Spike Pit Checkpoint": [ + "Glacial Peak - Ice Climbers' Shop", + "Glacial Peak - Left", + ], + "Air Swag Checkpoint": [ + "Glacial Peak - Glacial Mega Shard Shop", + "Glacial Peak - Free Climbing Checkpoint", + ], + "Free Climbing Checkpoint": [ + "Glacial Peak - Air Swag Checkpoint", + "Glacial Peak - Tower Entrance Shop", + ], + }, + "Tower of Time": { + "Left": [ + "Tower of Time - Final Chance Shop", + ], + "Final Chance Shop": [ + "Tower of Time - First Checkpoint", + ], + "Arcane Golem Shop": [ + "Tower of Time - Sixth Checkpoint", + ], + "First Checkpoint": [ + "Tower of Time - Second Checkpoint", + ], + "Second Checkpoint": [ + "Tower of Time - Third Checkpoint", + ], + "Third Checkpoint": [ + "Tower of Time - Fourth Checkpoint", + ], + "Fourth Checkpoint": [ + "Tower of Time - Fifth Checkpoint", + ], + "Fifth Checkpoint": [ + "Tower of Time - Sixth Checkpoint", + ], + "Sixth Checkpoint": [ + "Tower of Time - Arcane Golem Shop", + ], + }, + "Cloud Ruins": { + "Left": [ + "Glacial Peak - Top", + "Cloud Ruins - Cloud Entrance Shop", + ], + "Cloud Entrance Shop": [ + "Cloud Ruins - Left", + "Cloud Ruins - Spike Float Checkpoint", + ], + "Pillar Glide Shop": [ + "Cloud Ruins - Spike Float Checkpoint", + "Cloud Ruins - Ghost Pit Checkpoint", + "Cloud Ruins - Crushers' Descent Shop", + ], + "Crushers' Descent Shop": [ + "Cloud Ruins - Pillar Glide Shop", + "Cloud Ruins - Toothbrush Alley Checkpoint", + ], + "Seeing Spikes Shop": [ + "Cloud Ruins - Toothbrush Alley Checkpoint", + "Cloud Ruins - Sliding Spikes Shop", + ], + "Sliding Spikes Shop": [ + "Cloud Ruins - Seeing Spikes Shop", + "Cloud Ruins - Saw Pit Checkpoint", + ], + "Final Flight Shop": [ + "Cloud Ruins - Saw Pit Checkpoint", + "Cloud Ruins - Manfred's Shop", + ], + "Manfred's Shop": [ + "Cloud Ruins - Final Flight Shop", + ], + "Spike Float Checkpoint": [ + "Cloud Ruins - Cloud Entrance Shop", + "Cloud Ruins - Pillar Glide Shop", + ], + "Ghost Pit Checkpoint": [ + "Cloud Ruins - Pillar Glide Shop", + ], + "Toothbrush Alley Checkpoint": [ + "Cloud Ruins - Crushers' Descent Shop", + "Cloud Ruins - Seeing Spikes Shop", + ], + "Saw Pit Checkpoint": [ + "Cloud Ruins - Sliding Spikes Shop", + "Cloud Ruins - Final Flight Shop", + ], + }, + "Underworld": { + "Left": [ + "Underworld - Left Shop", + "Searing Crags - Right", + ], + "Left Shop": [ + "Underworld - Left", + "Underworld - Hot Dip Checkpoint", + ], + "Fireball Wave Shop": [ + "Underworld - Hot Dip Checkpoint", + "Underworld - Long Climb Shop", + ], + "Long Climb Shop": [ + "Underworld - Fireball Wave Shop", + "Underworld - Hot Tub Checkpoint", + ], + "Barm'athaziel Shop": [ + "Underworld - Hot Tub Checkpoint", + ], + "Key of Chaos Shop": [ + ], + "Hot Dip Checkpoint": [ + "Underworld - Left Shop", + "Underworld - Fireball Wave Shop", + "Underworld - Lava Run Checkpoint", + ], + "Hot Tub Checkpoint": [ + "Underworld - Long Climb Shop", + "Underworld - Barm'athaziel Shop", + ], + "Lava Run Checkpoint": [ + "Underworld - Hot Dip Checkpoint", + "Underworld - Key of Chaos Shop", + ], + }, + "Dark Cave": { + "Right": [ + "Catacombs - Bottom", + "Dark Cave - Left", + ], + "Left": [ + "Riviere Turquoise - Right", + ], + }, + "Riviere Turquoise": { + "Right": [ + "Riviere Turquoise - Portal", + ], + "Portal": [ + "Riviere Turquoise - Waterfall Shop", + "Tower HQ", + ], + "Waterfall Shop": [ + "Riviere Turquoise - Portal", + "Riviere Turquoise - Flower Flight Checkpoint", + ], + "Launch of Faith Shop": [ + "Riviere Turquoise - Flower Flight Checkpoint", + "Riviere Turquoise - Log Flume Shop", + ], + "Log Flume Shop": [ + "Riviere Turquoise - Log Climb Shop", + ], + "Log Climb Shop": [ + "Riviere Turquoise - Restock Shop", + ], + "Restock Shop": [ + "Riviere Turquoise - Butterfly Matriarch Shop", + ], + "Butterfly Matriarch Shop": [ + ], + "Flower Flight Checkpoint": [ + "Riviere Turquoise - Waterfall Shop", + "Riviere Turquoise - Launch of Faith Shop", + ], + }, + "Elemental Skylands": { + "Air Shmup": [ + "Elemental Skylands - Air Intro Shop", + ], + "Air Intro Shop": [ + "Elemental Skylands - Air Seal Checkpoint", + "Elemental Skylands - Air Generator Shop", + ], + "Air Seal Checkpoint": [ + "Elemental Skylands - Air Intro Shop", + "Elemental Skylands - Air Generator Shop", + ], + "Air Generator Shop": [ + "Elemental Skylands - Earth Shmup", + ], + "Earth Shmup": [ + "Elemental Skylands - Earth Intro Shop", + ], + "Earth Intro Shop": [ + "Elemental Skylands - Earth Generator Shop", + ], + "Earth Generator Shop": [ + "Elemental Skylands - Fire Shmup", + ], + "Fire Shmup": [ + "Elemental Skylands - Fire Intro Shop", + ], + "Fire Intro Shop": [ + "Elemental Skylands - Fire Generator Shop", + ], + "Fire Generator Shop": [ + "Elemental Skylands - Water Shmup", + ], + "Water Shmup": [ + "Elemental Skylands - Water Intro Shop", + ], + "Water Intro Shop": [ + "Elemental Skylands - Water Generator Shop", + ], + "Water Generator Shop": [ + "Elemental Skylands - Right", + ], + "Right": [ + "Glacial Peak - Left", + ], + }, + "Sunken Shrine": { + "Left": [ + "Howling Grotto - Bottom", + "Sunken Shrine - Portal", + ], + "Portal": [ + "Sunken Shrine - Left", + "Sunken Shrine - Above Portal Shop", + "Sunken Shrine - Sun Path Shop", + "Sunken Shrine - Moon Path Shop", + "Tower HQ", + ], + "Above Portal Shop": [ + "Sunken Shrine - Portal", + "Sunken Shrine - Lifeguard Shop", + ], + "Lifeguard Shop": [ + "Sunken Shrine - Above Portal Shop", + "Sunken Shrine - Lightfoot Tabi Checkpoint", + ], + "Sun Path Shop": [ + "Sunken Shrine - Portal", + "Sunken Shrine - Tabi Gauntlet Shop", + ], + "Tabi Gauntlet Shop": [ + "Sunken Shrine - Sun Path Shop", + "Sunken Shrine - Sun Crest Checkpoint", + ], + "Moon Path Shop": [ + "Sunken Shrine - Portal", + "Sunken Shrine - Waterfall Paradise Checkpoint", + ], + "Lightfoot Tabi Checkpoint": [ + "Sunken Shrine - Portal", + ], + "Sun Crest Checkpoint": [ + "Sunken Shrine - Tabi Gauntlet Shop", + "Sunken Shrine - Portal", + ], + "Waterfall Paradise Checkpoint": [ + "Sunken Shrine - Moon Path Shop", + "Sunken Shrine - Moon Crest Checkpoint", + ], + "Moon Crest Checkpoint": [ + "Sunken Shrine - Waterfall Paradise Checkpoint", + "Sunken Shrine - Portal", + ], + }, +} + +RANDOMIZED_CONNECTIONS: Dict[str, str] = { + "Ninja Village - Right": "Autumn Hills - Left", + "Autumn Hills - Left": "Ninja Village - Right", + "Autumn Hills - Right": "Forlorn Temple - Left", + "Autumn Hills - Bottom": "Catacombs - Bottom Left", + "Forlorn Temple - Left": "Autumn Hills - Right", + "Forlorn Temple - Right": "Bamboo Creek - Top Left", + "Forlorn Temple - Bottom": "Catacombs - Top Left", + "Catacombs - Top Left": "Forlorn Temple - Bottom", + "Catacombs - Bottom Left": "Autumn Hills - Bottom", + "Catacombs - Bottom": "Dark Cave - Right", + "Catacombs - Right": "Bamboo Creek - Bottom Left", + "Bamboo Creek - Bottom Left": "Catacombs - Right", + "Bamboo Creek - Right": "Howling Grotto - Left", + "Bamboo Creek - Top Left": "Forlorn Temple - Right", + "Howling Grotto - Left": "Bamboo Creek - Right", + "Howling Grotto - Top": "Quillshroom Marsh - Bottom Left", + "Howling Grotto - Right": "Quillshroom Marsh - Top Left", + "Howling Grotto - Bottom": "Sunken Shrine - Left", + "Quillshroom Marsh - Top Left": "Howling Grotto - Right", + "Quillshroom Marsh - Bottom Left": "Howling Grotto - Top", + "Quillshroom Marsh - Top Right": "Searing Crags - Left", + "Quillshroom Marsh - Bottom Right": "Searing Crags - Bottom", + "Searing Crags - Left": "Quillshroom Marsh - Top Right", + "Searing Crags - Top": "Glacial Peak - Bottom", + "Searing Crags - Bottom": "Quillshroom Marsh - Bottom Right", + "Searing Crags - Right": "Underworld - Left", + "Glacial Peak - Bottom": "Searing Crags - Top", + "Glacial Peak - Top": "Cloud Ruins - Left", + "Glacial Peak - Left": "Elemental Skylands - Air Shmup", + "Cloud Ruins - Left": "Glacial Peak - Top", + "Elemental Skylands - Right": "Glacial Peak - Left", + "Tower HQ": "Tower of Time - Left", + "Artificer": "Corrupted Future", + "Underworld - Left": "Searing Crags - Right", + "Dark Cave - Right": "Catacombs - Bottom", + "Dark Cave - Left": "Riviere Turquoise - Right", + "Sunken Shrine - Left": "Howling Grotto - Bottom", +} + +TRANSITIONS: List[str] = [ + "Ninja Village - Right", + "Autumn Hills - Left", + "Autumn Hills - Right", + "Autumn Hills - Bottom", + "Forlorn Temple - Left", + "Forlorn Temple - Bottom", + "Forlorn Temple - Right", + "Catacombs - Top Left", + "Catacombs - Right", + "Catacombs - Bottom", + "Catacombs - Bottom Left", + "Dark Cave - Right", + "Dark Cave - Left", + "Riviere Turquoise - Right", + "Howling Grotto - Left", + "Howling Grotto - Right", + "Howling Grotto - Top", + "Howling Grotto - Bottom", + "Sunken Shrine - Left", + "Bamboo Creek - Top Left", + "Bamboo Creek - Bottom Left", + "Bamboo Creek - Right", + "Quillshroom Marsh - Top Left", + "Quillshroom Marsh - Bottom Left", + "Quillshroom Marsh - Top Right", + "Quillshroom Marsh - Bottom Right", + "Searing Crags - Left", + "Searing Crags - Bottom", + "Searing Crags - Right", + "Searing Crags - Top", + "Glacial Peak - Bottom", + "Glacial Peak - Top", + "Glacial Peak - Left", + "Elemental Skylands - Air Shmup", + "Elemental Skylands - Right", + "Tower HQ", + "Tower of Time - Left", + "Corrupted Future", + "Cloud Ruins - Left", + "Underworld - Left", +] diff --git a/worlds/messenger/constants.py b/worlds/messenger/constants.py index f05d276ceaf4..0c4d6a944cef 100644 --- a/worlds/messenger/constants.py +++ b/worlds/messenger/constants.py @@ -24,6 +24,8 @@ # "Astral Seed", # "Astral Tea Leaves", "Money Wrench", + "Candle", + "Seashell", ] PHOBEKINS = [ @@ -103,6 +105,52 @@ "Searing Crags - Pyro", "Bamboo Creek - Claustro", "Cloud Ruins - Acro", + # seals + "Ninja Village Seal - Tree House", + "Autumn Hills Seal - Trip Saws", + "Autumn Hills Seal - Double Swing Saws", + "Autumn Hills Seal - Spike Ball Swing", + "Autumn Hills Seal - Spike Ball Darts", + "Catacombs Seal - Triple Spike Crushers", + "Catacombs Seal - Crusher Gauntlet", + "Catacombs Seal - Dirty Pond", + "Bamboo Creek Seal - Spike Crushers and Doors", + "Bamboo Creek Seal - Spike Ball Pits", + "Bamboo Creek Seal - Spike Crushers and Doors v2", + "Howling Grotto Seal - Windy Saws and Balls", + "Howling Grotto Seal - Crushing Pits", + "Howling Grotto Seal - Breezy Crushers", + "Quillshroom Marsh Seal - Spikey Window", + "Quillshroom Marsh Seal - Sand Trap", + "Quillshroom Marsh Seal - Do the Spike Wave", + "Searing Crags Seal - Triple Ball Spinner", + "Searing Crags Seal - Raining Rocks", + "Searing Crags Seal - Rhythm Rocks", + "Glacial Peak Seal - Ice Climbers", + "Glacial Peak Seal - Projectile Spike Pit", + "Glacial Peak Seal - Glacial Air Swag", + "Tower of Time Seal - Time Waster", + "Tower of Time Seal - Lantern Climb", + "Tower of Time Seal - Arcane Orbs", + "Cloud Ruins Seal - Ghost Pit", + "Cloud Ruins Seal - Toothbrush Alley", + "Cloud Ruins Seal - Saw Pit", + "Cloud Ruins Seal - Money Farm Room", + "Underworld Seal - Sharp and Windy Climb", + "Underworld Seal - Spike Wall", + "Underworld Seal - Fireball Wave", + "Underworld Seal - Rising Fanta", + "Forlorn Temple Seal - Rocket Maze", + "Forlorn Temple Seal - Rocket Sunset", + "Sunken Shrine Seal - Ultra Lifeguard", + "Sunken Shrine Seal - Waterfall Paradise", + "Sunken Shrine Seal - Tabi Gauntlet", + "Riviere Turquoise Seal - Bounces and Balls", + "Riviere Turquoise Seal - Launch of Faith", + "Riviere Turquoise Seal - Flower Power", + "Elemental Skylands Seal - Air", + "Elemental Skylands Seal - Water", + "Elemental Skylands Seal - Fire", ] BOSS_LOCATIONS = [ diff --git a/worlds/messenger/docs/en_The Messenger.md b/worlds/messenger/docs/en_The Messenger.md index 374753b487a0..f071ba1c1435 100644 --- a/worlds/messenger/docs/en_The Messenger.md +++ b/worlds/messenger/docs/en_The Messenger.md @@ -69,8 +69,8 @@ for it. The groups you can use for The Messenger are: * Sometimes upon teleporting back to HQ, Ninja will run left and enter a different portal than the one entered by the player. This may also cause a softlock. * Text entry menus don't accept controller input -* Opening the shop chest in power seal hunt mode from the tower of time HQ will softlock the game. -* If you are unable to reset file slots, load into a save slot, let the game save, and close it. +* In power seal hunt mode, the chest must be opened by entering the shop from a level. Teleporting to HQ and opening the + chest will not work. ## What do I do if I have a problem? diff --git a/worlds/messenger/docs/setup_en.md b/worlds/messenger/docs/setup_en.md index 9617baf3e007..d986b70f9c98 100644 --- a/worlds/messenger/docs/setup_en.md +++ b/worlds/messenger/docs/setup_en.md @@ -9,10 +9,20 @@ ## Installation -1. Read the [Game Info Page](/games/The%20Messenger/info/en) for how the game works, caveats and known issues -2. Download and install Courier Mod Loader using the instructions on the release page +Read changes to the base game on the [Game Info Page](/games/The%20Messenger/info/en) + +### Automated Installation + +1. Download and install the latest [Archipelago release](https://github.com/ArchipelagoMW/Archipelago/releases/latest) +2. Launch the Archipelago Launcher (ArchipelagoLauncher.exe) +3. Click on "The Messenger" +4. Follow the prompts + +### Manual Installation + +1. Download and install Courier Mod Loader using the instructions on the release page * [Latest release is currently 0.7.1](https://github.com/Brokemia/Courier/releases) -3. Download and install the randomizer mod +2. Download and install the randomizer mod 1. Download the latest TheMessengerRandomizerAP.zip from [The Messenger Randomizer Mod AP releases page](https://github.com/alwaysintreble/TheMessengerRandomizerModAP/releases) 2. Extract the zip file to `TheMessenger/Mods/` of your game's install location @@ -32,19 +42,17 @@ ## Joining a MultiWorld Game 1. Launch the game -2. Navigate to `Options > Third Party Mod Options` -3. Select `Reset Randomizer File Slots` - * This will set up all of your save slots with new randomizer save files. You can have up to 3 randomizer files at a - time, but must do this step again to start new runs afterward. -4. Enter connection info using the relevant option buttons +2. Navigate to `Options > Archipelago Options` +3. Enter connection info using the relevant option buttons * **The game is limited to alphanumerical characters, `.`, and `-`.** * This defaults to `archipelago.gg` and does not need to be manually changed if connecting to a game hosted on the website. * If using a name that cannot be entered in the in game menus, there is a config file (APConfig.toml) in the game directory. When using this, all connection information must be entered in the file. -5. Select the `Connect to Archipelago` button -6. Navigate to save file selection -7. Select a new valid randomizer save +4. Select the `Connect to Archipelago` button +5. Navigate to save file selection +6. Start a new game + * If you're already connected, deleting a save will not disconnect you and is completely safe. ## Continuing a MultiWorld Game @@ -52,6 +60,5 @@ At any point while playing, it is completely safe to quit. Returning to the titl disconnect you from the server. To reconnect to an in progress MultiWorld, simply load the correct save file for that MultiWorld. -If the reconnection fails, the message on screen will state you are disconnected. If this happens, you can return to the -main menu and connect to the server as in [Joining a Multiworld Game](#joining-a-multiworld-game), then load the correct -save file. +If the reconnection fails, the message on screen will state you are disconnected. If this happens, the game will attempt +to reconnect in the background. An option will also be added to the in game menu to change the port, if necessary. diff --git a/worlds/messenger/options.py b/worlds/messenger/options.py index 6984e215472a..c56ee700438f 100644 --- a/worlds/messenger/options.py +++ b/worlds/messenger/options.py @@ -17,29 +17,78 @@ class Logic(Choice): """ The level of logic to use when determining what locations in your world are accessible. - Normal: can require damage boosts, but otherwise approachable for someone who has beaten the game. - Hard: has leashing, normal clips, time warps and turtle boosting in logic. - OoB: places everything with the minimum amount of rules possible. Expect to do OoB. Not guaranteed completable. + Normal: Can require damage boosts, but otherwise approachable for someone who has beaten the game. + Hard: Expects more knowledge and tighter execution. Has leashing, normal clips and much tighter d-boosting in logic. """ display_name = "Logic Level" option_normal = 0 option_hard = 1 - option_oob = 2 + alias_oob = 1 alias_challenging = 1 -class PowerSeals(DefaultOnToggle): - """Whether power seal locations should be randomized.""" - display_name = "Shuffle Seals" - - class MegaShards(Toggle): """Whether mega shards should be item locations.""" display_name = "Shuffle Mega Time Shards" +class LimitedMovement(Toggle): + """ + Removes either rope dart or wingsuit from the itempool. Forces logic to at least hard and accessibility to minimal. + """ + display_name = "Limited Movement" + + +class EarlyMed(Toggle): + """Guarantees meditation will be found early""" + display_name = "Early Meditation" + + +class AvailablePortals(Range): + """Number of portals that are available from the start. Autumn Hills, Howling Grotto, and Glacial Peak are always available. If portal outputs are not randomized, Searing Crags will also be available.""" + display_name = "Available Starting Portals" + range_start = 3 + range_end = 6 + default = 6 + + +class ShufflePortals(Choice): + """ + Whether the portals lead to random places. + Entering a portal from its vanilla area will always lead to HQ, and will unlock it if relevant. + Supports plando. + + None: Portals will take you where they're supposed to. + Shops: Portals can lead to any area except Music Box and Elemental Skylands, with each portal output guaranteed to not overlap with another portal's. Will only put you at a portal or a shop. + Checkpoints: Like Shops except checkpoints without shops are also valid drop points. + Anywhere: Like Checkpoints except it's possible for multiple portals to output to the same map. + """ + display_name = "Shuffle Portal Outputs" + option_none = 0 + alias_off = 0 + option_shops = 1 + option_checkpoints = 2 + option_anywhere = 3 + + +class ShuffleTransitions(Choice): + """ + Whether the transitions between the levels should be randomized. + Supports plando. + + None: Level transitions lead where they should. + Coupled: Returning through a transition will take you from whence you came. + Decoupled: Any level transition can take you to any other level transition. + """ + display_name = "Shuffle Level Transitions" + option_none = 0 + alias_off = 0 + option_coupled = 1 + option_decoupled = 2 + + class Goal(Choice): - """Requirement to finish the game. Power Seal Hunt will force power seal locations to be shuffled.""" + """Requirement to finish the game.""" display_name = "Goal" option_open_music_box = 0 option_power_seal_hunt = 1 @@ -137,8 +186,12 @@ class MessengerOptions(DeathLinkMixin, PerGameCommonOptions): accessibility: MessengerAccessibility start_inventory: StartInventoryPool logic_level: Logic - shuffle_seals: PowerSeals shuffle_shards: MegaShards + limited_movement: LimitedMovement + early_meditation: EarlyMed + available_portals: AvailablePortals + shuffle_portals: ShufflePortals + # shuffle_transitions: ShuffleTransitions goal: Goal music_box: MusicBox notes_needed: NotesNeeded diff --git a/worlds/messenger/portals.py b/worlds/messenger/portals.py new file mode 100644 index 000000000000..64438b018400 --- /dev/null +++ b/worlds/messenger/portals.py @@ -0,0 +1,290 @@ +from typing import List, TYPE_CHECKING + +from BaseClasses import CollectionState, PlandoOptions +from .options import ShufflePortals +from ..generic import PlandoConnection + +if TYPE_CHECKING: + from . import MessengerWorld + + +PORTALS = [ + "Autumn Hills", + "Riviere Turquoise", + "Howling Grotto", + "Sunken Shrine", + "Searing Crags", + "Glacial Peak", +] + + +REGION_ORDER = [ + "Autumn Hills", + "Forlorn Temple", + "Catacombs", + "Bamboo Creek", + "Howling Grotto", + "Quillshroom Marsh", + "Searing Crags", + "Glacial Peak", + "Tower of Time", + "Cloud Ruins", + "Underworld", + "Riviere Turquoise", + "Elemental Skylands", + "Sunken Shrine", +] + + +SHOP_POINTS = { + "Autumn Hills": [ + "Climbing Claws", + "Hope Path", + "Dimension Climb", + "Leaf Golem", + ], + "Forlorn Temple": [ + "Outside", + "Entrance", + "Climb", + "Rocket Sunset", + "Descent", + "Saw Gauntlet", + "Demon King", + ], + "Catacombs": [ + "Triple Spike Crushers", + "Ruxxtin", + ], + "Bamboo Creek": [ + "Spike Crushers", + "Abandoned", + "Time Loop", + ], + "Howling Grotto": [ + "Wingsuit", + "Crushing Pits", + "Emerald Golem", + ], + "Quillshroom Marsh": [ + "Spikey Window", + "Sand Trap", + "Queen of Quills", + ], + "Searing Crags": [ + "Rope Dart", + "Falling Rocks", + "Searing Mega Shard", + "Before Final Climb", + "Colossuses", + "Key of Strength", + ], + "Glacial Peak": [ + "Ice Climbers'", + "Glacial Mega Shard", + "Tower Entrance", + ], + "Tower of Time": [ + "Final Chance", + "Arcane Golem", + ], + "Cloud Ruins": [ + "Cloud Entrance", + "Pillar Glide", + "Crushers' Descent", + "Seeing Spikes", + "Final Flight", + "Manfred's", + ], + "Underworld": [ + "Left", + "Fireball Wave", + "Long Climb", + # "Barm'athaziel", # not currently valid + "Key of Chaos", + ], + "Riviere Turquoise": [ + "Waterfall", + "Launch of Faith", + "Log Flume", + "Log Climb", + "Restock", + "Butterfly Matriarch", + ], + "Elemental Skylands": [ + "Air Intro", + "Air Generator", + "Earth Intro", + "Earth Generator", + "Fire Intro", + "Fire Generator", + "Water Intro", + "Water Generator", + ], + "Sunken Shrine": [ + "Above Portal", + "Lifeguard", + "Sun Path", + "Tabi Gauntlet", + "Moon Path", + ] +} + + +CHECKPOINTS = { + "Autumn Hills": [ + "Hope Latch", + "Key of Hope", + "Lakeside", + "Double Swing", + "Spike Ball Swing", + ], + "Forlorn Temple": [ + "Sunny Day", + "Rocket Maze", + ], + "Catacombs": [ + "Death Trap", + "Crusher Gauntlet", + "Dirty Pond", + ], + "Bamboo Creek": [ + "Spike Ball Pits", + "Spike Doors", + ], + "Howling Grotto": [ + "Lost Woods", + "Breezy Crushers", + ], + "Quillshroom Marsh": [ + "Seashell", + "Quicksand", + "Spike Wave", + ], + "Searing Crags": [ + "Triple Ball Spinner", + "Raining Rocks", + ], + "Glacial Peak": [ + "Projectile Spike Pit", + "Air Swag", + "Free Climbing", + ], + "Tower of Time": [ + "First", + "Second", + "Third", + "Fourth", + "Fifth", + "Sixth", + ], + "Cloud Ruins": [ + "Spike Float", + "Ghost Pit", + "Toothbrush Alley", + "Saw Pit", + ], + "Underworld": [ + "Hot Dip", + "Hot Tub", + "Lava Run", + ], + "Riviere Turquoise": [ + "Flower Flight", + ], + "Elemental Skylands": [ + "Air Seal", + ], + "Sunken Shrine": [ + "Lightfoot Tabi", + "Sun Crest", + "Waterfall Paradise", + "Moon Crest", + ] +} + + +def shuffle_portals(world: "MessengerWorld") -> None: + def create_mapping(in_portal: str, warp: str) -> None: + nonlocal available_portals + parent = out_to_parent[warp] + exit_string = f"{parent.strip(' ')} - " + + if "Portal" in warp: + exit_string += "Portal" + world.portal_mapping.append(int(f"{REGION_ORDER.index(parent)}00")) + elif warp_point in SHOP_POINTS[parent]: + exit_string += f"{warp_point} Shop" + world.portal_mapping.append(int(f"{REGION_ORDER.index(parent)}1{SHOP_POINTS[parent].index(warp_point)}")) + else: + exit_string += f"{warp_point} Checkpoint" + world.portal_mapping.append(int(f"{REGION_ORDER.index(parent)}2{CHECKPOINTS[parent].index(warp_point)}")) + + world.spoiler_portal_mapping[in_portal] = exit_string + connect_portal(world, in_portal, exit_string) + + available_portals.remove(warp) + if shuffle_type < ShufflePortals.option_anywhere: + available_portals = [port for port in available_portals if port not in shop_points[parent]] + + def handle_planned_portals(plando_connections: List[PlandoConnection]) -> None: + for connection in plando_connections: + if connection.entrance not in PORTALS: + continue + # let it crash here if input is invalid + create_mapping(connection.entrance, connection.exit) + world.plando_portals.append(connection.entrance) + + shuffle_type = world.options.shuffle_portals + shop_points = SHOP_POINTS.copy() + for portal in PORTALS: + shop_points[portal].append(f"{portal} Portal") + if shuffle_type > ShufflePortals.option_shops: + shop_points.update(CHECKPOINTS) + out_to_parent = {checkpoint: parent for parent, checkpoints in shop_points.items() for checkpoint in checkpoints} + available_portals = [val for zone in shop_points.values() for val in zone] + + plando = world.multiworld.plando_connections[world.player] + if plando and world.multiworld.plando_options & PlandoOptions.connections: + handle_planned_portals(plando) + world.multiworld.plando_connections[world.player] = [connection for connection in plando + if connection.entrance not in PORTALS] + for portal in PORTALS: + warp_point = world.random.choice(available_portals) + create_mapping(portal, warp_point) + + +def connect_portal(world: "MessengerWorld", portal: str, out_region: str) -> None: + entrance = world.multiworld.get_entrance(f"ToTHQ {portal} Portal", world.player) + entrance.connect(world.multiworld.get_region(out_region, world.player)) + + +def disconnect_portals(world: "MessengerWorld") -> None: + for portal in [port for port in PORTALS if port not in world.plando_portals]: + entrance = world.multiworld.get_entrance(f"ToTHQ {portal} Portal", world.player) + entrance.connected_region.entrances.remove(entrance) + entrance.connected_region = None + if portal in world.spoiler_portal_mapping: + del world.spoiler_portal_mapping[portal] + if len(world.portal_mapping) > len(world.spoiler_portal_mapping): + world.portal_mapping = world.portal_mapping[:len(world.spoiler_portal_mapping)] + + +def validate_portals(world: "MessengerWorld") -> bool: + # if world.options.shuffle_transitions: + # return True + new_state = CollectionState(world.multiworld) + new_state.update_reachable_regions(world.player) + reachable_locs = 0 + for loc in world.multiworld.get_locations(world.player): + reachable_locs += loc.can_reach(new_state) + if reachable_locs > 5: + return True + return False + + +def add_closed_portal_reqs(world: "MessengerWorld") -> None: + closed_portals = [entrance for entrance in PORTALS if f"{entrance} Portal" not in world.starting_portals] + for portal in closed_portals: + tower_exit = world.multiworld.get_entrance(f"ToTHQ {portal} Portal", world.player) + tower_exit.access_rule = lambda state: state.has(portal, world.player) diff --git a/worlds/messenger/regions.py b/worlds/messenger/regions.py index 43de4dd1f6d0..153f8510f1bd 100644 --- a/worlds/messenger/regions.py +++ b/worlds/messenger/regions.py @@ -1,103 +1,446 @@ -from typing import Dict, List, Set +from typing import Dict, List -REGIONS: Dict[str, List[str]] = { - "Menu": [], - "Tower HQ": [], - "The Shop": [], - "The Craftsman's Corner": [], - "Tower of Time": [], - "Ninja Village": ["Ninja Village - Candle", "Ninja Village - Astral Seed"], - "Autumn Hills": ["Autumn Hills - Climbing Claws", "Autumn Hills - Key of Hope", "Autumn Hills - Leaf Golem"], - "Forlorn Temple": ["Forlorn Temple - Demon King"], - "Catacombs": ["Catacombs - Necro", "Catacombs - Ruxxtin's Amulet", "Catacombs - Ruxxtin"], - "Bamboo Creek": ["Bamboo Creek - Claustro"], - "Howling Grotto": ["Howling Grotto - Wingsuit", "Howling Grotto - Emerald Golem"], - "Quillshroom Marsh": ["Quillshroom Marsh - Seashell", "Quillshroom Marsh - Queen of Quills"], - "Searing Crags": ["Searing Crags - Rope Dart"], - "Searing Crags Upper": ["Searing Crags - Power Thistle", "Searing Crags - Key of Strength", - "Searing Crags - Astral Tea Leaves"], - "Glacial Peak": [], - "Cloud Ruins": [], - "Cloud Ruins Right": ["Cloud Ruins - Acro"], - "Underworld": ["Searing Crags - Pyro", "Underworld - Key of Chaos"], - "Dark Cave": [], - "Riviere Turquoise Entrance": [], - "Riviere Turquoise": ["Riviere Turquoise - Butterfly Matriarch"], - "Sunken Shrine": ["Sunken Shrine - Lightfoot Tabi", "Sunken Shrine - Sun Crest", "Sunken Shrine - Moon Crest", - "Sunken Shrine - Key of Love"], - "Elemental Skylands": ["Elemental Skylands - Key of Symbiosis"], + +LOCATIONS: Dict[str, List[str]] = { + "Ninja Village - Nest": [ + "Ninja Village - Candle", + "Ninja Village - Astral Seed", + "Ninja Village Seal - Tree House", + ], + "Autumn Hills - Climbing Claws Shop": [ + "Autumn Hills - Climbing Claws", + "Autumn Hills Seal - Trip Saws", + ], + "Autumn Hills - Key of Hope Checkpoint": [ + "Autumn Hills - Key of Hope", + ], + "Autumn Hills - Double Swing Checkpoint": [ + "Autumn Hills Seal - Double Swing Saws", + ], + "Autumn Hills - Spike Ball Swing Checkpoint": [ + "Autumn Hills Seal - Spike Ball Swing", + "Autumn Hills Seal - Spike Ball Darts", + ], + "Autumn Hills - Leaf Golem Shop": [ + "Autumn Hills - Leaf Golem", + ], + "Forlorn Temple - Rocket Maze Checkpoint": [ + "Forlorn Temple Seal - Rocket Maze", + ], + "Forlorn Temple - Rocket Sunset Shop": [ + "Forlorn Temple Seal - Rocket Sunset", + ], + "Forlorn Temple - Demon King Shop": [ + "Forlorn Temple - Demon King", + ], + "Catacombs - Top Left": [ + "Catacombs - Necro", + ], + "Catacombs - Triple Spike Crushers Shop": [ + "Catacombs Seal - Triple Spike Crushers", + ], + "Catacombs - Dirty Pond Checkpoint": [ + "Catacombs Seal - Crusher Gauntlet", + "Catacombs Seal - Dirty Pond", + ], + "Catacombs - Ruxxtin Shop": [ + "Catacombs - Ruxxtin's Amulet", + "Catacombs - Ruxxtin", + ], + "Bamboo Creek - Spike Crushers Shop": [ + "Bamboo Creek Seal - Spike Crushers and Doors", + ], + "Bamboo Creek - Spike Ball Pits Checkpoint": [ + "Bamboo Creek Seal - Spike Ball Pits", + ], + "Bamboo Creek - Time Loop Shop": [ + "Bamboo Creek Seal - Spike Crushers and Doors v2", + "Bamboo Creek - Claustro", + ], + "Howling Grotto - Wingsuit Shop": [ + "Howling Grotto - Wingsuit", + "Howling Grotto Seal - Windy Saws and Balls", + ], + "Howling Grotto - Crushing Pits Shop": [ + "Howling Grotto Seal - Crushing Pits", + ], + "Howling Grotto - Breezy Crushers Checkpoint": [ + "Howling Grotto Seal - Breezy Crushers", + ], + "Howling Grotto - Emerald Golem Shop": [ + "Howling Grotto - Emerald Golem", + ], + "Quillshroom Marsh - Seashell Checkpoint": [ + "Quillshroom Marsh - Seashell", + ], + "Quillshroom Marsh - Spikey Window Shop": [ + "Quillshroom Marsh Seal - Spikey Window", + ], + "Quillshroom Marsh - Sand Trap Shop": [ + "Quillshroom Marsh Seal - Sand Trap", + ], + "Quillshroom Marsh - Spike Wave Checkpoint": [ + "Quillshroom Marsh Seal - Do the Spike Wave", + ], + "Quillshroom Marsh - Queen of Quills Shop": [ + "Quillshroom Marsh - Queen of Quills", + ], + "Searing Crags - Rope Dart Shop": [ + "Searing Crags - Rope Dart", + ], + "Searing Crags - Triple Ball Spinner Checkpoint": [ + "Searing Crags Seal - Triple Ball Spinner", + ], + "Searing Crags - Raining Rocks Checkpoint": [ + "Searing Crags Seal - Raining Rocks", + ], + "Searing Crags - Colossuses Shop": [ + "Searing Crags Seal - Rhythm Rocks", + "Searing Crags - Power Thistle", + "Searing Crags - Astral Tea Leaves", + ], + "Searing Crags - Key of Strength Shop": [ + "Searing Crags - Key of Strength", + ], + "Searing Crags - Portal": [ + "Searing Crags - Pyro", + ], + "Glacial Peak - Ice Climbers' Shop": [ + "Glacial Peak Seal - Ice Climbers", + ], + "Glacial Peak - Projectile Spike Pit Checkpoint": [ + "Glacial Peak Seal - Projectile Spike Pit", + ], + "Glacial Peak - Air Swag Checkpoint": [ + "Glacial Peak Seal - Glacial Air Swag", + ], + "Tower of Time - First Checkpoint": [ + "Tower of Time Seal - Time Waster", + ], + "Tower of Time - Fourth Checkpoint": [ + "Tower of Time Seal - Lantern Climb", + ], + "Tower of Time - Fifth Checkpoint": [ + "Tower of Time Seal - Arcane Orbs", + ], + "Cloud Ruins - Ghost Pit Checkpoint": [ + "Cloud Ruins Seal - Ghost Pit", + ], + "Cloud Ruins - Toothbrush Alley Checkpoint": [ + "Cloud Ruins Seal - Toothbrush Alley", + ], + "Cloud Ruins - Saw Pit Checkpoint": [ + "Cloud Ruins Seal - Saw Pit", + ], + "Cloud Ruins - Final Flight Shop": [ + "Cloud Ruins - Acro", + ], + "Cloud Ruins - Manfred's Shop": [ + "Cloud Ruins Seal - Money Farm Room", + ], + "Underworld - Left Shop": [ + "Underworld Seal - Sharp and Windy Climb", + ], + "Underworld - Fireball Wave Shop": [ + "Underworld Seal - Spike Wall", + "Underworld Seal - Fireball Wave", + ], + "Underworld - Hot Tub Checkpoint": [ + "Underworld Seal - Rising Fanta", + ], + "Underworld - Key of Chaos Shop": [ + "Underworld - Key of Chaos", + ], + "Riviere Turquoise - Waterfall Shop": [ + "Riviere Turquoise Seal - Bounces and Balls", + ], + "Riviere Turquoise - Launch of Faith Shop": [ + "Riviere Turquoise Seal - Launch of Faith", + ], + "Riviere Turquoise - Restock Shop": [ + "Riviere Turquoise Seal - Flower Power", + ], + "Riviere Turquoise - Butterfly Matriarch Shop": [ + "Riviere Turquoise - Butterfly Matriarch", + ], + "Sunken Shrine - Lifeguard Shop": [ + "Sunken Shrine Seal - Ultra Lifeguard", + ], + "Sunken Shrine - Lightfoot Tabi Checkpoint": [ + "Sunken Shrine - Lightfoot Tabi", + ], + "Sunken Shrine - Portal": [ + "Sunken Shrine - Key of Love", + ], + "Sunken Shrine - Tabi Gauntlet Shop": [ + "Sunken Shrine Seal - Tabi Gauntlet", + ], + "Sunken Shrine - Sun Crest Checkpoint": [ + "Sunken Shrine - Sun Crest", + ], + "Sunken Shrine - Waterfall Paradise Checkpoint": [ + "Sunken Shrine Seal - Waterfall Paradise", + ], + "Sunken Shrine - Moon Crest Checkpoint": [ + "Sunken Shrine - Moon Crest", + ], + "Elemental Skylands - Air Seal Checkpoint": [ + "Elemental Skylands Seal - Air", + ], + "Elemental Skylands - Water Intro Shop": [ + "Elemental Skylands Seal - Water", + ], + "Elemental Skylands - Fire Intro Shop": [ + "Elemental Skylands Seal - Fire", + ], + "Elemental Skylands - Right": [ + "Elemental Skylands - Key of Symbiosis", + ], "Corrupted Future": ["Corrupted Future - Key of Courage"], "Music Box": ["Rescue Phantom"], } -SEALS: Dict[str, List[str]] = { - "Ninja Village": ["Ninja Village Seal - Tree House"], - "Autumn Hills": ["Autumn Hills Seal - Trip Saws", "Autumn Hills Seal - Double Swing Saws", - "Autumn Hills Seal - Spike Ball Swing", "Autumn Hills Seal - Spike Ball Darts"], - "Catacombs": ["Catacombs Seal - Triple Spike Crushers", "Catacombs Seal - Crusher Gauntlet", - "Catacombs Seal - Dirty Pond"], - "Bamboo Creek": ["Bamboo Creek Seal - Spike Crushers and Doors", "Bamboo Creek Seal - Spike Ball Pits", - "Bamboo Creek Seal - Spike Crushers and Doors v2"], - "Howling Grotto": ["Howling Grotto Seal - Windy Saws and Balls", "Howling Grotto Seal - Crushing Pits", - "Howling Grotto Seal - Breezy Crushers"], - "Quillshroom Marsh": ["Quillshroom Marsh Seal - Spikey Window", "Quillshroom Marsh Seal - Sand Trap", - "Quillshroom Marsh Seal - Do the Spike Wave"], - "Searing Crags": ["Searing Crags Seal - Triple Ball Spinner"], - "Searing Crags Upper": ["Searing Crags Seal - Raining Rocks", "Searing Crags Seal - Rhythm Rocks"], - "Glacial Peak": ["Glacial Peak Seal - Ice Climbers", "Glacial Peak Seal - Projectile Spike Pit", - "Glacial Peak Seal - Glacial Air Swag"], - "Tower of Time": ["Tower of Time Seal - Time Waster", "Tower of Time Seal - Lantern Climb", - "Tower of Time Seal - Arcane Orbs"], - "Cloud Ruins Right": ["Cloud Ruins Seal - Ghost Pit", "Cloud Ruins Seal - Toothbrush Alley", - "Cloud Ruins Seal - Saw Pit", "Cloud Ruins Seal - Money Farm Room"], - "Underworld": ["Underworld Seal - Sharp and Windy Climb", "Underworld Seal - Spike Wall", - "Underworld Seal - Fireball Wave", "Underworld Seal - Rising Fanta"], - "Forlorn Temple": ["Forlorn Temple Seal - Rocket Maze", "Forlorn Temple Seal - Rocket Sunset"], - "Sunken Shrine": ["Sunken Shrine Seal - Ultra Lifeguard", "Sunken Shrine Seal - Waterfall Paradise", - "Sunken Shrine Seal - Tabi Gauntlet"], - "Riviere Turquoise Entrance": ["Riviere Turquoise Seal - Bounces and Balls"], - "Riviere Turquoise": ["Riviere Turquoise Seal - Launch of Faith", "Riviere Turquoise Seal - Flower Power"], - "Elemental Skylands": ["Elemental Skylands Seal - Air", "Elemental Skylands Seal - Water", - "Elemental Skylands Seal - Fire"] + +SUB_REGIONS: Dict[str, List[str]] = { + "Ninja Village": [ + "Right", + ], + "Autumn Hills": [ + "Left", + "Right", + "Bottom", + "Portal", + "Climbing Claws Shop", + "Hope Path Shop", + "Dimension Climb Shop", + "Leaf Golem Shop", + "Hope Path Checkpoint", + "Key of Hope Checkpoint", + "Lakeside Checkpoint", + "Double Swing Checkpoint", + "Spike Ball Swing Checkpoint", + ], + "Forlorn Temple": [ + "Left", + "Right", + "Bottom", + "Outside Shop", + "Entrance Shop", + "Climb Shop", + "Rocket Sunset Shop", + "Descent Shop", + "Final Fall Shop", + "Demon King Shop", + "Sunny Day Checkpoint", + "Rocket Maze Checkpoint", + ], + "Catacombs": [ + "Top Left", + "Bottom Left", + "Bottom", + "Right", + "Triple Spike Crushers Shop", + "Ruxxtin Shop", + "Death Trap Checkpoint", + "Crusher Gauntlet Checkpoint", + "Dirty Pond Checkpoint", + ], + "Bamboo Creek": [ + "Bottom Left", + "Top Left", + "Right", + "Spike Crushers Shop", + "Abandoned Shop", + "Time Loop Shop", + "Spike Ball Pits Checkpoint", + "Spike Doors Checkpoint", + ], + "Howling Grotto": [ + "Left", + "Top", + "Right", + "Bottom", + "Portal", + "Wingsuit Shop", + "Crushing Pits Shop", + "Emerald Golem Shop", + "Lost Woods Checkpoint", + "Breezy Crushers Checkpoint", + ], + "Quillshroom Marsh": [ + "Top Left", + "Bottom Left", + "Top Right", + "Bottom Right", + "Spikey Window Shop", + "Sand Trap Shop", + "Queen of Quills Shop", + "Seashell Checkpoint", + "Quicksand Checkpoint", + "Spike Wave Checkpoint", + ], + "Searing Crags": [ + "Left", + "Top", + "Bottom", + "Right", + "Portal", + "Rope Dart Shop", + "Falling Rocks Shop", + "Searing Mega Shard Shop", + "Before Final Climb Shop", + "Colossuses Shop", + "Key of Strength Shop", + "Triple Ball Spinner Checkpoint", + "Raining Rocks Checkpoint", + ], + "Glacial Peak": [ + "Bottom", + "Top", + "Portal", + "Ice Climbers' Shop", + "Glacial Mega Shard Shop", + "Tower Entrance Shop", + "Projectile Spike Pit Checkpoint", + "Air Swag Checkpoint", + "Free Climbing Checkpoint", + ], + "Tower of Time": [ + "Left", + "Entrance Shop", + "Arcane Golem Shop", + "First Checkpoint", + "Second Checkpoint", + "Third Checkpoint", + "Fourth Checkpoint", + "Fifth Checkpoint", + "Sixth Checkpoint", + ], + "Cloud Ruins": [ + "Left", + "Entrance Shop", + "Pillar Glide Shop", + "Crushers' Descent Shop", + "Seeing Spikes Shop", + "Sliding Spikes Shop", + "Final Flight Shop", + "Manfred's Shop", + "Spike Float Checkpoint", + "Ghost Pit Checkpoint", + "Toothbrush Alley Checkpoint", + "Saw Pit Checkpoint", + ], + "Underworld": [ + "Left", + "Entrance Shop", + "Fireball Wave Shop", + "Long Climb Shop", + "Barm'athaziel Shop", + "Key of Chaos Shop", + "Hot Dip Checkpoint", + "Hot Tub Checkpoint", + "Lava Run Checkpoint", + ], + "Riviere Turquoise": [ + "Right", + "Portal", + "Waterfall Shop", + "Launch of Faith Shop", + "Log Flume Shop", + "Log Climb Shop", + "Restock Shop", + "Butterfly Matriarch Shop", + "Flower Flight Checkpoint", + ], + "Elemental Skylands": [ + "Air Shmup", + "Air Intro Shop", + "Air Seal Checkpoint", + "Air Generator Shop", + "Earth Shmup", + "Earth Intro Shop", + "Earth Generator Shop", + "Fire Shmup", + "Fire Intro Shop", + "Fire Generator Shop", + "Water Shmup", + "Water Intro Shop", + "Water Generator Shop", + "Right", + ], + "Sunken Shrine": [ + "Left", + "Portal", + "Entrance Shop", + "Lifeguard Shop", + "Sun Path Shop", + "Tabi Gauntlet Shop", + "Moon Path Shop", + "Ninja Tabi Checkpoint", + "Sun Crest Checkpoint", + "Waterfall Paradise Checkpoint", + "Moon Crest Checkpoint", + ], } + +# order is slightly funky here for back compat MEGA_SHARDS: Dict[str, List[str]] = { - "Autumn Hills": ["Autumn Hills Mega Shard", "Hidden Entrance Mega Shard"], - "Catacombs": ["Catacombs Mega Shard"], - "Bamboo Creek": ["Above Entrance Mega Shard", "Abandoned Mega Shard", "Time Loop Mega Shard"], - "Howling Grotto": ["Bottom Left Mega Shard", "Near Portal Mega Shard", "Pie in the Sky Mega Shard"], - "Quillshroom Marsh": ["Quillshroom Marsh Mega Shard"], - "Searing Crags Upper": ["Searing Crags Mega Shard"], - "Glacial Peak": ["Glacial Peak Mega Shard"], - "Cloud Ruins": ["Cloud Entrance Mega Shard", "Time Warp Mega Shard"], - "Cloud Ruins Right": ["Money Farm Room Mega Shard 1", "Money Farm Room Mega Shard 2"], - "Underworld": ["Under Entrance Mega Shard", "Hot Tub Mega Shard", "Projectile Pit Mega Shard"], - "Forlorn Temple": ["Sunny Day Mega Shard", "Down Under Mega Shard"], - "Sunken Shrine": ["Mega Shard of the Moon", "Beginner's Mega Shard", "Mega Shard of the Stars", "Mega Shard of the Sun"], - "Riviere Turquoise Entrance": ["Waterfall Mega Shard"], - "Riviere Turquoise": ["Quick Restock Mega Shard 1", "Quick Restock Mega Shard 2"], - "Elemental Skylands": ["Earth Mega Shard", "Water Mega Shard"], + "Autumn Hills - Lakeside Checkpoint": ["Autumn Hills Mega Shard"], + "Forlorn Temple - Outside Shop": ["Hidden Entrance Mega Shard"], + "Catacombs - Top Left": ["Catacombs Mega Shard"], + "Bamboo Creek - Spike Crushers Shop": ["Above Entrance Mega Shard"], + "Bamboo Creek - Abandoned Shop": ["Abandoned Mega Shard"], + "Bamboo Creek - Time Loop Shop": ["Time Loop Mega Shard"], + "Howling Grotto - Lost Woods Checkpoint": ["Bottom Left Mega Shard"], + "Howling Grotto - Breezy Crushers Checkpoint": ["Near Portal Mega Shard", "Pie in the Sky Mega Shard"], + "Quillshroom Marsh - Spikey Window Shop": ["Quillshroom Marsh Mega Shard"], + "Searing Crags - Searing Mega Shard Shop": ["Searing Crags Mega Shard"], + "Glacial Peak - Glacial Mega Shard Shop": ["Glacial Peak Mega Shard"], + "Cloud Ruins - Cloud Entrance Shop": ["Cloud Entrance Mega Shard", "Time Warp Mega Shard"], + "Cloud Ruins - Manfred's Shop": ["Money Farm Room Mega Shard 1", "Money Farm Room Mega Shard 2"], + "Underworld - Left Shop": ["Under Entrance Mega Shard"], + "Underworld - Hot Tub Checkpoint": ["Hot Tub Mega Shard", "Projectile Pit Mega Shard"], + "Forlorn Temple - Sunny Day Checkpoint": ["Sunny Day Mega Shard"], + "Forlorn Temple - Demon King Shop": ["Down Under Mega Shard"], + "Sunken Shrine - Waterfall Paradise Checkpoint": ["Mega Shard of the Moon"], + "Sunken Shrine - Portal": ["Beginner's Mega Shard"], + "Sunken Shrine - Above Portal Shop": ["Mega Shard of the Stars"], + "Sunken Shrine - Sun Crest Checkpoint": ["Mega Shard of the Sun"], + "Riviere Turquoise - Waterfall Shop": ["Waterfall Mega Shard"], + "Riviere Turquoise - Restock Shop": ["Quick Restock Mega Shard 1", "Quick Restock Mega Shard 2"], + "Elemental Skylands - Earth Intro Shop": ["Earth Mega Shard"], + "Elemental Skylands - Water Generator Shop": ["Water Mega Shard"], } -REGION_CONNECTIONS: Dict[str, Set[str]] = { - "Menu": {"Tower HQ"}, - "Tower HQ": {"Autumn Hills", "Howling Grotto", "Searing Crags", "Glacial Peak", "Tower of Time", - "Riviere Turquoise Entrance", "Sunken Shrine", "Corrupted Future", "The Shop", - "The Craftsman's Corner", "Music Box"}, - "Autumn Hills": {"Ninja Village", "Forlorn Temple", "Catacombs"}, - "Forlorn Temple": {"Catacombs", "Bamboo Creek"}, - "Catacombs": {"Autumn Hills", "Bamboo Creek", "Dark Cave"}, - "Bamboo Creek": {"Catacombs", "Howling Grotto"}, - "Howling Grotto": {"Bamboo Creek", "Quillshroom Marsh", "Sunken Shrine"}, - "Quillshroom Marsh": {"Howling Grotto", "Searing Crags"}, - "Searing Crags": {"Searing Crags Upper", "Quillshroom Marsh", "Underworld"}, - "Searing Crags Upper": {"Searing Crags", "Glacial Peak"}, - "Glacial Peak": {"Searing Crags Upper", "Tower HQ", "Cloud Ruins", "Elemental Skylands"}, - "Cloud Ruins": {"Cloud Ruins Right"}, - "Cloud Ruins Right": {"Underworld"}, - "Dark Cave": {"Catacombs", "Riviere Turquoise Entrance"}, - "Riviere Turquoise Entrance": {"Riviere Turquoise"}, - "Sunken Shrine": {"Howling Grotto"}, +REGION_CONNECTIONS: Dict[str, Dict[str, str]] = { + "Menu": {"Tower HQ": "Start Game"}, + "Tower HQ": { + "Autumn Hills - Portal": "ToTHQ Autumn Hills Portal", + "Howling Grotto - Portal": "ToTHQ Howling Grotto Portal", + "Searing Crags - Portal": "ToTHQ Searing Crags Portal", + "Glacial Peak - Portal": "ToTHQ Glacial Peak Portal", + "Tower of Time - Left": "Artificer's Challenge", + "Riviere Turquoise - Portal": "ToTHQ Riviere Turquoise Portal", + "Sunken Shrine - Portal": "ToTHQ Sunken Shrine Portal", + "Corrupted Future": "Artificer's Portal", + "The Shop": "Home", + "Music Box": "Shrink Down", + }, + "The Shop": { + "The Craftsman's Corner": "Money Sink", + }, } -"""Vanilla layout mapping with all Tower HQ portals open. from -> to""" +"""Vanilla layout mapping with all Tower HQ portals open. format is source[exit_region][entrance_name]""" + + +# regions that don't have sub-regions +LEVELS: List[str] = [ + "Menu", + "Tower HQ", + "The Shop", + "The Craftsman's Corner", + "Corrupted Future", + "Music Box", +] diff --git a/worlds/messenger/rules.py b/worlds/messenger/rules.py index b13a453f7f59..50e1fa113d19 100644 --- a/worlds/messenger/rules.py +++ b/worlds/messenger/rules.py @@ -1,7 +1,7 @@ from typing import Dict, TYPE_CHECKING from BaseClasses import CollectionState -from worlds.generic.Rules import add_rule, allow_self_locking_items, CollectionRule +from worlds.generic.Rules import CollectionRule, add_rule, allow_self_locking_items from .constants import NOTES, PHOBEKINS from .options import MessengerAccessibility @@ -12,6 +12,7 @@ class MessengerRules: player: int world: "MessengerWorld" + connection_rules: Dict[str, CollectionRule] region_rules: Dict[str, CollectionRule] location_rules: Dict[str, CollectionRule] maximum_price: int @@ -27,83 +28,286 @@ def __init__(self, world: "MessengerWorld") -> None: self.maximum_price = min(maximum_price, world.total_shards) self.required_seals = max(1, world.required_seals) - self.region_rules = { - "Ninja Village": self.has_wingsuit, - "Autumn Hills": self.has_wingsuit, - "Catacombs": self.has_wingsuit, - "Bamboo Creek": self.has_wingsuit, - "Searing Crags Upper": self.has_vertical, - "Cloud Ruins": lambda state: self.has_vertical(state) and state.has("Ruxxtin's Amulet", self.player), - "Cloud Ruins Right": lambda state: self.has_wingsuit(state) and - (self.has_dart(state) or self.can_dboost(state)), - "Underworld": self.has_tabi, - "Riviere Turquoise": lambda state: self.has_dart(state) or - (self.has_wingsuit(state) and self.can_destroy_projectiles(state)), - "Forlorn Temple": lambda state: state.has_all({"Wingsuit", *PHOBEKINS}, self.player) and self.can_dboost(state), - "Glacial Peak": self.has_vertical, - "Elemental Skylands": lambda state: state.has("Magic Firefly", self.player) and self.has_wingsuit(state), - "Music Box": lambda state: (state.has_all(NOTES, self.player) - or self.has_enough_seals(state)) and self.has_dart(state), - "The Craftsman's Corner": lambda state: state.has("Money Wrench", self.player) and self.can_shop(state), + # dict of connection names and requirements to traverse the exit + self.connection_rules = { + # from ToTHQ + "Artificer's Portal": + lambda state: state.has_all({"Demon King Crown", "Magic Firefly"}, self.player), + "Shrink Down": + lambda state: state.has_all(NOTES, self.player) or self.has_enough_seals(state), + # the shop + "Money Sink": + lambda state: state.has("Money Wrench", self.player) and self.can_shop(state), + # Autumn Hills + "Autumn Hills - Portal -> Autumn Hills - Dimension Climb Shop": + lambda state: self.has_wingsuit(state) and self.has_dart(state), + "Autumn Hills - Dimension Climb Shop -> Autumn Hills - Portal": + self.has_vertical, + "Autumn Hills - Climbing Claws Shop -> Autumn Hills - Hope Path Shop": + self.has_dart, + "Autumn Hills - Climbing Claws Shop -> Autumn Hills - Key of Hope Checkpoint": + self.false, # hard logic only + "Autumn Hills - Hope Path Shop -> Autumn Hills - Hope Latch Checkpoint": + self.has_dart, + "Autumn Hills - Hope Path Shop -> Autumn Hills - Climbing Claws Shop": + lambda state: self.has_dart(state) and self.can_dboost(state), + "Autumn Hills - Hope Path Shop -> Autumn Hills - Lakeside Checkpoint": + lambda state: self.has_dart(state) and self.can_dboost(state), + "Autumn Hills - Hope Latch Checkpoint -> Autumn Hills - Hope Path Shop": + self.can_dboost, + "Autumn Hills - Hope Latch Checkpoint -> Autumn Hills - Key of Hope Checkpoint": + lambda state: self.has_dart(state) and self.has_wingsuit(state), + # Forlorn Temple + "Forlorn Temple - Outside Shop -> Forlorn Temple - Entrance Shop": + lambda state: state.has_all(PHOBEKINS, self.player), + "Forlorn Temple - Entrance Shop -> Forlorn Temple - Outside Shop": + lambda state: state.has_all(PHOBEKINS, self.player), + "Forlorn Temple - Entrance Shop -> Forlorn Temple - Sunny Day Checkpoint": + lambda state: self.has_vertical(state) and self.can_dboost(state), + "Forlorn Temple - Sunny Day Checkpoint -> Forlorn Temple - Rocket Maze Checkpoint": + self.has_vertical, + "Forlorn Temple - Rocket Sunset Shop -> Forlorn Temple - Descent Shop": + lambda state: self.has_dart(state) and (self.can_dboost(state) or self.has_wingsuit(state)), + "Forlorn Temple - Saw Gauntlet Shop -> Forlorn Temple - Demon King Shop": + self.has_vertical, + "Forlorn Temple - Demon King Shop -> Forlorn Temple - Saw Gauntlet Shop": + self.has_vertical, + # Howling Grotto + "Howling Grotto - Portal -> Howling Grotto - Crushing Pits Shop": + self.has_wingsuit, + "Howling Grotto - Wingsuit Shop -> Howling Grotto - Left": + self.has_wingsuit, + "Howling Grotto - Wingsuit Shop -> Howling Grotto - Lost Woods Checkpoint": + self.has_wingsuit, + "Howling Grotto - Lost Woods Checkpoint -> Howling Grotto - Bottom": + lambda state: state.has("Seashell", self.player), + "Howling Grotto - Crushing Pits Shop -> Howling Grotto - Portal": + lambda state: self.has_wingsuit(state) or self.can_dboost(state), + "Howling Grotto - Breezy Crushers Checkpoint -> Howling Grotto - Emerald Golem Shop": + self.has_wingsuit, + "Howling Grotto - Breezy Crushers Checkpoint -> Howling Grotto - Crushing Pits Shop": + lambda state: (self.has_wingsuit(state) or self.can_dboost( + state + ) or self.can_destroy_projectiles(state)) + and state.multiworld.get_region( + "Howling Grotto - Emerald Golem Shop", self.player + ).can_reach(state), + "Howling Grotto - Emerald Golem Shop -> Howling Grotto - Right": + self.has_wingsuit, + # Searing Crags + "Searing Crags - Rope Dart Shop -> Searing Crags - Triple Ball Spinner Checkpoint": + self.has_vertical, + "Searing Crags - Portal -> Searing Crags - Right": + self.has_tabi, + "Searing Crags - Portal -> Searing Crags - Before Final Climb Shop": + self.has_wingsuit, + "Searing Crags - Portal -> Searing Crags - Colossuses Shop": + self.has_wingsuit, + "Searing Crags - Bottom -> Searing Crags - Portal": + self.has_wingsuit, + "Searing Crags - Right -> Searing Crags - Portal": + lambda state: self.has_tabi(state) and self.has_wingsuit(state), + "Searing Crags - Colossuses Shop -> Searing Crags - Key of Strength Shop": + lambda state: state.has("Power Thistle", self.player) + and (self.has_dart(state) + or (self.has_wingsuit(state) + and self.can_destroy_projectiles(state))), + "Searing Crags - Falling Rocks Shop -> Searing Crags - Searing Mega Shard Shop": + self.has_dart, + "Searing Crags - Searing Mega Shard Shop -> Searing Crags - Before Final Climb Shop": + lambda state: self.has_dart(state) or self.can_destroy_projectiles(state), + "Searing Crags - Searing Mega Shard Shop -> Searing Crags - Falling Rocks Shop": + self.has_dart, + "Searing Crags - Searing Mega Shard Shop -> Searing Crags - Key of Strength Shop": + self.false, + "Searing Crags - Before Final Climb Shop -> Searing Crags - Colossuses Shop": + self.has_dart, + # Glacial Peak + "Glacial Peak - Portal -> Glacial Peak - Tower Entrance Shop": + self.has_vertical, + "Glacial Peak - Left -> Elemental Skylands - Air Shmup": + lambda state: state.has("Magic Firefly", self.player) + and state.multiworld.get_location("Quillshroom Marsh - Queen of Quills", self.player) + .can_reach(state), + "Glacial Peak - Tower Entrance Shop -> Glacial Peak - Top": + lambda state: state.has("Ruxxtin's Amulet", self.player), + "Glacial Peak - Projectile Spike Pit Checkpoint -> Glacial Peak - Left": + lambda state: self.has_dart(state) or (self.can_dboost(state) and self.has_wingsuit(state)), + # Tower of Time + "Tower of Time - Left -> Tower of Time - Final Chance Shop": + self.has_dart, + "Tower of Time - Second Checkpoint -> Tower of Time - Third Checkpoint": + lambda state: self.has_wingsuit(state) and (self.has_dart(state) or self.can_dboost(state)), + "Tower of Time - Third Checkpoint -> Tower of Time - Fourth Checkpoint": + lambda state: self.has_wingsuit(state) or self.can_dboost(state), + "Tower of Time - Fourth Checkpoint -> Tower of Time - Fifth Checkpoint": + lambda state: self.has_wingsuit(state) and self.has_dart(state), + "Tower of Time - Fifth Checkpoint -> Tower of Time - Sixth Checkpoint": + self.has_wingsuit, + # Cloud Ruins + "Cloud Ruins - Cloud Entrance Shop -> Cloud Ruins - Spike Float Checkpoint": + self.has_wingsuit, + "Cloud Ruins - Spike Float Checkpoint -> Cloud Ruins - Cloud Entrance Shop": + lambda state: self.has_vertical(state) or self.can_dboost(state), + "Cloud Ruins - Spike Float Checkpoint -> Cloud Ruins - Pillar Glide Shop": + lambda state: self.has_vertical(state) or self.can_dboost(state), + "Cloud Ruins - Pillar Glide Shop -> Cloud Ruins - Spike Float Checkpoint": + lambda state: self.has_vertical(state) and self.can_double_dboost(state), + "Cloud Ruins - Pillar Glide Shop -> Cloud Ruins - Ghost Pit Checkpoint": + lambda state: self.has_dart(state) and self.has_wingsuit(state), + "Cloud Ruins - Pillar Glide Shop -> Cloud Ruins - Crushers' Descent Shop": + lambda state: self.has_wingsuit(state) and (self.has_dart(state) or self.can_dboost(state)), + "Cloud Ruins - Toothbrush Alley Checkpoint -> Cloud Ruins - Seeing Spikes Shop": + self.has_vertical, + "Cloud Ruins - Seeing Spikes Shop -> Cloud Ruins - Sliding Spikes Shop": + self.has_wingsuit, + "Cloud Ruins - Sliding Spikes Shop -> Cloud Ruins - Seeing Spikes Shop": + self.has_wingsuit, + "Cloud Ruins - Sliding Spikes Shop -> Cloud Ruins - Saw Pit Checkpoint": + self.has_vertical, + "Cloud Ruins - Final Flight Shop -> Cloud Ruins - Manfred's Shop": + lambda state: self.has_wingsuit(state) and self.has_dart(state), + "Cloud Ruins - Manfred's Shop -> Cloud Ruins - Final Flight Shop": + lambda state: self.has_wingsuit(state) and self.can_dboost(state), + # Underworld + "Underworld - Left -> Underworld - Left Shop": + self.has_tabi, + "Underworld - Left Shop -> Underworld - Left": + self.has_tabi, + "Underworld - Hot Dip Checkpoint -> Underworld - Lava Run Checkpoint": + self.has_tabi, + "Underworld - Fireball Wave Shop -> Underworld - Long Climb Shop": + lambda state: self.can_destroy_projectiles(state) or self.has_tabi(state) or self.has_vertical(state), + "Underworld - Long Climb Shop -> Underworld - Hot Tub Checkpoint": + lambda state: self.has_tabi(state) + and (self.can_destroy_projectiles(state) + or self.has_wingsuit(state)) + or (self.has_wingsuit(state) + and (self.has_dart(state) + or self.can_dboost(state) + or self.can_destroy_projectiles(state))), + "Underworld - Hot Tub Checkpoint -> Underworld - Long Climb Shop": + lambda state: self.has_tabi(state) + or self.can_destroy_projectiles(state) + or (self.has_dart(state) and self.has_wingsuit(state)), + # Dark Cave + "Dark Cave - Right -> Dark Cave - Left": + lambda state: state.has("Candle", self.player) and self.has_dart(state), + # Riviere Turquoise + "Riviere Turquoise - Waterfall Shop -> Riviere Turquoise - Flower Flight Checkpoint": + lambda state: self.has_dart(state) or ( + self.has_wingsuit(state) and self.can_destroy_projectiles(state)), + "Riviere Turquoise - Launch of Faith Shop -> Riviere Turquoise - Flower Flight Checkpoint": + lambda state: self.has_dart(state) and self.can_dboost(state), + "Riviere Turquoise - Flower Flight Checkpoint -> Riviere Turquoise - Waterfall Shop": + lambda state: False, + # Elemental Skylands + "Elemental Skylands - Air Intro Shop -> Elemental Skylands - Air Seal Checkpoint": + self.has_wingsuit, + "Elemental Skylands - Air Intro Shop -> Elemental Skylands - Air Generator Shop": + self.has_wingsuit, + # Sunken Shrine + "Sunken Shrine - Portal -> Sunken Shrine - Sun Path Shop": + self.has_tabi, + "Sunken Shrine - Portal -> Sunken Shrine - Moon Path Shop": + self.has_tabi, + "Sunken Shrine - Moon Path Shop -> Sunken Shrine - Waterfall Paradise Checkpoint": + self.has_tabi, + "Sunken Shrine - Waterfall Paradise Checkpoint -> Sunken Shrine - Moon Path Shop": + self.has_tabi, + "Sunken Shrine - Tabi Gauntlet Shop -> Sunken Shrine - Sun Path Shop": + lambda state: self.can_dboost(state) or self.has_dart(state), } self.location_rules = { # ninja village - "Ninja Village Seal - Tree House": self.has_dart, + "Ninja Village Seal - Tree House": + self.has_dart, + "Ninja Village - Candle": + lambda state: state.multiworld.get_location("Searing Crags - Astral Tea Leaves", self.player).can_reach( + state), # autumn hills - "Autumn Hills - Key of Hope": self.has_dart, - "Autumn Hills Seal - Spike Ball Darts": self.is_aerobatic, + "Autumn Hills Seal - Spike Ball Darts": + self.is_aerobatic, + "Autumn Hills Seal - Trip Saws": + self.has_wingsuit, + # forlorn temple + "Forlorn Temple Seal - Rocket Maze": + self.has_vertical, # bamboo creek - "Bamboo Creek - Claustro": lambda state: self.has_dart(state) or self.can_dboost(state), + "Bamboo Creek - Claustro": + lambda state: self.has_wingsuit(state) and (self.has_dart(state) or self.can_dboost(state)), + "Above Entrance Mega Shard": + lambda state: self.has_dart(state) or self.can_dboost(state), + "Bamboo Creek Seal - Spike Ball Pits": + self.has_wingsuit, # howling grotto - "Howling Grotto Seal - Windy Saws and Balls": self.has_wingsuit, - "Howling Grotto Seal - Crushing Pits": lambda state: self.has_wingsuit(state) and self.has_dart(state), - "Howling Grotto - Emerald Golem": self.has_wingsuit, + "Howling Grotto Seal - Windy Saws and Balls": + self.has_wingsuit, + "Howling Grotto Seal - Crushing Pits": + lambda state: self.has_wingsuit(state) and self.has_dart(state), + "Howling Grotto - Emerald Golem": + self.has_wingsuit, # searing crags - "Searing Crags Seal - Triple Ball Spinner": self.has_vertical, "Searing Crags - Astral Tea Leaves": - lambda state: state.can_reach("Ninja Village - Astral Seed", "Location", self.player), - "Searing Crags - Key of Strength": lambda state: state.has("Power Thistle", self.player) - and (self.has_dart(state) - or (self.has_wingsuit(state) - and self.can_destroy_projectiles(state))), + lambda state: state.multiworld.get_location("Ninja Village - Astral Seed", self.player).can_reach(state), + "Searing Crags Seal - Triple Ball Spinner": + self.can_dboost, + "Searing Crags - Pyro": + self.has_tabi, # glacial peak - "Glacial Peak Seal - Ice Climbers": self.has_dart, - "Glacial Peak Seal - Projectile Spike Pit": self.can_destroy_projectiles, - # cloud ruins - "Cloud Ruins Seal - Ghost Pit": self.has_dart, + "Glacial Peak Seal - Ice Climbers": + self.has_dart, + "Glacial Peak Seal - Projectile Spike Pit": + self.can_destroy_projectiles, # tower of time - "Tower of Time Seal - Time Waster": self.has_dart, - "Tower of Time Seal - Lantern Climb": lambda state: self.has_wingsuit(state) and self.has_dart(state), - "Tower of Time Seal - Arcane Orbs": lambda state: self.has_wingsuit(state) and self.has_dart(state), + "Tower of Time Seal - Time Waster": + self.has_dart, + # cloud ruins + "Time Warp Mega Shard": + lambda state: self.has_vertical(state) or self.can_dboost(state), + "Cloud Ruins Seal - Ghost Pit": + self.has_vertical, + "Cloud Ruins Seal - Toothbrush Alley": + self.has_dart, + "Cloud Ruins Seal - Saw Pit": + self.has_vertical, # underworld - "Underworld Seal - Sharp and Windy Climb": self.has_wingsuit, - "Underworld Seal - Fireball Wave": self.is_aerobatic, - "Underworld Seal - Rising Fanta": self.has_dart, + "Underworld Seal - Sharp and Windy Climb": + self.has_wingsuit, + "Underworld Seal - Fireball Wave": + self.is_aerobatic, + "Underworld Seal - Rising Fanta": + self.has_dart, + "Hot Tub Mega Shard": + lambda state: self.has_tabi(state) or self.has_dart(state), # sunken shrine - "Sunken Shrine - Sun Crest": self.has_tabi, - "Sunken Shrine - Moon Crest": self.has_tabi, - "Sunken Shrine - Key of Love": lambda state: state.has_all({"Sun Crest", "Moon Crest"}, self.player), - "Sunken Shrine Seal - Waterfall Paradise": self.has_tabi, - "Sunken Shrine Seal - Tabi Gauntlet": self.has_tabi, - "Mega Shard of the Moon": self.has_tabi, - "Mega Shard of the Sun": self.has_tabi, + "Sunken Shrine - Key of Love": + lambda state: state.has_all({"Sun Crest", "Moon Crest"}, self.player), + "Sunken Shrine Seal - Waterfall Paradise": + self.has_tabi, + "Sunken Shrine Seal - Tabi Gauntlet": + self.has_tabi, + "Mega Shard of the Sun": + self.has_tabi, # riviere turquoise - "Riviere Turquoise Seal - Bounces and Balls": self.can_dboost, - "Riviere Turquoise Seal - Launch of Faith": lambda state: self.can_dboost(state) or self.has_dart(state), + "Riviere Turquoise Seal - Bounces and Balls": + self.can_dboost, + "Riviere Turquoise Seal - Launch of Faith": + lambda state: self.has_vertical(state), # elemental skylands - "Elemental Skylands - Key of Symbiosis": self.has_dart, - "Elemental Skylands Seal - Air": self.has_wingsuit, - "Elemental Skylands Seal - Water": lambda state: self.has_dart(state) and - state.has("Currents Master", self.player), - "Elemental Skylands Seal - Fire": lambda state: self.has_dart(state) and self.can_destroy_projectiles(state), - "Earth Mega Shard": self.has_dart, - "Water Mega Shard": self.has_dart, - # corrupted future - "Corrupted Future - Key of Courage": lambda state: state.has_all({"Demon King Crown", "Magic Firefly"}, - self.player), - # tower hq - "Money Wrench": self.can_shop, + "Elemental Skylands - Key of Symbiosis": + self.has_dart, + "Elemental Skylands Seal - Air": + self.has_wingsuit, + "Elemental Skylands Seal - Water": + lambda state: self.has_dart(state) and state.has("Currents Master", self.player), + "Elemental Skylands Seal - Fire": + lambda state: self.has_dart(state) and self.can_destroy_projectiles(state) and self.is_aerobatic(state), + "Earth Mega Shard": + self.has_dart, + "Water Mega Shard": + self.has_dart, } def has_wingsuit(self, state: CollectionState) -> bool: @@ -128,6 +332,9 @@ def can_dboost(self, state: CollectionState) -> bool: return state.has_any({"Path of Resilience", "Meditation"}, self.player) and \ state.has("Second Wind", self.player) + def can_double_dboost(self, state: CollectionState) -> bool: + return state.has_all({"Path of Resilience", "Meditation", "Second Wind"}, self.player) + def is_aerobatic(self, state: CollectionState) -> bool: return self.has_wingsuit(state) and state.has("Aerobatics Warrior", self.player) @@ -135,87 +342,147 @@ def true(self, state: CollectionState) -> bool: """I know this is stupid, but it's easier to read in the dicts.""" return True + def false(self, state: CollectionState) -> bool: + """It's a bit easier to just always create the connections that are only possible in hard or higher logic.""" + return False + def can_shop(self, state: CollectionState) -> bool: return state.has("Shards", self.player, self.maximum_price) def set_messenger_rules(self) -> None: multiworld = self.world.multiworld - for region in multiworld.get_regions(self.player): - if region.name in self.region_rules: - for entrance in region.entrances: - entrance.access_rule = self.region_rules[region.name] - for loc in region.locations: - if loc.name in self.location_rules: - loc.access_rule = self.location_rules[loc.name] - - multiworld.completion_condition[self.player] = lambda state: state.has("Rescue Phantom", self.player) - if multiworld.accessibility[self.player]: # not locations accessibility + for entrance_name, rule in self.connection_rules.items(): + entrance = multiworld.get_entrance(entrance_name, self.player) + entrance.access_rule = rule + for loc in multiworld.get_locations(self.player): + if loc.name in self.location_rules: + loc.access_rule = self.location_rules[loc.name] + + if self.world.options.music_box and not self.world.options.limited_movement: + add_rule(multiworld.get_entrance("Shrink Down", self.player), self.has_dart) + multiworld.completion_condition[self.player] = lambda state: state.has("Do the Thing!", self.player) + if self.world.options.accessibility: # not locations accessibility set_self_locking_items(self.world, self.player) class MessengerHardRules(MessengerRules): - extra_rules: Dict[str, CollectionRule] - def __init__(self, world: "MessengerWorld") -> None: super().__init__(world) - self.region_rules.update({ - "Ninja Village": self.has_vertical, - "Autumn Hills": self.has_vertical, - "Catacombs": self.has_vertical, - "Bamboo Creek": self.has_vertical, - "Riviere Turquoise": self.true, - "Forlorn Temple": lambda state: self.has_vertical(state) and state.has_all(PHOBEKINS, self.player), - "Searing Crags Upper": lambda state: self.can_destroy_projectiles(state) or self.has_windmill(state) - or self.has_vertical(state), - "Glacial Peak": lambda state: self.can_destroy_projectiles(state) or self.has_windmill(state) - or self.has_vertical(state), - "Elemental Skylands": lambda state: state.has("Magic Firefly", self.player) or - self.has_windmill(state) or - self.has_dart(state), - }) - - self.location_rules.update({ - "Howling Grotto Seal - Windy Saws and Balls": self.true, - "Searing Crags Seal - Triple Ball Spinner": self.true, - "Searing Crags Seal - Raining Rocks": lambda state: self.has_vertical(state) or self.can_destroy_projectiles(state), - "Searing Crags Seal - Rhythm Rocks": lambda state: self.has_vertical(state) or self.can_destroy_projectiles(state), - "Searing Crags - Power Thistle": lambda state: self.has_vertical(state) or self.can_destroy_projectiles(state), - "Glacial Peak Seal - Ice Climbers": lambda state: self.has_vertical(state) or self.can_dboost(state), - "Glacial Peak Seal - Projectile Spike Pit": self.true, - "Glacial Peak Seal - Glacial Air Swag": lambda state: self.has_windmill(state) or self.has_vertical(state), - "Glacial Peak Mega Shard": lambda state: self.has_windmill(state) or self.has_vertical(state), - "Cloud Ruins Seal - Ghost Pit": self.true, - "Bamboo Creek - Claustro": self.has_wingsuit, - "Tower of Time Seal - Lantern Climb": self.has_wingsuit, - "Elemental Skylands Seal - Water": lambda state: self.has_dart(state) or self.can_dboost(state) - or self.has_windmill(state), - "Elemental Skylands Seal - Fire": lambda state: (self.has_dart(state) or self.can_dboost(state) - or self.has_windmill(state)) and - self.can_destroy_projectiles(state), - "Earth Mega Shard": lambda state: self.has_dart(state) or self.can_dboost(state) or self.has_windmill(state), - "Water Mega Shard": lambda state: self.has_dart(state) or self.can_dboost(state) or self.has_windmill(state), - }) - - self.extra_rules = { - "Searing Crags - Key of Strength": lambda state: self.has_dart(state) or self.has_windmill(state), - "Elemental Skylands - Key of Symbiosis": lambda state: self.has_windmill(state) or self.can_dboost(state), - "Autumn Hills Seal - Spike Ball Darts": lambda state: self.has_dart(state) or self.has_windmill(state), - "Underworld Seal - Fireball Wave": self.has_windmill, - } + self.connection_rules.update( + { + # Autumn Hills + "Autumn Hills - Portal -> Autumn Hills - Dimension Climb Shop": + self.has_dart, + "Autumn Hills - Climbing Claws Shop -> Autumn Hills - Key of Hope Checkpoint": + self.true, # super easy normal clip - also possible with moderately difficult cloud stepping + # Howling Grotto + "Howling Grotto - Portal -> Howling Grotto - Crushing Pits Shop": + self.true, + "Howling Grotto - Lost Woods Checkpoint -> Howling Grotto - Bottom": + self.true, # just memorize the pattern :) + "Howling Grotto - Crushing Pits Shop -> Howling Grotto - Portal": + self.true, + "Howling Grotto - Breezy Crushers Checkpoint -> Howling Grotto - Emerald Golem Shop": + lambda state: self.has_wingsuit(state) or # there's a very easy normal clip here but it's 16-bit only + "Howling Grotto - Breezy Crushers Checkpoint" in self.world.spoiler_portal_mapping.values(), + # Searing Crags + "Searing Crags - Rope Dart Shop -> Searing Crags - Triple Ball Spinner Checkpoint": + lambda state: self.has_vertical(state) or self.can_destroy_projectiles(state), + # it's doable without anything but one jump is pretty hard and time warping is no longer reliable + "Searing Crags - Falling Rocks Shop -> Searing Crags - Searing Mega Shard Shop": + lambda state: self.has_vertical(state) or self.can_destroy_projectiles(state), + "Searing Crags - Searing Mega Shard Shop -> Searing Crags - Falling Rocks Shop": + lambda state: self.has_dart(state) or + (self.can_destroy_projectiles(state) and + (self.has_wingsuit(state) or self.can_dboost(state))), + "Searing Crags - Searing Mega Shard Shop -> Searing Crags - Key of Strength Shop": + lambda state: self.can_leash(state) or self.has_windmill(state), + "Searing Crags - Before Final Climb Shop -> Searing Crags - Colossuses Shop": + self.true, + # Glacial Peak + "Glacial Peak - Left -> Elemental Skylands - Air Shmup": + lambda state: self.has_windmill(state) or + (state.has("Magic Firefly", self.player) and + state.multiworld.get_location( + "Quillshroom Marsh - Queen of Quills", self.player).can_reach(state)) or + (self.has_dart(state) and self.can_dboost(state)), + "Glacial Peak - Projectile Spike Pit Checkpoint -> Glacial Peak - Left": + lambda state: self.has_vertical(state) or self.has_windmill(state), + # Cloud Ruins + "Cloud Ruins - Sliding Spikes Shop -> Cloud Ruins - Saw Pit Checkpoint": + self.true, + # Elemental Skylands + "Elemental Skylands - Air Intro Shop -> Elemental Skylands - Air Generator Shop": + self.true, + # Riviere Turquoise + "Riviere Turquoise - Waterfall Shop -> Riviere Turquoise - Flower Flight Checkpoint": + self.true, + "Riviere Turquoise - Launch of Faith Shop -> Riviere Turquoise - Flower Flight Checkpoint": + self.can_dboost, + "Riviere Turquoise - Flower Flight Checkpoint -> Riviere Turquoise - Waterfall Shop": + self.can_double_dboost, + } + ) + + self.location_rules.update( + { + "Autumn Hills Seal - Spike Ball Darts": + lambda state: self.has_vertical(state) and self.has_windmill(state) or self.is_aerobatic(state), + "Bamboo Creek - Claustro": + self.has_wingsuit, + "Bamboo Creek Seal - Spike Ball Pits": + self.true, + "Howling Grotto Seal - Windy Saws and Balls": + self.true, + "Searing Crags Seal - Triple Ball Spinner": + self.true, + "Glacial Peak Seal - Ice Climbers": + lambda state: self.has_vertical(state) or self.can_dboost(state), + "Glacial Peak Seal - Projectile Spike Pit": + lambda state: self.can_dboost(state) or self.can_destroy_projectiles(state), + "Glacial Peak Seal - Glacial Air Swag": + lambda state: self.has_windmill(state) or self.has_vertical(state), + "Glacial Peak Mega Shard": + lambda state: self.has_windmill(state) or self.has_vertical(state), + "Cloud Ruins Seal - Ghost Pit": + self.true, + "Cloud Ruins Seal - Toothbrush Alley": + self.true, + "Cloud Ruins Seal - Saw Pit": + self.true, + "Underworld Seal - Fireball Wave": + lambda state: self.is_aerobatic(state) or self.has_windmill(state), + "Riviere Turquoise Seal - Bounces and Balls": + self.true, + "Riviere Turquoise Seal - Launch of Faith": + lambda state: self.can_dboost(state) or self.has_vertical(state), + "Elemental Skylands - Key of Symbiosis": + lambda state: self.has_dart(state) or self.can_dboost(state) or self.has_windmill(state), + "Elemental Skylands Seal - Water": + lambda state: self.has_dart(state) or self.can_dboost(state) or self.has_windmill(state), + "Elemental Skylands Seal - Fire": + lambda state: (self.has_dart(state) or self.can_dboost(state) or self.has_windmill(state)) + and self.can_destroy_projectiles(state), + "Earth Mega Shard": + lambda state: self.has_dart(state) or self.can_dboost(state) or self.has_windmill(state), + "Water Mega Shard": + lambda state: self.has_dart(state) or self.can_dboost(state) or self.has_windmill(state), + } + ) def has_windmill(self, state: CollectionState) -> bool: return state.has("Windmill Shuriken", self.player) - def set_messenger_rules(self) -> None: - super().set_messenger_rules() - for loc, rule in self.extra_rules.items(): - if not self.world.options.shuffle_seals and "Seal" in loc: - continue - if not self.world.options.shuffle_shards and "Shard" in loc: - continue - add_rule(self.world.multiworld.get_location(loc, self.player), rule, "or") + def can_dboost(self, state: CollectionState) -> bool: + return state.has("Second Wind", self.player) # who really needs meditation + + def can_destroy_projectiles(self, state: CollectionState) -> bool: + return super().can_destroy_projectiles(state) or self.has_windmill(state) + + def can_leash(self, state: CollectionState) -> bool: + return self.has_dart(state) and self.can_dboost(state) class MessengerOOBRules(MessengerRules): @@ -226,7 +493,9 @@ def __init__(self, world: "MessengerWorld") -> None: self.required_seals = max(1, world.required_seals) self.region_rules = { "Elemental Skylands": - lambda state: state.has_any({"Windmill Shuriken", "Wingsuit", "Rope Dart", "Magic Firefly"}, self.player), + lambda state: state.has_any( + {"Windmill Shuriken", "Wingsuit", "Rope Dart", "Magic Firefly"}, self.player + ), "Music Box": lambda state: state.has_all(set(NOTES), self.player) or self.has_enough_seals(state), } @@ -240,8 +509,10 @@ def __init__(self, world: "MessengerWorld") -> None: lambda state: state.has_all({"Demon King Crown", "Magic Firefly"}, self.player), "Autumn Hills Seal - Spike Ball Darts": self.has_dart, "Ninja Village Seal - Tree House": self.has_dart, - "Underworld Seal - Fireball Wave": lambda state: state.has_any({"Wingsuit", "Windmill Shuriken"}, - self.player), + "Underworld Seal - Fireball Wave": lambda state: state.has_any( + {"Wingsuit", "Windmill Shuriken"}, + self.player + ), "Tower of Time Seal - Time Waster": self.has_dart, } @@ -251,18 +522,8 @@ def set_messenger_rules(self) -> None: def set_self_locking_items(world: "MessengerWorld", player: int) -> None: - multiworld = world.multiworld - - # do the ones for seal shuffle on and off first - allow_self_locking_items(multiworld.get_location("Searing Crags - Key of Strength", player), "Power Thistle") - allow_self_locking_items(multiworld.get_location("Sunken Shrine - Key of Love", player), "Sun Crest", "Moon Crest") - allow_self_locking_items(multiworld.get_location("Corrupted Future - Key of Courage", player), "Demon King Crown") - - # add these locations when seals are shuffled - if world.options.shuffle_seals: - allow_self_locking_items(multiworld.get_location("Elemental Skylands Seal - Water", player), "Currents Master") - # add these locations when seals and shards aren't shuffled - elif not world.options.shuffle_shards: - for entrance in multiworld.get_region("Cloud Ruins", player).entrances: - entrance.access_rule = lambda state: state.has("Wingsuit", player) or state.has("Rope Dart", player) - allow_self_locking_items(multiworld.get_region("Forlorn Temple", player), *PHOBEKINS) + # locations where these placements are always valid + allow_self_locking_items(world.get_location("Searing Crags - Key of Strength"), "Power Thistle") + allow_self_locking_items(world.get_location("Sunken Shrine - Key of Love"), "Sun Crest", "Moon Crest") + allow_self_locking_items(world.get_location("Corrupted Future - Key of Courage"), "Demon King Crown") + allow_self_locking_items(world.get_location("Elemental Skylands Seal - Water"), "Currents Master") diff --git a/worlds/messenger/subclasses.py b/worlds/messenger/subclasses.py index b6a0b80b21a6..b60aeb179feb 100644 --- a/worlds/messenger/subclasses.py +++ b/worlds/messenger/subclasses.py @@ -1,36 +1,48 @@ from functools import cached_property -from typing import Optional, TYPE_CHECKING, cast +from typing import Optional, TYPE_CHECKING -from BaseClasses import CollectionState, Item, ItemClassification, Location, Region -from .constants import NOTES, PHOBEKINS, PROG_ITEMS, USEFUL_ITEMS -from .regions import MEGA_SHARDS, REGIONS, SEALS -from .shop import FIGURINES, PROG_SHOP_ITEMS, SHOP_ITEMS, USEFUL_SHOP_ITEMS +from BaseClasses import CollectionState, Entrance, Item, ItemClassification, Location, Region +from .regions import LOCATIONS, MEGA_SHARDS +from .shop import FIGURINES, SHOP_ITEMS if TYPE_CHECKING: from . import MessengerWorld +class MessengerEntrance(Entrance): + world: Optional["MessengerWorld"] = None + + class MessengerRegion(Region): - - def __init__(self, name: str, world: "MessengerWorld") -> None: + parent: str + entrance_type = MessengerEntrance + + def __init__(self, name: str, world: "MessengerWorld", parent: Optional[str] = None) -> None: super().__init__(name, world.player, world.multiworld) - locations = [loc for loc in REGIONS[self.name]] - if self.name == "The Shop": + self.parent = parent + locations = [] + if name in LOCATIONS: + locations = [loc for loc in LOCATIONS[name]] + # portal event locations since portals can be opened from their exit regions + if name.endswith("Portal"): + locations.append(name.replace(" -", "")) + + if name == "The Shop": shop_locations = {f"The Shop - {shop_loc}": world.location_name_to_id[f"The Shop - {shop_loc}"] for shop_loc in SHOP_ITEMS} self.add_locations(shop_locations, MessengerShopLocation) - elif self.name == "The Craftsman's Corner": + elif name == "The Craftsman's Corner": self.add_locations({figurine: world.location_name_to_id[figurine] for figurine in FIGURINES}, MessengerLocation) - elif self.name == "Tower HQ": + elif name == "Tower HQ": locations.append("Money Wrench") - if world.options.shuffle_seals and self.name in SEALS: - locations += [seal_loc for seal_loc in SEALS[self.name]] - if world.options.shuffle_shards and self.name in MEGA_SHARDS: - locations += [shard for shard in MEGA_SHARDS[self.name]] + + if world.options.shuffle_shards and name in MEGA_SHARDS: + locations += MEGA_SHARDS[name] loc_dict = {loc: world.location_name_to_id.get(loc, None) for loc in locations} self.add_locations(loc_dict, MessengerLocation) - world.multiworld.regions.append(self) + + self.multiworld.regions.append(self) class MessengerLocation(Location): @@ -39,46 +51,36 @@ class MessengerLocation(Location): def __init__(self, player: int, name: str, loc_id: Optional[int], parent: MessengerRegion) -> None: super().__init__(player, name, loc_id, parent) if loc_id is None: - self.place_locked_item(MessengerItem(name, parent.player, None)) + if name == "Rescue Phantom": + name = "Do the Thing!" + self.place_locked_item(MessengerItem(name, ItemClassification.progression, None, parent.player)) class MessengerShopLocation(MessengerLocation): @cached_property def cost(self) -> int: name = self.name.replace("The Shop - ", "") # TODO use `remove_prefix` when 3.8 finally gets dropped - world = cast("MessengerWorld", self.parent_region.multiworld.worlds[self.player]) + world = self.parent_region.multiworld.worlds[self.player] shop_data = SHOP_ITEMS[name] if shop_data.prerequisite: prereq_cost = 0 if isinstance(shop_data.prerequisite, set): for prereq in shop_data.prerequisite: - prereq_cost +=\ - cast(MessengerShopLocation, - world.multiworld.get_location(prereq, self.player)).cost + loc = world.multiworld.get_location(prereq, self.player) + assert isinstance(loc, MessengerShopLocation) + prereq_cost += loc.cost else: - prereq_cost +=\ - cast(MessengerShopLocation, - world.multiworld.get_location(shop_data.prerequisite, self.player)).cost + loc = world.multiworld.get_location(shop_data.prerequisite, self.player) + assert isinstance(loc, MessengerShopLocation) + prereq_cost += loc.cost return world.shop_prices[name] + prereq_cost return world.shop_prices[name] def access_rule(self, state: CollectionState) -> bool: - world = cast("MessengerWorld", state.multiworld.worlds[self.player]) + world = state.multiworld.worlds[self.player] can_afford = state.has("Shards", self.player, min(self.cost, world.total_shards)) return can_afford class MessengerItem(Item): game = "The Messenger" - - def __init__(self, name: str, player: int, item_id: Optional[int] = None, override_progression: bool = False, - count: int = 0) -> None: - if count: - item_class = ItemClassification.progression_skip_balancing - elif item_id is None or override_progression or name in {*NOTES, *PROG_ITEMS, *PHOBEKINS, *PROG_SHOP_ITEMS}: - item_class = ItemClassification.progression - elif name in {*USEFUL_ITEMS, *USEFUL_SHOP_ITEMS}: - item_class = ItemClassification.useful - else: - item_class = ItemClassification.filler - super().__init__(name, item_class, item_id, player) diff --git a/worlds/messenger/test/__init__.py b/worlds/messenger/test/__init__.py index f3fcd4ae2d60..83bb248d6483 100644 --- a/worlds/messenger/test/__init__.py +++ b/worlds/messenger/test/__init__.py @@ -1,4 +1,4 @@ -from test.TestBase import WorldTestBase +from test.bases import WorldTestBase from .. import MessengerWorld diff --git a/worlds/messenger/test/test_access.py b/worlds/messenger/test/test_access.py index 7a77a9b06695..016f3b57cdef 100644 --- a/worlds/messenger/test/test_access.py +++ b/worlds/messenger/test/test_access.py @@ -22,11 +22,27 @@ def test_tabi(self) -> None: def test_dart(self) -> None: """locations that hard require the Rope Dart""" locations = [ - "Ninja Village Seal - Tree House", "Autumn Hills - Key of Hope", "Howling Grotto Seal - Crushing Pits", - "Glacial Peak Seal - Ice Climbers", "Tower of Time Seal - Time Waster", "Tower of Time Seal - Lantern Climb", - "Tower of Time Seal - Arcane Orbs", "Cloud Ruins Seal - Ghost Pit", "Underworld Seal - Rising Fanta", - "Elemental Skylands - Key of Symbiosis", "Elemental Skylands Seal - Water", - "Elemental Skylands Seal - Fire", "Earth Mega Shard", "Water Mega Shard", "Rescue Phantom", + "Ninja Village Seal - Tree House", + "Autumn Hills - Key of Hope", + "Forlorn Temple - Demon King", + "Down Under Mega Shard", + "Howling Grotto Seal - Crushing Pits", + "Glacial Peak Seal - Ice Climbers", + "Tower of Time Seal - Time Waster", + "Tower of Time Seal - Lantern Climb", + "Tower of Time Seal - Arcane Orbs", + "Cloud Ruins Seal - Ghost Pit", + "Cloud Ruins Seal - Money Farm Room", + "Cloud Ruins Seal - Toothbrush Alley", + "Money Farm Room Mega Shard 1", + "Money Farm Room Mega Shard 2", + "Underworld Seal - Rising Fanta", + "Elemental Skylands - Key of Symbiosis", + "Elemental Skylands Seal - Water", + "Elemental Skylands Seal - Fire", + "Earth Mega Shard", + "Water Mega Shard", + "Rescue Phantom", ] items = [["Rope Dart"]] self.assertAccessDependency(locations, items) @@ -136,11 +152,37 @@ def test_crown(self) -> None: items = [["Demon King Crown"]] self.assertAccessDependency(locations, items) + def test_dboost(self) -> None: + """ + short for damage boosting, d-boosting is a technique in video games where the player intentionally or + unintentionally takes damage and uses the several following frames of invincibility to defeat or get past an + enemy or obstacle, most commonly used in platformers such as the Super Mario games + """ + locations = [ + "Riviere Turquoise Seal - Bounces and Balls", "Searing Crags Seal - Triple Ball Spinner", + "Forlorn Temple - Demon King", "Forlorn Temple Seal - Rocket Maze", "Forlorn Temple Seal - Rocket Sunset", + "Sunny Day Mega Shard", "Down Under Mega Shard", + ] + items = [["Path of Resilience", "Meditation", "Second Wind"]] + self.assertAccessDependency(locations, items) + + def test_currents(self) -> None: + """there's one of these but oh man look at it go""" + self.assertAccessDependency(["Elemental Skylands Seal - Water"], [["Currents Master"]]) + + def test_strike(self) -> None: + """strike is pretty cool but it doesn't block much""" + locations = [ + "Glacial Peak Seal - Projectile Spike Pit", "Elemental Skylands Seal - Fire", + ] + items = [["Strike of the Ninja"]] + self.assertAccessDependency(locations, items) + def test_goal(self) -> None: """Test some different states to verify goal requires the correct items""" - self.collect_all_but([*NOTES, "Rescue Phantom"]) + self.collect_all_but([*NOTES, "Do the Thing!"]) self.assertEqual(self.can_reach_location("Rescue Phantom"), False) - self.collect_all_but(["Key of Love", "Rescue Phantom"]) + self.collect_all_but(["Key of Love", "Do the Thing!"]) self.assertBeatable(False) self.collect_by_name(["Key of Love"]) self.assertEqual(self.can_reach_location("Rescue Phantom"), True) @@ -159,14 +201,12 @@ def test_self_locking_items(self) -> None: "Searing Crags - Key of Strength": ["Power Thistle"], "Sunken Shrine - Key of Love": ["Sun Crest", "Moon Crest"], "Corrupted Future - Key of Courage": ["Demon King Crown"], - "Cloud Ruins - Acro": ["Ruxxtin's Amulet"], - "Forlorn Temple - Demon King": PHOBEKINS } - self.multiworld.state = self.multiworld.get_all_state(True) - self.remove_by_name(location_lock_pairs.values()) + self.collect_all_but([item for items in location_lock_pairs.values() for item in items]) for loc in location_lock_pairs: for item_name in location_lock_pairs[loc]: item = self.get_item_by_name(item_name) with self.subTest("Fulfills Accessibility", location=loc, item=item_name): - self.assertTrue(self.multiworld.get_location(loc, self.player).can_fill(self.multiworld.state, item, True)) + self.assertTrue(self.multiworld.get_location(loc, self.player).can_fill(self.multiworld.state, item, + True)) diff --git a/worlds/messenger/test/test_logic.py b/worlds/messenger/test/test_logic.py index 15df89b92097..c13bd5c5a008 100644 --- a/worlds/messenger/test/test_logic.py +++ b/worlds/messenger/test/test_logic.py @@ -41,7 +41,7 @@ def test_vertical(self) -> None: # cloud ruins "Cloud Ruins - Acro", "Cloud Ruins Seal - Ghost Pit", "Cloud Ruins Seal - Toothbrush Alley", "Cloud Ruins Seal - Saw Pit", "Cloud Ruins Seal - Money Farm Room", - "Cloud Entrance Mega Shard", "Time Warp Mega Shard", "Money Farm Room Mega Shard 1", "Money Farm Room Mega Shard 2", + "Money Farm Room Mega Shard 1", "Money Farm Room Mega Shard 2", # underworld "Underworld Seal - Rising Fanta", "Underworld Seal - Sharp and Windy Climb", # elemental skylands @@ -80,18 +80,6 @@ def test_windmill(self) -> None: self.collect(item) self.assertTrue(self.can_reach_location(special_loc)) - def test_glacial(self) -> None: - """Test Glacial Peak locations.""" - self.assertAccessDependency(["Glacial Peak Seal - Ice Climbers"], - [["Second Wind", "Meditation"], ["Rope Dart"], ["Wingsuit"]], - True) - self.assertAccessDependency(["Glacial Peak Seal - Projectile Spike Pit"], - [["Strike of the Ninja"], ["Windmill Shuriken"], ["Rope Dart"], ["Wingsuit"]], - True) - self.assertAccessDependency(["Glacial Peak Seal - Glacial Air Swag", "Glacial Peak Mega Shard"], - [["Windmill Shuriken"], ["Wingsuit"], ["Rope Dart"]], - True) - class NoLogicTest(MessengerTestBase): options = { diff --git a/worlds/messenger/test/test_notes.py b/worlds/messenger/test/test_notes.py index 46cec5f3c819..fdb1cef1dfbe 100644 --- a/worlds/messenger/test/test_notes.py +++ b/worlds/messenger/test/test_notes.py @@ -2,29 +2,19 @@ from ..constants import NOTES -class TwoNoteGoalTest(MessengerTestBase): - options = { - "notes_needed": 2, - } - - def test_precollected_notes(self) -> None: - self.assertEqual(self.multiworld.state.count_group("Notes", self.player), 4) - - -class FourNoteGoalTest(MessengerTestBase): - options = { - "notes_needed": 4, - } - - def test_precollected_notes(self) -> None: - self.assertEqual(self.multiworld.state.count_group("Notes", self.player), 2) +class PrecollectedNotesTestBase(MessengerTestBase): + starting_notes: int = 0 + @property + def run_default_tests(self) -> bool: + return False -class DefaultGoalTest(MessengerTestBase): def test_precollected_notes(self) -> None: - self.assertEqual(self.multiworld.state.count_group("Notes", self.player), 0) + self.assertEqual(self.multiworld.state.count_group("Notes", self.player), self.starting_notes) def test_goal(self) -> None: + if self.__class__ is not PrecollectedNotesTestBase: + return self.assertBeatable(False) self.collect_by_name(NOTES) rope_dart = self.get_item_by_name("Rope Dart") @@ -33,3 +23,17 @@ def test_goal(self) -> None: self.remove(rope_dart) self.collect_by_name("Wingsuit") self.assertBeatable(True) + + +class TwoNoteGoalTest(PrecollectedNotesTestBase): + options = { + "notes_needed": 2, + } + starting_notes = 4 + + +class FourNoteGoalTest(PrecollectedNotesTestBase): + options = { + "notes_needed": 4, + } + starting_notes = 2 diff --git a/worlds/messenger/test/test_options.py b/worlds/messenger/test/test_options.py new file mode 100644 index 000000000000..ea84af80388f --- /dev/null +++ b/worlds/messenger/test/test_options.py @@ -0,0 +1,35 @@ +from BaseClasses import CollectionState +from Fill import distribute_items_restrictive +from . import MessengerTestBase +from .. import MessengerWorld +from ..options import Logic + + +class LimitedMovementTest(MessengerTestBase): + options = { + "limited_movement": "true", + "shuffle_shards": "true", + } + + @property + def run_default_tests(self) -> bool: + # This test base fails reachability tests. Not sure if the core tests should change to support that + return False + + def test_options(self) -> None: + """Tests that options were correctly changed.""" + assert isinstance(self.multiworld.worlds[self.player], MessengerWorld) + self.assertEqual(Logic.option_hard, self.world.options.logic_level) + + +class EarlyMeditationTest(MessengerTestBase): + options = { + "early_meditation": "true", + } + + def test_option(self) -> None: + """Checks that Meditation gets placed early""" + distribute_items_restrictive(self.multiworld) + sphere1 = self.multiworld.get_reachable_locations(CollectionState(self.multiworld)) + items = [loc.item.name for loc in sphere1] + self.assertIn("Meditation", items) diff --git a/worlds/messenger/test/test_portals.py b/worlds/messenger/test/test_portals.py new file mode 100644 index 000000000000..6ebb18381331 --- /dev/null +++ b/worlds/messenger/test/test_portals.py @@ -0,0 +1,33 @@ +from BaseClasses import CollectionState +from . import MessengerTestBase +from ..portals import PORTALS + + +class PortalTestBase(MessengerTestBase): + def test_portal_reqs(self) -> None: + """tests the paths to open a portal if only that portal is closed with vanilla connections.""" + # portal and requirements to reach it if it's the only closed portal + portal_requirements = { + "Autumn Hills Portal": [["Wingsuit"]], # grotto -> bamboo -> catacombs -> hills + "Riviere Turquoise Portal": [["Candle", "Wingsuit", "Rope Dart"]], # hills -> catacombs -> dark cave -> riviere + "Howling Grotto Portal": [["Wingsuit"], ["Meditation", "Second Wind"]], # crags -> quillshroom -> grotto + "Sunken Shrine Portal": [["Seashell"]], # crags -> quillshroom -> grotto -> shrine + "Searing Crags Portal": [["Wingsuit"], ["Rope Dart"]], # grotto -> quillshroom -> crags there's two separate paths + "Glacial Peak Portal": [["Wingsuit", "Second Wind", "Meditation"], ["Rope Dart"]], # grotto -> quillshroom -> crags -> peak or crags -> peak + } + + for portal in PORTALS: + name = f"{portal} Portal" + entrance_name = f"ToTHQ {name}" + with self.subTest(portal=name, entrance_name=entrance_name): + entrance = self.multiworld.get_entrance(entrance_name, self.player) + # this emulates the portal being initially closed + entrance.access_rule = lambda state: state.has(name, self.player) + for grouping in portal_requirements[name]: + test_state = CollectionState(self.multiworld) + self.assertFalse(entrance.can_reach(test_state), "reachable with nothing") + items = self.get_items_by_name(grouping) + for item in items: + test_state.collect(item) + self.assertTrue(entrance.can_reach(test_state), grouping) + entrance.access_rule = lambda state: True diff --git a/worlds/messenger/test/test_shop.py b/worlds/messenger/test/test_shop.py index ee7e82d6cdbe..971ff1763b47 100644 --- a/worlds/messenger/test/test_shop.py +++ b/worlds/messenger/test/test_shop.py @@ -24,25 +24,6 @@ def test_shop_prices(self) -> None: self.assertTrue(loc in SHOP_ITEMS) self.assertEqual(len(prices), len(SHOP_ITEMS)) - def test_dboost(self) -> None: - locations = [ - "Riviere Turquoise Seal - Bounces and Balls", - "Forlorn Temple - Demon King", "Forlorn Temple Seal - Rocket Maze", "Forlorn Temple Seal - Rocket Sunset", - "Sunny Day Mega Shard", "Down Under Mega Shard", - ] - items = [["Path of Resilience", "Meditation", "Second Wind"]] - self.assertAccessDependency(locations, items) - - def test_currents(self) -> None: - self.assertAccessDependency(["Elemental Skylands Seal - Water"], [["Currents Master"]]) - - def test_strike(self) -> None: - locations = [ - "Glacial Peak Seal - Projectile Spike Pit", "Elemental Skylands Seal - Fire", - ] - items = [["Strike of the Ninja"]] - self.assertAccessDependency(locations, items) - class ShopCostMinTest(ShopCostTest): options = { diff --git a/worlds/messenger/test/test_shop_chest.py b/worlds/messenger/test/test_shop_chest.py index f2030c63de99..2ac306972614 100644 --- a/worlds/messenger/test/test_shop_chest.py +++ b/worlds/messenger/test/test_shop_chest.py @@ -4,19 +4,14 @@ class AllSealsRequired(MessengerTestBase): options = { - "shuffle_seals": "false", "goal": "power_seal_hunt", } - def test_seals_shuffled(self) -> None: - """Shuffle seals should be forced on when shop chest is the goal so test it.""" - self.assertTrue(self.multiworld.shuffle_seals[self.player]) - def test_chest_access(self) -> None: """Defaults to a total of 45 power seals in the pool and required.""" with self.subTest("Access Dependency"): self.assertEqual(len([seal for seal in self.multiworld.itempool if seal.name == "Power Seal"]), - self.multiworld.total_seals[self.player]) + self.world.options.total_seals) locations = ["Rescue Phantom"] items = [["Power Seal"]] self.assertAccessDependency(locations, items) @@ -24,7 +19,7 @@ def test_chest_access(self) -> None: self.assertEqual(self.can_reach_location("Rescue Phantom"), False) self.assertBeatable(False) - self.collect_all_but(["Power Seal", "Rescue Phantom"]) + self.collect_all_but(["Power Seal", "Do the Thing!"]) self.assertEqual(self.can_reach_location("Rescue Phantom"), False) self.assertBeatable(False) self.collect_by_name("Power Seal") @@ -40,7 +35,7 @@ class HalfSealsRequired(MessengerTestBase): def test_seals_amount(self) -> None: """Should have 45 power seals in the item pool and half that required""" - self.assertEqual(self.multiworld.total_seals[self.player], 45) + self.assertEqual(self.world.options.total_seals, 45) self.assertEqual(self.world.total_seals, 45) self.assertEqual(self.world.required_seals, 22) total_seals = [seal for seal in self.multiworld.itempool if seal.name == "Power Seal"] @@ -59,7 +54,7 @@ class ThirtyThirtySeals(MessengerTestBase): def test_seals_amount(self) -> None: """Should have 30 power seals in the pool and 33 percent of that required.""" - self.assertEqual(self.multiworld.total_seals[self.player], 30) + self.assertEqual(self.world.options.total_seals, 30) self.assertEqual(self.world.total_seals, 30) self.assertEqual(self.world.required_seals, 10) total_seals = [seal for seal in self.multiworld.itempool if seal.name == "Power Seal"] @@ -77,7 +72,7 @@ class MaxSealsNoShards(MessengerTestBase): def test_seals_amount(self) -> None: """Should set total seals to 70 since shards aren't shuffled.""" - self.assertEqual(self.multiworld.total_seals[self.player], 85) + self.assertEqual(self.world.options.total_seals, 85) self.assertEqual(self.world.total_seals, 70) @@ -90,7 +85,7 @@ class MaxSealsWithShards(MessengerTestBase): def test_seals_amount(self) -> None: """Should have 85 seals in the pool with all required and be a valid seed.""" - self.assertEqual(self.multiworld.total_seals[self.player], 85) + self.assertEqual(self.world.options.total_seals, 85) self.assertEqual(self.world.total_seals, 85) self.assertEqual(self.world.required_seals, 85) total_seals = [seal for seal in self.multiworld.itempool if seal.name == "Power Seal"]