|
7 | 7 | import secrets |
8 | 8 | import warnings |
9 | 9 | from argparse import Namespace |
10 | | -from collections import Counter, deque |
| 10 | +from collections import Counter, deque, defaultdict |
11 | 11 | from collections.abc import Collection, MutableSequence |
12 | 12 | from enum import IntEnum, IntFlag |
13 | 13 | from typing import (AbstractSet, Any, Callable, ClassVar, Dict, Iterable, Iterator, List, Literal, Mapping, NamedTuple, |
14 | | - Optional, Protocol, Set, Tuple, Union, TYPE_CHECKING) |
| 14 | + Optional, Protocol, Set, Tuple, Union, TYPE_CHECKING, Literal, overload) |
15 | 15 | import dataclasses |
16 | 16 |
|
17 | 17 | from typing_extensions import NotRequired, TypedDict |
@@ -183,7 +183,7 @@ def set_player_attr(attr: str, val) -> None: |
183 | 183 | set_player_attr('completion_condition', lambda state: True) |
184 | 184 | self.worlds = {} |
185 | 185 | self.per_slot_randoms = Utils.DeprecateDict("Using per_slot_randoms is now deprecated. Please use the " |
186 | | - "world's random object instead (usually self.random)") |
| 186 | + "world's random object instead (usually self.random)", True) |
187 | 187 | self.plando_options = PlandoOptions.none |
188 | 188 |
|
189 | 189 | def get_all_ids(self) -> Tuple[int, ...]: |
@@ -585,26 +585,9 @@ def can_beat_game(self, |
585 | 585 | if self.has_beaten_game(state): |
586 | 586 | return True |
587 | 587 |
|
588 | | - base_locations = self.get_locations() if locations is None else locations |
589 | | - prog_locations = {location for location in base_locations if location.item |
590 | | - and location.item.advancement and location not in state.locations_checked} |
591 | | - |
592 | | - while prog_locations: |
593 | | - sphere: Set[Location] = set() |
594 | | - # build up spheres of collection radius. |
595 | | - # Everything in each sphere is independent from each other in dependencies and only depends on lower spheres |
596 | | - for location in prog_locations: |
597 | | - if location.can_reach(state): |
598 | | - sphere.add(location) |
599 | | - |
600 | | - if not sphere: |
601 | | - # ran out of places and did not finish yet, quit |
602 | | - return False |
603 | | - |
604 | | - for location in sphere: |
605 | | - state.collect(location.item, True, location) |
606 | | - prog_locations -= sphere |
607 | | - |
| 588 | + for _ in state.sweep_for_advancements(locations, |
| 589 | + yield_each_sweep=True, |
| 590 | + checked_locations=state.locations_checked): |
608 | 591 | if self.has_beaten_game(state): |
609 | 592 | return True |
610 | 593 |
|
@@ -889,20 +872,133 @@ def sweep_for_events(self, locations: Optional[Iterable[Location]] = None) -> No |
889 | 872 | "Please switch over to sweep_for_advancements.") |
890 | 873 | return self.sweep_for_advancements(locations) |
891 | 874 |
|
892 | | - def sweep_for_advancements(self, locations: Optional[Iterable[Location]] = None) -> None: |
| 875 | + def _sweep_for_advancements_impl(self, advancements_per_player: List[Tuple[int, List[Location]]], |
| 876 | + yield_each_sweep: bool) -> Iterator[None]: |
| 877 | + """ |
| 878 | + The implementation for sweep_for_advancements is separated here because it returns a generator due to the use |
| 879 | + of a yield statement. |
| 880 | + """ |
| 881 | + all_players = {player for player, _ in advancements_per_player} |
| 882 | + players_to_check = all_players |
| 883 | + # As an optimization, it is assumed that each player's world only logically depends on itself. However, worlds |
| 884 | + # are allowed to logically depend on other worlds, so once there are no more players that should be checked |
| 885 | + # under this assumption, an extra sweep iteration is performed that checks every player, to confirm that the |
| 886 | + # sweep is finished. |
| 887 | + checking_if_finished = False |
| 888 | + while players_to_check: |
| 889 | + next_advancements_per_player: List[Tuple[int, List[Location]]] = [] |
| 890 | + next_players_to_check = set() |
| 891 | + |
| 892 | + for player, locations in advancements_per_player: |
| 893 | + if player not in players_to_check: |
| 894 | + next_advancements_per_player.append((player, locations)) |
| 895 | + continue |
| 896 | + |
| 897 | + # Accessibility of each location is checked first because a player's region accessibility cache becomes |
| 898 | + # stale whenever one of their own items is collected into the state. |
| 899 | + reachable_locations: List[Location] = [] |
| 900 | + unreachable_locations: List[Location] = [] |
| 901 | + for location in locations: |
| 902 | + if location.can_reach(self): |
| 903 | + # Locations containing items that do not belong to `player` could be collected immediately |
| 904 | + # because they won't stale `player`'s region accessibility cache, but, for simplicity, all the |
| 905 | + # items at reachable locations are collected in a single loop. |
| 906 | + reachable_locations.append(location) |
| 907 | + else: |
| 908 | + unreachable_locations.append(location) |
| 909 | + if unreachable_locations: |
| 910 | + next_advancements_per_player.append((player, unreachable_locations)) |
| 911 | + |
| 912 | + # A previous player's locations processed in the current `while players_to_check` iteration could have |
| 913 | + # collected items belonging to `player`, but now that all of `player`'s reachable locations have been |
| 914 | + # found, it can be assumed that `player` will not gain any more reachable locations until another one of |
| 915 | + # their items is collected. |
| 916 | + # It would be clearer to not add players to `next_players_to_check` in the first place if they have yet |
| 917 | + # to be processed in the current `while players_to_check` iteration, but checking if a player should be |
| 918 | + # added to `next_players_to_check` would need to be run once for every item that is collected, so it is |
| 919 | + # more performant to instead discard `player` from `next_players_to_check` once their locations have |
| 920 | + # been processed. |
| 921 | + next_players_to_check.discard(player) |
| 922 | + |
| 923 | + # Collect the items from the reachable locations. |
| 924 | + for advancement in reachable_locations: |
| 925 | + self.advancements.add(advancement) |
| 926 | + item = advancement.item |
| 927 | + assert isinstance(item, Item), "tried to collect advancement Location with no Item" |
| 928 | + if self.collect(item, True, advancement): |
| 929 | + # The player the item belongs to may be able to reach additional locations in the next sweep |
| 930 | + # iteration. |
| 931 | + next_players_to_check.add(item.player) |
| 932 | + |
| 933 | + if not next_players_to_check: |
| 934 | + if not checking_if_finished: |
| 935 | + # It is assumed that each player's world only logically depends on itself, which may not be the |
| 936 | + # case, so confirm that the sweep is finished by doing an extra iteration that checks every player. |
| 937 | + checking_if_finished = True |
| 938 | + next_players_to_check = all_players |
| 939 | + else: |
| 940 | + checking_if_finished = False |
| 941 | + |
| 942 | + players_to_check = next_players_to_check |
| 943 | + advancements_per_player = next_advancements_per_player |
| 944 | + |
| 945 | + if yield_each_sweep: |
| 946 | + yield |
| 947 | + |
| 948 | + @overload |
| 949 | + def sweep_for_advancements(self, locations: Optional[Iterable[Location]] = None, *, |
| 950 | + yield_each_sweep: Literal[True], |
| 951 | + checked_locations: Optional[Set[Location]] = None) -> Iterator[None]: ... |
| 952 | + |
| 953 | + @overload |
| 954 | + def sweep_for_advancements(self, locations: Optional[Iterable[Location]] = None, |
| 955 | + yield_each_sweep: Literal[False] = False, |
| 956 | + checked_locations: Optional[Set[Location]] = None) -> None: ... |
| 957 | + |
| 958 | + def sweep_for_advancements(self, locations: Optional[Iterable[Location]] = None, yield_each_sweep: bool = False, |
| 959 | + checked_locations: Optional[Set[Location]] = None) -> Optional[Iterator[None]]: |
| 960 | + """ |
| 961 | + Sweep through the locations that contain uncollected advancement items, collecting the items into the state |
| 962 | + until there are no more reachable locations that contain uncollected advancement items. |
| 963 | +
|
| 964 | + :param locations: The locations to sweep through, defaulting to all locations in the multiworld. |
| 965 | + :param yield_each_sweep: When True, return a generator that yields at the end of each sweep iteration. |
| 966 | + :param checked_locations: Optional override of locations to filter out from the locations argument, defaults to |
| 967 | + self.advancements when None. |
| 968 | + """ |
| 969 | + if checked_locations is None: |
| 970 | + checked_locations = self.advancements |
| 971 | + |
| 972 | + # Since the sweep loop usually performs many iterations, the locations are filtered in advance. |
| 973 | + # A list of tuples is used, instead of a dictionary, because it is faster to iterate. |
| 974 | + advancements_per_player: List[Tuple[int, List[Location]]] |
893 | 975 | if locations is None: |
894 | | - locations = self.multiworld.get_filled_locations() |
895 | | - reachable_advancements = True |
896 | | - # since the loop has a good chance to run more than once, only filter the advancements once |
897 | | - locations = {location for location in locations if location.advancement and location not in self.advancements} |
898 | | - |
899 | | - while reachable_advancements: |
900 | | - reachable_advancements = {location for location in locations if location.can_reach(self)} |
901 | | - locations -= reachable_advancements |
902 | | - for advancement in reachable_advancements: |
903 | | - self.advancements.add(advancement) |
904 | | - assert isinstance(advancement.item, Item), "tried to collect Event with no Item" |
905 | | - self.collect(advancement.item, True, advancement) |
| 976 | + # `location.advancement` can only be True for filled locations, so unfilled locations are filtered out. |
| 977 | + advancements_per_player = [] |
| 978 | + for player, locations_dict in self.multiworld.regions.location_cache.items(): |
| 979 | + filtered_locations = [location for location in locations_dict.values() |
| 980 | + if location.advancement and location not in checked_locations] |
| 981 | + if filtered_locations: |
| 982 | + advancements_per_player.append((player, filtered_locations)) |
| 983 | + else: |
| 984 | + # Filter and separate the locations into a list for each player. |
| 985 | + advancements_per_player_dict: Dict[int, List[Location]] = defaultdict(list) |
| 986 | + for location in locations: |
| 987 | + if location.advancement and location not in checked_locations: |
| 988 | + advancements_per_player_dict[location.player].append(location) |
| 989 | + # Convert to a list of tuples. |
| 990 | + advancements_per_player = list(advancements_per_player_dict.items()) |
| 991 | + del advancements_per_player_dict |
| 992 | + |
| 993 | + if yield_each_sweep: |
| 994 | + # Return a generator that will yield at the end of each sweep iteration. |
| 995 | + return self._sweep_for_advancements_impl(advancements_per_player, True) |
| 996 | + else: |
| 997 | + # Create the generator, but tell it not to yield anything, so it will run to completion in zero iterations |
| 998 | + # once started, then start and exhaust the generator by attempting to iterate it. |
| 999 | + for _ in self._sweep_for_advancements_impl(advancements_per_player, False): |
| 1000 | + assert False, "Generator yielded when it should have run to completion without yielding" |
| 1001 | + return None |
906 | 1002 |
|
907 | 1003 | # item name related |
908 | 1004 | def has(self, item: str, player: int, count: int = 1) -> bool: |
|
0 commit comments