diff --git a/BaseClasses.py b/BaseClasses.py index b7d29526..64e6eb97 100644 --- a/BaseClasses.py +++ b/BaseClasses.py @@ -1093,6 +1093,8 @@ class Item(): trap: bool = False # change manually to ensure that a specific non-progression item never goes on an excluded location never_exclude = False + # item is not considered by progression balancing despite being progression + skip_in_prog_balancing: bool = False # need to find a decent place for these to live and to allow other games to register texts if they want. pedestal_credit_text: str = "and the Unknown Item" diff --git a/Fill.py b/Fill.py index 2af54e93..54d12a5c 100644 --- a/Fill.py +++ b/Fill.py @@ -301,6 +301,12 @@ def flood_items(world: MultiWorld): def balance_multiworld_progression(world: MultiWorld): + # 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 = {player for player in range(1, world.players + 1) if world.progression_balancing[player]} if not balanceable_players: logging.info('Skipping multiworld progression balancing.') @@ -311,21 +317,37 @@ def balance_multiworld_progression(world: MultiWorld): unchecked_locations = set(world.get_locations()) reachable_locations_count = {player: 0 for player in world.get_all_ids()} + total_locations_count = {player: sum(1 for loc in world.get_locations() if not loc.locked and loc.player == player) for player in world.player_ids} + sphere_num = 1 + moved_item_count = 0 def get_sphere_locations(sphere_state, locations): 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, num): + 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) - reachable_locations_count[location.player] += 1 + if not location.locked: + reachable_locations_count[location.player] += 1 + + logging.debug(f"Sphere {sphere_num}") + logging.debug(f"Reachable locations: {reachable_locations_count}") + logging.debug(f"Reachable percentages: { {player: round(item_percentage(player, num), 2) for player, num in reachable_locations_count.items()} }\n") + sphere_num += 1 if checked_locations: - threshold = max(reachable_locations_count.values()) - 20 + # The 10% threshold can be modified for "progression balancing strength" -- right now it approximates the old 20/216 bound. + threshold_percentage = max(map(lambda p: item_percentage(p, reachable_locations_count[p]), reachable_locations_count)) - 0.10 + logging.debug(f"Threshold: {threshold_percentage}") balancing_players = {player for player, reachables in reachable_locations_count.items() if - reachables < threshold and player in balanceable_players} + item_percentage(player, reachables) < threshold_percentage and player in balanceable_players} if balancing_players: balancing_state = state.copy() balancing_unchecked_locations = unchecked_locations.copy() @@ -333,25 +355,30 @@ def balance_multiworld_progression(world: MultiWorld): balancing_sphere = sphere_locations.copy() candidate_items = 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 + 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) - balancing_reachables[location.player] += 1 + if not location.locked: + balancing_reachables[location.player] += 1 if world.has_beaten_game(balancing_state) or all( - reachables >= threshold for reachables in balancing_reachables.values()): + item_percentage(player, reachables) >= threshold_percentage + for player, reachables in balancing_reachables.items()): 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 = collections.defaultdict(set) for l in unchecked_locations: if l not in balancing_unchecked_locations: @@ -374,7 +401,7 @@ def balance_multiworld_progression(world: MultiWorld): items_to_replace.append(testing) else: reduced_sphere = get_sphere_locations(reducing_state, locations_to_test) - if reachable_locations_count[player] + len(reduced_sphere) < threshold: + if item_percentage(player, reachable_locations_count[player] + len(reduced_sphere)) < threshold_percentage: items_to_replace.append(testing) replaced_items = False @@ -386,6 +413,7 @@ def balance_multiworld_progression(world: MultiWorld): 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: @@ -395,6 +423,7 @@ def balance_multiworld_progression(world: MultiWorld): 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 @@ -402,10 +431,12 @@ def balance_multiworld_progression(world: MultiWorld): 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) - reachable_locations_count[location.player] += 1 + if not location.locked: + reachable_locations_count[location.player] += 1 sphere_locations.add(location) for location in sphere_locations: diff --git a/worlds/oot/Items.py b/worlds/oot/Items.py index 901d677c..964137f8 100644 --- a/worlds/oot/Items.py +++ b/worlds/oot/Items.py @@ -36,6 +36,8 @@ class OOTItem(Item): self.trap = name == 'Ice Trap' if force_not_advancement: self.never_exclude = True + if name == 'Gold Skulltula Token': + self.skip_in_prog_balancing = True # The playthrough calculation calls a function that uses "sweep_for_events(key_only=True)" # This checks if the item it's looking for is a small key, using the small key property. diff --git a/worlds/sm64ex/__init__.py b/worlds/sm64ex/__init__.py index 13eaf675..e8601267 100644 --- a/worlds/sm64ex/__init__.py +++ b/worlds/sm64ex/__init__.py @@ -41,6 +41,8 @@ class SM64World(World): def create_item(self, name: str) -> Item: item_id = item_table[name] item = SM64Item(name, name != "1Up Mushroom", item_id, self.player) + if name == "Power Star": + item.skip_in_prog_balancing = True return item def generate_basic(self):