From 785569c40c6d4103e0b999d86ad3b1303621fa3f Mon Sep 17 00:00:00 2001 From: BadMagic100 Date: Sat, 15 Mar 2025 10:56:07 -0700 Subject: [PATCH] Core: Generic ER fails in stage 1 when the last available target is an indirect conditioned dead end (#4679) * Add test that stage1 ER will not fail due to speculative sweeping an indirect conditioned dead end * Skip speculative sweep if it's the last entrance placement * Better implementation of needs_speculative_sweep * pep8 --- entrance_rando.py | 34 +++++++++++++++++++++++++---- test/general/test_entrance_rando.py | 32 ++++++++++++++++++++++++++- 2 files changed, 61 insertions(+), 5 deletions(-) diff --git a/entrance_rando.py b/entrance_rando.py index 2cfebe74..1d2fbc2b 100644 --- a/entrance_rando.py +++ b/entrance_rando.py @@ -358,6 +358,34 @@ def randomize_entrances( if on_connect: on_connect(er_state, placed_exits) + def needs_speculative_sweep(dead_end: bool, require_new_exits: bool, placeable_exits: list[Entrance]) -> bool: + # speculative sweep is expensive. We currently only do it as a last resort, if we might cap off the graph + # entirely + if len(placeable_exits) > 1: + return False + + # in certain stages of randomization we either expect or don't care if the search space shrinks. + # we should never speculative sweep here. + if dead_end or not require_new_exits or not perform_validity_check: + return False + + # edge case - if all dead ends have pre-placed progression or indirect connections, they are pulled forward + # into the non dead end stage. In this case, and only this case, it's possible that the last connection may + # actually be placeable in stage 1. We need to skip speculative sweep in this case because we expect the graph + # to get capped off. + + # check to see if we are proposing the last placement + if not coupled: + # in uncoupled, this check is easy as there will only be one target. + is_last_placement = len(entrance_lookup) == 1 + else: + # a bit harder, there may be 1 or 2 targets depending on if the exit to place is one way or two way. + # if it is two way, we can safely assume that one of the targets is the logical pair of the exit. + desired_target_count = 2 if placeable_exits[0].randomization_type == EntranceType.TWO_WAY else 1 + is_last_placement = len(entrance_lookup) == desired_target_count + # if it's not the last placement, we need a sweep + return not is_last_placement + def find_pairing(dead_end: bool, require_new_exits: bool) -> bool: nonlocal perform_validity_check placeable_exits = er_state.find_placeable_exits(perform_validity_check, exits) @@ -371,11 +399,9 @@ def randomize_entrances( # very last exit and check whatever exits we open up are functionally accessible. # this requirement can be ignored on a beaten minimal, islands are no issue there. exit_requirement_satisfied = (not perform_validity_check or not require_new_exits - or target_entrance.connected_region not in er_state.placed_regions) - needs_speculative_sweep = (not dead_end and require_new_exits and perform_validity_check - and len(placeable_exits) == 1) + or target_entrance.connected_region not in er_state.placed_regions) if exit_requirement_satisfied and source_exit.can_connect_to(target_entrance, dead_end, er_state): - if (needs_speculative_sweep + if (needs_speculative_sweep(dead_end, require_new_exits, placeable_exits) and not er_state.test_speculative_connection(source_exit, target_entrance, exits_set)): continue do_placement(source_exit, target_entrance) diff --git a/test/general/test_entrance_rando.py b/test/general/test_entrance_rando.py index 7e904d33..d2c1e168 100644 --- a/test/general/test_entrance_rando.py +++ b/test/general/test_entrance_rando.py @@ -218,7 +218,7 @@ class TestRandomizeEntrances(unittest.TestCase): self.assertEqual(80, len(result.pairings)) self.assertEqual(80, len(result.placements)) - def test_coupling(self): + def test_coupled(self): """tests that in coupled mode, all 2 way transitions have an inverse""" multiworld = generate_test_multiworld() generate_disconnected_region_grid(multiworld, 5) @@ -236,6 +236,36 @@ class TestRandomizeEntrances(unittest.TestCase): # if we didn't visit every placement the verification on_connect doesn't really mean much self.assertEqual(len(result.placements), seen_placement_count) + def test_uncoupled_succeeds_stage1_indirect_condition(self): + multiworld = generate_test_multiworld() + menu = multiworld.get_region("Menu", 1) + generate_entrance_pair(menu, "_right", ERTestGroups.RIGHT) + end = Region("End", 1, multiworld) + multiworld.regions.append(end) + generate_entrance_pair(end, "_left", ERTestGroups.LEFT) + multiworld.register_indirect_condition(end, None) + + result = randomize_entrances(multiworld.worlds[1], False, directionally_matched_group_lookup) + self.assertSetEqual({ + ("Menu_right", "End_left"), + ("End_left", "Menu_right") + }, set(result.pairings)) + + def test_coupled_succeeds_stage1_indirect_condition(self): + multiworld = generate_test_multiworld() + menu = multiworld.get_region("Menu", 1) + generate_entrance_pair(menu, "_right", ERTestGroups.RIGHT) + end = Region("End", 1, multiworld) + multiworld.regions.append(end) + generate_entrance_pair(end, "_left", ERTestGroups.LEFT) + multiworld.register_indirect_condition(end, None) + + result = randomize_entrances(multiworld.worlds[1], True, directionally_matched_group_lookup) + self.assertSetEqual({ + ("Menu_right", "End_left"), + ("End_left", "Menu_right") + }, set(result.pairings)) + def test_uncoupled(self): """tests that in uncoupled mode, no transitions have an (intentional) inverse""" multiworld = generate_test_multiworld()