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