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 if all_locations_weighted and (current_player.num_random_starting_items_placed >= current_player.configuration.minimum_random_starting_items): index_owner_state, pickup_index = select_element_with_weight(all_locations_weighted, rng) index_owner_state.assign_pickup(pickup_index, PickupTarget(action, current_player.index)) # Place a hint for the new item hint_location = _calculate_hint_location_for_action( action, UncollectedState.from_reach(index_owner_state.reach), pickup_index, rng, index_owner_state.scan_asset_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) 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
def add_relative_hint(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_category or (cat != ItemCategory.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].area_asset_id)), rng) distance_offset = 0 if not precise_distance: distance_offset = max_distance - distances[area] if relative_type == HintLocationPrecision.RELATIVE_TO_AREA: relative = RelativeDataArea(distance_offset, world_list.area_to_area_location(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]) -> Optional[PlayerState]: """ Gets the next player a pickup should be placed for. :param rng: :param player_states: :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 } 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) raise UnableToGenerate( f"No players with possible actions after {total_actions} total actions." )
def fill_unassigned_hints(patches: GamePatches, world_list: WorldList, rng: Random, scan_asset_initial_pickups: Dict[LogbookAsset, FrozenSet[PickupIndex]], ) -> GamePatches: new_hints = copy.copy(patches.hints) # Get all LogbookAssets from the WorldList potential_hint_locations: Set[LogbookAsset] = { node.resource() 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 should_have_hint(patches.pickup_assignment[index].pickup.item_category)} 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()) 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 retcon_playthrough_filler( rng: Random, player_states: List[PlayerState], status_update: Callable[[str], None], ) -> Tuple[Dict[PlayerState, GamePatches], Tuple[str, ...]]: """ Runs the retcon logic. :param rng: :param player_states: :param status_update: :return: A GamePatches for each player and a sequence of placed items. """ debug.debug_print("{}\nRetcon filler started with major items:\n{}".format( "*" * 100, "\n".join("Player {}: {}".format( player_state.index, pprint.pformat({ item.name: player_state.pickups_left.count(item) for item in sorted(set(player_state.pickups_left), key=lambda item: item.name) })) for player_state in player_states))) last_message = "Starting." def action_report(message: str): status_update("{} {}".format(last_message, message)) for player_state in player_states: player_state.update_for_new_state() actions_log = [] while True: current_player = _get_next_player(rng, player_states) if current_player is None: break weighted_actions = current_player.weighted_potential_actions( action_report) try: action = 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 action = rng.choice(current_player.potential_actions) if isinstance(action, PickupEntry): log_entry = _assign_pickup_somewhere(action, current_player, player_states, rng) actions_log.append(log_entry) debug.debug_print(f"\n>>>> {log_entry}") # TODO: this item is potentially dangerous and we should remove the invalidated paths current_player.pickups_left.remove(action) current_player.num_actions += 1 count_pickups_left = sum( len(player_state.pickups_left) for player_state in player_states) last_message = "{} items left.".format(count_pickups_left) status_update(last_message) else: last_message = "Triggered an event out of {} options.".format( len(weighted_actions)) status_update(last_message) debug_print_collect_event(action, current_player.game) # This action is potentially dangerous. Use `act_on` to remove invalid paths current_player.reach.act_on(action) current_player.reach = advance_reach_with_possible_unsafe_resources( current_player.reach) current_player.update_for_new_state() all_patches = { player_state: player_state.reach.state.patches for player_state in player_states } return all_patches, tuple(actions_log)