def flood_items(world: MultiWorld) -> None: # get items to distribute world.random.shuffle(world.itempool) itempool = world.itempool progress_done = False # sweep once to pick up preplaced items world.state.sweep_for_events() # fill world from top of itempool while we can while not progress_done: location_list = world.get_unfilled_locations() world.random.shuffle(location_list) spot_to_fill = None for location in location_list: if location.can_fill(world.state, itempool[0]): spot_to_fill = location break if spot_to_fill: item = itempool.pop(0) world.push_item(spot_to_fill, item, True) continue # ran out of spots, check if we need to step in and correct things if len(world.get_reachable_locations()) == len(world.get_locations()): progress_done = True continue # need to place a progress item instead of an already placed item, find candidate item_to_place = None candidate_item_to_place = None for item in itempool: if item.advancement: candidate_item_to_place = item if world.unlocks_new_location(item): item_to_place = item break # we might be in a situation where all new locations require multiple items to reach. # If that is the case, just place any advancement item we've found and continue trying if item_to_place is None: if candidate_item_to_place is not None: item_to_place = candidate_item_to_place else: raise FillError('No more progress items left to place.') # find item to replace with progress item location_list = world.get_reachable_locations() world.random.shuffle(location_list) for location in location_list: if location.item is not None and not location.item.advancement: # safe to replace replace_item = location.item replace_item.location = None itempool.append(replace_item) world.push_item(location, item_to_place, True) itempool.remove(item_to_place) break
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 distribute_items_restrictive(world: MultiWorld) -> None: fill_locations = sorted(world.get_unfilled_locations()) world.random.shuffle(fill_locations) # get items to distribute itempool = sorted(world.itempool) world.random.shuffle(itempool) progitempool: typing.List[Item] = [] nonexcludeditempool: typing.List[Item] = [] localrestitempool: typing.Dict[int, typing.List[Item]] = { player: [] for player in range(1, world.players + 1) } nonlocalrestitempool: typing.List[Item] = [] restitempool: typing.List[Item] = [] for item in itempool: if item.advancement: progitempool.append(item) elif item.useful: # this only gets nonprogression items which should not appear in excluded locations nonexcludeditempool.append(item) elif item.name in world.local_items[item.player].value: localrestitempool[item.player].append(item) elif item.name in world.non_local_items[item.player].value: nonlocalrestitempool.append(item) else: restitempool.append(item) call_all(world, "fill_hook", progitempool, nonexcludeditempool, localrestitempool, nonlocalrestitempool, restitempool, fill_locations) locations: typing.Dict[LocationProgressType, typing.List[Location]] = { loc_type: [] for loc_type in LocationProgressType } for loc in fill_locations: locations[loc.progress_type].append(loc) prioritylocations = locations[LocationProgressType.PRIORITY] defaultlocations = locations[LocationProgressType.DEFAULT] excludedlocations = locations[LocationProgressType.EXCLUDED] fill_restrictive(world, world.state, prioritylocations, progitempool, lock=True) if prioritylocations: defaultlocations = prioritylocations + defaultlocations if progitempool: fill_restrictive(world, world.state, defaultlocations, progitempool) if progitempool: raise FillError( f'Not enough locations for progress items. There are {len(progitempool)} more items than locations' ) if nonexcludeditempool: world.random.shuffle(defaultlocations) # needs logical fill to not conflict with local items fill_restrictive(world, world.state, defaultlocations, nonexcludeditempool) if nonexcludeditempool: raise FillError( f'Not enough locations for non-excluded items. There are {len(nonexcludeditempool)} more items than locations' ) defaultlocations = defaultlocations + excludedlocations world.random.shuffle(defaultlocations) if any(localrestitempool.values() ): # we need to make sure some fills are limited to certain worlds local_locations: typing.Dict[int, typing.List[Location]] = { player: [] for player in world.player_ids } for location in defaultlocations: local_locations[location.player].append(location) for player_locations in local_locations.values(): world.random.shuffle(player_locations) for player, items in localrestitempool.items( ): # items already shuffled player_local_locations = local_locations[player] for item_to_place in items: if not player_local_locations: logging.warning( f"Ran out of local locations for player {player}, " f"cannot place {item_to_place}.") break spot_to_fill = player_local_locations.pop() world.push_item(spot_to_fill, item_to_place, False) defaultlocations.remove(spot_to_fill) for item_to_place in nonlocalrestitempool: for i, location in enumerate(defaultlocations): if location.player != item_to_place.player: world.push_item(defaultlocations.pop(i), item_to_place, False) break else: logging.warning( f"Could not place non_local_item {item_to_place} among {defaultlocations}, tossing." ) world.random.shuffle(defaultlocations) restitempool, defaultlocations = fast_fill(world, restitempool, defaultlocations) unplaced = progitempool + restitempool unfilled = defaultlocations if unplaced or unfilled: logging.warning( f'Unplaced items({len(unplaced)}): {unplaced} - Unfilled Locations({len(unfilled)}): {unfilled}' ) items_counter = Counter(location.item.player for location in world.get_locations() if location.item) locations_counter = Counter(location.player for location in world.get_locations()) items_counter.update(item.player for item in unplaced) locations_counter.update(location.player for location in unfilled) print_data = {"items": items_counter, "locations": locations_counter} logging.info(f'Per-Player counts: {print_data})')