Core: Generic Entrance Rando (#2883)
* Initial implementation of Generic ER * Move ERType to Entrance.Type, fix typing imports * updates based on testing (read: flailing) * Updates from feedback * Various bug fixes in ERCollectionState * Use deque instead of queue.Queue * Allow partial entrances in collection state earlier, doc improvements * Prevent early loops in region graph, improve reusability of ER stage code * Typos, grammar, PEP8, and style "fixes" * use RuntimeError instead of bare Exceptions * return tuples from connect since it's slightly faster for our purposes * move the shuffle to the beginning of find_pairing * do er_state placements within pairing lookups to remove code duplication * requested adjustments * Add some temporary performance logging * Use CollectionState to track available exits and placed regions * Add a method to automatically disconnect entrances in a coupled-compliant way Update docs and cleanup todos * Make find_placeable_exits deterministic by sorting blocked_connections set * Move EntranceType out of Entrance * Handle minimal accessibility, autodetect regions, and improvements to disconnect * Add on_connect callback to react to succeeded entrance placements * Relax island-prevention constraints after a successful run on minimal accessibility; better error message on failure * First set of unit tests for generic ER * Change on_connect to send lists, add unit tests for EntranceLookup * Fix duplicated location names in tests * Update tests after merge * Address review feedback, start docs with diagrams * Fix rendering of hidden nodes in ER doc * Move most docstring content into a docs article * Clarify when randomize_entrances can be called safely * Address review feedback * Apply suggestions from code review Co-authored-by: Aaron Wagener <mmmcheese158@gmail.com> * Docs on ERPlacementState, add coupled/uncoupled handling to deadend detection * Documentation clarifications * Update groups to allow any hashable * Restrict groups from hashable to int * Implement speculative sweeping in stage 1, address misc review comments * Clean unused imports in BaseClasses.py * Restrictive region/speculative sweep test * sweep_for_events->advancement * Remove redundant __str__ Co-authored-by: Doug Hoskisson <beauxq@users.noreply.github.com> * Allow partial entrances in auto indirect condition sweep * Treat regions needed for logic as non-dead-end regardless of if they have exits, flip order of stage 3 and 4 to ensure there are enough exits for the dead ends * Typing fixes suggested by mypy * Remove erroneous newline Not sure why the merge conflict editor is different and worse than the normal editor. Crazy * Use modern typing for ER * Enforce the use of explicit indirect conditions * Improve doc on required indirect conditions --------- Co-authored-by: qwint <qwint.42@gmail.com> Co-authored-by: alwaysintreble <mmmcheese158@gmail.com> Co-authored-by: Doug Hoskisson <beauxq@users.noreply.github.com>
This commit is contained in:
		
							parent
							
								
									b9642a482f
								
							
						
					
					
						commit
						218f28912e
					
				|  | @ -19,6 +19,7 @@ import Options | |||
| import Utils | ||||
| 
 | ||||
| if TYPE_CHECKING: | ||||
|     from entrance_rando import ERPlacementState | ||||
|     from worlds import AutoWorld | ||||
| 
 | ||||
| 
 | ||||
|  | @ -426,12 +427,12 @@ class MultiWorld(): | |||
|     def get_location(self, location_name: str, player: int) -> Location: | ||||
|         return self.regions.location_cache[player][location_name] | ||||
| 
 | ||||
|     def get_all_state(self, use_cache: bool) -> CollectionState: | ||||
|     def get_all_state(self, use_cache: bool, allow_partial_entrances: bool = False) -> CollectionState: | ||||
|         cached = getattr(self, "_all_state", None) | ||||
|         if use_cache and cached: | ||||
|             return cached.copy() | ||||
| 
 | ||||
|         ret = CollectionState(self) | ||||
|         ret = CollectionState(self, allow_partial_entrances) | ||||
| 
 | ||||
|         for item in self.itempool: | ||||
|             self.worlds[item.player].collect(ret, item) | ||||
|  | @ -717,10 +718,11 @@ class CollectionState(): | |||
|     path: Dict[Union[Region, Entrance], PathValue] | ||||
|     locations_checked: Set[Location] | ||||
|     stale: Dict[int, bool] | ||||
|     allow_partial_entrances: bool | ||||
|     additional_init_functions: List[Callable[[CollectionState, MultiWorld], None]] = [] | ||||
|     additional_copy_functions: List[Callable[[CollectionState, CollectionState], CollectionState]] = [] | ||||
| 
 | ||||
|     def __init__(self, parent: MultiWorld): | ||||
|     def __init__(self, parent: MultiWorld, allow_partial_entrances: bool = False): | ||||
|         self.prog_items = {player: Counter() for player in parent.get_all_ids()} | ||||
|         self.multiworld = parent | ||||
|         self.reachable_regions = {player: set() for player in parent.get_all_ids()} | ||||
|  | @ -729,6 +731,7 @@ class CollectionState(): | |||
|         self.path = {} | ||||
|         self.locations_checked = set() | ||||
|         self.stale = {player: True for player in parent.get_all_ids()} | ||||
|         self.allow_partial_entrances = allow_partial_entrances | ||||
|         for function in self.additional_init_functions: | ||||
|             function(self, parent) | ||||
|         for items in parent.precollected_items.values(): | ||||
|  | @ -763,6 +766,8 @@ class CollectionState(): | |||
|             if new_region in reachable_regions: | ||||
|                 blocked_connections.remove(connection) | ||||
|             elif connection.can_reach(self): | ||||
|                 if self.allow_partial_entrances and not new_region: | ||||
|                     continue | ||||
|                 assert new_region, f"tried to search through an Entrance \"{connection}\" with no connected Region" | ||||
|                 reachable_regions.add(new_region) | ||||
|                 blocked_connections.remove(connection) | ||||
|  | @ -788,7 +793,9 @@ class CollectionState(): | |||
|                 if new_region in reachable_regions: | ||||
|                     blocked_connections.remove(connection) | ||||
|                 elif connection.can_reach(self): | ||||
|                     assert new_region, f"tried to search through an Entrance \"{connection}\" with no Region" | ||||
|                     if self.allow_partial_entrances and not new_region: | ||||
|                         continue | ||||
|                     assert new_region, f"tried to search through an Entrance \"{connection}\" with no connected Region" | ||||
|                     reachable_regions.add(new_region) | ||||
|                     blocked_connections.remove(connection) | ||||
|                     blocked_connections.update(new_region.exits) | ||||
|  | @ -808,6 +815,7 @@ class CollectionState(): | |||
|         ret.advancements = self.advancements.copy() | ||||
|         ret.path = self.path.copy() | ||||
|         ret.locations_checked = self.locations_checked.copy() | ||||
|         ret.allow_partial_entrances = self.allow_partial_entrances | ||||
|         for function in self.additional_copy_functions: | ||||
|             ret = function(self, ret) | ||||
|         return ret | ||||
|  | @ -972,6 +980,11 @@ class CollectionState(): | |||
|             self.stale[item.player] = True | ||||
| 
 | ||||
| 
 | ||||
| class EntranceType(IntEnum): | ||||
|     ONE_WAY = 1 | ||||
|     TWO_WAY = 2 | ||||
| 
 | ||||
| 
 | ||||
| class Entrance: | ||||
|     access_rule: Callable[[CollectionState], bool] = staticmethod(lambda state: True) | ||||
|     hide_path: bool = False | ||||
|  | @ -979,19 +992,24 @@ class Entrance: | |||
|     name: str | ||||
|     parent_region: Optional[Region] | ||||
|     connected_region: Optional[Region] = None | ||||
|     randomization_group: int | ||||
|     randomization_type: EntranceType | ||||
|     # LttP specific, TODO: should make a LttPEntrance | ||||
|     addresses = None | ||||
|     target = None | ||||
| 
 | ||||
|     def __init__(self, player: int, name: str = "", parent: Optional[Region] = None) -> None: | ||||
|     def __init__(self, player: int, name: str = "", parent: Optional[Region] = None, | ||||
|                  randomization_group: int = 0, randomization_type: EntranceType = EntranceType.ONE_WAY) -> None: | ||||
|         self.name = name | ||||
|         self.parent_region = parent | ||||
|         self.player = player | ||||
|         self.randomization_group = randomization_group | ||||
|         self.randomization_type = randomization_type | ||||
| 
 | ||||
|     def can_reach(self, state: CollectionState) -> bool: | ||||
|         assert self.parent_region, f"called can_reach on an Entrance \"{self}\" with no parent_region" | ||||
|         if self.parent_region.can_reach(state) and self.access_rule(state): | ||||
|             if not self.hide_path and not self in state.path: | ||||
|             if not self.hide_path and self not in state.path: | ||||
|                 state.path[self] = (self.name, state.path.get(self.parent_region, (self.parent_region.name, None))) | ||||
|             return True | ||||
| 
 | ||||
|  | @ -1003,6 +1021,32 @@ class Entrance: | |||
|         self.addresses = addresses | ||||
|         region.entrances.append(self) | ||||
| 
 | ||||
|     def is_valid_source_transition(self, er_state: "ERPlacementState") -> bool: | ||||
|         """ | ||||
|         Determines whether this is a valid source transition, that is, whether the entrance | ||||
|         randomizer is allowed to pair it to place any other regions. By default, this is the | ||||
|         same as a reachability check, but can be modified by Entrance implementations to add | ||||
|         other restrictions based on the placement state. | ||||
| 
 | ||||
|         :param er_state: The current (partial) state of the ongoing entrance randomization | ||||
|         """ | ||||
|         return self.can_reach(er_state.collection_state) | ||||
| 
 | ||||
|     def can_connect_to(self, other: Entrance, dead_end: bool, er_state: "ERPlacementState") -> bool: | ||||
|         """ | ||||
|         Determines whether a given Entrance is a valid target transition, that is, whether | ||||
|         the entrance randomizer is allowed to pair this Entrance to that Entrance. By default, | ||||
|         only allows connection between entrances of the same type (one ways only go to one ways, | ||||
|         two ways always go to two ways) and prevents connecting an exit to itself in coupled mode. | ||||
| 
 | ||||
|         :param other: The proposed Entrance to connect to | ||||
|         :param dead_end: Whether the other entrance considered a dead end by Entrance randomization | ||||
|         :param er_state: The current (partial) state of the ongoing entrance randomization | ||||
|         """ | ||||
|         # the implementation of coupled causes issues for self-loops since the reverse entrance will be the | ||||
|         # same as the forward entrance. In uncoupled they are ok. | ||||
|         return self.randomization_type == other.randomization_type and (not er_state.coupled or self.name != other.name) | ||||
| 
 | ||||
|     def __repr__(self): | ||||
|         multiworld = self.parent_region.multiworld if self.parent_region else None | ||||
|         return multiworld.get_name_string_for_object(self) if multiworld else f'{self.name} (Player {self.player})' | ||||
|  | @ -1152,6 +1196,16 @@ class Region: | |||
|         self.exits.append(exit_) | ||||
|         return exit_ | ||||
| 
 | ||||
|     def create_er_target(self, name: str) -> Entrance: | ||||
|         """ | ||||
|         Creates and returns an Entrance object as an entrance to this region | ||||
| 
 | ||||
|         :param name: name of the Entrance being created | ||||
|         """ | ||||
|         entrance = self.entrance_type(self.player, name) | ||||
|         entrance.connect(self) | ||||
|         return entrance | ||||
| 
 | ||||
|     def add_exits(self, exits: Union[Iterable[str], Dict[str, Optional[str]]], | ||||
|                   rules: Dict[str, Callable[[CollectionState], bool]] = None) -> List[Entrance]: | ||||
|         """ | ||||
|  |  | |||
|  | @ -0,0 +1,430 @@ | |||
| # Entrance Randomization | ||||
| 
 | ||||
| This document discusses the API and underlying implementation of the generic entrance randomization algorithm | ||||
| exposed in [entrance_rando.py](/entrance_rando.py). Throughout the doc, entrance randomization is frequently abbreviated | ||||
| as "ER." | ||||
| 
 | ||||
| This doc assumes familiarity with Archipelago's graph logic model. If you don't have a solid understanding of how | ||||
| regions work, you should start there. | ||||
| 
 | ||||
| ## Entrance randomization concepts | ||||
| 
 | ||||
| ### Terminology | ||||
| 
 | ||||
| Some important terminology to understand when reading this doc and working with ER is listed below. | ||||
| 
 | ||||
| * Entrance rando - sometimes called "room rando," "transition rando," "door rando," or similar, | ||||
|   this is a game mode in which the game map itself is randomized. | ||||
|   In Archipelago, these things are often represented as `Entrance`s in the region graph, so we call it Entrance rando. | ||||
| * Entrances and exits - entrances are ways into your region, exits are ways out of the region. In code, they are both | ||||
|   represented as `Entrance` objects. In this doc, the terms "entrances" and "exits" will be used in this sense; the | ||||
|   `Entrance` class will always be referenced in a code block with an uppercase E. | ||||
| * Dead end - a connected group of regions which can never help ER progress. This means that it: | ||||
|   * Is not in any indirect conditions/access rules. | ||||
|   * Has no plando'd or otherwise preplaced progression items, including events. | ||||
|   * Has no randomized exits. | ||||
| * One way transition - a transition that, in the game, is not safe to reverse through (for example, in Hollow Knight, | ||||
|   some transitions are inaccessible backwards in vanilla and would put you out of bounds). One way transitions are  | ||||
|   paired together during randomization to prevent such unsafe game states. Most transitions are not one way. | ||||
| 
 | ||||
| ### Basic randomization strategy | ||||
| 
 | ||||
| The Generic ER algorithm works by using the logic structures you are already familiar with. To give a basic example, | ||||
| let's assume a toy world is defined with the vanilla region graph modeled below. In this diagram, the smaller boxes | ||||
| represent regions while the larger boxes represent scenes. Scenes are not an Archipelago concept, the grouping is | ||||
| purely illustrative. | ||||
| 
 | ||||
| ```mermaid | ||||
| %%{init: {"graph": {"defaultRenderer": "elk"}} }%% | ||||
| graph LR | ||||
|     subgraph startingRoom [Starting Room] | ||||
|         S[Starting Room Right Door] | ||||
|     end | ||||
|     subgraph sceneB [Scene B] | ||||
|         BR1[Scene B Right Door] | ||||
|     end | ||||
|     subgraph sceneA [Scene A] | ||||
|         AL1[Scene A Lower Left Door] <--> AR1[Scene A Right Door] | ||||
|         AL2[Scene A Upper Left Door] <--> AR1 | ||||
|     end | ||||
|     subgraph sceneC [Scene C] | ||||
|         CL1[Scene C Left Door] <--> CR1[Scene C Upper Right Door] | ||||
|         CL1 <--> CR2[Scene C Lower Right Door] | ||||
|     end | ||||
|     subgraph sceneD [Scene D] | ||||
|         DL1[Scene D Left Door] <--> DR1[Scene D Right Door] | ||||
|     end | ||||
|     subgraph endingRoom [Ending Room] | ||||
|         EL1[Ending Room Upper Left Door] <--> Victory | ||||
|         EL2[Ending Room Lower Left Door] <--> Victory | ||||
|     end | ||||
|     Menu --> S | ||||
|     S <--> AL2 | ||||
|     BR1 <--> AL1 | ||||
|     AR1 <--> CL1 | ||||
|     CR1 <--> DL1 | ||||
|     DR1 <--> EL1 | ||||
|     CR2 <--> EL2 | ||||
|      | ||||
|     classDef hidden display:none; | ||||
| ``` | ||||
| 
 | ||||
| First, the world begins by splitting the `Entrance`s which should be randomized. This is essentially all that has to be | ||||
| done on the world side; calling the `randomize_entrances` function will do the rest, using your region definitions and | ||||
| logic to generate a valid world layout by connecting the partially connected edges you've defined. After you have done | ||||
| that, your region graph might look something like the following diagram. Note how each randomizable entrance/exit pair | ||||
| (represented as a bidirectional arrow) is disconnected on one end. | ||||
| 
 | ||||
| > [!NOTE] | ||||
| > It is required to use explicit indirect conditions when using Generic ER. Without this restriction, | ||||
| > Generic ER would have no way to correctly determine that a region may be required in logic, | ||||
| > leading to significantly higher failure rates due to mis-categorized regions. | ||||
| 
 | ||||
| ```mermaid | ||||
| %%{init: {"graph": {"defaultRenderer": "elk"}} }%% | ||||
| graph LR | ||||
|     subgraph startingRoom [Starting Room] | ||||
|         S[Starting Room Right Door] | ||||
|     end | ||||
|     subgraph sceneA [Scene A] | ||||
|         AL1[Scene A Upper Left Door] <--> AR1[Scene A Right Door] | ||||
|         AL2[Scene A Lower Left Door] <--> AR1 | ||||
|     end | ||||
|     subgraph sceneB [Scene B] | ||||
|         BR1[Scene B Right Door] | ||||
|     end | ||||
|     subgraph sceneC [Scene C] | ||||
|         CL1[Scene C Left Door] <--> CR1[Scene C Upper Right Door] | ||||
|         CL1 <--> CR2[Scene C Lower Right Door] | ||||
|     end | ||||
|     subgraph sceneD [Scene D] | ||||
|         DL1[Scene D Left Door] <--> DR1[Scene D Right Door] | ||||
|     end | ||||
|     subgraph endingRoom [Ending Room] | ||||
|         EL1[Ending Room Upper Left Door] <--> Victory | ||||
|         EL2[Ending Room Lower Left Door] <--> Victory | ||||
|     end | ||||
|     Menu --> S | ||||
|     S <--> T1:::hidden | ||||
|     T2:::hidden <--> AL1 | ||||
|     T3:::hidden <--> AL2 | ||||
|     AR1 <--> T5:::hidden | ||||
|     BR1 <--> T4:::hidden | ||||
|     T6:::hidden <--> CL1 | ||||
|     CR1 <--> T7:::hidden | ||||
|     CR2 <--> T11:::hidden | ||||
|     T8:::hidden <--> DL1 | ||||
|     DR1 <--> T9:::hidden | ||||
|     T10:::hidden <--> EL1 | ||||
|     T12:::hidden <--> EL2 | ||||
|      | ||||
|     classDef hidden display:none; | ||||
| ``` | ||||
| 
 | ||||
| From here, you can call the `randomize_entrances` function and Archipelago takes over. Starting from the Menu region, | ||||
| the algorithm will sweep out to find eligible region exits to randomize. It will then select an eligible target entrance | ||||
| and connect them, prioritizing giving access to unvisited regions first until all regions are placed. Once the exit has | ||||
| been connected to the new region, placeholder entrances are deleted. This process is visualized in the diagram below | ||||
| with the newly connected edge highlighted in red. | ||||
| 
 | ||||
| ```mermaid | ||||
| %%{init: {"graph": {"defaultRenderer": "elk"}} }%% | ||||
| graph LR | ||||
|     subgraph startingRoom [Starting Room] | ||||
|         S[Starting Room Right Door] | ||||
|     end | ||||
|     subgraph sceneA [Scene A] | ||||
|         AL1[Scene A Upper Left Door] <--> AR1[Scene A Right Door] | ||||
|         AL2[Scene A Lower Left Door] <--> AR1 | ||||
|     end | ||||
|     subgraph sceneB [Scene B] | ||||
|         BR1[Scene B Right Door] | ||||
|     end | ||||
|     subgraph sceneC [Scene C] | ||||
|         CL1[Scene C Left Door] <--> CR1[Scene C Upper Right Door] | ||||
|         CL1 <--> CR2[Scene C Lower Right Door] | ||||
|     end | ||||
|     subgraph sceneD [Scene D] | ||||
|         DL1[Scene D Left Door] <--> DR1[Scene D Right Door] | ||||
|     end | ||||
|     subgraph endingRoom [Ending Room] | ||||
|         EL1[Ending Room Upper Left Door] <--> Victory | ||||
|         EL2[Ending Room Lower Left Door] <--> Victory | ||||
|     end | ||||
|     Menu --> S | ||||
|     S <--> CL1 | ||||
|     T2:::hidden <--> AL1 | ||||
|     T3:::hidden <--> AL2 | ||||
|     AR1 <--> T5:::hidden | ||||
|     BR1 <--> T4:::hidden | ||||
|     CR1 <--> T7:::hidden | ||||
|     CR2 <--> T11:::hidden | ||||
|     T8:::hidden <--> DL1 | ||||
|     DR1 <--> T9:::hidden | ||||
|     T10:::hidden <--> EL1 | ||||
|     T12:::hidden <--> EL2 | ||||
|      | ||||
|     classDef hidden display:none; | ||||
|     linkStyle 8 stroke:red,stroke-width:5px; | ||||
| ``` | ||||
| 
 | ||||
| This process is then repeated until all disconnected `Entrance`s have been connected or deleted, eventually resulting | ||||
| in a randomized region layout. | ||||
| 
 | ||||
| ```mermaid | ||||
| %%{init: {"graph": {"defaultRenderer": "elk"}} }%% | ||||
| graph LR | ||||
|     subgraph startingRoom [Starting Room] | ||||
|         S[Starting Room Right Door] | ||||
|     end | ||||
|     subgraph sceneA [Scene A] | ||||
|         AL1[Scene A Upper Left Door] <--> AR1[Scene A Right Door] | ||||
|         AL2[Scene A Lower Left Door] <--> AR1 | ||||
|     end | ||||
|     subgraph sceneB [Scene B] | ||||
|         BR1[Scene B Right Door] | ||||
|     end | ||||
|     subgraph sceneC [Scene C] | ||||
|         CL1[Scene C Left Door] <--> CR1[Scene C Upper Right Door] | ||||
|         CL1 <--> CR2[Scene C Lower Right Door] | ||||
|     end | ||||
|     subgraph sceneD [Scene D] | ||||
|         DL1[Scene D Left Door] <--> DR1[Scene D Right Door] | ||||
|     end | ||||
|     subgraph endingRoom [Ending Room] | ||||
|         EL1[Ending Room Upper Left Door] <--> Victory | ||||
|         EL2[Ending Room Lower Left Door] <--> Victory | ||||
|     end | ||||
|     Menu --> S | ||||
|     S <--> CL1 | ||||
|     AR1 <--> DL1 | ||||
|     BR1 <--> EL2 | ||||
|     CR1 <--> EL1 | ||||
|     CR2 <--> AL1 | ||||
|     DR1 <--> AL2 | ||||
|      | ||||
|     classDef hidden display:none; | ||||
| ``` | ||||
| 
 | ||||
| #### ER and minimal accessibility | ||||
| 
 | ||||
| In general, even on minimal accessibility, ER will prefer to provide access to as many regions as possible. This is for | ||||
| 2 reasons: | ||||
| 1. Generally, having items spread across the world is going to be a more fun/engaging experience for players than | ||||
|    severely restricting their map. Imagine an ER arrangement with just the start region, the goal region, and exactly | ||||
|    enough locations in between them to get the goal - this may be the intuitive behavior of minimal, or even the desired | ||||
|    behavior in some cases, but it is not a particularly interesting randomizer. | ||||
| 2. Giving access to more of the world will give item fill a higher chance to succeed. | ||||
| 
 | ||||
| However, ER will cull unreachable regions and exits if necessary to save the generation of a beaten minimal. | ||||
| 
 | ||||
| ## Usage | ||||
| 
 | ||||
| ### Defining entrances to be randomized | ||||
| 
 | ||||
| The first step to using generic ER is defining entrances to be randomized. In order to do this, you will need to | ||||
| leave partially disconnected exits without a `target_region` and partially disconnected entrances without a | ||||
| `parent_region`. You can do this either by hand using `region.create_exit` and `region.create_er_target`, or you can | ||||
| create your vanilla region graph and then use `disconnect_entrance_for_randomization` to split the desired edges. | ||||
| If you're not sure which to use, prefer the latter approach as it will automatically satisfy the requirements for  | ||||
| coupled randomization (discussed in more depth later). | ||||
| 
 | ||||
| > [!TIP] | ||||
| > It's recommended to give your `Entrance`s non-default names when creating them. The default naming scheme is  | ||||
| > `f"{parent_region} -> {target_region}"` which is generally not helpful in an entrance rando context - after all, | ||||
| > the target region will not be the same as vanilla and regions are often not user-facing anyway. Instead consider names | ||||
| > that describe the location of the exit, such as "Starting Room Right Door." | ||||
| 
 | ||||
| When creating your `Entrance`s you should also set the randomization type and group. One-way `Entrance`s represent | ||||
| transitions which are impossible to traverse in reverse. All other transitions are two-ways. To ensure that all | ||||
| transitions can be accessed in the game, one-ways are only randomized with other one-ways and two-ways are only | ||||
| randomized with other two-ways. You can set whether an `Entrance` is one-way or two-way using the `randomization_type` | ||||
| attribute. | ||||
| 
 | ||||
| `Entrance`s can also set the `randomization_group` attribute to allow for grouping during randomization. This can be | ||||
| any integer you define and may be based on player options. Some possible use cases for grouping include: | ||||
| * Directional matching - only match leftward-facing transitions to rightward-facing ones | ||||
| * Terrain matching - only match water transitions to water transitions and land transitions to land transitions | ||||
| * Dungeon shuffle - only shuffle entrances within a dungeon/area with each other | ||||
| * Combinations of the above | ||||
| 
 | ||||
| By default, all `Entrance`s are placed in the group 0. An entrance can only be a member of one group, but a given group | ||||
| may connect to many other groups. | ||||
| 
 | ||||
| ### Calling generic ER | ||||
| 
 | ||||
| Once you have defined all your entrances and exits and connected the Menu region to your region graph, you can call  | ||||
| `randomize_entrances` to perform randomization. | ||||
| 
 | ||||
| #### Coupled and uncoupled modes | ||||
| 
 | ||||
| In coupled randomization, an entrance placed from A to B guarantees that the reverse placement B to A also exists | ||||
| (assuming that A and B are both two-way doors). Uncoupled randomization does not make this guarantee. | ||||
| 
 | ||||
| When using coupled mode, there are some requirements for how placeholder ER targets for two-ways are named.  | ||||
| `disconnect_entrance_for_randomization` will handle this for you. However, if you opt to create your ER targets and | ||||
| exits by hand, you will need to ensure that ER targets into a region are named the same as the exit they correspond to. | ||||
| This allows the randomizer to find and connect the reverse pairing after the first pairing is completed. See the diagram | ||||
| below for an example of incorrect and correct naming. | ||||
| 
 | ||||
| Incorrect target naming: | ||||
| 
 | ||||
| ```mermaid | ||||
| %%{init: {"graph": {"defaultRenderer": "elk"}} }%% | ||||
| graph LR | ||||
|     subgraph a [" "] | ||||
|         direction TB | ||||
|         target1 | ||||
|         target2 | ||||
|     end | ||||
|     subgraph b [" "] | ||||
|         direction TB | ||||
|         Region | ||||
|     end | ||||
|     Region["Room1"] -->|Room1 Right Door| target1:::hidden | ||||
|     Region --- target2:::hidden -->|Room2 Left Door| Region | ||||
| 
 | ||||
|     linkStyle 1 stroke:none; | ||||
|     classDef hidden display:none; | ||||
|     style a display:none; | ||||
|     style b display:none; | ||||
| ``` | ||||
| 
 | ||||
| Correct target naming: | ||||
| 
 | ||||
| ```mermaid | ||||
| %%{init: {"graph": {"defaultRenderer": "elk"}} }%% | ||||
| graph LR | ||||
|     subgraph a [" "] | ||||
|         direction TB | ||||
|         target1 | ||||
|         target2 | ||||
|     end | ||||
|     subgraph b [" "] | ||||
|         direction TB | ||||
|         Region | ||||
|     end | ||||
|     Region["Room1"] -->|Room1 Right Door| target1:::hidden | ||||
|     Region --- target2:::hidden -->|Room1 Right Door| Region | ||||
| 
 | ||||
|     linkStyle 1 stroke:none; | ||||
|     classDef hidden display:none; | ||||
|     style a display:none; | ||||
|     style b display:none; | ||||
| ``` | ||||
| 
 | ||||
| #### Implementing grouping | ||||
| 
 | ||||
| When you created your entrances, you defined the group each entrance belongs to. Now you will have to define how groups | ||||
| should connect with each other. This is done with the `target_group_lookup` and `preserve_group_order` parameters. | ||||
| There is also a convenience function `bake_target_group_lookup` which can help to prepare group lookups when more | ||||
| complex group mapping logic is needed. Some recipes for `target_group_lookup` are presented here. | ||||
| 
 | ||||
| For the recipes below, assume the following groups (if the syntax used here is unfamiliar to you, "bit masking" and  | ||||
| "bitwise operators" would be the terms to search for): | ||||
| ```python | ||||
| class Groups(IntEnum): | ||||
|     # Directions | ||||
|     LEFT = 1 | ||||
|     RIGHT = 2 | ||||
|     TOP = 3 | ||||
|     BOTTOM = 4 | ||||
|     DOOR = 5 | ||||
|     # Areas | ||||
|     FIELD = 1 << 3 | ||||
|     CAVE = 2 << 3 | ||||
|     MOUNTAIN = 3 << 3 | ||||
|     # Bitmasks | ||||
|     DIRECTION_MASK = FIELD - 1 | ||||
|     AREA_MASK = ~0 << 3 | ||||
| ``` | ||||
| 
 | ||||
| Directional matching: | ||||
| ```python | ||||
| direction_matching_group_lookup = { | ||||
|     # with preserve_group_order = False, pair a left transition to either a right transition or door randomly | ||||
|     # with preserve_group_order = True, pair a left transition to a right transition, or else a door if no | ||||
|     #   viable right transitions remain | ||||
|     Groups.LEFT: [Groups.RIGHT, Groups.DOOR], | ||||
|     # ... | ||||
| } | ||||
| ``` | ||||
| 
 | ||||
| Terrain matching or dungeon shuffle: | ||||
| ```python | ||||
| def randomize_within_same_group(group: int) -> List[int]: | ||||
|     return [group] | ||||
| identity_group_lookup = bake_target_group_lookup(world, randomize_within_same_group) | ||||
| ``` | ||||
| 
 | ||||
| Directional + area shuffle: | ||||
| ```python | ||||
| def get_target_groups(group: int) -> List[int]: | ||||
|     # example group: LEFT | CAVE | ||||
|     # example result: [RIGHT | CAVE, DOOR | CAVE] | ||||
|     direction = group & Groups.DIRECTION_MASK | ||||
|     area = group & Groups.AREA_MASK | ||||
|     return [pair_direction | area for pair_direction in direction_matching_group_lookup[direction]] | ||||
| target_group_lookup = bake_target_group_lookup(world, get_target_groups) | ||||
| ``` | ||||
| 
 | ||||
| #### When to call `randomize_entrances` | ||||
| 
 | ||||
| The short answer is that you will almost always want to do ER in `pre_fill`. For more information why, continue reading. | ||||
| 
 | ||||
| ER begins by collecting the entire item pool and then uses your access rules to try and prevent some kinds of failures.  | ||||
| This means 2 things about when you can call ER: | ||||
| 1. You must supply your item pool before calling ER, or call ER before setting any rules which require items. | ||||
| 2. If you have rules dependent on anything other than items (e.g. `Entrance`s or events), you must set your rules | ||||
|    and create your events before you call ER if you want to guarantee a correct output. | ||||
| 
 | ||||
| If the conditions above are met, you could theoretically do ER as early as `create_regions`. However, plando is also  | ||||
| a consideration. Since item plando happens between `set_rules` and `pre_fill` and modifies the item pool, doing ER  | ||||
| in `pre_fill` is the only way to account for placements made by item plando, otherwise you risk impossible seeds or  | ||||
| generation failures. Obviously, if your world implements entrance plando, you will likely want to do that before ER as | ||||
| well. | ||||
| 
 | ||||
| #### Informing your client about randomized entrances | ||||
| 
 | ||||
| `randomize_entrances` returns the completed `ERPlacementState`. The `pairings` attribute contains a list of the | ||||
| created placements by name which can be used to populate slot data. | ||||
| 
 | ||||
| ### Imposing custom constraints on randomization | ||||
| 
 | ||||
| Generic ER is, as the name implies, generic! That means that your world may have some use case which is not covered by | ||||
| the ER implementation. To solve this, you can create a custom `Entrance` class which provides custom implementations | ||||
| for `is_valid_source_transition` and `can_connect_to`. These allow arbitrary constraints to be implemented on | ||||
| randomization, for instance helping to prevent restrictive sphere 1s or ensuring a maximum distance from a "hub" region. | ||||
| 
 | ||||
| > [!IMPORTANT] | ||||
| > When implementing these functions, make sure to use `super().is_valid_source_transition` and `super().can_connect_to` | ||||
| > as part of your implementation. Otherwise ER may behave unexpectedly. | ||||
| 
 | ||||
| ## Implementation details | ||||
| 
 | ||||
| This section is a medium-level explainer of the implementation of ER for those who don't want to decipher the code. | ||||
| However, a basic understanding of the mechanics of `fill_restrictive` will be helpful as many of the underlying | ||||
| algorithms are shared | ||||
| 
 | ||||
| ER uses a forward fill approach to create the region layout. First, ER collects `all_state` and performs a region sweep | ||||
| from Menu, similar to fill. ER then proceeds in stages to complete the randomization: | ||||
| 1. Attempt to connect all non-dead-end regions, prioritizing access to unseen regions so there will always be new exits | ||||
|    to pair off. | ||||
| 2. Attempt to connect all dead-end regions, so that all regions will be placed | ||||
| 3. Connect all remaining dangling edges now that all regions are placed. | ||||
|     1. Connect any other dead end entrances (e.g. second entrances to the same dead end regions). | ||||
|     2. Connect all remaining non-dead-ends amongst each other. | ||||
| 
 | ||||
| The process for each connection will do the following: | ||||
| 1. Select a randomizable exit of a reachable region which is a valid source transition. | ||||
| 2. Get its group and check `target_group_lookup` to determine which groups are valid targets. | ||||
| 3. Look up ER targets from those groups and find one which is valid according to `can_connect_to` | ||||
| 4. Connect the source exit to the target's target_region and delete the target. | ||||
|    * In stage 1, before placing the last valid source transition, an additional speculative sweep is performed to ensure | ||||
|      that there will be an available exit after the placement so randomization can continue. | ||||
| 5. If it's coupled mode, find the reverse exit and target by name and connect them as well. | ||||
| 6. Sweep to update reachable regions. | ||||
| 7. Call the `on_connect` callback. | ||||
| 
 | ||||
| This process repeats until the stage is complete, no valid source transition is found, or no valid target transition is | ||||
| found for any source transition. Unlike fill, there is no attempt made to save a failed randomization. | ||||
|  | @ -0,0 +1,447 @@ | |||
| import itertools | ||||
| import logging | ||||
| import random | ||||
| import time | ||||
| from collections import deque | ||||
| from collections.abc import Callable, Iterable | ||||
| 
 | ||||
| from BaseClasses import CollectionState, Entrance, Region, EntranceType | ||||
| from Options import Accessibility | ||||
| from worlds.AutoWorld import World | ||||
| 
 | ||||
| 
 | ||||
| class EntranceRandomizationError(RuntimeError): | ||||
|     pass | ||||
| 
 | ||||
| 
 | ||||
| class EntranceLookup: | ||||
|     class GroupLookup: | ||||
|         _lookup: dict[int, list[Entrance]] | ||||
| 
 | ||||
|         def __init__(self): | ||||
|             self._lookup = {} | ||||
| 
 | ||||
|         def __len__(self): | ||||
|             return sum(map(len, self._lookup.values())) | ||||
| 
 | ||||
|         def __bool__(self): | ||||
|             return bool(self._lookup) | ||||
| 
 | ||||
|         def __getitem__(self, item: int) -> list[Entrance]: | ||||
|             return self._lookup.get(item, []) | ||||
| 
 | ||||
|         def __iter__(self): | ||||
|             return itertools.chain.from_iterable(self._lookup.values()) | ||||
| 
 | ||||
|         def __repr__(self): | ||||
|             return str(self._lookup) | ||||
| 
 | ||||
|         def add(self, entrance: Entrance) -> None: | ||||
|             self._lookup.setdefault(entrance.randomization_group, []).append(entrance) | ||||
| 
 | ||||
|         def remove(self, entrance: Entrance) -> None: | ||||
|             group = self._lookup[entrance.randomization_group] | ||||
|             group.remove(entrance) | ||||
|             if not group: | ||||
|                 del self._lookup[entrance.randomization_group] | ||||
| 
 | ||||
|     dead_ends: GroupLookup | ||||
|     others: GroupLookup | ||||
|     _random: random.Random | ||||
|     _expands_graph_cache: dict[Entrance, bool] | ||||
|     _coupled: bool | ||||
| 
 | ||||
|     def __init__(self, rng: random.Random, coupled: bool): | ||||
|         self.dead_ends = EntranceLookup.GroupLookup() | ||||
|         self.others = EntranceLookup.GroupLookup() | ||||
|         self._random = rng | ||||
|         self._expands_graph_cache = {} | ||||
|         self._coupled = coupled | ||||
| 
 | ||||
|     def _can_expand_graph(self, entrance: Entrance) -> bool: | ||||
|         """ | ||||
|         Checks whether an entrance is able to expand the region graph, either by | ||||
|         providing access to randomizable exits or by granting access to items or | ||||
|         regions used in logic conditions. | ||||
| 
 | ||||
|         :param entrance: A randomizable (no parent) region entrance | ||||
|         """ | ||||
|         # we've seen this, return cached result | ||||
|         if entrance in self._expands_graph_cache: | ||||
|             return self._expands_graph_cache[entrance] | ||||
| 
 | ||||
|         visited = set() | ||||
|         q: deque[Region] = deque() | ||||
|         q.append(entrance.connected_region) | ||||
| 
 | ||||
|         while q: | ||||
|             region = q.popleft() | ||||
|             visited.add(region) | ||||
| 
 | ||||
|             # check if the region itself is progression | ||||
|             if region in region.multiworld.indirect_connections: | ||||
|                 self._expands_graph_cache[entrance] = True | ||||
|                 return True | ||||
| 
 | ||||
|             # check if any placed locations are progression | ||||
|             for loc in region.locations: | ||||
|                 if loc.advancement: | ||||
|                     self._expands_graph_cache[entrance] = True | ||||
|                     return True | ||||
| 
 | ||||
|             # check if there is a randomized exit out (expands the graph directly) or else search any connected | ||||
|             # regions to see if they are/have progression | ||||
|             for exit_ in region.exits: | ||||
|                 # randomizable exits which are not reverse of the incoming entrance. | ||||
|                 # uncoupled mode is an exception because in this case going back in the door you just came in could | ||||
|                 # actually lead somewhere new | ||||
|                 if not exit_.connected_region and (not self._coupled or exit_.name != entrance.name): | ||||
|                     self._expands_graph_cache[entrance] = True | ||||
|                     return True | ||||
|                 elif exit_.connected_region and exit_.connected_region not in visited: | ||||
|                     q.append(exit_.connected_region) | ||||
| 
 | ||||
|         self._expands_graph_cache[entrance] = False | ||||
|         return False | ||||
| 
 | ||||
|     def add(self, entrance: Entrance) -> None: | ||||
|         lookup = self.others if self._can_expand_graph(entrance) else self.dead_ends | ||||
|         lookup.add(entrance) | ||||
| 
 | ||||
|     def remove(self, entrance: Entrance) -> None: | ||||
|         lookup = self.others if self._can_expand_graph(entrance) else self.dead_ends | ||||
|         lookup.remove(entrance) | ||||
| 
 | ||||
|     def get_targets( | ||||
|             self, | ||||
|             groups: Iterable[int], | ||||
|             dead_end: bool, | ||||
|             preserve_group_order: bool | ||||
|     ) -> Iterable[Entrance]: | ||||
| 
 | ||||
|         lookup = self.dead_ends if dead_end else self.others | ||||
|         if preserve_group_order: | ||||
|             for group in groups: | ||||
|                 self._random.shuffle(lookup[group]) | ||||
|             ret = [entrance for group in groups for entrance in lookup[group]] | ||||
|         else: | ||||
|             ret = [entrance for group in groups for entrance in lookup[group]] | ||||
|             self._random.shuffle(ret) | ||||
|         return ret | ||||
| 
 | ||||
|     def __len__(self): | ||||
|         return len(self.dead_ends) + len(self.others) | ||||
| 
 | ||||
| 
 | ||||
| class ERPlacementState: | ||||
|     """The state of an ongoing or completed entrance randomization""" | ||||
|     placements: list[Entrance] | ||||
|     """The list of randomized Entrance objects which have been connected successfully""" | ||||
|     pairings: list[tuple[str, str]] | ||||
|     """A list of pairings of connected entrance names, of the form (source_exit, target_entrance)""" | ||||
|     world: World | ||||
|     """The world which is having its entrances randomized""" | ||||
|     collection_state: CollectionState | ||||
|     """The CollectionState backing the entrance randomization logic""" | ||||
|     coupled: bool | ||||
|     """Whether entrance randomization is operating in coupled mode""" | ||||
| 
 | ||||
|     def __init__(self, world: World, coupled: bool): | ||||
|         self.placements = [] | ||||
|         self.pairings = [] | ||||
|         self.world = world | ||||
|         self.coupled = coupled | ||||
|         self.collection_state = world.multiworld.get_all_state(False, True) | ||||
| 
 | ||||
|     @property | ||||
|     def placed_regions(self) -> set[Region]: | ||||
|         return self.collection_state.reachable_regions[self.world.player] | ||||
| 
 | ||||
|     def find_placeable_exits(self, check_validity: bool) -> list[Entrance]: | ||||
|         if check_validity: | ||||
|             blocked_connections = self.collection_state.blocked_connections[self.world.player] | ||||
|             blocked_connections = sorted(blocked_connections, key=lambda x: x.name) | ||||
|             placeable_randomized_exits = [connection for connection in blocked_connections | ||||
|                                           if not connection.connected_region | ||||
|                                           and connection.is_valid_source_transition(self)] | ||||
|         else: | ||||
|             # this is on a beaten minimal attempt, so any exit anywhere is fair game | ||||
|             placeable_randomized_exits = [ex for region in self.world.multiworld.get_regions(self.world.player) | ||||
|                                           for ex in region.exits if not ex.connected_region] | ||||
|         self.world.random.shuffle(placeable_randomized_exits) | ||||
|         return placeable_randomized_exits | ||||
| 
 | ||||
|     def _connect_one_way(self, source_exit: Entrance, target_entrance: Entrance) -> None: | ||||
|         target_region = target_entrance.connected_region | ||||
| 
 | ||||
|         target_region.entrances.remove(target_entrance) | ||||
|         source_exit.connect(target_region) | ||||
| 
 | ||||
|         self.collection_state.stale[self.world.player] = True | ||||
|         self.placements.append(source_exit) | ||||
|         self.pairings.append((source_exit.name, target_entrance.name)) | ||||
| 
 | ||||
|     def test_speculative_connection(self, source_exit: Entrance, target_entrance: Entrance) -> bool: | ||||
|         copied_state = self.collection_state.copy() | ||||
|         # simulated connection. A real connection is unsafe because the region graph is shallow-copied and would | ||||
|         # propagate back to the real multiworld. | ||||
|         copied_state.reachable_regions[self.world.player].add(target_entrance.connected_region) | ||||
|         copied_state.blocked_connections[self.world.player].remove(source_exit) | ||||
|         copied_state.blocked_connections[self.world.player].update(target_entrance.connected_region.exits) | ||||
|         copied_state.update_reachable_regions(self.world.player) | ||||
|         copied_state.sweep_for_advancements() | ||||
|         # test that at there are newly reachable randomized exits that are ACTUALLY reachable | ||||
|         available_randomized_exits = copied_state.blocked_connections[self.world.player] | ||||
|         for _exit in available_randomized_exits: | ||||
|             if _exit.connected_region: | ||||
|                 continue | ||||
|             # ignore the source exit, and, if coupled, the reverse exit. They're not actually new | ||||
|             if _exit.name == source_exit.name or (self.coupled and _exit.name == target_entrance.name): | ||||
|                 continue | ||||
|             # technically this should be is_valid_source_transition, but that may rely on side effects from | ||||
|             # on_connect, which have not happened here (because we didn't do a real connection, and if we did, we would | ||||
|             # not want them to persist). can_reach is a close enough approximation most of the time. | ||||
|             if _exit.can_reach(copied_state): | ||||
|                 return True | ||||
|         return False | ||||
| 
 | ||||
|     def connect( | ||||
|             self, | ||||
|             source_exit: Entrance, | ||||
|             target_entrance: Entrance | ||||
|     ) -> tuple[list[Entrance], list[Entrance]]: | ||||
|         """ | ||||
|         Connects a source exit to a target entrance in the graph, accounting for coupling | ||||
| 
 | ||||
|         :returns: The newly placed exits and the dummy entrance(s) which were removed from the graph | ||||
|         """ | ||||
|         source_region = source_exit.parent_region | ||||
|         target_region = target_entrance.connected_region | ||||
| 
 | ||||
|         self._connect_one_way(source_exit, target_entrance) | ||||
|         # if we're doing coupled randomization place the reverse transition as well. | ||||
|         if self.coupled and source_exit.randomization_type == EntranceType.TWO_WAY: | ||||
|             for reverse_entrance in source_region.entrances: | ||||
|                 if reverse_entrance.name == source_exit.name: | ||||
|                     if reverse_entrance.parent_region: | ||||
|                         raise EntranceRandomizationError( | ||||
|                             f"Could not perform coupling on {source_exit.name} -> {target_entrance.name} " | ||||
|                             f"because the reverse entrance is already parented to " | ||||
|                             f"{reverse_entrance.parent_region.name}.") | ||||
|                     break | ||||
|             else: | ||||
|                 raise EntranceRandomizationError(f"Two way exit {source_exit.name} had no corresponding entrance in " | ||||
|                                                  f"{source_exit.parent_region.name}") | ||||
|             for reverse_exit in target_region.exits: | ||||
|                 if reverse_exit.name == target_entrance.name: | ||||
|                     if reverse_exit.connected_region: | ||||
|                         raise EntranceRandomizationError( | ||||
|                             f"Could not perform coupling on {source_exit.name} -> {target_entrance.name} " | ||||
|                             f"because the reverse exit is already connected to " | ||||
|                             f"{reverse_exit.connected_region.name}.") | ||||
|                     break | ||||
|             else: | ||||
|                 raise EntranceRandomizationError(f"Two way entrance {target_entrance.name} had no corresponding exit " | ||||
|                                                  f"in {target_region.name}.") | ||||
|             self._connect_one_way(reverse_exit, reverse_entrance) | ||||
|             return [source_exit, reverse_exit], [target_entrance, reverse_entrance] | ||||
|         return [source_exit], [target_entrance] | ||||
| 
 | ||||
| 
 | ||||
| def bake_target_group_lookup(world: World, get_target_groups: Callable[[int], list[int]]) \ | ||||
|         -> dict[int, list[int]]: | ||||
|     """ | ||||
|     Applies a transformation to all known entrance groups on randomizable exists to build a group lookup table. | ||||
| 
 | ||||
|     :param world: Your World instance | ||||
|     :param get_target_groups: Function to call that returns the groups that a specific group type is allowed to | ||||
|                               connect to | ||||
|     """ | ||||
|     unique_groups = { entrance.randomization_group for entrance in world.multiworld.get_entrances(world.player) | ||||
|                       if entrance.parent_region and not entrance.connected_region } | ||||
|     return { group: get_target_groups(group) for group in unique_groups } | ||||
| 
 | ||||
| 
 | ||||
| def disconnect_entrance_for_randomization(entrance: Entrance, target_group: int | None = None) -> None: | ||||
|     """ | ||||
|     Given an entrance in a "vanilla" region graph, splits that entrance to prepare it for randomization | ||||
|     in randomize_entrances. This should be done after setting the type and group of the entrance. | ||||
| 
 | ||||
|     :param entrance: The entrance which will be disconnected in preparation for randomization. | ||||
|     :param target_group: The group to assign to the created ER target. If not specified, the group from | ||||
|                          the original entrance will be copied. | ||||
|     """ | ||||
|     child_region = entrance.connected_region | ||||
|     parent_region = entrance.parent_region | ||||
| 
 | ||||
|     # disconnect the edge | ||||
|     child_region.entrances.remove(entrance) | ||||
|     entrance.connected_region = None | ||||
| 
 | ||||
|     # create the needed ER target | ||||
|     if entrance.randomization_type == EntranceType.TWO_WAY: | ||||
|         # for 2-ways, create a target in the parent region with a matching name to support coupling. | ||||
|         # targets in the child region will be created when the other direction edge is disconnected | ||||
|         target = parent_region.create_er_target(entrance.name) | ||||
|     else: | ||||
|         # for 1-ways, the child region needs a target and coupling/naming is not a concern | ||||
|         target = child_region.create_er_target(child_region.name) | ||||
|     target.randomization_type = entrance.randomization_type | ||||
|     target.randomization_group = target_group or entrance.randomization_group | ||||
| 
 | ||||
| 
 | ||||
| def randomize_entrances( | ||||
|         world: World, | ||||
|         coupled: bool, | ||||
|         target_group_lookup: dict[int, list[int]], | ||||
|         preserve_group_order: bool = False, | ||||
|         er_targets: list[Entrance] | None = None, | ||||
|         exits: list[Entrance] | None = None, | ||||
|         on_connect: Callable[[ERPlacementState, list[Entrance]], None] | None = None | ||||
| ) -> ERPlacementState: | ||||
|     """ | ||||
|     Randomizes Entrances for a single world in the multiworld. | ||||
| 
 | ||||
|     :param world: Your World instance | ||||
|     :param coupled: Whether connected entrances should be coupled to go in both directions | ||||
|     :param target_group_lookup: Map from each group to a list of the groups that it can be connect to. Every group | ||||
|                                 used on an exit must be provided and must map to at least one other group. The default | ||||
|                                 group is 0. | ||||
|     :param preserve_group_order: Whether the order of groupings should be preserved for the returned target_groups | ||||
|     :param er_targets: The list of ER targets (Entrance objects with no parent region) to use for randomization. | ||||
|                        Remember to be deterministic! If not provided, automatically discovers all valid targets | ||||
|                        in your world. | ||||
|     :param exits: The list of exits (Entrance objects with no target region) to use for randomization. | ||||
|                   Remember to be deterministic! If not provided, automatically discovers all valid exits in your world. | ||||
|     :param on_connect: A callback function which allows specifying side effects after a placement is completed | ||||
|                        successfully and the underlying collection state has been updated. | ||||
|     """ | ||||
|     if not world.explicit_indirect_conditions: | ||||
|         raise EntranceRandomizationError("Entrance randomization requires explicit indirect conditions in order " | ||||
|                                          + "to correctly analyze whether dead end regions can be required in logic.") | ||||
| 
 | ||||
|     start_time = time.perf_counter() | ||||
|     er_state = ERPlacementState(world, coupled) | ||||
|     entrance_lookup = EntranceLookup(world.random, coupled) | ||||
|     # similar to fill, skip validity checks on entrances if the game is beatable on minimal accessibility | ||||
|     perform_validity_check = True | ||||
| 
 | ||||
|     def do_placement(source_exit: Entrance, target_entrance: Entrance) -> None: | ||||
|         placed_exits, removed_entrances = er_state.connect(source_exit, target_entrance) | ||||
|         # remove the placed targets from consideration | ||||
|         for entrance in removed_entrances: | ||||
|             entrance_lookup.remove(entrance) | ||||
|         # propagate new connections | ||||
|         er_state.collection_state.update_reachable_regions(world.player) | ||||
|         er_state.collection_state.sweep_for_advancements() | ||||
|         if on_connect: | ||||
|             on_connect(er_state, placed_exits) | ||||
| 
 | ||||
|     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) | ||||
|         for source_exit in placeable_exits: | ||||
|             target_groups = target_group_lookup[source_exit.randomization_group] | ||||
|             for target_entrance in entrance_lookup.get_targets(target_groups, dead_end, preserve_group_order): | ||||
|                 # when requiring new exits, ideally we would like to make it so that every placement increases | ||||
|                 # (or keeps the same number of) reachable exits. The goal is to continue to expand the search space | ||||
|                 # so that we do not crash. In the interest of performance and bias reduction, generally, just checking | ||||
|                 # that we are going to a new region is a good approximation. however, we should take extra care on the | ||||
|                 # 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) | ||||
|                 if exit_requirement_satisfied and source_exit.can_connect_to(target_entrance, dead_end, er_state): | ||||
|                     if (needs_speculative_sweep | ||||
|                             and not er_state.test_speculative_connection(source_exit, target_entrance)): | ||||
|                         continue | ||||
|                     do_placement(source_exit, target_entrance) | ||||
|                     return True | ||||
|         else: | ||||
|             # no source exits had any valid target so this stage is deadlocked. retries may be implemented if early | ||||
|             # deadlocking is a frequent issue. | ||||
|             lookup = entrance_lookup.dead_ends if dead_end else entrance_lookup.others | ||||
| 
 | ||||
|             # if we're in a stage where we're trying to get to new regions, we could also enter this | ||||
|             # branch in a success state (when all regions of the preferred type have been placed, but there are still | ||||
|             # additional unplaced entrances into those regions) | ||||
|             if require_new_exits: | ||||
|                 if all(e.connected_region in er_state.placed_regions for e in lookup): | ||||
|                     return False | ||||
| 
 | ||||
|             # if we're on minimal accessibility and can guarantee the game is beatable, | ||||
|             # we can prevent a failure by bypassing future validity checks. this check may be | ||||
|             # expensive; fortunately we only have to do it once | ||||
|             if perform_validity_check and world.options.accessibility == Accessibility.option_minimal \ | ||||
|                     and world.multiworld.has_beaten_game(er_state.collection_state, world.player): | ||||
|                 # ensure that we have enough locations to place our progression | ||||
|                 accessible_location_count = 0 | ||||
|                 prog_item_count = sum(er_state.collection_state.prog_items[world.player].values()) | ||||
|                 # short-circuit location checking in this case | ||||
|                 if prog_item_count == 0: | ||||
|                     return True | ||||
|                 for region in er_state.placed_regions: | ||||
|                     for loc in region.locations: | ||||
|                         if loc.can_reach(er_state.collection_state): | ||||
|                             accessible_location_count += 1 | ||||
|                             if accessible_location_count >= prog_item_count: | ||||
|                                 perform_validity_check = False | ||||
|                                 # pretend that this was successful to retry the current stage | ||||
|                                 return True | ||||
| 
 | ||||
|             unplaced_entrances = [entrance for region in world.multiworld.get_regions(world.player) | ||||
|                                   for entrance in region.entrances if not entrance.parent_region] | ||||
|             unplaced_exits = [exit_ for region in world.multiworld.get_regions(world.player) | ||||
|                               for exit_ in region.exits if not exit_.connected_region] | ||||
|             entrance_kind = "dead ends" if dead_end else "non-dead ends" | ||||
|             region_access_requirement = "requires" if require_new_exits else "does not require" | ||||
|             raise EntranceRandomizationError( | ||||
|                 f"None of the available entrances are valid targets for the available exits.\n" | ||||
|                 f"Randomization stage is placing {entrance_kind} and {region_access_requirement} " | ||||
|                 f"new region/exit access by default\n" | ||||
|                 f"Placeable entrances: {lookup}\n" | ||||
|                 f"Placeable exits: {placeable_exits}\n" | ||||
|                 f"All unplaced entrances: {unplaced_entrances}\n" | ||||
|                 f"All unplaced exits: {unplaced_exits}") | ||||
| 
 | ||||
|     if not er_targets: | ||||
|         er_targets = sorted([entrance for region in world.multiworld.get_regions(world.player) | ||||
|                              for entrance in region.entrances if not entrance.parent_region], key=lambda x: x.name) | ||||
|     if not exits: | ||||
|         exits = sorted([ex for region in world.multiworld.get_regions(world.player) | ||||
|                         for ex in region.exits if not ex.connected_region], key=lambda x: x.name) | ||||
|     if len(er_targets) != len(exits): | ||||
|         raise EntranceRandomizationError(f"Unable to randomize entrances due to a mismatched count of " | ||||
|                                          f"entrances ({len(er_targets)}) and exits ({len(exits)}.") | ||||
|     for entrance in er_targets: | ||||
|         entrance_lookup.add(entrance) | ||||
| 
 | ||||
|     # place the menu region and connected start region(s) | ||||
|     er_state.collection_state.update_reachable_regions(world.player) | ||||
| 
 | ||||
|     # stage 1 - try to place all the non-dead-end entrances | ||||
|     while entrance_lookup.others: | ||||
|         if not find_pairing(dead_end=False, require_new_exits=True): | ||||
|             break | ||||
|     # stage 2 - try to place all the dead-end entrances | ||||
|     while entrance_lookup.dead_ends: | ||||
|         if not find_pairing(dead_end=True, require_new_exits=True): | ||||
|             break | ||||
|     # stage 3 - all the regions should be placed at this point. We now need to connect dangling edges | ||||
|     # stage 3a - get the rest of the dead ends (e.g. second entrances into already-visited regions) | ||||
|     #            doing this before the non-dead-ends is important to ensure there are enough connections to | ||||
|     #            go around | ||||
|     while entrance_lookup.dead_ends: | ||||
|         find_pairing(dead_end=True, require_new_exits=False) | ||||
|     # stage 3b - tie all the other loose ends connecting visited regions to each other | ||||
|     while entrance_lookup.others: | ||||
|         find_pairing(dead_end=False, require_new_exits=False) | ||||
| 
 | ||||
|     running_time = time.perf_counter() - start_time | ||||
|     if running_time > 1.0: | ||||
|         logging.info(f"Took {running_time:.4f} seconds during entrance randomization for player {world.player}," | ||||
|                      f"named {world.multiworld.player_name[world.player]}") | ||||
| 
 | ||||
|     return er_state | ||||
|  | @ -0,0 +1,387 @@ | |||
| import unittest | ||||
| from enum import IntEnum | ||||
| 
 | ||||
| from BaseClasses import Region, EntranceType, MultiWorld, Entrance | ||||
| from entrance_rando import disconnect_entrance_for_randomization, randomize_entrances, EntranceRandomizationError, \ | ||||
|     ERPlacementState, EntranceLookup, bake_target_group_lookup | ||||
| from Options import Accessibility | ||||
| from test.general import generate_test_multiworld, generate_locations, generate_items | ||||
| from worlds.generic.Rules import set_rule | ||||
| 
 | ||||
| 
 | ||||
| class ERTestGroups(IntEnum): | ||||
|     LEFT = 1 | ||||
|     RIGHT = 2 | ||||
|     TOP = 3 | ||||
|     BOTTOM = 4 | ||||
| 
 | ||||
| 
 | ||||
| directionally_matched_group_lookup = { | ||||
|     ERTestGroups.LEFT: [ERTestGroups.RIGHT], | ||||
|     ERTestGroups.RIGHT: [ERTestGroups.LEFT], | ||||
|     ERTestGroups.TOP: [ERTestGroups.BOTTOM], | ||||
|     ERTestGroups.BOTTOM: [ERTestGroups.TOP] | ||||
| } | ||||
| 
 | ||||
| 
 | ||||
| def generate_entrance_pair(region: Region, name_suffix: str, group: int): | ||||
|     lx = region.create_exit(region.name + name_suffix) | ||||
|     lx.randomization_group = group | ||||
|     lx.randomization_type = EntranceType.TWO_WAY | ||||
|     le = region.create_er_target(region.name + name_suffix) | ||||
|     le.randomization_group = group | ||||
|     le.randomization_type = EntranceType.TWO_WAY | ||||
| 
 | ||||
| 
 | ||||
| def generate_disconnected_region_grid(multiworld: MultiWorld, grid_side_length: int, region_size: int = 0, | ||||
|                                       region_type: type[Region] = Region): | ||||
|     """ | ||||
|     Generates a grid-like region structure for ER testing, where menu is connected to the top-left region, and each | ||||
|     region "in vanilla" has 2 2-way exits going either down or to the right, until reaching the goal region in the | ||||
|     bottom right | ||||
|     """ | ||||
|     for row in range(grid_side_length): | ||||
|         for col in range(grid_side_length): | ||||
|             index = row * grid_side_length + col | ||||
|             name = f"region{index}" | ||||
|             region = region_type(name, 1, multiworld) | ||||
|             multiworld.regions.append(region) | ||||
|             generate_locations(region_size, 1, region=region, tag=f"_{name}") | ||||
| 
 | ||||
|             if row == 0 and col == 0: | ||||
|                 multiworld.get_region("Menu", 1).connect(region) | ||||
|             if col != 0: | ||||
|                 generate_entrance_pair(region, "_left", ERTestGroups.LEFT) | ||||
|             if col != grid_side_length - 1: | ||||
|                 generate_entrance_pair(region, "_right", ERTestGroups.RIGHT) | ||||
|             if row != 0: | ||||
|                 generate_entrance_pair(region, "_top", ERTestGroups.TOP) | ||||
|             if row != grid_side_length - 1: | ||||
|                 generate_entrance_pair(region, "_bottom", ERTestGroups.BOTTOM) | ||||
| 
 | ||||
| 
 | ||||
| class TestEntranceLookup(unittest.TestCase): | ||||
|     def test_shuffled_targets(self): | ||||
|         """tests that get_targets shuffles targets between groups when requested""" | ||||
|         multiworld = generate_test_multiworld() | ||||
|         generate_disconnected_region_grid(multiworld, 5) | ||||
| 
 | ||||
|         lookup = EntranceLookup(multiworld.worlds[1].random, coupled=True) | ||||
|         er_targets = [entrance for region in multiworld.get_regions(1) | ||||
|                       for entrance in region.entrances if not entrance.parent_region] | ||||
|         for entrance in er_targets: | ||||
|             lookup.add(entrance) | ||||
| 
 | ||||
|         retrieved_targets = lookup.get_targets([ERTestGroups.TOP, ERTestGroups.BOTTOM], | ||||
|                                                False, False) | ||||
|         prev = None | ||||
|         group_order = [prev := group.randomization_group for group in retrieved_targets | ||||
|                        if prev != group.randomization_group] | ||||
|         # technically possible that group order may not be shuffled, by some small chance, on some seeds. but generally | ||||
|         # a shuffled list should alternate more frequently which is the desired behavior here | ||||
|         self.assertGreater(len(group_order), 2) | ||||
| 
 | ||||
| 
 | ||||
|     def test_ordered_targets(self): | ||||
|         """tests that get_targets does not shuffle targets between groups when requested""" | ||||
|         multiworld = generate_test_multiworld() | ||||
|         generate_disconnected_region_grid(multiworld, 5) | ||||
| 
 | ||||
|         lookup = EntranceLookup(multiworld.worlds[1].random, coupled=True) | ||||
|         er_targets = [entrance for region in multiworld.get_regions(1) | ||||
|                       for entrance in region.entrances if not entrance.parent_region] | ||||
|         for entrance in er_targets: | ||||
|             lookup.add(entrance) | ||||
| 
 | ||||
|         retrieved_targets = lookup.get_targets([ERTestGroups.TOP, ERTestGroups.BOTTOM], | ||||
|                                                False, True) | ||||
|         prev = None | ||||
|         group_order = [prev := group.randomization_group for group in retrieved_targets if prev != group.randomization_group] | ||||
|         self.assertEqual([ERTestGroups.TOP, ERTestGroups.BOTTOM], group_order) | ||||
| 
 | ||||
| 
 | ||||
| class TestBakeTargetGroupLookup(unittest.TestCase): | ||||
|     def test_lookup_generation(self): | ||||
|         multiworld = generate_test_multiworld() | ||||
|         generate_disconnected_region_grid(multiworld, 5) | ||||
|         world = multiworld.worlds[1] | ||||
|         expected = { | ||||
|             ERTestGroups.LEFT: [-ERTestGroups.LEFT], | ||||
|             ERTestGroups.RIGHT: [-ERTestGroups.RIGHT], | ||||
|             ERTestGroups.TOP: [-ERTestGroups.TOP], | ||||
|             ERTestGroups.BOTTOM: [-ERTestGroups.BOTTOM] | ||||
|         } | ||||
|         actual = bake_target_group_lookup(world, lambda g: [-g]) | ||||
|         self.assertEqual(expected, actual) | ||||
| 
 | ||||
| 
 | ||||
| class TestDisconnectForRandomization(unittest.TestCase): | ||||
|     def test_disconnect_default_2way(self): | ||||
|         multiworld = generate_test_multiworld() | ||||
|         r1 = Region("r1", 1, multiworld) | ||||
|         r2 = Region("r2", 1, multiworld) | ||||
|         e = r1.create_exit("e") | ||||
|         e.randomization_type = EntranceType.TWO_WAY | ||||
|         e.randomization_group = 1 | ||||
|         e.connect(r2) | ||||
| 
 | ||||
|         disconnect_entrance_for_randomization(e) | ||||
| 
 | ||||
|         self.assertIsNone(e.connected_region) | ||||
|         self.assertEqual([], r2.entrances) | ||||
| 
 | ||||
|         self.assertEqual(1, len(r1.exits)) | ||||
|         self.assertEqual(e, r1.exits[0]) | ||||
| 
 | ||||
|         self.assertEqual(1, len(r1.entrances)) | ||||
|         self.assertIsNone(r1.entrances[0].parent_region) | ||||
|         self.assertEqual("e", r1.entrances[0].name) | ||||
|         self.assertEqual(EntranceType.TWO_WAY, r1.entrances[0].randomization_type) | ||||
|         self.assertEqual(1, r1.entrances[0].randomization_group) | ||||
| 
 | ||||
|     def test_disconnect_default_1way(self): | ||||
|         multiworld = generate_test_multiworld() | ||||
|         r1 = Region("r1", 1, multiworld) | ||||
|         r2 = Region("r2", 1, multiworld) | ||||
|         e = r1.create_exit("e") | ||||
|         e.randomization_type = EntranceType.ONE_WAY | ||||
|         e.randomization_group = 1 | ||||
|         e.connect(r2) | ||||
| 
 | ||||
|         disconnect_entrance_for_randomization(e) | ||||
| 
 | ||||
|         self.assertIsNone(e.connected_region) | ||||
|         self.assertEqual([], r1.entrances) | ||||
| 
 | ||||
|         self.assertEqual(1, len(r1.exits)) | ||||
|         self.assertEqual(e, r1.exits[0]) | ||||
| 
 | ||||
|         self.assertEqual(1, len(r2.entrances)) | ||||
|         self.assertIsNone(r2.entrances[0].parent_region) | ||||
|         self.assertEqual("r2", r2.entrances[0].name) | ||||
|         self.assertEqual(EntranceType.ONE_WAY, r2.entrances[0].randomization_type) | ||||
|         self.assertEqual(1, r2.entrances[0].randomization_group) | ||||
| 
 | ||||
|     def test_disconnect_uses_alternate_group(self): | ||||
|         multiworld = generate_test_multiworld() | ||||
|         r1 = Region("r1", 1, multiworld) | ||||
|         r2 = Region("r2", 1, multiworld) | ||||
|         e = r1.create_exit("e") | ||||
|         e.randomization_type = EntranceType.ONE_WAY | ||||
|         e.randomization_group = 1 | ||||
|         e.connect(r2) | ||||
| 
 | ||||
|         disconnect_entrance_for_randomization(e, 2) | ||||
| 
 | ||||
|         self.assertIsNone(e.connected_region) | ||||
|         self.assertEqual([], r1.entrances) | ||||
| 
 | ||||
|         self.assertEqual(1, len(r1.exits)) | ||||
|         self.assertEqual(e, r1.exits[0]) | ||||
| 
 | ||||
|         self.assertEqual(1, len(r2.entrances)) | ||||
|         self.assertIsNone(r2.entrances[0].parent_region) | ||||
|         self.assertEqual("r2", r2.entrances[0].name) | ||||
|         self.assertEqual(EntranceType.ONE_WAY, r2.entrances[0].randomization_type) | ||||
|         self.assertEqual(2, r2.entrances[0].randomization_group) | ||||
| 
 | ||||
| 
 | ||||
| class TestRandomizeEntrances(unittest.TestCase): | ||||
|     def test_determinism(self): | ||||
|         """tests that the same output is produced for the same input""" | ||||
|         multiworld1 = generate_test_multiworld() | ||||
|         generate_disconnected_region_grid(multiworld1, 5) | ||||
|         multiworld2 = generate_test_multiworld() | ||||
|         generate_disconnected_region_grid(multiworld2, 5) | ||||
| 
 | ||||
|         result1 = randomize_entrances(multiworld1.worlds[1], False, directionally_matched_group_lookup) | ||||
|         result2 = randomize_entrances(multiworld2.worlds[1], False, directionally_matched_group_lookup) | ||||
|         self.assertEqual(result1.pairings, result2.pairings) | ||||
|         for e1, e2 in zip(result1.placements, result2.placements): | ||||
|             self.assertEqual(e1.name, e2.name) | ||||
|             self.assertEqual(e1.parent_region.name, e1.parent_region.name) | ||||
|             self.assertEqual(e1.connected_region.name, e2.connected_region.name) | ||||
| 
 | ||||
|     def test_all_entrances_placed(self): | ||||
|         """tests that all entrances and exits were placed, all regions are connected, and no dangling edges exist""" | ||||
|         multiworld = generate_test_multiworld() | ||||
|         generate_disconnected_region_grid(multiworld, 5) | ||||
| 
 | ||||
|         result = randomize_entrances(multiworld.worlds[1], False, directionally_matched_group_lookup) | ||||
| 
 | ||||
|         self.assertEqual([], [entrance for region in multiworld.get_regions() | ||||
|                               for entrance in region.entrances if not entrance.parent_region]) | ||||
|         self.assertEqual([], [exit_ for region in multiworld.get_regions() | ||||
|                               for exit_ in region.exits if not exit_.connected_region]) | ||||
|         # 5x5 grid + menu | ||||
|         self.assertEqual(26, len(result.placed_regions)) | ||||
|         self.assertEqual(80, len(result.pairings)) | ||||
|         self.assertEqual(80, len(result.placements)) | ||||
| 
 | ||||
|     def test_coupling(self): | ||||
|         """tests that in coupled mode, all 2 way transitions have an inverse""" | ||||
|         multiworld = generate_test_multiworld() | ||||
|         generate_disconnected_region_grid(multiworld, 5) | ||||
|         seen_placement_count = 0 | ||||
| 
 | ||||
|         def verify_coupled(_: ERPlacementState, placed_entrances: list[Entrance]): | ||||
|             nonlocal seen_placement_count | ||||
|             seen_placement_count += len(placed_entrances) | ||||
|             self.assertEqual(2, len(placed_entrances)) | ||||
|             self.assertEqual(placed_entrances[0].parent_region, placed_entrances[1].connected_region) | ||||
|             self.assertEqual(placed_entrances[1].parent_region, placed_entrances[0].connected_region) | ||||
| 
 | ||||
|         result = randomize_entrances(multiworld.worlds[1], True, directionally_matched_group_lookup, | ||||
|                                      on_connect=verify_coupled) | ||||
|         # 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(self): | ||||
|         """tests that in uncoupled mode, no transitions have an (intentional) inverse""" | ||||
|         multiworld = generate_test_multiworld() | ||||
|         generate_disconnected_region_grid(multiworld, 5) | ||||
|         seen_placement_count = 0 | ||||
| 
 | ||||
|         def verify_uncoupled(state: ERPlacementState, placed_entrances: list[Entrance]): | ||||
|             nonlocal seen_placement_count | ||||
|             seen_placement_count += len(placed_entrances) | ||||
|             self.assertEqual(1, len(placed_entrances)) | ||||
| 
 | ||||
|         result = randomize_entrances(multiworld.worlds[1], False, directionally_matched_group_lookup, | ||||
|                                      on_connect=verify_uncoupled) | ||||
|         # 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_oneway_twoway_pairing(self): | ||||
|         """tests that 1 ways are only paired to 1 ways and 2 ways are only paired to 2 ways""" | ||||
|         multiworld = generate_test_multiworld() | ||||
|         generate_disconnected_region_grid(multiworld, 5) | ||||
|         region26 = Region("region26", 1, multiworld) | ||||
|         multiworld.regions.append(region26) | ||||
|         for index, region in enumerate(["region4", "region20", "region24"]): | ||||
|             x = multiworld.get_region(region, 1).create_exit(f"{region}_bottom_1way") | ||||
|             x.randomization_type = EntranceType.ONE_WAY | ||||
|             x.randomization_group = ERTestGroups.BOTTOM | ||||
|             e = region26.create_er_target(f"region26_top_1way{index}") | ||||
|             e.randomization_type = EntranceType.ONE_WAY | ||||
|             e.randomization_group = ERTestGroups.TOP | ||||
| 
 | ||||
|         result = randomize_entrances(multiworld.worlds[1], False, directionally_matched_group_lookup) | ||||
|         for exit_name, entrance_name in result.pairings: | ||||
|             # we have labeled our entrances in such a way that all the 1 way entrances have 1way in the name, | ||||
|             # so test for that since the ER target will have been discarded | ||||
|             if "1way" in exit_name: | ||||
|                 self.assertIn("1way", entrance_name) | ||||
| 
 | ||||
|     def test_group_constraints_satisfied(self): | ||||
|         """tests that all grouping constraints are satisfied""" | ||||
|         multiworld = generate_test_multiworld() | ||||
|         generate_disconnected_region_grid(multiworld, 5) | ||||
| 
 | ||||
|         result = randomize_entrances(multiworld.worlds[1], False, directionally_matched_group_lookup) | ||||
|         for exit_name, entrance_name in result.pairings: | ||||
|             # we have labeled our entrances in such a way that all the entrances contain their group in the name | ||||
|             # so test for that since the ER target will have been discarded | ||||
|             if "top" in exit_name: | ||||
|                 self.assertIn("bottom", entrance_name) | ||||
|             if "bottom" in exit_name: | ||||
|                 self.assertIn("top", entrance_name) | ||||
|             if "left" in exit_name: | ||||
|                 self.assertIn("right", entrance_name) | ||||
|             if "right" in exit_name: | ||||
|                 self.assertIn("left", entrance_name) | ||||
| 
 | ||||
|     def test_minimal_entrance_rando(self): | ||||
|         """tests that entrance randomization can complete with minimal accessibility and unreachable exits""" | ||||
|         multiworld = generate_test_multiworld() | ||||
|         multiworld.worlds[1].options.accessibility = Accessibility.from_any(Accessibility.option_minimal) | ||||
|         multiworld.completion_condition[1] = lambda state: state.can_reach("region24", player=1) | ||||
|         generate_disconnected_region_grid(multiworld, 5, 1) | ||||
|         prog_items = generate_items(10, 1, True) | ||||
|         multiworld.itempool += prog_items | ||||
|         filler_items = generate_items(15, 1, False) | ||||
|         multiworld.itempool += filler_items | ||||
|         e = multiworld.get_entrance("region1_right", 1) | ||||
|         set_rule(e, lambda state: False) | ||||
| 
 | ||||
|         randomize_entrances(multiworld.worlds[1], False, directionally_matched_group_lookup) | ||||
| 
 | ||||
|         self.assertEqual([], [entrance for region in multiworld.get_regions() | ||||
|                               for entrance in region.entrances if not entrance.parent_region]) | ||||
|         self.assertEqual([], [exit_ for region in multiworld.get_regions() | ||||
|                               for exit_ in region.exits if not exit_.connected_region]) | ||||
| 
 | ||||
|     def test_restrictive_region_requirement_does_not_fail(self): | ||||
|         multiworld = generate_test_multiworld() | ||||
|         generate_disconnected_region_grid(multiworld, 2, 1) | ||||
| 
 | ||||
|         region = Region("region4", 1, multiworld) | ||||
|         multiworld.regions.append(region) | ||||
|         generate_entrance_pair(multiworld.get_region("region0", 1), "_right2", ERTestGroups.RIGHT) | ||||
|         generate_entrance_pair(region, "_left", ERTestGroups.LEFT) | ||||
| 
 | ||||
|         blocked_exits = ["region1_left", "region1_bottom", | ||||
|                          "region2_top", "region2_right", | ||||
|                          "region3_left", "region3_top"] | ||||
|         for exit_name in blocked_exits: | ||||
|             blocked_exit = multiworld.get_entrance(exit_name, 1) | ||||
|             blocked_exit.access_rule = lambda state: state.can_reach_region("region4", 1) | ||||
|             multiworld.register_indirect_condition(region, blocked_exit) | ||||
| 
 | ||||
|         result = randomize_entrances(multiworld.worlds[1], True, directionally_matched_group_lookup) | ||||
|         # verifying that we did in fact place region3 adjacent to region0 to unblock all the other connections | ||||
|         # (and implicitly, that ER didn't fail) | ||||
|         self.assertTrue(("region0_right", "region4_left") in result.pairings | ||||
|                         or ("region0_right2", "region4_left") in result.pairings) | ||||
| 
 | ||||
|     def test_fails_when_mismatched_entrance_and_exit_count(self): | ||||
|         """tests that entrance randomization fast-fails if the input exit and entrance count do not match""" | ||||
|         multiworld = generate_test_multiworld() | ||||
|         generate_disconnected_region_grid(multiworld, 5) | ||||
|         multiworld.get_region("region1", 1).create_exit("extra") | ||||
| 
 | ||||
|         self.assertRaises(EntranceRandomizationError, randomize_entrances, multiworld.worlds[1], False, | ||||
|                           directionally_matched_group_lookup) | ||||
| 
 | ||||
|     def test_fails_when_some_unreachable_exit(self): | ||||
|         """tests that entrance randomization fails if an exit is never reachable (non-minimal accessibility)""" | ||||
|         multiworld = generate_test_multiworld() | ||||
|         generate_disconnected_region_grid(multiworld, 5) | ||||
|         e = multiworld.get_entrance("region1_right", 1) | ||||
|         set_rule(e, lambda state: False) | ||||
| 
 | ||||
|         self.assertRaises(EntranceRandomizationError, randomize_entrances, multiworld.worlds[1], False, | ||||
|                           directionally_matched_group_lookup) | ||||
| 
 | ||||
|     def test_fails_when_some_unconnectable_exit(self): | ||||
|         """tests that entrance randomization fails if an exit can't be made into a valid placement (non-minimal)""" | ||||
|         class CustomEntrance(Entrance): | ||||
|             def can_connect_to(self, other: Entrance, dead_end: bool, er_state: "ERPlacementState") -> bool: | ||||
|                 if other.name == "region1_right": | ||||
|                     return False | ||||
| 
 | ||||
|         class CustomRegion(Region): | ||||
|             entrance_type = CustomEntrance | ||||
| 
 | ||||
|         multiworld = generate_test_multiworld() | ||||
|         generate_disconnected_region_grid(multiworld, 5, region_type=CustomRegion) | ||||
| 
 | ||||
|         self.assertRaises(EntranceRandomizationError, randomize_entrances, multiworld.worlds[1], False, | ||||
|                           directionally_matched_group_lookup) | ||||
| 
 | ||||
|     def test_minimal_er_fails_when_not_enough_locations_to_fit_progression(self): | ||||
|         """ | ||||
|         tests that entrance randomization fails in minimal accessibility if there are not enough locations | ||||
|         available to place all progression items locally | ||||
|         """ | ||||
|         multiworld = generate_test_multiworld() | ||||
|         multiworld.worlds[1].options.accessibility = Accessibility.from_any(Accessibility.option_minimal) | ||||
|         multiworld.completion_condition[1] = lambda state: state.can_reach("region24", player=1) | ||||
|         generate_disconnected_region_grid(multiworld, 5, 1) | ||||
|         prog_items = generate_items(30, 1, True) | ||||
|         multiworld.itempool += prog_items | ||||
|         e = multiworld.get_entrance("region1_right", 1) | ||||
|         set_rule(e, lambda state: False) | ||||
| 
 | ||||
|         self.assertRaises(EntranceRandomizationError, randomize_entrances, multiworld.worlds[1], False, | ||||
|                           directionally_matched_group_lookup) | ||||
		Loading…
	
		Reference in New Issue