The Messenger: content update (#2823)

* map option objects to a `World.options` dict

* convert RoR2 to options dict system for testing

* add temp behavior for lttp with notes

* copy/paste bad

* convert `set_default_common_options` to a namespace property

* reorganize test call order

* have fill_restrictive use the new options system

* update world api

* update soe tests

* fix world api

* core: auto initialize a dataclass on the World class with the option results

* core: auto initialize a dataclass on the World class with the option results: small tying improvement

* add `as_dict` method to the options dataclass

* fix namespace issues with tests

* have current option updates use `.value` instead of changing the option

* update ror2 to use the new options system again

* revert the junk pool dict since it's cased differently

* fix begin_with_loop typo

* write new and old options to spoiler

* change factorio option behavior back

* fix comparisons

* move common and per_game_common options to new system

* core: automatically create missing options_dataclass from legacy option_definitions

* remove spoiler special casing and add back the Factorio option changing but in new system

* give ArchipIDLE the default options_dataclass so its options get generated and spoilered properly

* reimplement `inspect.get_annotations`

* move option info generation for webhost to new system

* need to include Common and PerGame common since __annotations__ doesn't include super

* use get_type_hints for the options dictionary

* typing.get_type_hints returns the bases too.

* forgot to sweep through generate

* sweep through all the tests

* swap to a metaclass property

* move remaining usages from get_type_hints to metaclass property

* move remaining usages from __annotations__ to metaclass property

* move remaining usages from legacy dictionaries to metaclass property

* remove legacy dictionaries

* cache the metaclass property

* clarify inheritance in world api

* move the messenger to new options system

* add an assert for my dumb

* update the doc

* rename o to options

* missed a spot

* update new messenger options

* comment spacing

Co-authored-by: Doug Hoskisson <beauxq@users.noreply.github.com>

* fix tests

* fix missing import

* make the documentation definition more accurate

* use options system for loc creation

* type cast MessengerWorld

* fix typo and use quotes for cast

* LTTP: set random seed in tests

* ArchipIdle: remove change here as it's default on AutoWorld

* Stardew: Need to set state because `set_default_common_options` used to

* The Messenger: update shop rando and helpers to new system; optimize imports

* Add a kwarg to `as_dict` to do the casing for you

* RoR2: use new kwarg for less code

* RoR2: revert some accidental reverts

* The Messenger: remove an unnecessary variable

* remove TypeVar that isn't used

* CommonOptions not abstract

* Docs: fix mistake in options api.md

Co-authored-by: Doug Hoskisson <beauxq@users.noreply.github.com>

* create options for item link worlds

* revert accidental doc removals

* Item Links: set default options on group

* Messenger: Limited Movement option first draft

* The Messenger: add automated setup through the launcher

* drop tomllib

* don't uselessly import launcher

* The Messenger: fix missing goal requirement for power seal hunt

* make hard mode goal harder

* make fire seal a bit more lenient

* have limited movement force minimal accessibility

* add an early meditation option

* clean up precollected notes tests a bit

* add linux support

* add steam deck support

* await monokickstart

* minor styling cleanup

* more minor styling cleanup

* Initial implementation of Generic ER

* Move ERType to Entrance.Type, fix typing imports

* updates based on testing (read: flailing)

* Updates from feedback

* Various bug fixes in ERCollectionState

* Use deque instead of queue.Queue

* Allow partial entrances in collection state earlier, doc improvements

* Prevent early loops in region graph, improve reusability of ER stage code

* Typos, grammar, PEP8, and style "fixes"

* use RuntimeError instead of bare Exceptions

* return tuples from connect since it's slightly faster for our purposes

* move the shuffle to the beginning of find_pairing

* do er_state placements within pairing lookups to remove code duplication

* requested adjustments

* Add some temporary performance logging

* Use CollectionState to track available exits and placed regions

* remove seal shuffle option

* some cleanup stuff

* portal rando progress

* pre-emptive region creation

* seals need to be in the datapackage

* put mega shards in old order

* fix typos and make it actually work

* fix more missed connections and add portal events

* fix all the portal rando code

* finish initial logic implementation

* remove/comment out debug stuff

* does not actually support plando yet

* typos and fix a crash when 3 available portals was selected

* finish initial logic for all connections and remove/rename as necessary

* fix typos and add some more leniency

* move item classification determination to its own method rather than split between two spots

* super complicated solution for handling installing the alpha builds

* fix logic bugs and add a test

* implement logic to shuffle the cutscene portals even though it's probably not possible

* just use the one list

* fix some issues with the mod checking/downloading

* Core: have webhost slot name links go through the launcher so that components can use them

* add uri support to the launcher component function

* generate output file under specific conditions

* cleanup connections.py

* set topology_present to true when portals are shuffled

* add requirement for ghost pit loc since it's pretty hard without movement

* bring hard logic back

* misc cleanup

* fix asset grabbing of latest version

* implement ER

* just use the entrances for the spoiler instead of manipulating the cache

* remove test defaults

* remove excessive comprehension

* cleanup and cater data for the client

* add elemental skylands to the shuffle pools

* initial attempts at hint text

* use network items for offline seeds

* change around the offline seed data again

* move er after portal shuffle and ensure a minimal sphere 1

* Add a method to automatically disconnect entrances in a coupled-compliant way

 Update docs and cleanup todos

* Make find_placeable_exits deterministic by sorting blocked_connections set

* add more ER transitions

* fix spoiler output of portal warps

* add path to hint_data

* rename entrance to tot to be a bit clearer

* cleanup imports and update description for hard logic

* cleanup for PR to main

* missed a spot

* cleanup monokickstart

* add location_name_groups

* update docs for new setup

* client can reconnect on its own now, no need for a button.

* fix mod download link grabbing the wrong assets

* cleanup mod pulling a bit and display version it's trying to update to

* plando support

* comment out broken steam deck support

* supports plando

* satisfy flake for currently unused file

* fix the items accessibility test

* review comments

* add searing crags portal to starting portals when disabled like option says

* address sliver comments

* rip out currently unused transition shuffle

* add aerobatics warrior requirement to fire seal

---------

Co-authored-by: el-u <109771707+el-u@users.noreply.github.com>
Co-authored-by: Doug Hoskisson <beauxq@users.noreply.github.com>
Co-authored-by: Doug Hoskisson <beauxq@yahoo.com>
Co-authored-by: Sean Dempsey <dempsey.sean@outlook.com>
Co-authored-by: qwint <qwint.42@gmail.com>
This commit is contained in:
Aaron Wagener 2024-03-11 17:23:41 -05:00 committed by GitHub
parent 078d793073
commit d20d09e682
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 2570 additions and 417 deletions

View File

@ -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 @@ class MessengerWorld(World):
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 @@ class MessengerWorld(World):
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 @@ class MessengerWorld(World):
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 @@ class MessengerWorld(World):
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)

View File

@ -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)

View File

@ -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",
]

View File

@ -24,6 +24,8 @@ PROG_ITEMS = [
# "Astral Seed",
# "Astral Tea Leaves",
"Money Wrench",
"Candle",
"Seashell",
]
PHOBEKINS = [
@ -103,6 +105,52 @@ ALWAYS_LOCATIONS = [
"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 = [

View File

@ -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?

View File

@ -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.

View File

@ -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

290
worlds/messenger/portals.py Normal file
View File

@ -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)

View File

@ -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",
]

View File

@ -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 @@ if TYPE_CHECKING:
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 @@ class MessengerRules:
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 @@ class MessengerRules:
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 @@ class MessengerRules:
"""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]
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]
multiworld.completion_condition[self.player] = lambda state: state.has("Rescue Phantom", self.player)
if multiworld.accessibility[self.player]: # not locations accessibility
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.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({
"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.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 @@ class MessengerOOBRules(MessengerRules):
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 @@ class MessengerOOBRules(MessengerRules):
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 @@ class MessengerOOBRules(MessengerRules):
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")

View File

@ -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)

View File

@ -1,4 +1,4 @@
from test.TestBase import WorldTestBase
from test.bases import WorldTestBase
from .. import MessengerWorld

View File

@ -22,11 +22,27 @@ class AccessTest(MessengerTestBase):
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 @@ class AccessTest(MessengerTestBase):
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 @@ class ItemsAccessTest(MessengerTestBase):
"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))

View File

@ -41,7 +41,7 @@ class HardLogicTest(MessengerTestBase):
# 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 @@ class HardLogicTest(MessengerTestBase):
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 = {

View File

@ -2,29 +2,19 @@ from . import MessengerTestBase
from ..constants import NOTES
class TwoNoteGoalTest(MessengerTestBase):
options = {
"notes_needed": 2,
}
class PrecollectedNotesTestBase(MessengerTestBase):
starting_notes: int = 0
@property
def run_default_tests(self) -> bool:
return False
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 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 @@ class DefaultGoalTest(MessengerTestBase):
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

View File

@ -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)

View File

@ -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

View File

@ -24,25 +24,6 @@ class ShopCostTest(MessengerTestBase):
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 = {

View File

@ -4,19 +4,14 @@ from . import MessengerTestBase
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 @@ class AllSealsRequired(MessengerTestBase):
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"]