def balance_multiworld_progression(world: MultiWorld) -> None: # A system to reduce situations where players have no checks remaining, popularly known as "BK mode." # Overall progression balancing algorithm: # Gather up all locations in a sphere. # Define a threshold value based on the player with the most available locations. # If other players are below the threshold value, swap progression in this sphere into earlier spheres, # which gives more locations available by this sphere. balanceable_players: typing.Dict[int, float] = { player: world.progression_balancing[player] / 100 for player in world.player_ids if world.progression_balancing[player] > 0 } if not balanceable_players: logging.info('Skipping multiworld progression balancing.') else: logging.info( f'Balancing multiworld progression for {len(balanceable_players)} Players.' ) logging.debug(balanceable_players) state: CollectionState = CollectionState(world) checked_locations: typing.Set[Location] = set() unchecked_locations: typing.Set[Location] = set(world.get_locations()) reachable_locations_count: typing.Dict[int, int] = { player: 0 for player in world.player_ids if len(world.get_filled_locations(player)) != 0 } total_locations_count: typing.Counter[int] = Counter( location.player for location in world.get_locations() if not location.locked) balanceable_players = { player: balanceable_players[player] for player in balanceable_players if total_locations_count[player] } sphere_num: int = 1 moved_item_count: int = 0 def get_sphere_locations( sphere_state: CollectionState, locations: typing.Set[Location]) -> typing.Set[Location]: sphere_state.sweep_for_events(key_only=True, locations=locations) return {loc for loc in locations if sphere_state.can_reach(loc)} def item_percentage(player: int, num: int) -> float: return num / total_locations_count[player] while True: # Gather non-locked locations. # This ensures that only shuffled locations get counted for progression balancing, # i.e. the items the players will be checking. sphere_locations = get_sphere_locations(state, unchecked_locations) for location in sphere_locations: unchecked_locations.remove(location) if not location.locked: reachable_locations_count[location.player] += 1 logging.debug(f"Sphere {sphere_num}") logging.debug(f"Reachable locations: {reachable_locations_count}") debug_percentages = { player: round(item_percentage(player, num), 2) for player, num in reachable_locations_count.items() } logging.debug(f"Reachable percentages: {debug_percentages}\n") sphere_num += 1 if checked_locations: max_percentage = max( map( lambda p: item_percentage(p, reachable_locations_count[ p]), reachable_locations_count)) threshold_percentages = { player: max_percentage * balanceable_players[player] for player in balanceable_players } logging.debug(f"Thresholds: {threshold_percentages}") balancing_players = { player for player, reachables in reachable_locations_count.items() if (player in threshold_percentages and item_percentage( player, reachables) < threshold_percentages[player]) } if balancing_players: balancing_state = state.copy() balancing_unchecked_locations = unchecked_locations.copy() balancing_reachables = reachable_locations_count.copy() balancing_sphere = sphere_locations.copy() candidate_items: typing.Dict[ int, typing.Set[Location]] = collections.defaultdict(set) while True: # Check locations in the current sphere and gather progression items to swap earlier for location in balancing_sphere: if location.event: balancing_state.collect( location.item, True, location) player = location.item.player # only replace items that end up in another player's world if (not location.locked and not location.item. skip_in_prog_balancing and player in balancing_players and location.player != player and location.progress_type != LocationProgressType.PRIORITY): candidate_items[player].add(location) logging.debug( f"Candidate item: {location.name}, {location.item.name}" ) balancing_sphere = get_sphere_locations( balancing_state, balancing_unchecked_locations) for location in balancing_sphere: balancing_unchecked_locations.remove(location) if not location.locked: balancing_reachables[location.player] += 1 if world.has_beaten_game(balancing_state) or all( item_percentage(player, reachables) >= threshold_percentages[player] for player, reachables in balancing_reachables. items() if player in threshold_percentages): break elif not balancing_sphere: raise RuntimeError( 'Not all required items reachable. Something went terribly wrong here.' ) # Gather a set of locations which we can swap items into unlocked_locations: typing.Dict[ int, typing.Set[Location]] = collections.defaultdict(set) for l in unchecked_locations: if l not in balancing_unchecked_locations: unlocked_locations[l.player].add(l) items_to_replace: typing.List[Location] = [] for player in balancing_players: locations_to_test = unlocked_locations[player] items_to_test = list(candidate_items[player]) items_to_test.sort() world.random.shuffle(items_to_test) while items_to_test: testing = items_to_test.pop() reducing_state = state.copy() for location in itertools.chain( (l for l in items_to_replace if l.item.player == player), items_to_test): reducing_state.collect(location.item, True, location) reducing_state.sweep_for_events( locations=locations_to_test) if world.has_beaten_game(balancing_state): if not world.has_beaten_game(reducing_state): items_to_replace.append(testing) else: reduced_sphere = get_sphere_locations( reducing_state, locations_to_test) p = item_percentage( player, reachable_locations_count[player] + len(reduced_sphere)) if p < threshold_percentages[player]: items_to_replace.append(testing) replaced_items = False # sort then shuffle to maintain deterministic behaviour, # while allowing use of set for better algorithm growth behaviour elsewhere replacement_locations = sorted( l for l in checked_locations if not l.event and not l.locked) world.random.shuffle(replacement_locations) items_to_replace.sort() world.random.shuffle(items_to_replace) # Start swapping items. Since we swap into earlier spheres, no need for accessibility checks. while replacement_locations and items_to_replace: old_location = items_to_replace.pop() for new_location in replacement_locations: if new_location.can_fill(state, old_location.item, False) and \ old_location.can_fill(state, new_location.item, False): replacement_locations.remove(new_location) swap_location_item(old_location, new_location) logging.debug( f"Progression balancing moved {new_location.item} to {new_location}, " f"displacing {old_location.item} into {old_location}" ) moved_item_count += 1 state.collect(new_location.item, True, new_location) replaced_items = True break else: logging.warning( f"Could not Progression Balance {old_location.item}" ) if replaced_items: logging.debug( f"Moved {moved_item_count} items so far\n") unlocked = { fresh for player in balancing_players for fresh in unlocked_locations[player] } for location in get_sphere_locations(state, unlocked): unchecked_locations.remove(location) if not location.locked: reachable_locations_count[location.player] += 1 sphere_locations.add(location) for location in sphere_locations: if location.event: state.collect(location.item, True, location) checked_locations |= sphere_locations if world.has_beaten_game(state): break elif not sphere_locations: logging.warning("Progression Balancing ran out of paths.") break
def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations: typing.List[Location], itempool: typing.List[Item], single_player_placement: bool = False, lock: bool = False) -> None: unplaced_items: typing.List[Item] = [] placements: typing.List[Location] = [] swapped_items: typing.Counter[typing.Tuple[int, str]] = Counter() reachable_items: typing.Dict[int, typing.Deque[Item]] = {} for item in itempool: reachable_items.setdefault(item.player, deque()).append(item) while any(reachable_items.values()) and locations: # grab one item per player items_to_place = [ items.pop() for items in reachable_items.values() if items ] for item in items_to_place: itempool.remove(item) maximum_exploration_state = sweep_from_pool(base_state, itempool + unplaced_items) has_beaten_game = world.has_beaten_game(maximum_exploration_state) for item_to_place in items_to_place: spot_to_fill: typing.Optional[Location] = None if world.accessibility[item_to_place.player] == 'minimal': perform_access_check = not world.has_beaten_game(maximum_exploration_state, item_to_place.player) \ if single_player_placement else not has_beaten_game else: perform_access_check = True for i, location in enumerate(locations): if (not single_player_placement or location.player == item_to_place.player) \ and location.can_fill(maximum_exploration_state, item_to_place, perform_access_check): # poping by index is faster than removing by content, spot_to_fill = locations.pop(i) # skipping a scan for the element break else: # we filled all reachable spots. # try swapping this item with previously placed items for (i, location) in enumerate(placements): placed_item = location.item # Unplaceable items can sometimes be swapped infinitely. Limit the # number of times we will swap an individual item to prevent this swap_count = swapped_items[placed_item.player, placed_item.name] if swap_count > 1: continue location.item = None placed_item.location = None swap_state = sweep_from_pool(base_state) if (not single_player_placement or location.player == item_to_place.player) \ and location.can_fill(swap_state, item_to_place, perform_access_check): # Verify that placing this item won't reduce available locations prev_state = swap_state.copy() prev_state.collect(placed_item) prev_loc_count = len( world.get_reachable_locations(prev_state)) swap_state.collect(item_to_place, True) new_loc_count = len( world.get_reachable_locations(swap_state)) if new_loc_count >= prev_loc_count: # Add this item to the existing placement, and # add the old item to the back of the queue spot_to_fill = placements.pop(i) swap_count += 1 swapped_items[placed_item.player, placed_item.name] = swap_count reachable_items[placed_item.player].appendleft( placed_item) itempool.append(placed_item) break # Item can't be placed here, restore original item location.item = placed_item placed_item.location = location if spot_to_fill is None: # Can't place this item, move on to the next unplaced_items.append(item_to_place) continue world.push_item(spot_to_fill, item_to_place, False) spot_to_fill.locked = lock placements.append(spot_to_fill) spot_to_fill.event = item_to_place.advancement if len(unplaced_items) > 0 and len(locations) > 0: # There are leftover unplaceable items and locations that won't accept them if world.can_beat_game(): logging.warning( f'Not all items placed. Game beatable anyway. (Could not place {unplaced_items})' ) else: raise FillError( f'No more spots to place {unplaced_items}, locations {locations} are invalid. ' f'Already placed {len(placements)}: {", ".join(str(place) for place in placements)}' ) itempool.extend(unplaced_items)