Skip to content

Commit 9f17b59

Browse files
committed
Prevent early loops in region graph, improve reusability of ER stage code
1 parent e78b804 commit 9f17b59

File tree

1 file changed

+77
-82
lines changed

1 file changed

+77
-82
lines changed

EntranceRando.py

+77-82
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import functools
2+
import itertools
23
import queue
34
import random
45
from collections import deque
@@ -18,6 +19,18 @@ def __init__(self):
1819
def __bool__(self):
1920
return bool(self._lookup)
2021

22+
def __getitem__(self, item: str) -> List[Entrance]:
23+
return self._lookup.get(item, [])
24+
25+
def __iter__(self):
26+
return itertools.chain.from_iterable(self._lookup.values())
27+
28+
def __str__(self):
29+
return str(self._lookup)
30+
31+
def __repr__(self):
32+
return self.__str__()
33+
2134
def add(self, entrance: Entrance) -> None:
2235
group = self._lookup.setdefault(entrance.er_group, [])
2336
group.append(entrance)
@@ -28,9 +41,6 @@ def remove(self, entrance: Entrance) -> None:
2841
if not group:
2942
del self._lookup[entrance.er_group]
3043

31-
def __getitem__(self, item: str) -> List[Entrance]:
32-
return self._lookup.get(item, [])
33-
3444
rng: random.Random
3545
dead_ends: GroupLookup
3646
others: GroupLookup
@@ -272,13 +282,49 @@ def randomize_entrances(
272282
2. All placeholder entrances to regions will have been removed.
273283
"""
274284
state = ERPlacementState(world, coupled)
275-
# exits which had no candidate exits found in the non-dead-end stage.
276-
# they will never be placeable until we start placing dead ends so
277-
# hold them here and stop trying.
278-
unplaceable_exits: List[Entrance] = []
279285

280286
entrance_lookup = EntranceLookup(rng)
281287

288+
def find_pairing(dead_end: bool, require_new_regions: bool) -> Optional[Tuple[Entrance, Entrance]]:
289+
for source_exit in state._placeable_exits:
290+
target_groups = get_target_groups(source_exit.er_group)
291+
# anything can connect to the default group - if people don't like it the fix is to
292+
# assign a non-default group
293+
if 'Default' not in target_groups:
294+
target_groups.append('Default')
295+
for target_entrance in entrance_lookup.get_targets(target_groups, dead_end, preserve_group_order):
296+
# todo - requiring new regions is a proxy for requiring new entrances to be unlocked, which is
297+
# not quite full fidelity so we may need to revisit this in the future
298+
region_requirement_satisfied = (not require_new_regions
299+
or target_entrance.connected_region not in state.placed_regions)
300+
if region_requirement_satisfied and source_exit.can_connect_to(target_entrance, state):
301+
return source_exit, target_entrance
302+
else:
303+
# no source exits had any valid target so this stage is deadlocked. swap may be implemented if early
304+
# deadlocking is a frequent issue.
305+
lookup = entrance_lookup.dead_ends if dead_end else entrance_lookup.others
306+
307+
# if we're in a stage where we're trying to get to new regions, we could also enter this
308+
# branch in a success state (when all regions of the preferred type have been placed, but there are still
309+
# additional unplaced entrances into those regions)
310+
if require_new_regions:
311+
if all(e.connected_region in state.placed_regions for e in lookup):
312+
return None
313+
314+
raise Exception(f"None of the available entrances had valid targets.\n"
315+
f"Available exits: {state._placeable_exits}\n"
316+
f"Available entrances: {lookup}")
317+
318+
def do_placement(source_exit: Entrance, target_entrance: Entrance):
319+
removed_entrances = state.connect(source_exit, target_entrance)
320+
# remove the paired items from consideration
321+
state._placeable_exits.remove(source_exit)
322+
for entrance in removed_entrances:
323+
entrance_lookup.remove(entrance)
324+
# place and propagate
325+
state.place(target_entrance)
326+
state.sweep_pending_exits()
327+
282328
for region in regions:
283329
for entrance in region.entrances:
284330
if not entrance.parent_region:
@@ -287,84 +333,33 @@ def randomize_entrances(
287333
# place the menu region and connected start region(s)
288334
state.place(world.multiworld.get_region('Menu', world.player))
289335

290-
while state.has_placeable_exits() and entrance_lookup.others:
336+
# stage 1 - try to place all the non-dead-end entrances
337+
while entrance_lookup.others:
291338
# todo - this access to placeable_exits is ugly
292-
# todo - this should iterate placeable exits instead of immediately
293-
# giving up; can_connect_to may be stateful
294-
# todo - this doesn't prioritize placing new rooms like the original did;
295-
# that's problematic because early loops would lead to failures
296339
# this is needed to reduce bias; otherwise newer exits are prioritized
297340
rng.shuffle(state._placeable_exits)
298-
source_exit = state._placeable_exits.pop()
299-
300-
target_groups = get_target_groups(source_exit.er_group)
301-
# anything can connect to the default group - if people don't like it the fix is to
302-
# assign a non-default group
303-
if 'Default' not in target_groups:
304-
target_groups.append('Default')
305-
for target_entrance in entrance_lookup.get_targets(target_groups, False, preserve_group_order):
306-
if source_exit.can_connect_to(target_entrance, state):
307-
# we found a valid, connectable target entrance. We'll connect it in a moment
308-
break
309-
else:
310-
# there were no valid non-dead-end targets for this source, so give up on it for now
311-
unplaceable_exits.append(source_exit)
312-
continue
313-
314-
# place the new pairing. it is important to do connections first so that can_reach will function.
315-
removed_entrances = state.connect(source_exit, target_entrance)
316-
state.place(target_entrance)
317-
state.sweep_pending_exits()
318-
# remove paired entrances from the lookup so they don't get re-randomized
319-
for entrance in removed_entrances:
320-
entrance_lookup.remove(entrance)
321-
322-
if entrance_lookup.others:
323-
# this is generally an unsalvagable failure, we would need to implement swap earlier in the process
324-
# to prevent it. A stateful can_connect_to implementation may make this recoverable in some worlds as well.
325-
# why? there are no placeable exits, which means none of them have valid targets, and conversely
326-
# none of the existing targets can pair to the existing sources. Since dead ends will never add new sources
327-
# this means the current targets can never be paired (in most cases)
328-
# todo - investigate ways to prevent this case
329-
print("Unable to place all non-dead-end entrances with available source exits")
330-
return state # this short circuts the exception for testing purposes in order to see how far ER got.
331-
raise Exception("Unable to place all non-dead-end entrances with available source exits")
332-
333-
# anything we couldn't place before might be placeable now
334-
state._placeable_exits.extend(unplaceable_exits)
335-
unplaceable_exits.clear()
336-
337-
# repeat the above but try to place dead ends
338-
while state.has_placeable_exits() and entrance_lookup.dead_ends:
341+
pairing = find_pairing(False, True)
342+
if not pairing:
343+
break
344+
do_placement(*pairing)
345+
# stage 2 - try to place all the dead-end entrances
346+
while entrance_lookup.dead_ends:
339347
rng.shuffle(state._placeable_exits)
340-
source_exit = state._placeable_exits.pop()
341-
342-
target_groups = get_target_groups(source_exit.er_group)
343-
# anything can connect to the default group - if people don't like it the fix is to
344-
# assign a non-default group
345-
if 'Default' not in target_groups:
346-
target_groups.append('Default')
347-
for target_entrance in entrance_lookup.get_targets(target_groups, True, preserve_group_order):
348-
if source_exit.can_connect_to(target_entrance, state):
349-
# we found a valid, connectable target entrance. We'll connect it in a moment
350-
break
351-
else:
352-
# there were no valid dead-end targets for this source, so give up
353-
# todo - similar to above we should try and prevent this state.
354-
# also it can_connect_to may be stateful.
355-
print("Unable to place all dead-end entrances with available source exits")
356-
return state # this short circuts the exception for testing purposes in order to see how far ER got.
357-
raise Exception("Unable to place all dead-end entrances with available source exits")
358-
359-
# place the new pairing. it is important to do connections first so that can_reach will function.
360-
removed_entrances = state.connect(source_exit, target_entrance)
361-
state.place(target_entrance)
362-
state.sweep_pending_exits()
363-
# remove paired entrances from the lookup so they don't get re-randomized
364-
for entrance in removed_entrances:
365-
entrance_lookup.remove(entrance)
366-
367-
if state.has_placeable_exits():
368-
raise Exception("There are more exits than entrances")
348+
pairing = find_pairing(True, True)
349+
if not pairing:
350+
break
351+
do_placement(*pairing)
352+
# todo - stages 3 and 4 should ideally run "together" ie without respect to dead-endedness
353+
# as we are just trying to tie off loose ends rather than get you somewhere new
354+
# stage 3 - connect any dangling entrances that remain
355+
while entrance_lookup.others:
356+
rng.shuffle(state._placeable_exits)
357+
pairing = find_pairing(False, False)
358+
do_placement(*pairing)
359+
# stage 4 - last chance for dead ends
360+
while entrance_lookup.dead_ends:
361+
rng.shuffle(state._placeable_exits)
362+
pairing = find_pairing(True, False)
363+
do_placement(*pairing)
369364

370365
return state

0 commit comments

Comments
 (0)