Exemplo n.º 1
0
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
Exemplo n.º 2
0
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)