def update_for_new_state(self): debug.debug_print(f"\n>>> Updating state of {self}") self._advance_pickup_index_seen_count() self._advance_scan_asset_seen_count() self._advance_event_seen_count() self._calculate_potential_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.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])
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( patches: GamePatches, world_list: WorldList, rng: Random, ) -> 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) } # 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()} possible_indices -= { index for index in possible_indices if not should_have_hint(patches.pickup_assignment[index].item_category) } debug.debug_print( "fill_unassigned_hints had {} decent indices for {} hint locations". format(len(possible_indices), len(potential_hint_locations))) # 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 then shuffle possible_indices = list(sorted(possible_indices)) rng.shuffle(possible_indices) for logbook in sorted(potential_hint_locations): new_hints[logbook] = Hint(HintType.LOCATION, None, possible_indices.pop()) debug.debug_print( f"Added hint at {logbook} for item at {new_hints[logbook].target}") return dataclasses.replace(patches, hints=new_hints)
def should_have_hint(self, pickup: PickupEntry, current_uncollected: UncollectedState, all_locations_weighted: WeightedLocations) -> bool: if not pickup.item_category.is_major: return False config = self.configuration valid_locations = [ index for (owner, index), weight in all_locations_weighted.items() if (owner == self and weight >= config.minimum_location_weight_for_hint_placement and index in current_uncollected.indices) ] can_hint = len( valid_locations ) >= config.minimum_available_locations_for_hint_placement if not can_hint: debug.debug_print( f"+ Only {len(valid_locations)} qualifying open locations, hint refused." ) return can_hint
def retcon_playthrough_filler(rng: Random, player_states: Dict[int, PlayerState], status_update: Callable[[str], None], ) -> Tuple[Dict[int, 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_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_index, player_state in player_states.items() ) )) last_message = "Starting." def action_report(message: str): status_update("{} {}".format(last_message, message)) for player_state in player_states.values(): player_state.advance_pickup_index_seen_count() player_state.advance_scan_asset_seen_count() players_to_check = [] actions_log = [] while True: if not players_to_check: players_to_check = [player_index for player_index, player_state in player_states.items() if not player_state.victory_condition_satisfied()] if not players_to_check: debug.debug_print("Finished because we can win") break rng.shuffle(players_to_check) player_to_check = players_to_check.pop() current_player = player_states[player_to_check] actions_weights = current_player.calculate_potential_actions(action_report) try: action = next(iterate_with_weights(items=list(actions_weights.keys()), item_weights=actions_weights, rng=rng)) except StopIteration: if actions_weights: action = rng.choice(list(actions_weights.keys())) else: raise UnableToGenerate("Unable to generate; no actions found after placing {} items.".format( len(current_player.reach.state.patches.pickup_assignment))) if isinstance(action, PickupEntry): assert action in current_player.pickups_left all_weights = _calculate_all_pickup_indices_weight(player_states) if all_weights and (current_player.num_random_starting_items_placed >= current_player.configuration.minimum_random_starting_items): player_index, pickup_index = next(iterate_with_weights(items=iter(all_weights.keys()), item_weights=all_weights, rng=rng)) index_owner_state = player_states[player_index] index_owner_state.reach.state.patches = index_owner_state.reach.state.patches.assign_new_pickups([ (pickup_index, PickupTarget(action, player_to_check)), ]) # 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)) spoiler_entry = pickup_placement_spoiler_entry(player_to_check, action, index_owner_state.game, pickup_index, hint_location, player_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 {player_to_check + 1}" current_player.reach.advance_to(current_player.reach.state.assign_pickup_to_starting_items(action)) actions_log.append(spoiler_entry) debug.debug_print(f"\n>>>> {spoiler_entry}") # TODO: this item is potentially dangerous and we should remove the invalidated paths current_player.pickups_left.remove(action) count_pickups_left = sum(len(player_state.pickups_left) for player_state in player_states.values()) last_message = "{} items left.".format(count_pickups_left) status_update(last_message) else: last_message = "Triggered an event out of {} options.".format(len(actions_weights)) 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.advance_pickup_index_seen_count() current_player.advance_scan_asset_seen_count() return { index: player_state.reach.state.patches for index, player_state in player_states.items() }, tuple(actions_log)
def run_filler(rng: Random, player_pools: Dict[int, PlayerPool], status_update: Callable[[str], None], ) -> FillerResults: """ Runs the filler logic for the given configuration and item pool. Returns a GamePatches with progression items and hints assigned, along with all items in the pool that weren't assigned. :param player_pools: :param rng: :param status_update: :return: """ player_states = [] player_expansions: Dict[int, List[PickupEntry]] = {} for index, pool in player_pools.items(): status_update(f"Creating state for player {index + 1}") major_items, player_expansions[index] = _split_expansions(pool.pickups) rng.shuffle(major_items) rng.shuffle(player_expansions[index]) new_game, state = bootstrap.logic_bootstrap(pool.configuration, pool.game, pool.patches) new_game.patch_requirements(state.resources, pool.configuration.damage_strictness.value) major_configuration = pool.configuration.major_items_configuration player_states.append(PlayerState( index=index, game=new_game, initial_state=state, pickups_left=major_items, configuration=FillerConfiguration( randomization_mode=pool.configuration.available_locations.randomization_mode, minimum_random_starting_items=major_configuration.minimum_random_starting_items, maximum_random_starting_items=major_configuration.maximum_random_starting_items, indices_to_exclude=pool.configuration.available_locations.excluded_indices, ), )) try: filler_result, actions_log = retcon_playthrough_filler(rng, player_states, status_update=status_update) except UnableToGenerate as e: message = "{}\n\n{}".format( str(e), "\n\n".join( "#### Player {}\n{}".format(player.index + 1, player.current_state_report()) for player in player_states ), ) debug.debug_print(message) raise UnableToGenerate(message) from e results = {} for player_state, patches in filler_result.items(): game = player_state.game if game.game == RandovaniaGame.PRIME2: # Since we haven't added expansions yet, these hints will always be for items added by the filler. full_hints_patches = fill_unassigned_hints(patches, game.world_list, rng, player_state.scan_asset_initial_pickups) if player_pools[player_state.index].configuration.hints.item_hints: result = add_hints_precision(player_state, full_hints_patches, rng) else: result = replace_hints_without_precision_with_jokes(full_hints_patches) else: result = patches results[player_state.index] = FillerPlayerResult( game=game, patches=result, unassigned_pickups=player_state.pickups_left + player_expansions[player_state.index], ) return FillerResults(results, actions_log)
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: all_locations_weighted = _calculate_all_pickup_indices_weight( player_states) current_player = _get_next_player(rng, player_states, all_locations_weighted) if current_player is None: break weighted_actions = weighted_potential_actions(current_player, action_report, all_locations_weighted) action = select_weighted_action(rng, weighted_actions) if isinstance(action, tuple): new_pickups: list[PickupEntry] = sorted(action) rng.shuffle(new_pickups) debug.debug_print(f"\n>>> Will place {len(new_pickups)} pickups") for new_pickup in new_pickups: log_entry = _assign_pickup_somewhere(new_pickup, current_player, player_states, rng, all_locations_weighted) actions_log.append(log_entry) debug.debug_print(f"* {log_entry}") # TODO: this item is potentially dangerous and we should remove the invalidated paths current_player.pickups_left.remove(new_pickup) current_player.num_actions += 1 else: 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) last_message = "{} actions performed.".format( sum(player.num_actions for player in player_states)) status_update(last_message) current_player.reach = reach_lib.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)
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)
async def run_filler( rng: Random, player_pools: list[PlayerPool], status_update: Callable[[str], None], ) -> FillerResults: """ Runs the filler logic for the given configuration and item pool. Returns a GamePatches with progression items and hints assigned, along with all items in the pool that weren't assigned. :param player_pools: :param rng: :param status_update: :return: """ player_states = [] player_expansions: dict[int, list[PickupEntry]] = {} for index, pool in enumerate(player_pools): config = pool.configuration status_update(f"Creating state for player {index + 1}") if config.multi_pickup_placement: major_items, player_expansions[index] = list(pool.pickups), [] else: major_items, player_expansions[index] = _split_expansions( pool.pickups) rng.shuffle(major_items) rng.shuffle(player_expansions[index]) new_game, state = pool.game_generator.bootstrap.logic_bootstrap( config, pool.game, pool.patches) major_configuration = config.major_items_configuration player_states.append( PlayerState( index=index, game=new_game, initial_state=state, pickups_left=major_items, configuration=FillerConfiguration( randomization_mode=config.available_locations. randomization_mode, minimum_random_starting_items=major_configuration. minimum_random_starting_items, maximum_random_starting_items=major_configuration. maximum_random_starting_items, indices_to_exclude=config.available_locations. excluded_indices, multi_pickup_placement=config.multi_pickup_placement, multi_pickup_new_weighting=config. multi_pickup_new_weighting, logical_resource_action=config.logical_resource_action, first_progression_must_be_local=config. first_progression_must_be_local, minimum_available_locations_for_hint_placement=config. minimum_available_locations_for_hint_placement, minimum_location_weight_for_hint_placement=config. minimum_location_weight_for_hint_placement, ), )) try: filler_result, actions_log = retcon_playthrough_filler( rng, player_states, status_update=status_update) except UnableToGenerate as e: message = "{}\n\n{}".format( str(e), "\n\n".join( "#### Player {}\n{}".format(player.index + 1, player.current_state_report()) for player in player_states), ) debug.debug_print(message) raise UnableToGenerate(message) from e results = {} for player_state, patches in filler_result.items(): player_pool = player_pools[player_state.index] hint_distributor = player_pool.game_generator.hint_distributor results[player_state.index] = FillerPlayerResult( game=player_state.game, patches=await hint_distributor.assign_post_filler_hints(patches, rng, player_pool, player_state), unassigned_pickups=player_state.pickups_left + player_expansions[player_state.index], ) return FillerResults(results, actions_log)
def retcon_playthrough_filler( game: GameDescription, initial_state: State, pickups_left: List[PickupEntry], rng: Random, randomization_mode: RandomizationMode, minimum_random_starting_items: int, maximum_random_starting_items: int, status_update: Callable[[str], None], ) -> GamePatches: debug.debug_print("Major items: {}".format( [item.name for item in pickups_left])) last_message = "Starting." reach = advance_reach_with_possible_unsafe_resources( reach_with_all_safe_resources(game, initial_state)) pickup_index_seen_count: Dict[PickupIndex, int] = collections.defaultdict(int) scan_asset_seen_count: Dict[LogbookAsset, int] = collections.defaultdict(int) num_random_starting_items_placed = 0 while pickups_left: current_uncollected = UncollectedState.from_reach(reach) progression_pickups = _calculate_progression_pickups( pickups_left, reach) print_retcon_loop_start(current_uncollected, game, pickups_left, reach) for pickup_index in reach.state.collected_pickup_indices: pickup_index_seen_count[pickup_index] += 1 print_new_resources(game, reach, pickup_index_seen_count, "Pickup Index") for scan_asset in reach.state.collected_scan_assets: scan_asset_seen_count[scan_asset] += 1 print_new_resources(game, reach, scan_asset_seen_count, "Scan Asset") def action_report(message: str): status_update("{} {}".format(last_message, message)) actions_weights = _calculate_potential_actions( reach, progression_pickups, current_uncollected, maximum_random_starting_items - num_random_starting_items_placed, action_report) try: action = next( iterate_with_weights(items=list(actions_weights.keys()), item_weights=actions_weights, rng=rng)) except StopIteration: if actions_weights: action = rng.choice(list(actions_weights.keys())) else: raise UnableToGenerate( "Unable to generate; no actions found after placing {} items." .format(len(reach.state.patches.pickup_assignment))) if isinstance(action, PickupEntry): assert action in pickups_left if randomization_mode is RandomizationMode.FULL: uncollected_indices = current_uncollected.indices elif randomization_mode is RandomizationMode.MAJOR_MINOR_SPLIT: major_indices = { pickup_node.pickup_index for pickup_node in filter_pickup_nodes( reach.state.collected_resource_nodes) if pickup_node.major_location } uncollected_indices = current_uncollected.indices & major_indices if num_random_starting_items_placed >= minimum_random_starting_items and uncollected_indices: pickup_index_weight = { pickup_index: 1 / (min(pickup_index_seen_count[pickup_index], 10)**2) for pickup_index in uncollected_indices } assert pickup_index_weight, "Pickups should only be added to the actions dict " \ "when there are unassigned pickups" pickup_index = next( iterate_with_weights(items=uncollected_indices, item_weights=pickup_index_weight, rng=rng)) next_state = reach.state.assign_pickup_to_index( action, pickup_index) if current_uncollected.logbooks and _should_have_hint( action.item_category): hint_location: Optional[LogbookAsset] = rng.choice( list(current_uncollected.logbooks)) next_state.patches = next_state.patches.assign_hint( hint_location, Hint(HintType.LOCATION, None, pickup_index)) else: hint_location = None print_retcon_place_pickup(action, game, pickup_index, hint_location) else: num_random_starting_items_placed += 1 if num_random_starting_items_placed > maximum_random_starting_items: raise UnableToGenerate( "Attempting to place more extra starting items than the number allowed." ) if debug.debug_level() > 1: print(f"\n--> Adding {action.name} as a starting item") next_state = reach.state.assign_pickup_to_starting_items( action) # TODO: this item is potentially dangerous and we should remove the invalidated paths pickups_left.remove(action) last_message = "Placed {} items so far, {} left.".format( len(next_state.patches.pickup_assignment), len(pickups_left) - 1) status_update(last_message) reach.advance_to(next_state) else: last_message = "Triggered an event out of {} options.".format( len(actions_weights)) status_update(last_message) debug_print_collect_event(action, game) # This action is potentially dangerous. Use `act_on` to remove invalid paths reach.act_on(action) reach = advance_reach_with_possible_unsafe_resources(reach) if game.victory_condition.satisfied(reach.state.resources, reach.state.energy): debug.debug_print("Finished because we can win") break if not pickups_left: debug.debug_print( "Finished because we have nothing else to distribute") return reach.state.patches
def retcon_playthrough_filler( logic: Logic, initial_state: State, available_pickups: Tuple[PickupEntry, ...], rng: Random, status_update: Callable[[str], None], ) -> GamePatches: debug.debug_print("Major items: {}".format( [item.name for item in available_pickups])) last_message = "Starting." reach = advance_reach_with_possible_unsafe_resources( reach_with_all_safe_resources(logic, initial_state)) pickup_index_seen_count: Dict[PickupIndex, int] = collections.defaultdict(int) while True: current_uncollected = UncollectedState.from_reach(reach) pickups_left: Dict[str, PickupEntry] = { pickup.name: pickup for pickup in available_pickups if pickup not in reach.state.patches.pickup_assignment.values() } if not pickups_left: debug.debug_print( "Finished because we have nothing else to distribute") break progression_pickups = _calculate_progression_pickups( pickups_left, reach) print_retcon_loop_start(current_uncollected, logic, pickups_left, reach) for pickup_index in reach.state.collected_pickup_indices: pickup_index_seen_count[pickup_index] += 1 print_new_pickup_indices(logic, reach, pickup_index_seen_count) def action_report(message: str): status_update("{} {}".format(last_message, message)) actions_weights = _calculate_potential_actions(reach, progression_pickups, current_uncollected, action_report) try: action = next( iterate_with_weights(list(actions_weights.keys()), actions_weights, rng)) except StopIteration: if actions_weights: action = rng.choice(list(actions_weights.keys())) else: raise RuntimeError( "Unable to generate, no actions found after placing {} items." .format(len(reach.state.patches.pickup_assignment))) if isinstance(action, PickupEntry): pickup_index_weight = { pickup_index: 1 / (min(pickup_index_seen_count[pickup_index], 10)**2) for pickup_index in current_uncollected.indices } assert pickup_index_weight, "Pickups should only be added to the actions dict " \ "when there are unassigned pickups" # print(">>>>>>>>>>>>>") # world_list = logic.game.world_list # for pickup_index in sorted(current_uncollected.indices, key=lambda x: pickup_index_weight[x]): # print("{1:.6f} {2:5}: {0}".format( # world_list.node_name(find_pickup_node_with_index(pickup_index, world_list.all_nodes)), # pickup_index_weight[pickup_index], # pickup_index_seen_count[pickup_index])) pickup_index = next( iterate_with_weights(list(current_uncollected.indices), pickup_index_weight, rng)) # TODO: this item is potentially dangerous and we should remove the invalidated paths next_state = reach.state.assign_pickup_to_index( pickup_index, action) last_message = "Placed {} items so far, {} left.".format( len(next_state.patches.pickup_assignment), len(pickups_left) - 1) status_update(last_message) print_retcon_place_pickup(action, logic, pickup_index) reach.advance_to(next_state) else: last_message = "Triggered an event out of {} options.".format( len(actions_weights)) status_update(last_message) debug_print_collect_event(action, logic) # This action is potentially dangerous. Use `act_on` to remove invalid paths reach.act_on(action) reach = advance_reach_with_possible_unsafe_resources(reach) if logic.game.victory_condition.satisfied( reach.state.resources, reach.state.resource_database): debug.debug_print("Finished because we can win") break return reach.state.patches
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)
def retcon_playthrough_filler( game: GameDescription, initial_state: State, pickups_left: List[PickupEntry], rng: Random, configuration: FillerConfiguration, status_update: Callable[[str], None], ) -> GamePatches: debug.debug_print("{}\nRetcon filler started with major items:\n{}".format( "*" * 100, pprint.pformat({ item.name: pickups_left.count(item) for item in sorted(set(pickups_left), key=lambda item: item.name) }))) last_message = "Starting." minimum_random_starting_items = configuration.minimum_random_starting_items maximum_random_starting_items = configuration.maximum_random_starting_items reach = advance_reach_with_possible_unsafe_resources( reach_with_all_safe_resources(game, initial_state)) pickup_index_seen_count: DefaultDict[PickupIndex, int] = collections.defaultdict(int) scan_asset_seen_count: DefaultDict[LogbookAsset, int] = collections.defaultdict(int) scan_asset_initial_pickups: Dict[LogbookAsset, FrozenSet[PickupIndex]] = {} num_random_starting_items_placed = 0 indices_groups, all_indices = build_available_indices( game.world_list, configuration) while pickups_left: current_uncollected = UncollectedState.from_reach(reach) progression_pickups = _calculate_progression_pickups( pickups_left, reach) print_retcon_loop_start(current_uncollected, game, pickups_left, reach) for pickup_index in reach.state.collected_pickup_indices: pickup_index_seen_count[pickup_index] += 1 print_new_resources(game, reach, pickup_index_seen_count, "Pickup Index") for scan_asset in reach.state.collected_scan_assets: scan_asset_seen_count[scan_asset] += 1 if scan_asset_seen_count[scan_asset] == 1: scan_asset_initial_pickups[scan_asset] = frozenset( reach.state.collected_pickup_indices) print_new_resources(game, reach, scan_asset_seen_count, "Scan Asset") def action_report(message: str): status_update("{} {}".format(last_message, message)) actions_weights = _calculate_potential_actions( reach, progression_pickups, current_uncollected, maximum_random_starting_items - num_random_starting_items_placed, action_report) try: action = next( iterate_with_weights(items=list(actions_weights.keys()), item_weights=actions_weights, rng=rng)) except StopIteration: if actions_weights: action = rng.choice(list(actions_weights.keys())) else: raise UnableToGenerate( "Unable to generate; no actions found after placing {} items." .format(len(reach.state.patches.pickup_assignment))) if isinstance(action, PickupEntry): assert action in pickups_left uncollected_indices = current_uncollected.indices & all_indices if num_random_starting_items_placed >= minimum_random_starting_items and uncollected_indices: pickup_index_weights = _calculate_uncollected_index_weights( uncollected_indices, set(reach.state.patches.pickup_assignment), pickup_index_seen_count, indices_groups, ) assert pickup_index_weights, "Pickups should only be added to the actions dict " \ "when there are unassigned pickups" pickup_index = next( iterate_with_weights(items=iter(uncollected_indices), item_weights=pickup_index_weights, rng=rng)) next_state = reach.state.assign_pickup_to_index( action, pickup_index) # Place a hint for the new item hint_location = _calculate_hint_location_for_action( action, current_uncollected, pickup_index, rng, scan_asset_initial_pickups) if hint_location is not None: next_state.patches = next_state.patches.assign_hint( hint_location, Hint(HintType.LOCATION, None, pickup_index)) print_retcon_place_pickup(action, game, pickup_index, hint_location) else: num_random_starting_items_placed += 1 if num_random_starting_items_placed > maximum_random_starting_items: raise UnableToGenerate( "Attempting to place more extra starting items than the number allowed." ) if debug.debug_level() > 1: print(f"\n--> Adding {action.name} as a starting item") next_state = reach.state.assign_pickup_to_starting_items( action) # TODO: this item is potentially dangerous and we should remove the invalidated paths pickups_left.remove(action) last_message = "Placed {} items so far, {} left.".format( len(next_state.patches.pickup_assignment), len(pickups_left) - 1) status_update(last_message) reach.advance_to(next_state) else: last_message = "Triggered an event out of {} options.".format( len(actions_weights)) status_update(last_message) debug_print_collect_event(action, game) # This action is potentially dangerous. Use `act_on` to remove invalid paths reach.act_on(action) reach = advance_reach_with_possible_unsafe_resources(reach) if game.victory_condition.satisfied(reach.state.resources, reach.state.energy): debug.debug_print("Finished because we can win") break if not pickups_left: debug.debug_print( "Finished because we have nothing else to distribute") return reach.state.patches