diff --git a/worlds/messenger/__init__.py b/worlds/messenger/__init__.py index a05ff0aa88d2..8645755dd1eb 100644 --- a/worlds/messenger/__init__.py +++ b/worlds/messenger/__init__.py @@ -12,7 +12,7 @@ from .connections import CONNECTIONS, RANDOMIZED_CONNECTIONS, TRANSITIONS from .constants import ALL_ITEMS, ALWAYS_LOCATIONS, BOSS_LOCATIONS, FILLER, NOTES, PHOBEKINS, PROG_ITEMS, TRAPS, \ USEFUL_ITEMS -from .options import AvailablePortals, Goal, Logic, MessengerOptions, NotesNeeded, option_groups, ShuffleTransitions +from .options import AvailablePortals, Goal, Logic, MessengerOptions, NotesNeeded, option_groups, ShuffleTransitions, ShufflePortals 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 @@ -213,10 +213,12 @@ def create_regions(self) -> None: for reg_name in sub_region] for region in complex_regions: - region_name = region.name.removeprefix(f"{region.parent} - ") - connection_data = CONNECTIONS[region.parent][region_name] + parent_name = region.parent + region_name = region.name.removeprefix(f"{parent_name} - ") + connection_data: list[str] = CONNECTIONS[parent_name][region_name] for exit_region in connection_data: - region.connect(self.get_region(exit_region)) + connection_name = region.name + " exit" if not exit_region.startswith(parent_name) else None + region.connect(self.get_region(exit_region), name=connection_name) # 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]: @@ -298,7 +300,6 @@ def set_rules(self) -> None: def connect_entrances(self) -> None: if self.options.shuffle_transitions: disconnect_entrances(self) - keep_entrance_logic = False if hasattr(self.multiworld, "re_gen_passthrough"): slot_data = self.multiworld.re_gen_passthrough.get(self.game) @@ -306,7 +307,7 @@ def connect_entrances(self) -> None: self.multiworld.plando_options |= PlandoOptions.connections self.options.portal_plando.value = reverse_portal_exits_into_portal_plando(slot_data["portal_exits"]) self.options.plando_connections.value = reverse_transitions_into_plando_connections(slot_data["transitions"]) - keep_entrance_logic = True + add_closed_portal_reqs(self) # i need portal shuffle to happen after rules exist so i can validate it @@ -324,7 +325,7 @@ def connect_entrances(self) -> None: raise RuntimeError("Unable to generate valid portal output.") if self.options.shuffle_transitions: - shuffle_transitions(self, keep_entrance_logic) + shuffle_transitions(self) def write_spoiler_header(self, spoiler_handle: TextIO) -> None: if self.options.available_portals < 6: @@ -352,7 +353,7 @@ def write_spoiler_header(self, spoiler_handle: TextIO) -> None: 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.name if " exit" 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 "", @@ -398,8 +399,7 @@ def fill_slot_data(self) -> dict[str, Any]: "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": [[TRANSITIONS.index(RANDOMIZED_CONNECTIONS[transition.name]), TRANSITIONS.index(transition.connected_region.name)] for transition in self.transitions], **self.options.as_dict("music_box", "death_link", "logic_level"), diff --git a/worlds/messenger/connections.py b/worlds/messenger/connections.py index d3471a8dac09..89156cb3d83f 100644 --- a/worlds/messenger/connections.py +++ b/worlds/messenger/connections.py @@ -642,43 +642,43 @@ # fmt: off 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", + "Ninja Village - Right exit": "Autumn Hills - Left", + "Autumn Hills - Left exit": "Ninja Village - Right", + "Autumn Hills - Right exit": "Forlorn Temple - Left", + "Autumn Hills - Bottom exit": "Catacombs - Bottom Left", + "Forlorn Temple - Left exit": "Autumn Hills - Right", + "Forlorn Temple - Right exit": "Bamboo Creek - Top Left", + "Forlorn Temple - Bottom exit": "Catacombs - Top Left", + "Catacombs - Top Left exit": "Forlorn Temple - Bottom", + "Catacombs - Bottom Left exit": "Autumn Hills - Bottom", + "Catacombs - Bottom exit": "Dark Cave - Right", + "Catacombs - Right exit": "Bamboo Creek - Bottom Left", + "Bamboo Creek - Bottom Left exit": "Catacombs - Right", + "Bamboo Creek - Right exit": "Howling Grotto - Left", + "Bamboo Creek - Top Left exit": "Forlorn Temple - Right", + "Howling Grotto - Left exit": "Bamboo Creek - Right", + "Howling Grotto - Top exit": "Quillshroom Marsh - Bottom Left", + "Howling Grotto - Right exit": "Quillshroom Marsh - Top Left", + "Howling Grotto - Bottom exit": "Sunken Shrine - Left", + "Quillshroom Marsh - Top Left exit": "Howling Grotto - Right", + "Quillshroom Marsh - Bottom Left exit": "Howling Grotto - Top", + "Quillshroom Marsh - Top Right exit": "Searing Crags - Left", + "Quillshroom Marsh - Bottom Right exit": "Searing Crags - Bottom", + "Searing Crags - Left exit": "Quillshroom Marsh - Top Right", + "Searing Crags - Top exit": "Glacial Peak - Bottom", + "Searing Crags - Bottom exit": "Quillshroom Marsh - Bottom Right", + "Searing Crags - Right exit": "Underworld - Left", + "Glacial Peak - Bottom exit": "Searing Crags - Top", + "Glacial Peak - Top exit": "Cloud Ruins - Left", + "Glacial Peak - Left exit": "Elemental Skylands - Air Shmup", + "Cloud Ruins - Left exit": "Glacial Peak - Top", + "Elemental Skylands - Right exit": "Glacial Peak - Left", + "Artificer's Challenge": "Tower of Time - Left", + "Artificer's Portal": "Corrupted Future", + "Underworld - Left exit": "Searing Crags - Right", + "Dark Cave - Right exit": "Catacombs - Bottom", + "Dark Cave - Left exit": "Riviere Turquoise - Right", + "Sunken Shrine - Left exit": "Howling Grotto - Bottom", } # fmt: on @@ -718,9 +718,26 @@ "Glacial Peak - Left", "Elemental Skylands - Air Shmup", "Elemental Skylands - Right", - "Tower HQ", + "Artificer's Challenge", "Tower of Time - Left", "Corrupted Future", "Cloud Ruins - Left", "Underworld - Left", + "Artificer's Portal", ] + +ONE_WAY_EXITS = { + "Glacial Peak - Left exit", + "Artificer's Portal", + "Artificer's Challenge", + "Dark Cave - Left exit", + "Elemental Skylands - Right exit" +} + +ONE_WAY_ENTRANCES = { + "Elemental Skylands - Air Shmup", + "Corrupted Future", + "Tower of Time - Left", + "Riviere Turquoise - Right", + "Glacial Peak - Left" +} diff --git a/worlds/messenger/docs/plando_en.md b/worlds/messenger/docs/plando_en.md index 920cf029d833..271fd9c3de9f 100644 --- a/worlds/messenger/docs/plando_en.md +++ b/worlds/messenger/docs/plando_en.md @@ -64,22 +64,21 @@ will exit to the Sunny Day checkpoint, and the Searing Crags Portal will exit to ## Transition Plando This option allows you to specify certain connections when using transition shuffle. This will only work if -transition shuffle and the `connections` plando host setting are enabled. +transition shuffle and the `connections` plando host setting are enabled. Each transition connection is plandoed by specifying its attributes: -* `entrance` is where you will enter this transition from. +* `entrance` is the name of the transition (level exit). * `exit` is where the transition will lead. * `percentage` is the chance this connection will happen at all. * `direction` is used to specify whether this connection will also go in reverse. This entry will be ignored if the transition shuffle is set to `coupled` or if the specified connection can only occur in one direction, such as exiting to Riviere Turquoise. The default direction is "both", which will make it so that returning through the exit - transition will return you to where you entered it from. "entrance" and "exit" are treated the same, with them both - making this transition only one-way. + transition will return you to where you entered it from. Valid connections can be found in the [`RANDOMIZED_CONNECTIONS` dictionary](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/messenger/connections.py#L640). The keys (left) are entrances, and values (right) are exits. Whether you want the connection to go both ways or not, -both sides must either be two-way or one-way; E.g. connecting Artificer (Corrupted Future Portal) to one of the +both sides must either be two-way or one-way; E.g. connecting Artificer's Portal (Corrupted Future Portal) to one of the Quillshroom Marsh entrances is not a valid pairing. A pairing can be determined to be two-way if both the entrance and exit of that pair are an exit and entrance of another pairing, respectively. @@ -88,14 +87,15 @@ exit of that pair are an exit and entrance of another pairing, respectively. ```yaml The Messenger: plando_connections: - - entrance: Searing Crags - Top + - entrance: Searing Crags - Top exit exit: Dark Cave - Right - - entrance: Glacial Peak - Left + - entrance: Glacial Peak - Left exit exit: Corrupted Future ``` This block will create the following connections: + 1. Leaving Searing Crags towards Glacial Peak will take you to the beginning of Dark Cave, and leaving the Dark Cave door will return you to the top of Searing Crags. 2. Taking Manfred to leave Glacial Peak, will take you to Corrupted Future. There is no reverse connection here so it - will always be one-way. + will always be one-way. Taking the Corrupted Future portals sends you to the HQ. diff --git a/worlds/messenger/rules.py b/worlds/messenger/rules.py index babc4cce6cb0..e5030b5571a6 100644 --- a/worlds/messenger/rules.py +++ b/worlds/messenger/rules.py @@ -129,11 +129,11 @@ def __init__(self, world: "MessengerWorld") -> None: # Glacial Peak "Glacial Peak - Portal -> Glacial Peak - Tower Entrance Shop": self.has_vertical, - "Glacial Peak - Left -> Elemental Skylands - Air Shmup": + "Glacial Peak - Left exit": 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 - Top -> Cloud Ruins - Left": + "Glacial Peak - Top exit": 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)), @@ -200,7 +200,7 @@ def __init__(self, world: "MessengerWorld") -> None: # 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)), + 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": @@ -233,7 +233,7 @@ def __init__(self, world: "MessengerWorld") -> None: "Howling Grotto - Breezy Crushers Checkpoint -> Howling Grotto - Crushing Pits Shop": [ self.world.get_region("Howling Grotto - Emerald Golem Shop") ], - "Glacial Peak - Left -> Elemental Skylands - Air Shmup": [ + "Glacial Peak - Left exit": [ self.world.get_location("Quillshroom Marsh - Queen of Quills").parent_region ], } @@ -432,7 +432,7 @@ def __init__(self, world: "MessengerWorld") -> None: "Searing Crags - Before Final Climb Shop -> Searing Crags - Colossuses Shop": self.true, # Glacial Peak - "Glacial Peak - Left -> Elemental Skylands - Air Shmup": + "Glacial Peak - Left exit": lambda state: self.has_windmill(state) or (state.has("Magic Firefly", self.player) and state.multiworld.get_location( diff --git a/worlds/messenger/test/test_entrance_randomization.py b/worlds/messenger/test/test_entrance_randomization.py index 2a06a2e0348c..38374fdf9e2c 100644 --- a/worlds/messenger/test/test_entrance_randomization.py +++ b/worlds/messenger/test/test_entrance_randomization.py @@ -1,6 +1,9 @@ import unittest +from typing import ClassVar +from test.param import classvar_matrix from . import MessengerTestBase +from ..connections import RANDOMIZED_CONNECTIONS, TRANSITIONS, ONE_WAY_EXITS, ONE_WAY_ENTRANCES class StrictEntranceRandoTest(MessengerTestBase): @@ -17,3 +20,49 @@ class StrictEntranceRandoTest(MessengerTestBase): def test_all_state_can_reach_everything(self) -> None: """It's not possible to reach everything with these options so skip this test.""" pass + + +@classvar_matrix(entrance=ONE_WAY_EXITS, exit=ONE_WAY_ENTRANCES) +class OneWayTransitionPlandoTest(MessengerTestBase): + entrance: ClassVar[str] + exit: ClassVar[str] + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.options = { + "shuffle_transitions": "coupled", + "plando_connections": [ + {"entrance": self.entrance, "exit": self.exit, } + ], + } + + @property + def run_default_tests(self) -> bool: + return False + + def test_validate_plando(self) -> None: + entrance = self.world.get_entrance(self.entrance) + self.assertIsNotNone(entrance.connected_region) + self.assertEqual(self.exit, entrance.connected_region.name) + + +class ConnectionsConstantTest(MessengerTestBase): + + @property + def run_default_tests(self) -> bool: + return False + + def test_all_transitions_exist(self) -> None: + for transition in sorted(RANDOMIZED_CONNECTIONS.keys()): + with self.subTest(transition=transition): + self.assertIn(transition.replace(" exit", ""), TRANSITIONS) + entrance = self.world.get_entrance(transition) + self.assertIsNotNone(entrance) + + for transition in sorted(RANDOMIZED_CONNECTIONS.values()): + with self.subTest(transition=transition): + self.assertIn(transition, TRANSITIONS) + + if transition not in ONE_WAY_ENTRANCES: + entrance = self.world.get_entrance(transition + " exit") + self.assertIsNotNone(entrance) diff --git a/worlds/messenger/transitions.py b/worlds/messenger/transitions.py index 39ad591bf2d7..b2c3202f52ec 100644 --- a/worlds/messenger/transitions.py +++ b/worlds/messenger/transitions.py @@ -1,8 +1,8 @@ from typing import TYPE_CHECKING -from BaseClasses import Region, CollectionRule -from entrance_rando import EntranceType, randomize_entrances -from .connections import RANDOMIZED_CONNECTIONS, TRANSITIONS +from BaseClasses import Region, Entrance +from entrance_rando import EntranceType, randomize_entrances, disconnect_entrance_for_randomization +from .connections import RANDOMIZED_CONNECTIONS, TRANSITIONS, ONE_WAY_EXITS, ONE_WAY_ENTRANCES from .options import ShuffleTransitions, TransitionPlando if TYPE_CHECKING: @@ -10,45 +10,24 @@ def disconnect_entrances(world: "MessengerWorld") -> None: - def disconnect_entrance() -> None: - child = entrance.connected_region.name - child_region = entrance.connected_region - 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 = 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 region_exit, vanilla_connected_region in RANDOMIZED_CONNECTIONS.items(): + entrance = world.get_entrance(region_exit) + entrance.randomization_type = EntranceType.ONE_WAY if region_exit in ONE_WAY_EXITS else EntranceType.TWO_WAY + disconnect_entrance_for_randomization(entrance, one_way_target_name=vanilla_connected_region) - 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}") - disconnect_entrance() - -def connect_plando(world: "MessengerWorld", plando_connections: TransitionPlando, keep_logic: bool = False) -> None: - def remove_dangling_exit(region: Region) -> CollectionRule: - # find the disconnected exit and remove references to it +def connect_plando(world: "MessengerWorld", plando_connections: TransitionPlando) -> None: + def find_dangling_exit(region: Region) -> Entrance: + """Find the disconnected exit and return 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) - return _exit.access_rule + return _exit def remove_dangling_entrance(region: Region) -> None: - # find the disconnected entrance and remove references to it + """Find the disconnected entrance and remove references to it.""" for _entrance in region.entrances: if not _entrance.parent_region: break @@ -57,52 +36,35 @@ def remove_dangling_entrance(region: Region) -> None: region.entrances.remove(_entrance) for plando_connection in plando_connections: - # get the connecting regions - # need to handle these special because the names are unique but have the same parent region - if plando_connection.entrance in ("Artificer", "Tower HQ"): - reg1 = world.get_region("Tower HQ") - if plando_connection.entrance == "Artificer": - dangling_exit = world.get_entrance("Artificer's Portal") - else: - dangling_exit = world.get_entrance("Artificer's Challenge") - reg1.exits.remove(dangling_exit) - access_rule = dangling_exit.access_rule - else: - reg1 = world.get_region(plando_connection.entrance) - access_rule = remove_dangling_exit(reg1) + plando_entrance = world.get_entrance(plando_connection.entrance) + destination = world.get_region(plando_connection.exit) - reg2 = world.get_region(plando_connection.exit) - remove_dangling_entrance(reg2) - # connect the regions - new_exit1 = reg1.connect(reg2) - if keep_logic: - new_exit1.access_rule = access_rule + if plando_entrance.connected_region == destination: + # The connection was already made bidirectional, skipping. + continue + + remove_dangling_entrance(destination) + plando_entrance.connect(destination) # 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): - access_rule = remove_dangling_exit(reg2) - remove_dangling_entrance(reg1) - new_exit2 = reg2.connect(reg1) - if keep_logic: - new_exit2.access_rule = access_rule + and plando_connection.entrance not in ONE_WAY_EXITS + and plando_connection.exit not in ONE_WAY_ENTRANCES): + plando_reversed_entrance = find_dangling_exit(destination) + source = plando_entrance.parent_region + remove_dangling_entrance(source) + plando_reversed_entrance.connect(source) -def shuffle_transitions(world: "MessengerWorld", keep_logic: bool = False) -> None: +def shuffle_transitions(world: "MessengerWorld") -> None: coupled = world.options.shuffle_transitions == ShuffleTransitions.option_coupled plando = world.options.plando_connections if plando: - connect_plando(world, plando, keep_logic) + connect_plando(world, plando) - result = randomize_entrances(world, coupled, {0: [0]}) + er_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) + world.transitions = sorted(er_result.placements, + key=lambda entrance: TRANSITIONS.index(entrance.name if " exit" not in entrance.name else entrance.name.replace(" exit", "")))