From 218f28912e0e120e4cf91a63aba627e91cc451c5 Mon Sep 17 00:00:00 2001 From: BadMagic100 Date: Fri, 27 Dec 2024 12:04:02 -0800 Subject: [PATCH] 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 * 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 * 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 Co-authored-by: alwaysintreble Co-authored-by: Doug Hoskisson --- BaseClasses.py | 66 +++- docs/entrance randomization.md | 430 ++++++++++++++++++++++++++ entrance_rando.py | 447 ++++++++++++++++++++++++++++ test/general/test_entrance_rando.py | 387 ++++++++++++++++++++++++ 4 files changed, 1324 insertions(+), 6 deletions(-) create mode 100644 docs/entrance randomization.md create mode 100644 entrance_rando.py create mode 100644 test/general/test_entrance_rando.py diff --git a/BaseClasses.py b/BaseClasses.py index e5c187b9..e19ba5f7 100644 --- a/BaseClasses.py +++ b/BaseClasses.py @@ -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]: """ diff --git a/docs/entrance randomization.md b/docs/entrance randomization.md new file mode 100644 index 00000000..9e3e281b --- /dev/null +++ b/docs/entrance randomization.md @@ -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. \ No newline at end of file diff --git a/entrance_rando.py b/entrance_rando.py new file mode 100644 index 00000000..5aa16fa0 --- /dev/null +++ b/entrance_rando.py @@ -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 diff --git a/test/general/test_entrance_rando.py b/test/general/test_entrance_rando.py new file mode 100644 index 00000000..efbcf7df --- /dev/null +++ b/test/general/test_entrance_rando.py @@ -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)