From 070a92e76c945acddcea3a7c1c8ca180b17f35a7 Mon Sep 17 00:00:00 2001 From: alwaysintreble Date: Sun, 12 Mar 2023 09:05:50 -0500 Subject: [PATCH] The Messenger: implement new game (#1494) * initial commit of messenger integration * setup no_logic and needed slot_data * fix some typos and determinism * make all of it deterministic * add documentation * swapped to non local items so change the fed data * ~~deathlink~~ * satisfy the docs test * update doc test to show expected name * split custom classes into a separate file and fix an errant rule * make access dependency test give more useful errors * implement tests * remove some unneccessary back entrances and make names clearer * fix some big dumbs * successful unit tests are good also some slight reorganizing * add astral tea quest line, and potentially power seals as items * if TYPE_CHECKING... aahhhhhh * oop forgot to remove legacy code * having the seed and leaves as actual items doesn't seem to do anything so remove them. locations still work though * update setup guide with some changes * Tower HQ was creating duplicate locations * allow self locking items * cleanup * move self_locking_items function to core * docstring * implement choice of notes needed for music box * test the default value * don't create any starting inventory items * make item creation faster * change default accessibility and power seals options * improve documentation * precollected_items is a dict of Items... * implement shop chest goal * tests * always assign total and required seals * add new goals and set music box as requiring shop chest on shop chest goals instead of just setting it as the completion * fix dumb test quirk * implement music box skip as an option * world rewrite/cleanup * default to apworld and add game to readme * revert bleeding commits from other PRs * more bleeds * fix some errors in options docstrings * ??? * make my set rules method not have an awful name * test cleanup * add a test for item accessibility * fix issues with tests * make the self locking item behavior work correctly * misc cleanup * more general cleanup to be a good example * quick rules rewrite * more general cleanup and typing * more speed, more clean * bump data version * make sure the locked item belongs to current player * fix bad name and indent. call MessengerItem directly for events * add poptracker pack to docs * doc cleanup and "known issues" section that I probably won't be able to fix any time soon. * missed some spots * add another bug i forgot about * be consistently wrong --- README.md | 1 + setup.py | 1 + worlds/messenger/Constants.py | 153 +++++++++++++++++++++ worlds/messenger/Options.py | 66 +++++++++ worlds/messenger/Regions.py | 52 +++++++ worlds/messenger/Rules.py | 158 ++++++++++++++++++++++ worlds/messenger/SubClasses.py | 58 ++++++++ worlds/messenger/__init__.py | 125 +++++++++++++++++ worlds/messenger/docs/en_The Messenger.md | 75 ++++++++++ worlds/messenger/docs/setup_en.md | 52 +++++++ worlds/messenger/test/TestAccess.py | 149 ++++++++++++++++++++ worlds/messenger/test/TestNotes.py | 30 ++++ worlds/messenger/test/TestShopChest.py | 79 +++++++++++ worlds/messenger/test/__init__.py | 6 + 14 files changed, 1005 insertions(+) create mode 100644 worlds/messenger/Constants.py create mode 100644 worlds/messenger/Options.py create mode 100644 worlds/messenger/Regions.py create mode 100644 worlds/messenger/Rules.py create mode 100644 worlds/messenger/SubClasses.py create mode 100644 worlds/messenger/__init__.py create mode 100644 worlds/messenger/docs/en_The Messenger.md create mode 100644 worlds/messenger/docs/setup_en.md create mode 100644 worlds/messenger/test/TestAccess.py create mode 100644 worlds/messenger/test/TestNotes.py create mode 100644 worlds/messenger/test/TestShopChest.py create mode 100644 worlds/messenger/test/__init__.py diff --git a/README.md b/README.md index 9e6ed2b1..b99182f4 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,7 @@ Currently, the following games are supported: * Wargroove * Stardew Valley * The Legend of Zelda +* The Messenger For setup and instructions check out our [tutorials page](https://archipelago.gg/tutorial/). Downloads can be found at [Releases](https://github.com/ArchipelagoMW/Archipelago/releases), including compiled diff --git a/setup.py b/setup.py index 7c55a4d2..8ad4f32e 100644 --- a/setup.py +++ b/setup.py @@ -54,6 +54,7 @@ apworlds: set = { "Stardew Valley", "Timespinner", "Minecraft", + "The Messenger", } diff --git a/worlds/messenger/Constants.py b/worlds/messenger/Constants.py new file mode 100644 index 00000000..d57081ed --- /dev/null +++ b/worlds/messenger/Constants.py @@ -0,0 +1,153 @@ +# items +# listing individual groups first for easy lookup +NOTES = [ + "Key of Hope", + "Key of Chaos", + "Key of Courage", + "Key of Love", + "Key of Strength", + "Key of Symbiosis" +] + +PROG_ITEMS = [ + "Wingsuit", + "Rope Dart", + "Ninja Tabi", + "Power Thistle", + "Demon King Crown", + "Ruxxtin's Amulet", + "Fairy Bottle", + "Sun Crest", + "Moon Crest", + # "Astral Seed", + # "Astral Tea Leaves" +] + +PHOBEKINS = [ + "Necro", + "Pyro", + "Claustro", + "Acro" +] + +USEFUL_ITEMS = [ + "Windmill Shuriken" +] + +# item_name_to_id needs to be deterministic and match upstream +ALL_ITEMS = [ + *NOTES, + "Windmill Shuriken", + "Wingsuit", + "Rope Dart", + "Ninja Tabi", + # "Astral Seed", + # "Astral Tea Leaves", + "Candle", + "Seashell", + "Power Thistle", + "Demon King Crown", + "Ruxxtin's Amulet", + "Fairy Bottle", + "Sun Crest", + "Moon Crest", + *PHOBEKINS, + "Power Seal", + "Time Shard" # there's 45 separate instances of this in the client lookup, but hopefully we don't care? +] + +# locations +# the names of these don't actually matter, but using the upstream's names for now +# order must be exactly the same as upstream +ALWAYS_LOCATIONS = [ + # notes + "Key of Love", + "Key of Courage", + "Key of Chaos", + "Key of Symbiosis", + "Key of Strength", + "Key of Hope", + # upgrades + "Wingsuit", + "Rope Dart", + "Ninja Tabi", + "Climbing Claws", + # quest items + "Astral Seed", + "Astral Tea Leaves", + "Candle", + "Seashell", + "Power Thistle", + "Demon King Crown", + "Ruxxtin's Amulet", + "Fairy Bottle", + "Sun Crest", + "Moon Crest", + # phobekins + "Necro", + "Pyro", + "Claustro", + "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 Seal", + "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" +] diff --git a/worlds/messenger/Options.py b/worlds/messenger/Options.py new file mode 100644 index 00000000..1baca12e --- /dev/null +++ b/worlds/messenger/Options.py @@ -0,0 +1,66 @@ +from Options import DefaultOnToggle, DeathLink, Range, Accessibility, Choice + + +class MessengerAccessibility(Accessibility): + default = Accessibility.option_locations + # defaulting to locations accessibility since items makes certain items self-locking + __doc__ = Accessibility.__doc__.replace(f"default {Accessibility.default}", f"default {default}") + + +class Logic(DefaultOnToggle): + """Whether the seed should be guaranteed completable.""" + display_name = "Use Logic" + + +class PowerSeals(DefaultOnToggle): + """Whether power seal locations should be randomized.""" + display_name = "Shuffle Seals" + + +class Goal(Choice): + """Requirement to finish the game. Power Seal Hunt will force power seal locations to be shuffled.""" + display_name = "Goal" + option_open_music_box = 0 + option_power_seal_hunt = 1 + + +class MusicBox(DefaultOnToggle): + """Whether the music box gauntlet needs to be done.""" + display_name = "Music Box Gauntlet" + + +class NotesNeeded(Range): + """How many notes are needed to access the Music Box.""" + display_name = "Notes Needed" + range_start = 1 + range_end = 6 + default = range_end + + +class AmountSeals(Range): + """Number of power seals that exist in the item pool when power seal hunt is the goal.""" + display_name = "Total Power Seals" + range_start = 1 + range_end = 45 + default = range_end + + +class RequiredSeals(Range): + """Percentage of total seals required to open the shop chest.""" + display_name = "Percent Seals Required" + range_start = 10 + range_end = 100 + default = range_end + + +messenger_options = { + "accessibility": MessengerAccessibility, + "enable_logic": Logic, + "shuffle_seals": PowerSeals, + "goal": Goal, + "music_box": MusicBox, + "notes_needed": NotesNeeded, + "total_seals": AmountSeals, + "percent_seals_required": RequiredSeals, + "death_link": DeathLink, +} diff --git a/worlds/messenger/Regions.py b/worlds/messenger/Regions.py new file mode 100644 index 00000000..468c69cf --- /dev/null +++ b/worlds/messenger/Regions.py @@ -0,0 +1,52 @@ +from typing import Dict, Set, List + +REGIONS: Dict[str, List[str]] = { + "Menu": [], + "Tower HQ": [], + "The Shop": [], + "Tower of Time": [], + "Ninja Village": ["Candle", "Astral Seed"], + "Autumn Hills": ["Climbing Claws", "Key of Hope"], + "Forlorn Temple": ["Demon King Crown"], + "Catacombs": ["Necro", "Ruxxtin's Amulet"], + "Bamboo Creek": ["Claustro"], + "Howling Grotto": ["Wingsuit"], + "Quillshroom Marsh": ["Seashell"], + "Searing Crags": ["Rope Dart"], + "Searing Crags Upper": ["Power Thistle", "Key of Strength", "Astral Tea Leaves"], + "Glacial Peak": [], + "Cloud Ruins": ["Acro"], + "Underworld": ["Pyro", "Key of Chaos"], + "Dark Cave": [], + "Riviere Turquoise": ["Fairy Bottle"], + "Sunken Shrine": ["Ninja Tabi", "Sun Crest", "Moon Crest", "Key of Love"], + "Elemental Skylands": ["Key of Symbiosis"], + "Corrupted Future": ["Key of Courage"], + "Music Box": ["Rescue Phantom"] +} +"""seal locations have the region in their name and may not need to be created so skip them here""" + + +REGION_CONNECTIONS: Dict[str, Set[str]] = { + "Menu": {"Tower HQ"}, + "Tower HQ": {"Autumn Hills", "Howling Grotto", "Searing Crags", "Glacial Peak", "Tower of Time", "Riviere Turquoise", + "Sunken Shrine", "Corrupted Future", "The Shop", "Music Box"}, + "Tower of Time": set(), + "Ninja Village": set(), + "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": {"Underworld"}, + "Underworld": set(), + "Dark Cave": {"Catacombs", "Riviere Turquoise"}, + "Riviere Turquoise": set(), + "Sunken Shrine": {"Howling Grotto"}, + "Elemental Skylands": set() +} +"""Vanilla layout mapping with all Tower HQ portals open. from -> to""" diff --git a/worlds/messenger/Rules.py b/worlds/messenger/Rules.py new file mode 100644 index 00000000..c2731678 --- /dev/null +++ b/worlds/messenger/Rules.py @@ -0,0 +1,158 @@ +from typing import Dict, Callable, Optional, Tuple, Union, TYPE_CHECKING, List, Iterable + +from BaseClasses import CollectionState, MultiWorld, Location, Region, Entrance, Item +from .Options import MessengerAccessibility, Goal +from .Constants import NOTES, PHOBEKINS +from ..generic.Rules import add_rule, set_rule + +if TYPE_CHECKING: + from . import MessengerWorld +else: + MessengerWorld = object + + +class MessengerRules: + player: int + world: MessengerWorld + + def __init__(self, world: MessengerWorld): + self.player = world.player + self.world = world + + self.region_rules: Dict[str, Callable[[CollectionState], bool]] = { + "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_wingsuit(state) and state.has("Ruxxtin's Amulet", self.player), + "Underworld": self.has_tabi, + "Forlorn Temple": lambda state: state.has_all(PHOBEKINS, self.player) and self.has_wingsuit(state), + "Glacial Peak": self.has_vertical, + "Elemental Skylands": lambda state: state.has("Fairy Bottle", self.player), + "Music Box": lambda state: state.has_all(NOTES, self.player) + } + + self.location_rules: Dict[str, Callable[[CollectionState], bool]] = { + # ninja village + "Ninja Village Seal - Tree House": self.has_dart, + # autumn hills + "Key of Hope": self.has_dart, + # 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), + # searing crags + "Key of Strength": lambda state: state.has("Power Thistle", self.player), + # glacial peak + "Glacial Peak Seal - Ice Climbers": self.has_dart, + "Glacial Peak Seal - Projectile Spike Pit": self.has_vertical, + "Glacial Peak Seal - Glacial Air Swag": self.has_vertical, + # tower of time + "Tower of Time Seal - Time Waster Seal": self.has_dart, + "Tower of Time Seal - Lantern Climb": self.has_wingsuit, + "Tower of Time Seal - Arcane Orbs": lambda state: self.has_wingsuit(state) and self.has_dart(state), + # underworld + "Underworld Seal - Sharp and Windy Climb": self.has_wingsuit, + "Underworld Seal - Fireball Wave": self.has_wingsuit, + "Underworld Seal - Rising Fanta": self.has_dart, + # sunken shrine + "Sun Crest": self.has_tabi, + "Moon Crest": self.has_tabi, + "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, + # riviere turquoise + "Fairy Bottle": self.has_vertical, + "Riviere Turquoise Seal - Flower Power": self.has_vertical, + # elemental skylands + "Key of Symbiosis": self.has_dart, + "Elemental Skylands Seal - Air": self.has_wingsuit, + "Elemental Skylands Seal - Water": self.has_dart, + "Elemental Skylands Seal - Fire": self.has_dart, + # corrupted future + "Key of Courage": lambda state: state.has_all({"Demon King Crown", "Fairy Bottle"}, self.player), + # the shop + "Shop Chest": self.has_enough_seals + } + + def has_wingsuit(self, state: CollectionState) -> bool: + return state.has("Wingsuit", self.player) + + def has_dart(self, state: CollectionState) -> bool: + return state.has("Rope Dart", self.player) + + def has_tabi(self, state: CollectionState) -> bool: + return state.has("Ninja Tabi", self.player) + + def has_vertical(self, state: CollectionState) -> bool: + return self.has_wingsuit(state) or self.has_dart(state) + + def has_enough_seals(self, state: CollectionState) -> bool: + required_seals = state.multiworld.worlds[self.player].required_seals + return state.has("Power Seal", self.player, required_seals) + + 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] + if multiworld.goal[self.player] == Goal.option_power_seal_hunt: + set_rule(multiworld.get_entrance("Tower HQ -> Music Box", self.player), + lambda state: state.has("Shop Chest", self.player)) + + if multiworld.enable_logic[self.player]: + multiworld.completion_condition[self.player] = lambda state: state.has("Rescue Phantom", self.player) + else: + multiworld.accessibility[self.player].value = MessengerAccessibility.option_minimal + if multiworld.accessibility[self.player] > MessengerAccessibility.option_locations: + set_self_locking_items(multiworld, self.player) + + +def location_item_name(state: CollectionState, location_name: str, player: int) -> Optional[Tuple[str, int]]: + location = state.multiworld.get_location(location_name, player) + if location.item is None: + return None + return location.item.name, location.item.player + + +def allow_self_locking_items(spot: Union[Location, Region], *item_names: str) -> None: + """ + Sets rules on the supplied spot, such that the supplied item_name(s) can possibly be placed there. + :param spot: Location or Region that the item(s) are allowed to be placed in + :param item_names: item name or names that are allowed to be placed in the Location or Region + """ + player = spot.player + + def set_always_allow(location: Location, rule: Callable[[CollectionState, Item], bool]) -> None: + location.always_allow = rule + + def add_allowed_rules(area: Union[Location, Entrance], location: Location) -> None: + for item_name in item_names: + add_rule(area, lambda state, item_name=item_name: + location_item_name(state, location.name, player) == (item_name, player), "or") + set_always_allow(location, lambda state, item: + item.player == player and item.name in [item_name for item_name in item_names]) + + if isinstance(spot, Region): + for entrance in spot.entrances: + for location in spot.locations: + add_allowed_rules(entrance, location) + else: + add_allowed_rules(spot, spot) + + +def set_self_locking_items(multiworld: MultiWorld, player: int) -> None: + # do the ones for seal shuffle on and off first + allow_self_locking_items(multiworld.get_location("Key of Strength", player), "Power Thistle") + allow_self_locking_items(multiworld.get_location("Key of Love", player), "Sun Crest", "Moon Crest") + allow_self_locking_items(multiworld.get_location("Key of Courage", player), "Demon King Crown") + + # add these locations when seals aren't shuffled + if not multiworld.shuffle_seals[player]: + allow_self_locking_items(multiworld.get_region("Cloud Ruins", player), "Ruxxtin's Amulet") + allow_self_locking_items(multiworld.get_region("Forlorn Temple", player), *PHOBEKINS) diff --git a/worlds/messenger/SubClasses.py b/worlds/messenger/SubClasses.py new file mode 100644 index 00000000..32803f5e --- /dev/null +++ b/worlds/messenger/SubClasses.py @@ -0,0 +1,58 @@ +from typing import Set, TYPE_CHECKING, Optional, Dict + +from BaseClasses import Region, Location, Item, ItemClassification, Entrance +from .Constants import SEALS, NOTES, PROG_ITEMS, PHOBEKINS, USEFUL_ITEMS +from .Options import Goal +from .Regions import REGIONS + +if TYPE_CHECKING: + from . import MessengerWorld +else: + MessengerWorld = object + + +class MessengerRegion(Region): + def __init__(self, name: str, world: MessengerWorld): + super().__init__(name, world.player, world.multiworld) + self.add_locations(self.multiworld.worlds[self.player].location_name_to_id) + world.multiworld.regions.append(self) + + def add_locations(self, name_to_id: Dict[str, int]) -> None: + for loc in REGIONS[self.name]: + self.locations.append(MessengerLocation(loc, self, name_to_id.get(loc, None))) + if self.name == "The Shop" and self.multiworld.goal[self.player] > Goal.option_open_music_box: + self.locations.append(MessengerLocation("Shop Chest", self, name_to_id.get("Shop Chest", None))) + # putting some dumb special case for searing crags and ToT so i can split them into 2 regions + if self.multiworld.shuffle_seals[self.player] and self.name not in {"Searing Crags", "Tower HQ"}: + for seal_loc in SEALS: + if seal_loc.startswith(self.name.split(" ")[0]): + self.locations.append(MessengerLocation(seal_loc, self, name_to_id.get(seal_loc, None))) + + def add_exits(self, exits: Set[str]) -> None: + for exit in exits: + ret = Entrance(self.player, f"{self.name} -> {exit}", self) + self.exits.append(ret) + ret.connect(self.multiworld.get_region(exit, self.player)) + + +class MessengerLocation(Location): + game = "The Messenger" + + def __init__(self, name: str, parent: MessengerRegion, loc_id: Optional[int]): + super().__init__(parent.player, name, loc_id, parent) + if loc_id is None: + self.place_locked_item(MessengerItem(name, parent.player, None)) + + +class MessengerItem(Item): + game = "The Messenger" + + def __init__(self, name: str, player: int, item_id: Optional[int] = None): + if name in {*NOTES, *PROG_ITEMS, *PHOBEKINS} or item_id is None: + item_class = ItemClassification.progression + elif name in USEFUL_ITEMS: + item_class = ItemClassification.useful + else: + item_class = ItemClassification.filler + super().__init__(name, item_class, item_id, player) + diff --git a/worlds/messenger/__init__.py b/worlds/messenger/__init__.py new file mode 100644 index 00000000..1c42b304 --- /dev/null +++ b/worlds/messenger/__init__.py @@ -0,0 +1,125 @@ +from typing import Dict, Any, List, Optional + +from BaseClasses import Tutorial, ItemClassification +from worlds.AutoWorld import World, WebWorld +from .Constants import NOTES, PROG_ITEMS, PHOBEKINS, USEFUL_ITEMS, ALWAYS_LOCATIONS, SEALS, ALL_ITEMS +from .Options import messenger_options, NotesNeeded, Goal, PowerSeals +from .Regions import REGIONS, REGION_CONNECTIONS +from .Rules import MessengerRules +from .SubClasses import MessengerRegion, MessengerItem + + +class MessengerWeb(WebWorld): + theme = "ocean" + + bug_report_page = "https://github.com/minous27/TheMessengerRandomizerMod/issues" + + tut_en = Tutorial( + "Multiworld Setup Tutorial", + "A guide to setting up The Messenger randomizer on your computer.", + "English", + "setup_en.md", + "setup/en", + ["alwaysintreble"] + ) + + tutorials = [tut_en] + + +class MessengerWorld(World): + """ + As a demon army besieges his village, a young ninja ventures through a cursed world, to deliver a scroll paramount + to his clan’s survival. What begins as a classic action platformer soon unravels into an expansive time-traveling + 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), + "Shuriken": {"Windmill Shuriken"}, + } + + option_definitions = messenger_options + + base_offset = 0xADD_000 + item_name_to_id = {item: item_id + for item_id, item in enumerate(ALL_ITEMS, base_offset)} + location_name_to_id = {location: location_id + for location_id, location in enumerate([*ALWAYS_LOCATIONS, *SEALS], base_offset)} + + data_version = 1 + + web = MessengerWeb() + + total_seals: Optional[int] = None + required_seals: Optional[int] = None + + def generate_early(self) -> None: + if self.multiworld.goal[self.player] == Goal.option_power_seal_hunt: + self.multiworld.shuffle_seals[self.player].value = PowerSeals.option_true + self.total_seals = self.multiworld.total_seals[self.player].value + self.required_seals = int(self.multiworld.percent_seals_required[self.player].value / 100 * self.total_seals) + + def create_regions(self) -> None: + 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]) + + def create_items(self) -> None: + itempool: List[MessengerItem] = [] + if self.multiworld.goal[self.player] == Goal.option_power_seal_hunt: + 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 + else: + notes = self.multiworld.random.sample(NOTES, k=len(NOTES)) + precollected_notes_amount = NotesNeeded.range_end - self.multiworld.notes_needed[self.player] + if precollected_notes_amount: + for note in notes[:precollected_notes_amount]: + self.multiworld.push_precollected(self.create_item(note)) + itempool += [self.create_item(note) for note in notes[precollected_notes_amount:]] + + itempool += [self.create_item(item) + for item in self.item_name_to_id + if item not in + { + "Power Seal", "Time Shard", *NOTES, + *{collected_item.name for collected_item in self.multiworld.precollected_items[self.player]} + # this is a set and currently won't create items for anything that appears in here at all + # if we get in a position where this can have duplicates of items that aren't Power Seals + # or Time shards, this will need to be redone. + }] + itempool += [self.create_filler() + for _ in range(len(self.multiworld.get_unfilled_locations(self.player)) - len(itempool))] + + self.multiworld.itempool += itempool + + def set_rules(self) -> None: + MessengerRules(self).set_messenger_rules() + + def fill_slot_data(self) -> Dict[str, Any]: + locations: Dict[int, List[str]] = {} + for loc in self.multiworld.get_filled_locations(self.player): + if loc.item.code: + locations[loc.address] = [loc.item.name, self.multiworld.player_name[loc.item.player]] + + return { + "deathlink": self.multiworld.death_link[self.player].value, + "goal": self.multiworld.goal[self.player].current_key, + "music_box": self.multiworld.music_box[self.player].value, + "required_seals": self.required_seals, + "locations": locations, + "settings": {"Difficulty": "Basic" if not self.multiworld.shuffle_seals[self.player] else "Advanced"} + } + + def get_filler_item_name(self) -> str: + return "Time Shard" + + def create_item(self, name: str) -> MessengerItem: + item_id: Optional[int] = self.item_name_to_id.get(name, None) + return MessengerItem(name, self.player, item_id) diff --git a/worlds/messenger/docs/en_The Messenger.md b/worlds/messenger/docs/en_The Messenger.md new file mode 100644 index 00000000..f1b53cd1 --- /dev/null +++ b/worlds/messenger/docs/en_The Messenger.md @@ -0,0 +1,75 @@ +# The Messenger + +## Quick Links +- [Setup](../../../../games/The%20Messenger/setup/en) +- [Settings Page](../../../../games/The%20Messenger/player-settings) +- [Courier Github](https://github.com/Brokemia/Courier) +- [The Messenger Randomizer Github](https://github.com/minous27/TheMessengerRandomizerMod) +- [Jacksonbird8237's Item Tracker](https://github.com/Jacksonbird8237/TheMessengerItemTracker) +- [PopTracker Pack](https://github.com/alwaysintreble/TheMessengerTrackPack) + +## What does randomization do in this game? + +All items and upgrades that can be picked up by the player in the game are randomized. The player starts in the Tower of +Time HQ with the past section finished, all area portals open, and with the cloud step, and climbing claws already +obtained. You'll be forced to do sections of the game in different ways with your current abilities. Currently, logic +assumes you already have all shop upgrades. + +## What items can appear in other players' worlds? + +* The player's movement items +* Quest and pedestal items +* Music Box notes +* The Phobekins +* Time shards +* Power Seals + +## Where can I find items? + +You can find items wherever items can be picked up in the original game. This includes: +* Shopkeeper dialog where the player originally gains movement items +* Quest Item pickups +* Music Box notes +* Phobekins +* Power seals + +## What are the item name groups? + +When you attempt to hint for items in Archipelago you can use either the name for the specific item, or the name of a +group of items. Hinting for a group will choose a random item from the group that you do not currently have and hint +for it. The groups you can use for The Messenger are: +* Notes - This covers the music notes +* Keys - An alternative name for the music notes +* Crest - The Sun and Moon Crests +* Phobekin - Any of the Phobekins +* Phobe - An alternative name for the Phobekins +* Shuriken - The windmill shuriken + +## Other changes + +* The player can return to the Tower of Time HQ at any point by selecting the button from the options menu + * This can cause issues if used at specific times. Current known: + * During Boss fights + * After Courage Note collection (Corrupted Future chase) + * This is currently an expected action in logic. If you do need to teleport during this chase sequence, it +is recommended to quit to title and reload the save +* After reaching ninja village a teleport option is added to the menu to reach it quickly +* Toggle Windmill Shuriken button is added to option menu once the item is received + +## Currently known issues +* Necro cutscene will sometimes not play correctly, but will still reward the item +* Ruxxtin Coffin cutscene will sometimes not play correctly, but will still reward the item +* If you receive the Fairy Bottle while in Quillshroom Marsh, The Decurse Queen cutscene will not play +* If you defeat Barma'thazël, the cutscene afterward will not play correctly since that is what normally transitions +you to 2nd quest. The game will not kill you if you fall here, so you can teleport to HQ at any point after defeating him. +* Sometimes upon teleporting back to HQ, Ninja will run left and enter a different portal than the one entered by the +player. +* If playing the game in non-english, sometimes the text entry menus will say "What is your name?" in local language +instead of the correct text. This can be fixed by going into the game options and selecting your language in the menu. +It does not need to be changed to something else and back. +* Text entry menus don't accept controller input + +## What do I do if I have a problem? + +If you believe something happened that isn't intended, please get the `log.txt`from the folder of your game installation +and send a bug report either on github or the [Archipelago Discord Server](http://archipelago.gg/discord) diff --git a/worlds/messenger/docs/setup_en.md b/worlds/messenger/docs/setup_en.md new file mode 100644 index 00000000..59341e74 --- /dev/null +++ b/worlds/messenger/docs/setup_en.md @@ -0,0 +1,52 @@ +# The Messenger Randomizer Setup Guide + +## Quick Links +- [Main Page](../../../../games/The%20Messenger/info/en) +- [Settings Page](../../../../games/The%20Messenger/player-settings) +- [Courier Github](https://github.com/Brokemia/Courier) +- [The Messenger Randomizer Github](https://github.com/minous27/TheMessengerRandomizerMod) +- [Jacksonbird8237's Item Tracker](https://github.com/Jacksonbird8237/TheMessengerItemTracker) +- [PopTracker Pack](https://github.com/alwaysintreble/TheMessengerTrackPack) + +## Required Software + +- [The Messenger](https://store.steampowered.com/app/764790/The_Messenger/) + - Only Steam version is currently supported. +- [Courier Mod Loader](https://github.com/Brokemia/Courier/releases) +- [The Messenger Randomizer Mod](https://github.com/minous27/TheMessengerRandomizerMod/releases) + +## Installation + +1. Download and install Courier Mod Loader using the instructions on the release page +2. Download and install the randomizer mod + * Download the latest `TheMessengerRandomizer.zip` + * Extract the zip file to `TheMessenger/Mods/` of your game's install location + * Optionally, Backup your save game + 1. Press `Windows Key + R` to open run + 2. Type `%appdata%` to access AppData + 3. Navigate to `AppData/locallow/SabotageStudios/The Messenger` + 4. Rename `SaveGame.txt` to any name of your choice + +## 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 afterwards. +4. Enter connection info using the relevant option buttons + * **The game is limited to alphanumerical characters and `-` so when entering the host name replace `.` with ` ` and +ensure that your player name when generating a settings file follows these constrictions** + * This defaults to `archipelago.gg` and does not need to be manually changed if connecting to a game hosted on the +website. +5. Select the `Connect to Archipelago` button +6. Navigate to save file selection +7. Select a new valid randomizer save + +## Troubleshooting + +If you launch the game, and it hangs on the splash screen for more than 30 seconds try these steps: +1. Close the game and remove `TheMessengerRandomizer` from the `Mods` folder. +2. Launch The Messenger +3. Delete any save slot +4. Reinstall the randomizer mod following step 2 of the installation. \ No newline at end of file diff --git a/worlds/messenger/test/TestAccess.py b/worlds/messenger/test/TestAccess.py new file mode 100644 index 00000000..eba4ad9b --- /dev/null +++ b/worlds/messenger/test/TestAccess.py @@ -0,0 +1,149 @@ +from . import MessengerTestBase +from ..Constants import NOTES, PHOBEKINS +from ..Options import MessengerAccessibility + + +class AccessTest(MessengerTestBase): + + def testTabi(self) -> None: + """locations that hard require the Ninja Tabi""" + locations = ["Pyro", "Key of Chaos", "Underworld Seal - Sharp and Windy Climb", "Underworld Seal - Spike Wall", + "Underworld Seal - Fireball Wave", "Underworld Seal - Rising Fanta", "Sun Crest", "Moon Crest", + "Sunken Shrine Seal - Waterfall Paradise", "Sunken Shrine Seal - Tabi Gauntlet"] + items = [["Ninja Tabi"]] + self.assertAccessDependency(locations, items) + + def testDart(self) -> None: + """locations that hard require the Rope Dart""" + locations = ["Ninja Village Seal - Tree House", "Key of Hope", "Howling Grotto Seal - Crushing Pits", + "Glacial Peak Seal - Ice Climbers", "Tower of Time Seal - Time Waster Seal", + "Tower of Time Seal - Arcane Orbs", "Underworld Seal - Rising Fanta", "Key of Symbiosis", + "Elemental Skylands Seal - Water", "Elemental Skylands Seal - Fire"] + items = [["Rope Dart"]] + self.assertAccessDependency(locations, items) + + def testWingsuit(self) -> None: + """locations that hard require the Wingsuit""" + locations = ["Candle", "Ninja Village Seal - Tree House", "Climbing Claws", "Key of Hope", + "Autumn Hills Seal - Trip Saws", "Autumn Hills Seal - Double Swing Saws", + "Autumn Hills Seal - Spike Ball Swing", "Autumn Hills Seal - Spike Ball Darts", "Necro", + "Ruxxtin's Amulet", "Catacombs Seal - Triple Spike Crushers", "Catacombs Seal - Crusher Gauntlet", + "Catacombs Seal - Dirty Pond", "Claustro", "Acro", "Bamboo Creek Seal - Spike Crushers and Doors", + "Bamboo Creek Seal - Spike Ball Pits", "Bamboo Creek Seal - Spike Crushers and Doors v2", + "Howling Grotto Seal - Crushing Pits", "Howling Grotto Seal - Windy Saws and Balls", + "Tower of Time Seal - Lantern Climb", "Demon King Crown", "Cloud Ruins Seal - Ghost Pit", + "Cloud Ruins Seal - Toothbrush Alley", "Cloud Ruins Seal - Saw Pit", "Cloud Ruins Seal - Money Farm Room", + "Tower of Time Seal - Lantern Climb", "Tower of Time Seal - Arcane Orbs", + "Underworld Seal - Sharp and Windy Climb", "Underworld Seal - Fireball Wave", + "Elemental Skylands Seal - Air", "Forlorn Temple Seal - Rocket Maze", + "Forlorn Temple Seal - Rocket Sunset", "Astral Seed"] + items = [["Wingsuit"]] + self.assertAccessDependency(locations, items) + + def testVertical(self) -> None: + """locations that require either the Rope Dart or the Wingsuit""" + locations = ["Ninja Village Seal - Tree House", "Key of Hope", "Howling Grotto Seal - Crushing Pits", + "Glacial Peak Seal - Ice Climbers", "Tower of Time Seal - Time Waster Seal", + "Underworld Seal - Rising Fanta", "Key of Symbiosis", + "Elemental Skylands Seal - Water", "Elemental Skylands Seal - Fire", "Candle", + "Ninja Village Seal - Tree House", "Climbing Claws", "Key of Hope", + "Autumn Hills Seal - Trip Saws", "Autumn Hills Seal - Double Swing Saws", + "Autumn Hills Seal - Spike Ball Swing", "Autumn Hills Seal - Spike Ball Darts", "Necro", + "Ruxxtin's Amulet", "Catacombs Seal - Triple Spike Crushers", "Catacombs Seal - Crusher Gauntlet", + "Catacombs Seal - Dirty Pond", "Claustro", "Acro", "Bamboo Creek Seal - Spike Crushers and Doors", + "Bamboo Creek Seal - Spike Ball Pits", "Bamboo Creek Seal - Spike Crushers and Doors v2", + "Howling Grotto Seal - Crushing Pits", "Howling Grotto Seal - Windy Saws and Balls", + "Tower of Time Seal - Lantern Climb", "Demon King Crown", "Cloud Ruins Seal - Ghost Pit", + "Cloud Ruins Seal - Toothbrush Alley", "Cloud Ruins Seal - Saw Pit", "Cloud Ruins Seal - Money Farm Room", + "Tower of Time Seal - Lantern Climb", "Tower of Time Seal - Arcane Orbs", + "Underworld Seal - Sharp and Windy Climb", "Underworld Seal - Fireball Wave", + "Elemental Skylands Seal - Air", "Forlorn Temple Seal - Rocket Maze", "Forlorn Temple Seal - Rocket Sunset", + "Power Thistle", "Key of Strength", "Glacial Peak Seal - Projectile Spike Pit", + "Glacial Peak Seal - Glacial Air Swag", "Fairy Bottle", "Riviere Turquoise Seal - Flower Power", + "Searing Crags Seal - Triple Ball Spinner", "Searing Crags Seal - Raining Rocks", + "Searing Crags Seal - Rhythm Rocks", "Astral Seed", "Astral Tea Leaves"] + items = [["Wingsuit", "Rope Dart"]] + self.assertAccessDependency(locations, items) + + def testAmulet(self) -> None: + """Locations that require Ruxxtin's Amulet""" + locations = ["Acro", "Cloud Ruins Seal - Ghost Pit", "Cloud Ruins Seal - Toothbrush Alley", + "Cloud Ruins Seal - Saw Pit", "Cloud Ruins Seal - Money Farm Room"] + # Cloud Ruins requires Ruxxtin's Amulet + items = [["Ruxxtin's Amulet"]] + self.assertAccessDependency(locations, items) + + def testBottle(self) -> None: + """Elemental Skylands and Corrupted Future require the Fairy Bottle""" + locations = ["Key of Symbiosis", "Elemental Skylands Seal - Air", "Elemental Skylands Seal - Fire", + "Elemental Skylands Seal - Water", "Key of Courage"] + items = [["Fairy Bottle"]] + self.assertAccessDependency(locations, items) + + def testCrests(self) -> None: + """Test Key of Love nonsense""" + locations = ["Key of Love"] + items = [["Sun Crest", "Moon Crest"]] + self.assertAccessDependency(locations, items) + self.collect_all_but("Sun Crest") + self.assertEqual(self.can_reach_location("Key of Love"), False) + self.remove(self.get_item_by_name("Moon Crest")) + self.collect_by_name("Sun Crest") + self.assertEqual(self.can_reach_location("Key of Love"), False) + + def testThistle(self) -> None: + """I'm a chuckster!""" + locations = ["Key of Strength"] + items = [["Power Thistle"]] + self.assertAccessDependency(locations, items) + + def testCrown(self) -> None: + """Crocomire but not""" + locations = ["Key of Courage"] + items = [["Demon King Crown"]] + self.assertAccessDependency(locations, items) + + def testGoal(self) -> None: + """Test some different states to verify goal requires the correct items""" + self.collect_all_but([*NOTES, "Rescue Phantom"]) + self.assertEqual(self.can_reach_location("Rescue Phantom"), False) + self.collect_all_but(["Key of Love", "Rescue Phantom"]) + self.assertBeatable(False) + self.collect_by_name(["Key of Love"]) + self.assertEqual(self.can_reach_location("Rescue Phantom"), True) + self.assertBeatable(True) + + +class ItemsAccessTest(MessengerTestBase): + options = { + "shuffle_seals": False, + "accessibility": MessengerAccessibility.option_items + } + + def testSelfLockingItems(self) -> None: + """Force items that can be self locked to ensure it's valid placement.""" + location_lock_pairs = { + "Key of Strength": ["Power Thistle"], + "Key of Love": ["Sun Crest", "Moon Crest"], + "Key of Courage": ["Demon King Crown"], + "Acro": ["Ruxxtin's Amulet"], + "Demon King Crown": PHOBEKINS + } + + 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)) + + +class NoLogicTest(MessengerTestBase): + options = { + "enable_logic": "false" + } + + def testNoLogic(self) -> None: + """Test some funny locations to make sure they aren't reachable but we can still win""" + self.assertEqual(self.can_reach_location("Pyro"), False) + self.assertEqual(self.can_reach_location("Rescue Phantom"), False) + self.assertBeatable(True) diff --git a/worlds/messenger/test/TestNotes.py b/worlds/messenger/test/TestNotes.py new file mode 100644 index 00000000..07745e33 --- /dev/null +++ b/worlds/messenger/test/TestNotes.py @@ -0,0 +1,30 @@ +from . import MessengerTestBase +from ..Constants import NOTES + + +class TwoNoteGoalTest(MessengerTestBase): + options = { + "notes_needed": 2, + } + + def testPrecollectedNotes(self) -> None: + self.assertEqual(self.multiworld.state.count_group("Notes", self.player), 4) + + +class FourNoteGoalTest(MessengerTestBase): + options = { + "notes_needed": 4, + } + + def testPrecollectedNotes(self) -> None: + self.assertEqual(self.multiworld.state.count_group("Notes", self.player), 2) + + +class DefaultGoalTest(MessengerTestBase): + def testPrecollectedNotes(self) -> None: + self.assertEqual(self.multiworld.state.count_group("Notes", self.player), 0) + + def testGoal(self) -> None: + self.assertBeatable(False) + self.collect_by_name(NOTES) + self.assertBeatable(True) diff --git a/worlds/messenger/test/TestShopChest.py b/worlds/messenger/test/TestShopChest.py new file mode 100644 index 00000000..c3f2c4dd --- /dev/null +++ b/worlds/messenger/test/TestShopChest.py @@ -0,0 +1,79 @@ +from BaseClasses import ItemClassification, CollectionState +from . import MessengerTestBase + + +class NoLogicTest(MessengerTestBase): + options = { + "enable_logic": "false", + "goal": "power_seal_hunt", + } + + def testChestAccess(self): + """Test to make sure we can win even though we can't reach the chest.""" + self.assertEqual(self.can_reach_location("Shop Chest"), False) + self.assertBeatable(True) + + +class AllSealsRequired(MessengerTestBase): + options = { + "shuffle_seals": "false", + "goal": "power_seal_hunt", + } + + def testSealsShuffled(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 testChestAccess(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]) + locations = ["Shop Chest"] + items = [["Power Seal"]] + self.assertAccessDependency(locations, items) + self.multiworld.state = CollectionState(self.multiworld) + + self.assertEqual(self.can_reach_location("Shop Chest"), False) + self.assertBeatable(False) + self.collect_all_but(["Power Seal", "Shop Chest", "Rescue Phantom"]) + self.assertEqual(self.can_reach_location("Shop Chest"), False) + self.assertBeatable(False) + self.collect_by_name("Power Seal") + self.assertEqual(self.can_reach_location("Shop Chest"), True) + self.assertBeatable(True) + + +class HalfSealsRequired(MessengerTestBase): + options = { + "goal": "power_seal_hunt", + "percent_seals_required": 50, + } + + def testSealsAmount(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.multiworld.worlds[self.player].total_seals, 45) + self.assertEqual(self.multiworld.worlds[self.player].required_seals, 22) + total_seals = [seal for seal in self.multiworld.itempool if seal.name == "Power Seal"] + required_seals = [seal for seal in total_seals if seal.classification == ItemClassification.progression_skip_balancing] + self.assertEqual(len(total_seals), 45) + self.assertEqual(len(required_seals), 22) + + +class ThirtyThirtySeals(MessengerTestBase): + options = { + "goal": "power_seal_hunt", + "total_seals": 30, + "percent_seals_required": 34, + } + + def testSealsAmount(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.multiworld.worlds[self.player].total_seals, 30) + self.assertEqual(self.multiworld.worlds[self.player].required_seals, 10) + total_seals = [seal for seal in self.multiworld.itempool if seal.name == "Power Seal"] + required_seals = [seal for seal in total_seals if seal.classification == ItemClassification.progression_skip_balancing] + self.assertEqual(len(total_seals), 30) + self.assertEqual(len(required_seals), 10) diff --git a/worlds/messenger/test/__init__.py b/worlds/messenger/test/__init__.py new file mode 100644 index 00000000..7ab1e117 --- /dev/null +++ b/worlds/messenger/test/__init__.py @@ -0,0 +1,6 @@ +from test.TestBase import WorldTestBase + + +class MessengerTestBase(WorldTestBase): + game = "The Messenger" + player: int = 1