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:
BadMagic100 2024-12-27 12:04:02 -08:00 committed by GitHub
parent b9642a482f
commit 218f28912e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 1324 additions and 6 deletions

View File

@ -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]:
"""

View File

@ -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.

447
entrance_rando.py Normal file
View File

@ -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

View File

@ -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)