From 7c30c4a16952a3a6b325bdc6cb497b9fa0591714 Mon Sep 17 00:00:00 2001 From: Aaron Wagener Date: Mon, 10 Mar 2025 10:16:09 -0500 Subject: [PATCH] The Messenger: Transition Shuffle (#4402) * The Messenger: transition rando * remove unused import * always link both directions for plando when using coupled transitions * er_type was renamed to randomization_type * use frozenset for things that shouldn't change * review suggestions * do portal and transition shuffle in `connect_entrances` * remove some unnecessary connections that were causing entrance caching collisions * add test for strictest possible ER settings * use unittest.skip on the skipped test, so we don't waste time doing setUp and tearDown * use the world helpers * make the plando connection description more verbose * always add searing crags portal if portal shuffle is disabled * guarantee an arbitrary number of locations with first connection * make the constraints more lenient for a bit more variety --- worlds/messenger/__init__.py | 82 ++++++++++++++--- worlds/messenger/connections.py | 74 ++++++++------- worlds/messenger/options.py | 33 ++++--- worlds/messenger/portals.py | 10 +- worlds/messenger/subclasses.py | 23 ++++- .../test/test_entrance_randomization.py | 19 ++++ worlds/messenger/transitions.py | 92 +++++++++++++++++++ 7 files changed, 258 insertions(+), 75 deletions(-) create mode 100644 worlds/messenger/test/test_entrance_randomization.py create mode 100644 worlds/messenger/transitions.py diff --git a/worlds/messenger/__init__.py b/worlds/messenger/__init__.py index a6effc31..8bde3bbc 100644 --- a/worlds/messenger/__init__.py +++ b/worlds/messenger/__init__.py @@ -1,7 +1,7 @@ import logging from typing import Any, ClassVar, TextIO -from BaseClasses import CollectionState, Entrance, Item, ItemClassification, MultiWorld, Tutorial +from BaseClasses import CollectionState, Entrance, EntranceType, Item, ItemClassification, MultiWorld, Tutorial from Options import Accessibility from Utils import output_path from settings import FilePath, Group @@ -17,6 +17,7 @@ from .regions import LEVELS, MEGA_SHARDS, LOCATIONS, REGION_CONNECTIONS from .rules import MessengerHardRules, MessengerOOBRules, MessengerRules from .shop import FIGURINES, PROG_SHOP_ITEMS, SHOP_ITEMS, USEFUL_SHOP_ITEMS, shuffle_shop_prices from .subclasses import MessengerEntrance, MessengerItem, MessengerRegion, MessengerShopLocation +from .transitions import shuffle_transitions components.append( Component("The Messenger", component_type=Type.CLIENT, func=launch_game, game_name="The Messenger", supports_uri=True) @@ -128,7 +129,7 @@ class MessengerWorld(World): spoiler_portal_mapping: dict[str, str] portal_mapping: list[int] transitions: list[Entrance] - reachable_locs: int = 0 + reachable_locs: bool = False filler: dict[str, int] def generate_early(self) -> None: @@ -145,13 +146,13 @@ class MessengerWorld(World): self.shop_prices, self.figurine_prices = shuffle_shop_prices(self) - starting_portals = ["Autumn Hills", "Howling Grotto", "Glacial Peak", "Riviere Turquoise", "Sunken Shrine", "Searing Crags"] + 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 - # TODO 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") portals_to_strip = [portal for portal in ["Riviere Turquoise Portal", "Sunken Shrine Portal"] @@ -181,7 +182,7 @@ class MessengerWorld(World): region_name = region.name.removeprefix(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)) + region.connect(self.get_region(exit_region)) # 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]: @@ -256,6 +257,7 @@ class MessengerWorld(World): f" {logic} for {self.multiworld.get_player_name(self.player)}") # MessengerOOBRules(self).set_messenger_rules() + def connect_entrances(self) -> None: add_closed_portal_reqs(self) # i need portal shuffle to happen after rules exist so i can validate it attempts = 5 @@ -271,6 +273,9 @@ class MessengerWorld(World): else: raise RuntimeError("Unable to generate valid portal output.") + if self.options.shuffle_transitions: + shuffle_transitions(self) + def write_spoiler_header(self, spoiler_handle: TextIO) -> None: if self.options.available_portals < 6: spoiler_handle.write(f"\nStarting Portals:\n\n") @@ -286,9 +291,54 @@ class MessengerWorld(World): key=lambda portal: ["Autumn Hills", "Riviere Turquoise", "Howling Grotto", "Sunken Shrine", - "Searing Crags", "Glacial Peak"].index(portal[0])) + "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) + spoiler.set_entrance(f"{portal} Portal", output, "", self.player) + + if self.options.shuffle_transitions: + for transition in self.transitions: + if (transition.randomization_type == EntranceType.TWO_WAY + and (transition.connected_region.name, "both", self.player) in spoiler.entrances): + continue + spoiler.set_entrance( + transition.name if "->" not in transition.name else transition.parent_region.name, + transition.connected_region.name, + "both" if transition.randomization_type == EntranceType.TWO_WAY + and self.options.shuffle_transitions == ShuffleTransitions.option_coupled else "", + self.player + ) + + def extend_hint_information(self, hint_data: dict[int, dict[int, str]]) -> None: + if not self.options.shuffle_transitions: + return + + hint_data.update({self.player: {}}) + + all_state = self.multiworld.get_all_state(True) + # sometimes some of my regions aren't in path for some reason? + all_state.update_reachable_regions(self.player) + paths = all_state.path + start = self.get_region("Tower HQ") + start_connections = [entrance.name for entrance in start.exits if entrance not in {"Home", "Shrink Down"}] + transition_names = [transition.name for transition in self.transitions] + start_connections + for loc in self.get_locations(): + if (loc.parent_region.name in {"Tower HQ", "The Shop", "Music Box", "The Craftsman's Corner"} + or loc.address is None): + continue + path_to_loc: list[str] = [] + name, connection = paths.get(loc.parent_region, (None, None)) + while connection != ("Menu", None) and name is not None: + name, connection = connection + if name in transition_names: + if name in start_connections: + name = f"{name} -> {self.get_entrance(name).connected_region.name}" + path_to_loc.append(name) + + text = " => ".join(reversed(path_to_loc)) + if not text: + continue + hint_data[self.player][loc.address] = text def fill_slot_data(self) -> dict[str, Any]: slot_data = { @@ -308,11 +358,13 @@ class MessengerWorld(World): def get_filler_item_name(self) -> str: if not getattr(self, "_filler_items", None): - self._filler_items = [name for name in self.random.choices( - list(self.filler), - weights=list(self.filler.values()), - k=20 - )] + self._filler_items = [ + name for name in self.random.choices( + list(self.filler), + weights=list(self.filler.values()), + k=20 + ) + ] return self._filler_items.pop(0) def create_item(self, name: str) -> MessengerItem: @@ -331,7 +383,7 @@ class MessengerWorld(World): self.total_shards += count return ItemClassification.progression_skip_balancing if count else ItemClassification.filler - if name == "Windmill Shuriken" and getattr(self, "multiworld", None) is not None: + if name == "Windmill Shuriken": return ItemClassification.progression if self.options.logic_level else ItemClassification.filler if name == "Power Seal": @@ -344,7 +396,7 @@ class MessengerWorld(World): if name in {*USEFUL_ITEMS, *USEFUL_SHOP_ITEMS}: return ItemClassification.useful - + if name in TRAPS: return ItemClassification.trap @@ -354,7 +406,7 @@ class MessengerWorld(World): def create_group(cls, multiworld: "MultiWorld", new_player_id: int, players: set[int]) -> World: group = super().create_group(multiworld, new_player_id, players) assert isinstance(group, MessengerWorld) - + group.filler = FILLER.copy() group.options.traps.value = all(multiworld.worlds[player].options.traps for player in players) if group.options.traps: diff --git a/worlds/messenger/connections.py b/worlds/messenger/connections.py index 79912a56..84f7f9b2 100644 --- a/worlds/messenger/connections.py +++ b/worlds/messenger/connections.py @@ -244,14 +244,12 @@ CONNECTIONS: dict[str, dict[str, list[str]]] = { "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", ], @@ -639,43 +637,43 @@ CONNECTIONS: dict[str, dict[str, list[str]]] = { } 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", + "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", + "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] = [ diff --git a/worlds/messenger/options.py b/worlds/messenger/options.py index 8b61a943..9ee04d26 100644 --- a/worlds/messenger/options.py +++ b/worlds/messenger/options.py @@ -3,7 +3,8 @@ from dataclasses import dataclass from schema import And, Optional, Or, Schema from Options import Choice, DeathLinkMixin, DefaultOnToggle, ItemsAccessibility, OptionDict, PerGameCommonOptions, \ - PlandoConnections, Range, StartInventoryPool, Toggle, Visibility + PlandoConnections, Range, StartInventoryPool, Toggle +from . import RANDOMIZED_CONNECTIONS from .portals import CHECKPOINTS, PORTALS, SHOP_POINTS @@ -30,17 +31,25 @@ class PortalPlando(PlandoConnections): portals = [f"{portal} Portal" for portal in PORTALS] shop_points = [point for points in SHOP_POINTS.values() for point in points] checkpoints = [point for points in CHECKPOINTS.values() for point in points] - portal_entrances = PORTALS - portal_exits = portals + shop_points + checkpoints - entrances = portal_entrances - exits = portal_exits + + entrances = frozenset(PORTALS) + exits = frozenset(portals + shop_points + checkpoints) -# for back compatibility. To later be replaced with transition plando -class HiddenPortalPlando(PortalPlando): - visibility = Visibility.none - entrances = PortalPlando.entrances - exits = PortalPlando.exits +class TransitionPlando(PlandoConnections): + """ + Plando connections to be used with transition shuffle. + List of valid connections can be found at https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/messenger/connections.py#L641. + Dictionary keys (left) are entrances and values (right) are exits. If transition shuffle is on coupled all plando + connections will be coupled. If on decoupled, "entrance" and "exit" will be treated the same, simply making the + plando connection one-way from entrance to exit. + Example: + - entrance: Searing Crags - Top + exit: Dark Cave - Right + direction: both + """ + entrances = frozenset(RANDOMIZED_CONNECTIONS.keys()) + exits = frozenset(RANDOMIZED_CONNECTIONS.values()) class Logic(Choice): @@ -226,7 +235,7 @@ class MessengerOptions(DeathLinkMixin, PerGameCommonOptions): early_meditation: EarlyMed available_portals: AvailablePortals shuffle_portals: ShufflePortals - # shuffle_transitions: ShuffleTransitions + shuffle_transitions: ShuffleTransitions goal: Goal music_box: MusicBox notes_needed: NotesNeeded @@ -236,4 +245,4 @@ class MessengerOptions(DeathLinkMixin, PerGameCommonOptions): shop_price: ShopPrices shop_price_plan: PlannedShopPrices portal_plando: PortalPlando - plando_connections: HiddenPortalPlando + plando_connections: TransitionPlando diff --git a/worlds/messenger/portals.py b/worlds/messenger/portals.py index 896fefa6..70428589 100644 --- a/worlds/messenger/portals.py +++ b/worlds/messenger/portals.py @@ -1,7 +1,7 @@ from copy import deepcopy from typing import TYPE_CHECKING -from BaseClasses import CollectionState, PlandoOptions +from BaseClasses import CollectionState from Options import PlandoConnection if TYPE_CHECKING: @@ -252,9 +252,7 @@ def shuffle_portals(world: "MessengerWorld") -> None: world.random.shuffle(available_portals) plando = world.options.portal_plando.value - if not plando: - plando = world.options.plando_connections.value - if plando and world.multiworld.plando_options & PlandoOptions.connections and not world.plando_portals: + if plando and not world.plando_portals: try: handle_planned_portals(plando) # any failure i expect will trigger on available_portals.remove @@ -294,8 +292,8 @@ def disconnect_portals(world: "MessengerWorld") -> None: def validate_portals(world: "MessengerWorld") -> bool: - # if world.options.shuffle_transitions: - # return True + if world.options.shuffle_transitions: + return True new_state = CollectionState(world.multiworld) new_state.update_reachable_regions(world.player) reachable_locs = 0 diff --git a/worlds/messenger/subclasses.py b/worlds/messenger/subclasses.py index 29e3ea89..0138a3f0 100644 --- a/worlds/messenger/subclasses.py +++ b/worlds/messenger/subclasses.py @@ -1,7 +1,8 @@ from functools import cached_property from typing import TYPE_CHECKING -from BaseClasses import CollectionState, Entrance, Item, ItemClassification, Location, Region +from BaseClasses import CollectionState, Entrance, EntranceType, Item, ItemClassification, Location, Region +from entrance_rando import ERPlacementState from .regions import LOCATIONS, MEGA_SHARDS from .shop import FIGURINES, SHOP_ITEMS @@ -12,9 +13,21 @@ if TYPE_CHECKING: class MessengerEntrance(Entrance): world: "MessengerWorld | None" = None + def can_connect_to(self, other: Entrance, dead_end: bool, state: "ERPlacementState") -> bool: + can_connect = super().can_connect_to(other, dead_end, state) + world: MessengerWorld = getattr(self, "world", None) + if not world or world.reachable_locs or not can_connect: + return can_connect + empty_state = CollectionState(world.multiworld, True) + self.connected_region = other.connected_region + empty_state.update_reachable_regions(world.player) + world.reachable_locs = any(loc.can_reach(empty_state) and not loc.is_event for loc in world.get_locations()) + self.connected_region = None + return world.reachable_locs and (not state.coupled or self.name != other.name) + class MessengerRegion(Region): - parent: str + parent: str | None entrance_type = MessengerEntrance def __init__(self, name: str, world: "MessengerWorld", parent: str | None = None) -> None: @@ -32,8 +45,9 @@ class MessengerRegion(Region): for shop_loc in SHOP_ITEMS} self.add_locations(shop_locations, MessengerShopLocation) elif name == "The Craftsman's Corner": - self.add_locations({figurine: world.location_name_to_id[figurine] for figurine in FIGURINES}, - MessengerLocation) + self.add_locations( + {figurine: world.location_name_to_id[figurine] for figurine in FIGURINES}, + MessengerLocation) elif name == "Tower HQ": locations.append("Money Wrench") @@ -57,6 +71,7 @@ class MessengerLocation(Location): class MessengerShopLocation(MessengerLocation): + @cached_property def cost(self) -> int: name = self.name.removeprefix("The Shop - ") diff --git a/worlds/messenger/test/test_entrance_randomization.py b/worlds/messenger/test/test_entrance_randomization.py new file mode 100644 index 00000000..2a06a2e0 --- /dev/null +++ b/worlds/messenger/test/test_entrance_randomization.py @@ -0,0 +1,19 @@ +import unittest + +from . import MessengerTestBase + + +class StrictEntranceRandoTest(MessengerTestBase): + """Bare-bones world that tests the strictest possible settings to ensure it doesn't crash""" + auto_construct = True + options = { + "limited_movement": 1, + "available_portals": 3, + "shuffle_portals": 1, + "shuffle_transitions": 1, + } + + @unittest.skip + def test_all_state_can_reach_everything(self) -> None: + """It's not possible to reach everything with these options so skip this test.""" + pass diff --git a/worlds/messenger/transitions.py b/worlds/messenger/transitions.py new file mode 100644 index 00000000..1db975b3 --- /dev/null +++ b/worlds/messenger/transitions.py @@ -0,0 +1,92 @@ +from typing import TYPE_CHECKING + +from BaseClasses import Region +from entrance_rando import EntranceType, randomize_entrances +from .connections import RANDOMIZED_CONNECTIONS, TRANSITIONS +from .options import ShuffleTransitions, TransitionPlando + +if TYPE_CHECKING: + from . import MessengerWorld + + +def connect_plando(world: "MessengerWorld", plando_connections: TransitionPlando) -> None: + def remove_dangling_exit(region: Region) -> None: + # find the disconnected exit and remove references to it + for _exit in region.exits: + if not _exit.connected_region: + break + else: + raise ValueError(f"Unable to find randomized transition for {plando_connection}") + region.exits.remove(_exit) + + def remove_dangling_entrance(region: Region) -> None: + # find the disconnected entrance and remove references to it + for _entrance in region.entrances: + if not _entrance.parent_region: + break + else: + raise ValueError(f"Invalid target region for {plando_connection}") + region.entrances.remove(_entrance) + + for plando_connection in plando_connections: + # get the connecting regions + reg1 = world.get_region(plando_connection.entrance) + reg2 = world.get_region(plando_connection.exit) + + remove_dangling_exit(reg1) + remove_dangling_entrance(reg2) + # connect the regions + reg1.connect(reg2) + + # pretend the user set the plando direction as "both" regardless of what they actually put on coupled + if ((world.options.shuffle_transitions == ShuffleTransitions.option_coupled + or plando_connection.direction == "both") + and plando_connection.exit in RANDOMIZED_CONNECTIONS): + remove_dangling_exit(reg2) + remove_dangling_entrance(reg1) + reg2.connect(reg1) + + +def shuffle_transitions(world: "MessengerWorld") -> None: + coupled = world.options.shuffle_transitions == ShuffleTransitions.option_coupled + + def disconnect_entrance() -> None: + child_region.entrances.remove(entrance) + entrance.connected_region = None + + er_type = EntranceType.ONE_WAY if child == "Glacial Peak - Left" else \ + EntranceType.TWO_WAY if child in RANDOMIZED_CONNECTIONS else EntranceType.ONE_WAY + if er_type == EntranceType.TWO_WAY: + mock_entrance = parent_region.create_er_target(entrance.name) + else: + mock_entrance = child_region.create_er_target(child) + + entrance.randomization_type = er_type + mock_entrance.randomization_type = er_type + + for parent, child in RANDOMIZED_CONNECTIONS.items(): + if child == "Corrupted Future": + entrance = world.get_entrance("Artificer's Portal") + elif child == "Tower of Time - Left": + entrance = world.get_entrance("Artificer's Challenge") + else: + entrance = world.get_entrance(f"{parent} -> {child}") + parent_region = entrance.parent_region + child_region = entrance.connected_region + entrance.world = world + disconnect_entrance() + + plando = world.options.plando_connections + if plando: + connect_plando(world, plando) + + result = randomize_entrances(world, coupled, {0: [0]}) + + world.transitions = sorted(result.placements, key=lambda entrance: TRANSITIONS.index(entrance.parent_region.name)) + + for transition in world.transitions: + if "->" not in transition.name: + continue + transition.parent_region.exits.remove(transition) + transition.name = f"{transition.parent_region.name} -> {transition.connected_region.name}" + transition.parent_region.exits.append(transition)