diff --git a/BaseClasses.py b/BaseClasses.py index 98ada4f861ec..ac2426e62535 100644 --- a/BaseClasses.py +++ b/BaseClasses.py @@ -426,7 +426,7 @@ def get_entrance(self, entrance_name: str, player: int) -> Entrance: def get_location(self, location_name: str, player: int) -> Location: return self.regions.location_cache[player][location_name] - def get_all_state(self, use_cache: bool) -> CollectionState: + def get_all_state(self, use_cache: bool, collect_pre_fill_items: bool = True) -> CollectionState: cached = getattr(self, "_all_state", None) if use_cache and cached: return cached.copy() @@ -435,10 +435,11 @@ def get_all_state(self, use_cache: bool) -> CollectionState: for item in self.itempool: self.worlds[item.player].collect(ret, item) - for player in self.player_ids: - subworld = self.worlds[player] - for item in subworld.get_pre_fill_items(): - subworld.collect(ret, item) + if collect_pre_fill_items: + for player in self.player_ids: + subworld = self.worlds[player] + for item in subworld.get_pre_fill_items(): + subworld.collect(ret, item) ret.sweep_for_advancements() if use_cache: diff --git a/Fill.py b/Fill.py index 86a4639c51ce..a2cf4241312c 100644 --- a/Fill.py +++ b/Fill.py @@ -98,7 +98,7 @@ def fill_restrictive(multiworld: MultiWorld, base_state: CollectionState, locati # if minimal accessibility, only check whether location is reachable if game not beatable if multiworld.worlds[item_to_place.player].options.accessibility == Accessibility.option_minimal: perform_access_check = not multiworld.has_beaten_game(maximum_exploration_state, - item_to_place.player) \ + item_to_place.player) \ if single_player_placement else not has_beaten_game else: perform_access_check = True @@ -239,7 +239,7 @@ def remaining_fill(multiworld: MultiWorld, unplaced_items: typing.List[Item] = [] placements: typing.List[Location] = [] swapped_items: typing.Counter[typing.Tuple[int, str]] = Counter() - total = min(len(itempool), len(locations)) + total = min(len(itempool), len(locations)) placed = 0 while locations and itempool: item_to_place = itempool.pop() @@ -329,8 +329,10 @@ def fast_fill(multiworld: MultiWorld, def accessibility_corrections(multiworld: MultiWorld, state: CollectionState, locations, pool=[]): maximum_exploration_state = sweep_from_pool(state, pool) - minimal_players = {player for player in multiworld.player_ids if multiworld.worlds[player].options.accessibility == "minimal"} - unreachable_locations = [location for location in multiworld.get_locations() if location.player in minimal_players and + minimal_players = {player for player in multiworld.player_ids if + multiworld.worlds[player].options.accessibility == "minimal"} + unreachable_locations = [location for location in multiworld.get_locations() if + location.player in minimal_players and not location.can_reach(maximum_exploration_state)] for location in unreachable_locations: if (location.item is not None and location.item.advancement and location.address is not None and not @@ -351,7 +353,7 @@ def inaccessible_location_rules(multiworld: MultiWorld, state: CollectionState, unreachable_locations = [location for location in locations if not location.can_reach(maximum_exploration_state)] if unreachable_locations: def forbid_important_item_rule(item: Item): - return not ((item.classification & 0b0011) and multiworld.worlds[item.player].options.accessibility != 'minimal') + return not ((item.classification & 0b0011) and multiworld.worlds[item.player].options.accessibility != "minimal") for location in unreachable_locations: add_item_rule(location, forbid_important_item_rule) @@ -633,9 +635,9 @@ def balance_multiworld_progression(multiworld: MultiWorld) -> None: if multiworld.worlds[player].options.progression_balancing > 0 } if not balanceable_players: - logging.info('Skipping multiworld progression balancing.') + logging.info("Skipping multiworld progression balancing.") else: - logging.info(f'Balancing multiworld progression for {len(balanceable_players)} Players.') + logging.info(f"Balancing multiworld progression for {len(balanceable_players)} Players.") logging.debug(balanceable_players) state: CollectionState = CollectionState(multiworld) checked_locations: typing.Set[Location] = set() @@ -733,7 +735,7 @@ def item_percentage(player: int, num: int) -> float: if player in threshold_percentages): break elif not balancing_sphere: - raise RuntimeError('Not all required items reachable. Something went terribly wrong here.') + raise RuntimeError("Not all required items reachable. Something went terribly wrong here.") # Gather a set of locations which we can swap items into unlocked_locations: typing.Dict[int, typing.Set[Location]] = collections.defaultdict(set) for l in unchecked_locations: @@ -749,8 +751,8 @@ def item_percentage(player: int, num: int) -> float: testing = items_to_test.pop() reducing_state = state.copy() for location in itertools.chain(( - l for l in items_to_replace - if l.item.player == player + l for l in items_to_replace + if l.item.player == player ), items_to_test): reducing_state.collect(location.item, True, location) @@ -825,13 +827,13 @@ def swap_location_item(location_1: Location, location_2: Location, check_locked: def distribute_planned(multiworld: MultiWorld) -> None: def warn(warning: str, force: typing.Union[bool, str]) -> None: - if force in [True, 'fail', 'failure', 'none', False, 'warn', 'warning']: - logging.warning(f'{warning}') + if isinstance(force, bool): + logging.warning(f"{warning}") else: - logging.debug(f'{warning}') + logging.debug(f"{warning}") def failed(warning: str, force: typing.Union[bool, str]) -> None: - if force in [True, 'fail', 'failure']: + if force is True: raise Exception(warning) else: warn(warning, force) @@ -849,23 +851,19 @@ def failed(warning: str, force: typing.Union[bool, str]) -> None: world_name_lookup = multiworld.world_name_lookup - block_value = typing.Union[typing.List[str], typing.Dict[str, typing.Any], str] plando_blocks: typing.List[typing.Dict[str, typing.Any]] = [] player_ids = set(multiworld.player_ids) for player in player_ids: - for block in multiworld.plando_items[player]: - block['player'] = player - if 'force' not in block: - block['force'] = 'silent' - if 'from_pool' not in block: - block['from_pool'] = True - elif not isinstance(block['from_pool'], bool): - from_pool_type = type(block['from_pool']) - raise Exception(f'Plando "from_pool" has to be boolean, not {from_pool_type} for player {player}.') - if 'world' not in block: - target_world = False - else: - target_world = block['world'] + for block in multiworld.worlds[player].options.plando_items: + new_block: typing.Dict[str, typing.Any] = {"player": player} + if not isinstance(block.from_pool, bool): + raise Exception(f"Plando 'from_pool' has to be boolean, not {type(block.from_pool)} for player {player}.") + new_block["from_pool"] = block.from_pool + new_block["force"] = block.force + target_world = block.world + + if not (isinstance(block.force, bool) or block.force == "silent"): + raise Exception(f"Plando `force` has to be boolean or `silent`, not {block.force} for player {player}") if target_world is False or multiworld.players == 1: # target own world worlds: typing.Set[int] = {player} @@ -878,36 +876,25 @@ def failed(warning: str, force: typing.Union[bool, str]) -> None: for listed_world in target_world: if listed_world not in world_name_lookup: failed(f"Cannot place item to {target_world}'s world as that world does not exist.", - block['force']) + block.force) continue worlds.add(world_name_lookup[listed_world]) elif type(target_world) == int: # target world by slot number if target_world not in range(1, multiworld.players + 1): failed( f"Cannot place item in world {target_world} as it is not in range of (1, {multiworld.players})", - block['force']) + block.force) continue worlds = {target_world} else: # target world by slot name if target_world not in world_name_lookup: failed(f"Cannot place item to {target_world}'s world as that world does not exist.", - block['force']) + block.force) continue worlds = {world_name_lookup[target_world]} - block['world'] = worlds - - items: block_value = [] - if "items" in block: - items = block["items"] - if 'count' not in block: - block['count'] = False - elif "item" in block: - items = block["item"] - if 'count' not in block: - block['count'] = 1 - else: - failed("You must specify at least one item to place items with plando.", block['force']) - continue + new_block["world"] = worlds + + items: typing.Union[typing.List[str], typing.Dict[str, typing.Any]] = block.items if isinstance(items, dict): item_list: typing.List[str] = [] for key, value in items.items(): @@ -917,22 +904,19 @@ def failed(warning: str, force: typing.Union[bool, str]) -> None: items = item_list if isinstance(items, str): items = [items] - block['items'] = items + new_block["items"] = items - locations: block_value = [] - if 'location' in block: - locations = block['location'] # just allow 'location' to keep old yamls compatible - elif 'locations' in block: - locations = block['locations'] + locations: typing.List[str] = block.locations if isinstance(locations, str): locations = [locations] - - if isinstance(locations, dict): - location_list = [] - for key, value in locations.items(): - location_list += [key] * value - locations = location_list - + elif not isinstance(locations, list): + raise Exception(f"Plando 'locations' has to be a list, not {type(locations)} for player {player}.") + + locations_from_groups: typing.List[str] = [] + for target_player in worlds: + for group in multiworld.worlds[target_player].location_name_groups: + if group in locations: + locations_from_groups.extend(multiworld.worlds[target_player].location_name_groups[group]) if "early_locations" in locations: locations.remove("early_locations") for target_player in worlds: @@ -941,109 +925,99 @@ def failed(warning: str, force: typing.Union[bool, str]) -> None: locations.remove("non_early_locations") for target_player in worlds: locations += non_early_locations[target_player] - - block['locations'] = list(dict.fromkeys(locations)) - - if not block['count']: - block['count'] = (min(len(block['items']), len(block['locations'])) if - len(block['locations']) > 0 else len(block['items'])) - if isinstance(block['count'], int): - block['count'] = {'min': block['count'], 'max': block['count']} - if 'min' not in block['count']: - block['count']['min'] = 0 - if 'max' not in block['count']: - block['count']['max'] = (min(len(block['items']), len(block['locations'])) if - len(block['locations']) > 0 else len(block['items'])) - if block['count']['max'] > len(block['items']): - count = block['count'] - failed(f"Plando count {count} greater than items specified", block['force']) - block['count'] = len(block['items']) - if block['count']['max'] > len(block['locations']) > 0: - count = block['count'] - failed(f"Plando count {count} greater than locations specified", block['force']) - block['count'] = len(block['locations']) - block['count']['target'] = multiworld.random.randint(block['count']['min'], block['count']['max']) - - if block['count']['target'] > 0: - plando_blocks.append(block) + locations += locations_from_groups + + new_block["locations"] = list(dict.fromkeys(locations)) + + new_block["count"] = block.count + if not new_block["count"]: + new_block["count"] = (min(len(new_block["items"]), len(new_block["locations"])) if + len(new_block["locations"]) > 0 else len(new_block["items"])) + if isinstance(new_block["count"], int): + new_block["count"] = {"min": new_block["count"], "max": new_block["count"]} + if "min" not in new_block["count"]: + new_block["count"]["min"] = 0 + if "max" not in new_block["count"]: + new_block["count"]["max"] = (min(len(new_block["items"]), len(new_block["locations"])) if + len(new_block["locations"]) > 0 else len(new_block["items"])) + if new_block["count"]["max"] > len(new_block["items"]): + count = new_block["count"]["max"] + failed(f"Plando count {count} greater than items specified", block.force) + new_block["count"]["max"] = len(new_block["items"]) + if new_block["count"]["min"] > len(new_block["items"]): + new_block["count"]["min"] = len(new_block["items"]) + if new_block["count"]["max"] > len(new_block["locations"]) > 0: + count = new_block["count"]["max"] + failed(f"Plando count {count} greater than locations specified", block.force) + new_block["count"]["max"] = len(new_block["locations"]) + if new_block["count"]["min"] > len(new_block["locations"]): + new_block["count"]["min"] = len(new_block["locations"]) + new_block["count"]["target"] = multiworld.random.randint(new_block["count"]["min"], + new_block["count"]["max"]) + + if new_block["count"]["target"] > 0: + plando_blocks.append(new_block) # shuffle, but then sort blocks by number of locations minus number of items, # so less-flexible blocks get priority multiworld.random.shuffle(plando_blocks) - plando_blocks.sort(key=lambda block: (len(block['locations']) - block['count']['target'] - if len(block['locations']) > 0 - else len(multiworld.get_unfilled_locations(player)) - block['count']['target'])) - + plando_blocks.sort(key=lambda block: (len(block["locations"]) - block["count"]["target"] + if len(block["locations"]) > 0 + else len(multiworld.get_unfilled_locations(player)) - + block["count"]["target"])) for placement in plando_blocks: - player = placement['player'] + player = placement["player"] try: - worlds = placement['world'] - locations = placement['locations'] - items = placement['items'] - maxcount = placement['count']['target'] - from_pool = placement['from_pool'] - - candidates = list(multiworld.get_unfilled_locations_for_players(locations, sorted(worlds))) - multiworld.random.shuffle(candidates) - multiworld.random.shuffle(items) - count = 0 - err: typing.List[str] = [] - successful_pairs: typing.List[typing.Tuple[int, Item, Location]] = [] - claimed_indices: typing.Set[typing.Optional[int]] = set() - for item_name in items: - index_to_delete: typing.Optional[int] = None - if from_pool: - try: - # If from_pool, try to find an existing item with this name & player in the itempool and use it - index_to_delete, item = next( - (i, item) for i, item in enumerate(multiworld.itempool) - if item.player == player and item.name == item_name and i not in claimed_indices - ) - except StopIteration: - warn( - f"Could not remove {item_name} from pool for {multiworld.player_name[player]} as it's already missing from it.", - placement['force']) - item = multiworld.worlds[player].create_item(item_name) - else: - item = multiworld.worlds[player].create_item(item_name) - - for location in reversed(candidates): - if (location.address is None) == (item.code is None): # either both None or both not None - if not location.item: - if location.item_rule(item): - if location.can_fill(multiworld.state, item, False): - successful_pairs.append((index_to_delete, item, location)) - claimed_indices.add(index_to_delete) - candidates.remove(location) - count = count + 1 - break - else: - err.append(f"Can't place item at {location} due to fill condition not met.") - else: - err.append(f"{item_name} not allowed at {location}.") - else: - err.append(f"Cannot place {item_name} into already filled location {location}.") + worlds = placement["world"] + locations = placement["locations"] + items = placement["items"] + maxcount = placement["count"]["target"] + from_pool = placement["from_pool"] + + item_candidates = [] + if from_pool: + instances = [item for item in multiworld.itempool if item.player == player and item.name in items] + for item in multiworld.random.sample(items, maxcount): + candidate = next((i for i in instances if i.name == item), None) + if candidate is None: + warn(f"Could not remove {item} from pool for {multiworld.player_name[player]} as " + f"it's already missing from it", placement["force"]) + candidate = multiworld.worlds[player].create_item(item) else: - err.append(f"Mismatch between {item_name} and {location}, only one is an event.") - - if count == maxcount: - break - if count < placement['count']['min']: - m = placement['count']['min'] - failed( - f"Plando block failed to place {m - count} of {m} item(s) for {multiworld.player_name[player]}, error(s): {' '.join(err)}", - placement['force']) - - # Sort indices in reverse so we can remove them one by one - successful_pairs = sorted(successful_pairs, key=lambda successful_pair: successful_pair[0] or 0, reverse=True) - - for (index, item, location) in successful_pairs: - multiworld.push_item(location, item, collect=False) - location.locked = True - logging.debug(f"Plando placed {item} at {location}") - if index is not None: # If this item is from_pool and was found in the pool, remove it. - multiworld.itempool.pop(index) - + multiworld.itempool.remove(candidate) + instances.remove(candidate) + item_candidates.append(candidate) + else: + item_candidates = [multiworld.worlds[player].create_item(item) + for item in multiworld.random.sample(items, maxcount)] + if any(item.code is None for item in item_candidates) \ + and not all(item.code is None for item in item_candidates): + failed(f"Plando block for player {player} ({multiworld.player_name[player]}) contains both " + f"event items and non-event items. " + f"Event items: {[item for item in item_candidates if item.code is None]}, " + f"Non-event items: {[item for item in item_candidates if item.code is not None]}", + placement["force"]) + continue + else: + is_real = item_candidates[0].code is not None + candidates = [candidate for candidate in multiworld.get_unfilled_locations_for_players(locations, + sorted(worlds)) + if bool(candidate.address) == is_real] + multiworld.random.shuffle(candidates) + allstate = multiworld.get_all_state(False) + mincount = placement["count"]["min"] + allowed_margin = len(item_candidates) - mincount + fill_restrictive(multiworld, allstate, candidates, item_candidates, lock=True, + allow_partial=True, name="Plando Main Fill") + + if len(item_candidates) > allowed_margin: + failed(f"Could not place {len(item_candidates)} " + f"of {mincount + allowed_margin} item(s) " + f"for {multiworld.player_name[player]}, " + f"remaining items: {item_candidates}", + placement["force"]) + if from_pool: + multiworld.itempool.extend([item for item in item_candidates if item.code is not None]) except Exception as e: raise Exception( f"Error running plando for player {player} ({multiworld.player_name[player]})") from e diff --git a/Generate.py b/Generate.py index 35c39627b139..62f956297eb9 100644 --- a/Generate.py +++ b/Generate.py @@ -298,12 +298,6 @@ def handle_name(name: str, player: int, name_counter: Counter): return new_name -def roll_percentage(percentage: Union[int, float]) -> bool: - """Roll a percentage chance. - percentage is expected to be in range [0, 100]""" - return random.random() < (float(percentage) / 100) - - def update_weights(weights: dict, new_weights: dict, update_type: str, name: str) -> dict: logging.debug(f'Applying {new_weights}') cleaned_weights = {} @@ -369,7 +363,7 @@ def roll_linked_options(weights: dict) -> dict: if "name" not in option_set: raise ValueError("One of your linked options does not have a name.") try: - if roll_percentage(option_set["percentage"]): + if Options.roll_percentage(option_set["percentage"]): logging.debug(f"Linked option {option_set['name']} triggered.") new_options = option_set["options"] for category_name, category_options in new_options.items(): @@ -402,7 +396,7 @@ def roll_triggers(weights: dict, triggers: list, valid_keys: set) -> dict: trigger_result = get_choice("option_result", option_set) result = get_choice(key, currently_targeted_weights) currently_targeted_weights[key] = result - if result == trigger_result and roll_percentage(get_choice("percentage", option_set, 100)): + if result == trigger_result and Options.roll_percentage(get_choice("percentage", option_set, 100)): for category_name, category_options in option_set["options"].items(): currently_targeted_weights = weights if category_name: @@ -501,8 +495,6 @@ def roll_settings(weights: dict, plando_options: PlandoOptions = PlandoOptions.b if option_key in {"triggers", *valid_keys}: continue logging.warning(f"{option_key} is not a valid option name for {ret.game} and is not present in triggers.") - if PlandoOptions.items in plando_options: - ret.plando_items = copy.deepcopy(game_weights.get("plando_items", [])) if ret.game == "A Link to the Past": roll_alttp_settings(ret, game_weights) diff --git a/Options.py b/Options.py index 4e26a0d56c5c..25367f378282 100644 --- a/Options.py +++ b/Options.py @@ -23,6 +23,12 @@ import pathlib +def roll_percentage(percentage: int | float) -> bool: + """Roll a percentage chance. + percentage is expected to be in range [0, 100]""" + return random.random() < (float(percentage) / 100) + + class OptionError(ValueError): pass @@ -976,7 +982,7 @@ def from_any(cls, data: PlandoTextsFromAnyType) -> Self: if isinstance(data, typing.Iterable): for text in data: if isinstance(text, typing.Mapping): - if random.random() < float(text.get("percentage", 100)/100): + if roll_percentage(text.get("percentage", 100)): at = text.get("at", None) if at is not None: if isinstance(at, dict): @@ -1002,7 +1008,7 @@ def from_any(cls, data: PlandoTextsFromAnyType) -> Self: else: raise OptionError("\"at\" must be a valid string or weighted list of strings!") elif isinstance(text, PlandoText): - if random.random() < float(text.percentage/100): + if roll_percentage(text.percentage): texts.append(text) else: raise Exception(f"Cannot create plando text from non-dictionary type, got {type(text)}") @@ -1126,7 +1132,7 @@ def from_any(cls, data: PlandoConFromAnyType) -> Self: for connection in data: if isinstance(connection, typing.Mapping): percentage = connection.get("percentage", 100) - if random.random() < float(percentage / 100): + if roll_percentage(percentage): entrance = connection.get("entrance", None) if is_iterable_except_str(entrance): entrance = random.choice(sorted(entrance)) @@ -1144,7 +1150,7 @@ def from_any(cls, data: PlandoConFromAnyType) -> Self: percentage )) elif isinstance(connection, PlandoConnection): - if random.random() < float(connection.percentage / 100): + if roll_percentage(connection.percentage): value.append(connection) else: raise Exception(f"Cannot create connection from non-Dict type, got {type(connection)}.") @@ -1420,6 +1426,116 @@ def verify(self, world: typing.Type[World], player_name: str, plando_options: "P link["item_pool"] = list(pool) +@dataclass(frozen=True) +class PlandoItem: + items: list[str] | dict[str, typing.Any] + locations: list[str] + world: int | str | bool | None | typing.Iterable[str] | set[int] = False + from_pool: bool = True + force: bool | typing.Literal["silent"] = "silent" + count: int | bool | dict[str, int] = False + percentage: int = 100 + + +class PlandoItems(Option[typing.List[PlandoItem]]): + """Generic items plando.""" + default = () + supports_weighting = False + display_name = "Plando Items" + + def __init__(self, value: typing.Iterable[PlandoItem]) -> None: + self.value = list(deepcopy(value)) + super().__init__() + + @classmethod + def from_any(cls, data: typing.Any) -> Option[typing.List[PlandoItem]]: + if not isinstance(data, typing.Iterable): + raise Exception(f"Cannot create plando items from non-Iterable type, got {type(data)}") + + value: typing.List[PlandoItem] = [] + for item in data: + if isinstance(item, typing.Mapping): + percentage = item.get("percentage", 100) + if not isinstance(percentage, int): + raise Exception(f"Plando `percentage` has to be int, not {type(percentage)}.") + if not (0 <= percentage <= 100): + raise Exception(f"Plando `percentage` has to be between 0 and 100 (inclusive) not {percentage}.") + if roll_percentage(percentage): + count = item.get("count", False) + items = item.get("items", []) + if not items: + items = item.get("item", None) # explicitly throw an error here if not present + if not items: + raise Exception("You must specify at least one item to place items with plando.") + count = 1 + if isinstance(items, str): + items = [items] + elif not isinstance(items, dict): + raise Exception(f"Plando 'item' has to be string or dictionary, not {type(items)}.") + locations = item.get("locations", []) + if not locations: + locations = item.get("location", []) + if locations: + count = 1 + if isinstance(locations, str): + locations = [locations] + if not isinstance(locations, list): + raise Exception(f"Plando `location` has to be string or list, not {type(locations)}") + world = item.get("world", False) + from_pool = item.get("from_pool", True) + force = item.get("force", "silent") + value.append(PlandoItem(items, locations, world, from_pool, force, count, percentage)) + elif isinstance(item, PlandoItem): + if roll_percentage(item.percentage): + value.append(item) + else: + raise Exception(f"Cannot create plando item from non-Dict type, got {type(item)}.") + return cls(value) + + def verify(self, world: typing.Type[World], player_name: str, plando_options: "PlandoOptions") -> None: + if not self.value: + return + from BaseClasses import PlandoOptions + if not (PlandoOptions.items & plando_options): + # plando is disabled but plando options were given so overwrite the options + self.value = [] + logging.warning(f"The plando items module is turned off, " + f"so items for {player_name} will be ignored.") + else: + # filter down item groups + for plando in self.value: + items_copy = plando.items.copy() + if isinstance(plando.items, dict): + for item in items_copy: + if item in world.item_name_groups: + value = plando.items.pop(item) + group = sorted(world.item_name_groups[item]) + for group_item in group: + if group_item in plando.items: + raise Exception(f"Plando `items` contains both \"{group_item}\" and the group " + f"\"{item}\" which contains it. It cannot have both.") + plando.items.update({key: value for key in group}) + else: + assert isinstance(plando.items, list) # pycharm can't figure out the hinting without the hint + for item in items_copy: + if item in world.item_name_groups: + plando.items.remove(item) + plando.items.extend(sorted(world.item_name_groups[item])) + + @classmethod + def get_option_name(cls, value: typing.List[PlandoItem]) -> str: + return ", ".join(["%s-%s" % (item.items, item.locations) for item in value]) #TODO: see what a better way to display would be + + def __getitem__(self, index: typing.SupportsIndex) -> PlandoItem: + return self.value.__getitem__(index) + + def __iter__(self) -> typing.Iterator[PlandoItem]: + yield from self.value + + def __len__(self) -> int: + return len(self.value) + + class Removed(FreeText): """This Option has been Removed.""" rich_text_doc = True @@ -1442,6 +1558,7 @@ class PerGameCommonOptions(CommonOptions): exclude_locations: ExcludeLocations priority_locations: PriorityLocations item_links: ItemLinks + plando_items: PlandoItems @dataclass diff --git a/test/general/test_implemented.py b/test/general/test_implemented.py index e76d539451ea..12eaa13d4675 100644 --- a/test/general/test_implemented.py +++ b/test/general/test_implemented.py @@ -52,3 +52,19 @@ def test_slot_data(self): def test_no_failed_world_loads(self): if failed_world_loads: self.fail(f"The following worlds failed to load: {failed_world_loads}") + + def test_prefill_items(self): + """Test that every world can reach every location from allstate before pre_fill.""" + for gamename, world_type in AutoWorldRegister.world_types.items(): + if gamename not in ("Archipelago", "Sudoku", "Final Fantasy", "Test Game"): + with self.subTest(gamename): + multiworld = setup_solo_multiworld(world_type, ("generate_early", "create_regions", "create_items", + "set_rules", "generate_basic")) + allstate = multiworld.get_all_state(False) + locations = multiworld.get_locations() + reachable = multiworld.get_reachable_locations(allstate) + unreachable = [location for location in locations if location not in reachable] + + self.assertTrue(not unreachable, + f"Locations were not reachable with all state before prefill: " + f"{unreachable}") diff --git a/worlds/alttp/__init__.py b/worlds/alttp/__init__.py index b5489906889f..838e88c4675a 100644 --- a/worlds/alttp/__init__.py +++ b/worlds/alttp/__init__.py @@ -496,20 +496,20 @@ def collect_item(self, state: CollectionState, item: Item, remove=False): def pre_fill(self): from Fill import fill_restrictive, FillError attempts = 5 - world = self.multiworld - player = self.player - all_state = world.get_all_state(use_cache=True) + all_state = self.multiworld.get_all_state(use_cache=True).copy() crystals = [self.create_item(name) for name in ['Red Pendant', 'Blue Pendant', 'Green Pendant', 'Crystal 1', 'Crystal 2', 'Crystal 3', 'Crystal 4', 'Crystal 7', 'Crystal 5', 'Crystal 6']] - crystal_locations = [world.get_location('Turtle Rock - Prize', player), - world.get_location('Eastern Palace - Prize', player), - world.get_location('Desert Palace - Prize', player), - world.get_location('Tower of Hera - Prize', player), - world.get_location('Palace of Darkness - Prize', player), - world.get_location('Thieves\' Town - Prize', player), - world.get_location('Skull Woods - Prize', player), - world.get_location('Swamp Palace - Prize', player), - world.get_location('Ice Palace - Prize', player), - world.get_location('Misery Mire - Prize', player)] + for crystal in crystals: + all_state.remove(crystal) + crystal_locations = [self.multiworld.get_location('Turtle Rock - Prize', self.player), + self.multiworld.get_location('Eastern Palace - Prize', self.player), + self.multiworld.get_location('Desert Palace - Prize', self.player), + self.multiworld.get_location('Tower of Hera - Prize', self.player), + self.multiworld.get_location('Palace of Darkness - Prize', self.player), + self.multiworld.get_location('Thieves\' Town - Prize', self.player), + self.multiworld.get_location('Skull Woods - Prize', self.player), + self.multiworld.get_location('Swamp Palace - Prize', self.player), + self.multiworld.get_location('Ice Palace - Prize', self.player), + self.multiworld.get_location('Misery Mire - Prize', self.player)] placed_prizes = {loc.item.name for loc in crystal_locations if loc.item} unplaced_prizes = [crystal for crystal in crystals if crystal.name not in placed_prizes] empty_crystal_locations = [loc for loc in crystal_locations if not loc.item] @@ -517,8 +517,8 @@ def pre_fill(self): try: prizepool = unplaced_prizes.copy() prize_locs = empty_crystal_locations.copy() - world.random.shuffle(prize_locs) - fill_restrictive(world, all_state, prize_locs, prizepool, True, lock=True, + self.multiworld.random.shuffle(prize_locs) + fill_restrictive(self.multiworld, all_state, prize_locs, prizepool, True, lock=True, name="LttP Dungeon Prizes") except FillError as e: lttp_logger.exception("Failed to place dungeon prizes (%s). Will retry %s more times", e, @@ -529,10 +529,10 @@ def pre_fill(self): break else: raise FillError('Unable to place dungeon prizes') - if world.mode[player] == 'standard' and world.small_key_shuffle[player] \ - and world.small_key_shuffle[player] != small_key_shuffle.option_universal and \ - world.small_key_shuffle[player] != small_key_shuffle.option_own_dungeons: - world.local_early_items[player]["Small Key (Hyrule Castle)"] = 1 + if self.multiworld.mode[self.player] == 'standard' and self.multiworld.small_key_shuffle[self.player] \ + and self.multiworld.small_key_shuffle[self.player] != small_key_shuffle.option_universal and \ + self.multiworld.small_key_shuffle[self.player] != small_key_shuffle.option_own_dungeons: + self.multiworld.local_early_items[self.player]["Small Key (Hyrule Castle)"] = 1 @classmethod def stage_pre_fill(cls, world): @@ -802,12 +802,15 @@ def get_filler_item_name(self) -> str: return GetBeemizerItem(self.multiworld, self.player, item) def get_pre_fill_items(self): - res = [] + res = [self.create_item(name) for name in ('Red Pendant', 'Blue Pendant', 'Green Pendant', 'Crystal 1', + 'Crystal 2', 'Crystal 3', 'Crystal 4', 'Crystal 7', 'Crystal 5', + 'Crystal 6')] if self.dungeon_local_item_names: for dungeon in self.dungeons.values(): for item in dungeon.all_items: if item.name in self.dungeon_local_item_names: res.append(item) + return res def fill_slot_data(self): diff --git a/worlds/blasphemous/__init__.py b/worlds/blasphemous/__init__.py index a967fbac9289..f11b360f3417 100644 --- a/worlds/blasphemous/__init__.py +++ b/worlds/blasphemous/__init__.py @@ -203,7 +203,7 @@ def create_items(self): if self.options.thorn_shuffle == "local_only": self.options.local_items.value.add("Thorn Upgrade") - + def place_items_from_set(self, location_set: Set[str], name: str): for loc in location_set: diff --git a/worlds/hylics2/__init__.py b/worlds/hylics2/__init__.py index 18bcb0edc143..f94d9c225373 100644 --- a/worlds/hylics2/__init__.py +++ b/worlds/hylics2/__init__.py @@ -127,6 +127,10 @@ def pre_fill(self): tv = tvs.pop() self.get_location(tv).place_locked_item(self.create_item(gesture)) + def get_pre_fill_items(self) -> List["Item"]: + if self.options.gesture_shuffle: + return [self.create_item(gesture["name"]) for gesture in Items.gesture_item_table.values()] + return [] def fill_slot_data(self) -> Dict[str, Any]: slot_data: Dict[str, Any] = { diff --git a/worlds/kh2/__init__.py b/worlds/kh2/__init__.py index 2809460aed6a..9f74dd76ca67 100644 --- a/worlds/kh2/__init__.py +++ b/worlds/kh2/__init__.py @@ -431,10 +431,14 @@ def keyblade_pre_fill(self): Fills keyblade slots with abilities determined on player's setting """ keyblade_locations = [self.multiworld.get_location(location, self.player) for location in Keyblade_Slots.keys()] - state = self.multiworld.get_all_state(False) + state = self.multiworld.get_all_state(False, False) keyblade_ability_pool_copy = self.keyblade_ability_pool.copy() fill_restrictive(self.multiworld, state, keyblade_locations, keyblade_ability_pool_copy, True, True, allow_excluded=True) + def get_pre_fill_items(self) -> List["Item"]: + return [self.create_item(item) for item in [*DonaldAbility_Table.keys(), *GoofyAbility_Table.keys(), + *SupportAbility_Table.keys()]] + def starting_invo_verify(self): """ Making sure the player doesn't put too many abilities in their starting inventory. diff --git a/worlds/ladx/test/testShop.py b/worlds/ladx/test/testShop.py index 91d504d521b4..24ab70f1ad7c 100644 --- a/worlds/ladx/test/testShop.py +++ b/worlds/ladx/test/testShop.py @@ -1,6 +1,7 @@ from typing import Optional from Fill import distribute_planned +from Options import PlandoItems from test.general import setup_solo_multiworld from worlds.AutoWorld import call_all from . import LADXTestBase @@ -19,13 +20,13 @@ class PlandoTest(LADXTestBase): ], }], } - + def world_setup(self, seed: Optional[int] = None) -> None: self.multiworld = setup_solo_multiworld( LinksAwakeningWorld, ("generate_early", "create_regions", "create_items", "set_rules", "generate_basic") ) - self.multiworld.plando_items[1] = self.options["plando_items"] + self.multiworld.worlds[1].options.plando_items = PlandoItems.from_any(self.options["plando_items"]) distribute_planned(self.multiworld) call_all(self.multiworld, "pre_fill") diff --git a/worlds/oot/__init__.py b/worlds/oot/__init__.py index 975902ae6e64..181998aafd37 100644 --- a/worlds/oot/__init__.py +++ b/worlds/oot/__init__.py @@ -32,7 +32,7 @@ from settings import get_settings from BaseClasses import MultiWorld, CollectionState, Tutorial, LocationProgressType -from Options import Range, Toggle, VerifyKeys, Accessibility, PlandoConnections +from Options import Range, Toggle, VerifyKeys, Accessibility, PlandoConnections, PlandoItems from Fill import fill_restrictive, fast_fill, FillError from worlds.generic.Rules import exclusion_rules, add_item_rule from worlds.AutoWorld import World, AutoLogicRegister, WebWorld @@ -218,6 +218,8 @@ def generate_early(self): option_value = result.value elif isinstance(result, PlandoConnections): option_value = result.value + elif isinstance(result, PlandoItems): + option_value = result.value else: option_value = result.current_key setattr(self, option_name, option_value) diff --git a/worlds/pokemon_emerald/__init__.py b/worlds/pokemon_emerald/__init__.py index 7b62b9ef73b1..a320bc21affd 100644 --- a/worlds/pokemon_emerald/__init__.py +++ b/worlds/pokemon_emerald/__init__.py @@ -506,7 +506,7 @@ def pre_fill(self) -> None: if location.progress_type == LocationProgressType.EXCLUDED \ else location.progress_type - collection_state = self.multiworld.get_all_state(False) + collection_state = self.multiworld.get_all_state(False, False) # If HM shuffle is on, HMs are not placed and not in the pool, so # `get_all_state` did not contain them. Collect them manually for @@ -565,7 +565,7 @@ def pre_fill(self) -> None: if location.progress_type == LocationProgressType.EXCLUDED \ else location.progress_type - collection_state = self.multiworld.get_all_state(False) + collection_state = self.multiworld.get_all_state(False, False) # In specific very constrained conditions, fill_restrictive may run # out of swaps before it finds a valid solution if it gets unlucky. @@ -585,6 +585,16 @@ def pre_fill(self) -> None: logging.debug(f"Failed to shuffle HMs for player {self.player}. Retrying.") continue + def get_pre_fill_items(self) -> List[PokemonEmeraldItem]: + pool = [] + if self.options.badges == RandomizeBadges.option_shuffle: + pool.extend(badge for _, badge in self.badge_shuffle_info) + + if self.options.hms == RandomizeHms.option_shuffle: + pool.extend(hm for _, hm in self.hm_shuffle_info) + + return pool + def generate_output(self, output_directory: str) -> None: self.modified_trainers = copy.deepcopy(emerald_data.trainers) self.modified_tmhm_moves = copy.deepcopy(emerald_data.tmhm_moves) diff --git a/worlds/pokemon_rb/__init__.py b/worlds/pokemon_rb/__init__.py index 809179cbef74..a7bb478be2c3 100644 --- a/worlds/pokemon_rb/__init__.py +++ b/worlds/pokemon_rb/__init__.py @@ -404,7 +404,7 @@ def pre_fill(self) -> None: # Delete evolution events for Pokémon that are not in logic in an all_state so that accessibility check does not # fail. Re-use test_state from previous final loop. - all_state = self.multiworld.get_all_state(False) + all_state = self.multiworld.get_all_state(False, False) evolutions_region = self.multiworld.get_region("Evolution", self.player) for location in evolutions_region.locations.copy(): if not all_state.can_reach(location, player=self.player): @@ -460,7 +460,7 @@ def pre_fill(self) -> None: self.local_locs = locs - all_state = self.multiworld.get_all_state(False) + all_state = self.multiworld.get_all_state(False, False) reachable_mons = set() for mon in poke_data.pokemon_data: @@ -520,6 +520,12 @@ def pre_fill(self) -> None: else: raise Exception("Failed to remove corresponding item while deleting unreachable Dexsanity location") + def get_pre_fill_items(self) -> typing.List["Item"]: + pool = [self.create_item(mon) for mon in poke_data.pokemon_data] + return pool + + + @classmethod def stage_post_fill(cls, multiworld): # Convert all but one of each instance of a wild Pokemon to useful classification. diff --git a/worlds/raft/__init__.py b/worlds/raft/__init__.py index 3e33b417c04b..15caec0cc1c6 100644 --- a/worlds/raft/__init__.py +++ b/worlds/raft/__init__.py @@ -114,6 +114,14 @@ def set_rules(self): def create_regions(self): create_regions(self.multiworld, self.player) + def get_pre_fill_items(self): + if self.options.island_frequency_locations.is_filling_frequencies_in_world(): + return [self.create_item(frequency) for frequency in ("Vasagatan Frequency", "Balboa Island Frequency", + "Caravan Island Frequency", "Tangaroa Frequency", + "Varuna Point Frequency", "Temperance Frequency", + "Utopia Frequency")] + return [] + def create_item_replaceAsNecessary(self, name: str) -> Item: isFrequency = "Frequency" in name shouldUseProgressive = bool((isFrequency and self.options.island_frequency_locations == self.options.island_frequency_locations.option_progressive) diff --git a/worlds/shivers/__init__.py b/worlds/shivers/__init__.py index 3ca87ae164f2..13bcd718e867 100644 --- a/worlds/shivers/__init__.py +++ b/worlds/shivers/__init__.py @@ -199,7 +199,7 @@ def pre_fill(self) -> None: storageitems += [self.create_item("Empty") for i in range(3)] - state = self.multiworld.get_all_state(True) + state = self.multiworld.get_all_state(True, False) self.random.shuffle(storagelocs) self.random.shuffle(storageitems) @@ -208,6 +208,21 @@ def pre_fill(self) -> None: self.storage_placements = {location.name: location.item.name for location in storagelocs} + def get_pre_fill_items(self) -> List["Item"]: + if self.options.full_pots == "pieces": + return [self.create_item(self.item_id_to_name[self.shivers_item_id_offset + 70 + i]) for i in range(20)] + elif self.options.full_pots == "complete": + return [self.create_item(self.item_id_to_name[self.shivers_item_id_offset + 140 + i]) for i in range(10)] + else: + pool = [] + for i in range(10): + if self.pot_completed_list[i] == 0: + pool.extend([self.create_item(self.item_id_to_name[self.shivers_item_id_offset + 70 + i]), + self.create_item(self.item_id_to_name[self.shivers_item_id_offset + 80 + i])]) + else: + pool.append(self.create_item(self.item_id_to_name[self.shivers_item_id_offset + 140 + i])) + return pool + set_rules = set_rules def fill_slot_data(self) -> dict: