Beispiel #1
0
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()))
Beispiel #2
0
    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)
Beispiel #3
0
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."
            )
Beispiel #4
0
    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)
Beispiel #5
0
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
Beispiel #6
0
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)