def select_weighted_action(rng: Random, weighted_actions: dict[Action, float]) -> Action: """ Choose a random action, respecting the weights. If all actions have weight 0, select one randomly. """ try: return select_element_with_weight(weighted_actions, rng=rng) except StopIteration: # All actions had weight 0. Select one randomly instead. # No need to check if potential_actions is empty, _get_next_player only return players with actions return rng.choice(list(weighted_actions.keys()))
def add_relative_hint(self, world_list: WorldList, patches: GamePatches, rng: Random, target: PickupIndex, target_precision: HintItemPrecision, relative_type: HintLocationPrecision, precise_distance: bool, precision: Union[HintItemPrecision, HintRelativeAreaName], max_distance: int, ) -> Optional[Hint]: """ Creates a relative hint. :return: Might be None, if no hint could be created. """ target_node = node_search.pickup_index_to_node(world_list, target) target_area = world_list.nodes_to_area(target_node) distances = node_search.distances_to_node(world_list, target_node, patches=patches, cutoff=max_distance) def _major_pickups(area: Area) -> Iterator[PickupIndex]: for index in area.pickup_indices: t = patches.pickup_assignment.get(index) # FIXME: None should be ok, but this must be called after junk has been filled if t is not None: cat = t.pickup.item_category if cat.is_major or (not cat.is_expansion and target_precision == HintItemPrecision.DETAILED): yield index area_choices = { area: 1 / max(distance, 2) for area, distance in distances.items() if (distance > 0 and area.in_dark_aether == target_area.in_dark_aether and (relative_type == HintLocationPrecision.RELATIVE_TO_AREA or _not_empty(_major_pickups(area)))) } if not area_choices: return None area = random_lib.select_element_with_weight(dict(sorted(area_choices.items(), key=lambda a: a[0].name)), rng) distance_offset = None if not precise_distance: distance_offset = max_distance - distances[area] if relative_type == HintLocationPrecision.RELATIVE_TO_AREA: relative = RelativeDataArea(distance_offset, world_list.identifier_for_area(area), precision) elif relative_type == HintLocationPrecision.RELATIVE_TO_INDEX: relative = RelativeDataItem(distance_offset, rng.choice(list(_major_pickups(area))), precision) else: raise ValueError(f"Invalid relative_type: {relative_type}") precision_pair = PrecisionPair(relative_type, target_precision, include_owner=False, relative=relative) return Hint(HintType.LOCATION, precision_pair, target)
def _get_next_player( rng: Random, player_states: list[PlayerState], locations_weighted: WeightedLocations) -> Optional[PlayerState]: """ Gets the next player a pickup should be placed for. :param rng: :param player_states: :param locations_weighted: Which locations are available and their weight. :return: """ all_uncollected: dict[PlayerState, UncollectedState] = { player_state: UncollectedState.from_reach(player_state.reach) for player_state in player_states } max_actions = max(player_state.num_actions for player_state in player_states) max_uncollected = max( len(uncollected.indices) for uncollected in all_uncollected.values()) def _calculate_weight(player: PlayerState) -> float: return 1 + (max_actions - player.num_actions) * ( max_uncollected - len(all_uncollected[player].indices)) weighted_players = { player_state: _calculate_weight(player_state) for player_state in player_states if not player_state.victory_condition_satisfied() and player_state.potential_actions(locations_weighted) } if weighted_players: if debug.debug_level() > 1: print(f">>>>> Player Weights: {weighted_players}") return select_element_with_weight(weighted_players, rng) else: if all(player_state.victory_condition_satisfied() for player_state in player_states): debug.debug_print("Finished because we can win") return None else: total_actions = sum(player_state.num_actions for player_state in player_states) unfinished_players = ", ".join([ str(player_state) for player_state in player_states if not player_state.victory_condition_satisfied() ]) raise UnableToGenerate( f"{unfinished_players} with no possible actions after {total_actions} total actions." )
def fill_unassigned_hints( self, patches: GamePatches, world_list: WorldList, rng: Random, scan_asset_initial_pickups: dict[NodeIdentifier, frozenset[PickupIndex]], ) -> GamePatches: new_hints = copy.copy(patches.hints) # Get all LogbookAssets from the WorldList potential_hint_locations: set[NodeIdentifier] = { world_list.identifier_for_node(node) for node in world_list.all_nodes if isinstance(node, LogbookNode) } for logbook in potential_hint_locations: if logbook not in scan_asset_initial_pickups: scan_asset_initial_pickups[logbook] = frozenset() # But remove these that already have hints potential_hint_locations -= patches.hints.keys() # Get interesting items to place hints for possible_indices = set(patches.pickup_assignment.keys()) possible_indices -= { hint.target for hint in patches.hints.values() if hint.target is not None } possible_indices -= { index for index in possible_indices if not self.interesting_pickup_to_hint( patches.pickup_assignment[index].pickup) } debug.debug_print( "fill_unassigned_hints had {} decent indices for {} hint locations" .format(len(possible_indices), len(potential_hint_locations))) if debug.debug_level() > 1: print(f"> Num pickups per asset:") for asset, pickups in scan_asset_initial_pickups.items(): print(f"* {asset}: {len(pickups)} pickups") print("> Done.") # But if we don't have enough hints, just pick randomly from everything if len(possible_indices) < len(potential_hint_locations): possible_indices = { node.pickup_index for node in world_list.all_nodes if isinstance(node, PickupNode) } # Get an stable order ordered_possible_indices = list(sorted(possible_indices)) ordered_potential_hint_locations = list( sorted(potential_hint_locations)) num_logbooks: dict[PickupIndex, int] = { index: sum(1 for indices in scan_asset_initial_pickups.values() if index in indices) for index in ordered_possible_indices } max_seen = max(num_logbooks.values()) if num_logbooks else 0 pickup_indices_weight: dict[PickupIndex, int] = { index: max_seen - num_logbook for index, num_logbook in num_logbooks.items() } # Ensure all indices are present with at least weight 0 for index in ordered_possible_indices: if index not in pickup_indices_weight: pickup_indices_weight[index] = 0 for logbook in sorted(ordered_potential_hint_locations, key=lambda r: len(scan_asset_initial_pickups[r]), reverse=True): try: new_index = random_lib.select_element_with_weight( pickup_indices_weight, rng) except StopIteration: # If everything has weight 0, then just choose randomly. new_index = random_lib.random_key(pickup_indices_weight, rng) del pickup_indices_weight[new_index] new_hints[logbook] = Hint(HintType.LOCATION, None, new_index) debug.debug_print( f"Added hint at {logbook} for item at {new_index}") return dataclasses.replace(patches, hints=new_hints)
def _assign_pickup_somewhere( action: PickupEntry, current_player: PlayerState, player_states: list[PlayerState], rng: Random, all_locations_weighted: WeightedLocations, ) -> str: """ Assigns a PickupEntry to a free, collected PickupIndex or as a starting item. :param action: :param current_player: :param player_states: :param rng: :return: """ assert action in current_player.pickups_left locations_weighted = current_player.filter_usable_locations( all_locations_weighted) if locations_weighted and ( current_player.num_random_starting_items_placed >= current_player.configuration.minimum_random_starting_items): if debug.debug_level() > 1: debug_print_weighted_locations(all_locations_weighted) index_owner_state, pickup_index = select_element_with_weight( locations_weighted, rng) index_owner_state.assign_pickup( pickup_index, PickupTarget(action, current_player.index)) all_locations_weighted.pop((index_owner_state, pickup_index)) # Place a hint for the new item hint_location = _calculate_hint_location_for_action( action, index_owner_state, all_locations_weighted, UncollectedState.from_reach(index_owner_state.reach), pickup_index, rng, index_owner_state.hint_initial_pickups, ) if hint_location is not None: index_owner_state.reach.state.patches = index_owner_state.reach.state.patches.assign_hint( hint_location, Hint(HintType.LOCATION, None, pickup_index)) if pickup_index in index_owner_state.reach.state.collected_pickup_indices: current_player.reach.advance_to( current_player.reach.state.assign_pickup_resources(action)) else: # FIXME: isn't that condition always true? pass spoiler_entry = pickup_placement_spoiler_entry( current_player.index, action, index_owner_state.game, pickup_index, hint_location, index_owner_state.index, len(player_states) > 1, index_owner_state.reach.node_context()) else: current_player.num_random_starting_items_placed += 1 if (current_player.num_random_starting_items_placed > current_player.configuration.maximum_random_starting_items): raise UnableToGenerate( "Attempting to place more extra starting items than the number allowed." ) spoiler_entry = f"{action.name} as starting item" if len(player_states) > 1: spoiler_entry += f" for Player {current_player.index + 1}" current_player.reach.advance_to( current_player.reach.state.assign_pickup_to_starting_items(action)) return spoiler_entry
class HintDistributor(ABC): @property def num_joke_hints(self) -> int: return 0 def get_generic_logbook_nodes(self, prefill: PreFillParams) -> list[NodeIdentifier]: return [ prefill.game.world_list.identifier_for_node(node) for node in prefill.game.world_list.iterate_nodes() if isinstance(node, LogbookNode) and node.lore_type.holds_generic_hint ] async def get_specific_pickup_precision_pair_overrides(self, patches: GamePatches, prefill: PreFillParams ) -> dict[NodeIdentifier, PrecisionPair]: return {} async def assign_specific_location_hints(self, patches: GamePatches, prefill: PreFillParams) -> GamePatches: specific_location_precisions = await self.get_specific_pickup_precision_pair_overrides(patches, prefill) # TODO: this is an Echoes default. Should not have a default and all nodes have one in the DB. default_precision = PrecisionPair(HintLocationPrecision.KEYBEARER, HintItemPrecision.BROAD_CATEGORY, include_owner=True) wl = prefill.game.world_list for node in wl.iterate_nodes(): if isinstance(node, LogbookNode) and node.lore_type == LoreType.SPECIFIC_PICKUP: identifier = wl.identifier_for_node(node) patches = patches.assign_hint( identifier, Hint(HintType.LOCATION, specific_location_precisions.get(identifier, default_precision), PickupIndex(node.hint_index)) ) return patches async def get_guranteed_hints(self, patches: GamePatches, prefill: PreFillParams) -> list[HintTargetPrecision]: return [] async def assign_guaranteed_indices_hints(self, patches: GamePatches, identifiers: list[NodeIdentifier], prefill: PreFillParams) -> GamePatches: # Specific Pickup/any LogbookNode Hints indices_with_hint = await self.get_guranteed_hints(patches, prefill) prefill.rng.shuffle(indices_with_hint) all_hint_identifiers = [identifier for identifier in identifiers if identifier not in patches.hints] prefill.rng.shuffle(all_hint_identifiers) for index, precision in indices_with_hint: if not all_hint_identifiers: break identifier = all_hint_identifiers.pop() patches = patches.assign_hint(identifier, Hint(HintType.LOCATION, precision, index)) identifiers.remove(identifier) return patches async def assign_other_hints(self, patches: GamePatches, identifiers: list[NodeIdentifier], prefill: PreFillParams) -> GamePatches: return patches async def assign_joke_hints(self, patches: GamePatches, identifiers: list[NodeIdentifier], prefill: PreFillParams) -> GamePatches: all_hint_identifiers = [identifier for identifier in identifiers if identifier not in patches.hints] prefill.rng.shuffle(all_hint_identifiers) num_joke = self.num_joke_hints while num_joke > 0 and all_hint_identifiers: identifier = all_hint_identifiers.pop() patches = patches.assign_hint(identifier, Hint(HintType.JOKE, None)) num_joke -= 1 identifiers.remove(identifier) return patches async def assign_pre_filler_hints(self, patches: GamePatches, prefill: PreFillParams, rng_required: bool = True) -> GamePatches: patches = await self.assign_specific_location_hints(patches, prefill) hint_identifiers = self.get_generic_logbook_nodes(prefill) if rng_required or prefill.rng is not None: prefill.rng.shuffle(hint_identifiers) patches = await self.assign_guaranteed_indices_hints(patches, hint_identifiers, prefill) patches = await self.assign_other_hints(patches, hint_identifiers, prefill) patches = await self.assign_joke_hints(patches, hint_identifiers, prefill) return patches async def assign_post_filler_hints(self, patches: GamePatches, rng: Random, player_pool: PlayerPool, player_state: PlayerState, ) -> GamePatches: # Since we haven't added expansions yet, these hints will always be for items added by the filler. full_hints_patches = self.fill_unassigned_hints( patches, player_state.game.world_list, rng, player_state.hint_initial_pickups, ) return await self.assign_precision_to_hints(full_hints_patches, rng, player_pool, player_state) async def assign_precision_to_hints(self, patches: GamePatches, rng: Random, player_pool: PlayerPool, player_state: PlayerState) -> GamePatches: """ Ensures no hints present in `patches` has no precision. :param patches: :param rng: :param player_pool: :param player_state: :return: """ raise NotImplementedError() def interesting_pickup_to_hint(self, pickup: PickupEntry) -> bool: return pickup.item_category.is_major def fill_unassigned_hints(self, patches: GamePatches, world_list: WorldList, rng: Random, scan_asset_initial_pickups: dict[NodeIdentifier, frozenset[PickupIndex]], ) -> GamePatches: new_hints = copy.copy(patches.hints) # Get all LogbookAssets from the WorldList potential_hint_locations: set[NodeIdentifier] = { world_list.identifier_for_node(node) for node in world_list.iterate_nodes() if isinstance(node, LogbookNode) } for logbook in potential_hint_locations: if logbook not in scan_asset_initial_pickups: scan_asset_initial_pickups[logbook] = frozenset() # But remove these that already have hints potential_hint_locations -= patches.hints.keys() # We try our best to not hint the same thing twice hinted_indices: set[PickupIndex] = {hint.target for hint in patches.hints.values() if hint.target is not None} # Get interesting items to place hints for possible_indices: set[PickupIndex] = { index for index, target in patches.pickup_assignment.items() if self.interesting_pickup_to_hint(target.pickup) } possible_indices -= hinted_indices debug.debug_print("fill_unassigned_hints had {} decent indices for {} hint locations".format( len(possible_indices), len(potential_hint_locations))) if debug.debug_level() > 1: print(f"> Num pickups per asset:") for asset, pickups in scan_asset_initial_pickups.items(): print(f"* {asset}: {len(pickups)} pickups") print("> Done.") all_pickup_indices = [ node.pickup_index for node in world_list.iterate_nodes() if isinstance(node, PickupNode) ] rng.shuffle(all_pickup_indices) # If there isn't enough indices, use unhinted non-majors placed by generator if (num_indices_needed := len(potential_hint_locations) - len(possible_indices)) > 0: potential_indices = [ index for index in all_pickup_indices if index not in possible_indices and index not in hinted_indices ] debug.debug_print( f"Had only {len(possible_indices)} hintable indices, but needed {len(potential_hint_locations)}." f" Found {len(potential_indices)} less desirable locations.") possible_indices |= set(potential_indices[:num_indices_needed]) # But if we don't have enough hints, just pick randomly from everything while len(possible_indices) < len(potential_hint_locations): debug.debug_print( f"Still only {len(possible_indices)} indices out of {len(potential_hint_locations)} target." f"Desperate pool has {len(all_pickup_indices)} left." ) try: possible_indices.add(all_pickup_indices.pop()) except IndexError: raise UnableToGenerate("Not enough PickupNodes in the game to fill all hint locations.") # Get an stable order ordered_possible_indices = list(sorted(possible_indices)) ordered_potential_hint_locations = list(sorted(potential_hint_locations)) num_logbooks: dict[PickupIndex, int] = { index: sum(1 for indices in scan_asset_initial_pickups.values() if index in indices) for index in ordered_possible_indices } max_seen = max(num_logbooks.values()) if num_logbooks else 0 pickup_indices_weight: dict[PickupIndex, int] = { index: max_seen - num_logbook for index, num_logbook in num_logbooks.items() } # Ensure all indices are present with at least weight 0 for index in ordered_possible_indices: if index not in pickup_indices_weight: pickup_indices_weight[index] = 0 for logbook in sorted(ordered_potential_hint_locations, key=lambda r: len(scan_asset_initial_pickups[r]), reverse=True): try: new_index = random_lib.select_element_with_weight(pickup_indices_weight, rng) except StopIteration: # If everything has weight 0, then just choose randomly. new_index = random_lib.random_key(pickup_indices_weight, rng) del pickup_indices_weight[new_index] new_hints[logbook] = Hint(HintType.LOCATION, None, new_index) debug.debug_print(f"Added hint at {logbook} for item at " f"{world_list.node_name(world_list.node_from_pickup_index(new_index))}") return dataclasses.replace(patches, hints=new_hints)