diff --git a/BaseClasses.py b/BaseClasses.py index e59e96e1..f480cbbd 100644 --- a/BaseClasses.py +++ b/BaseClasses.py @@ -9,8 +9,9 @@ from argparse import Namespace from collections import Counter, deque from collections.abc import Collection, MutableSequence from enum import IntEnum, IntFlag -from typing import (AbstractSet, Any, Callable, ClassVar, Dict, Iterable, Iterator, List, Mapping, NamedTuple, +from typing import (AbstractSet, Any, Callable, ClassVar, Dict, Iterable, Iterator, List, Literal, Mapping, NamedTuple, Optional, Protocol, Set, Tuple, Union, TYPE_CHECKING) +import dataclasses from typing_extensions import NotRequired, TypedDict @@ -54,12 +55,21 @@ class HasNameAndPlayer(Protocol): player: int +@dataclasses.dataclass +class PlandoItemBlock: + player: int + from_pool: bool + force: bool | Literal["silent"] + worlds: set[int] = dataclasses.field(default_factory=set) + items: list[str] = dataclasses.field(default_factory=list) + locations: list[str] = dataclasses.field(default_factory=list) + resolved_locations: list[Location] = dataclasses.field(default_factory=list) + count: dict[str, int] = dataclasses.field(default_factory=dict) + + class MultiWorld(): debug_types = False player_name: Dict[int, str] - plando_texts: List[Dict[str, str]] - plando_items: List[List[Dict[str, Any]]] - plando_connections: List worlds: Dict[int, "AutoWorld.World"] groups: Dict[int, Group] regions: RegionManager @@ -83,6 +93,8 @@ class MultiWorld(): start_location_hints: Dict[int, Options.StartLocationHints] item_links: Dict[int, Options.ItemLinks] + plando_item_blocks: Dict[int, List[PlandoItemBlock]] + game: Dict[int, str] random: random.Random @@ -160,13 +172,12 @@ class MultiWorld(): self.local_early_items = {player: {} for player in self.player_ids} self.indirect_connections = {} self.start_inventory_from_pool: Dict[int, Options.StartInventoryPool] = {} + self.plando_item_blocks = {} for player in range(1, players + 1): def set_player_attr(attr: str, val) -> None: self.__dict__.setdefault(attr, {})[player] = val - set_player_attr('plando_items', []) - set_player_attr('plando_texts', {}) - set_player_attr('plando_connections', []) + set_player_attr('plando_item_blocks', []) set_player_attr('game', "Archipelago") set_player_attr('completion_condition', lambda state: True) self.worlds = {} @@ -427,7 +438,8 @@ class MultiWorld(): 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, allow_partial_entrances: bool = False) -> CollectionState: + def get_all_state(self, use_cache: bool, allow_partial_entrances: bool = False, + collect_pre_fill_items: bool = True) -> CollectionState: cached = getattr(self, "_all_state", None) if use_cache and cached: return cached.copy() @@ -436,10 +448,11 @@ class MultiWorld(): 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 cce7aec2..ff59aa22 100644 --- a/Fill.py +++ b/Fill.py @@ -4,7 +4,7 @@ import logging import typing from collections import Counter, deque -from BaseClasses import CollectionState, Item, Location, LocationProgressType, MultiWorld +from BaseClasses import CollectionState, Item, Location, LocationProgressType, MultiWorld, PlandoItemBlock from Options import Accessibility from worlds.AutoWorld import call_all @@ -100,7 +100,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 @@ -242,7 +242,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 # Optimisation: Decide whether to do full location.can_fill check (respect excluded), or only check the item rule @@ -343,8 +343,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 @@ -365,7 +367,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) @@ -677,9 +679,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() @@ -777,7 +779,7 @@ def balance_multiworld_progression(multiworld: MultiWorld) -> None: 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: @@ -793,8 +795,8 @@ def balance_multiworld_progression(multiworld: MultiWorld) -> None: 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) @@ -867,52 +869,30 @@ def swap_location_item(location_1: Location, location_2: Location, check_locked: location_2.item.location = location_2 -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}') +def parse_planned_blocks(multiworld: MultiWorld) -> dict[int, list[PlandoItemBlock]]: + def warn(warning: str, force: bool | str) -> None: + 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']: + def failed(warning: str, force: bool | str) -> None: + if force is True: raise Exception(warning) else: warn(warning, force) - swept_state = multiworld.state.copy() - swept_state.sweep_for_advancements() - reachable = frozenset(multiworld.get_reachable_locations(swept_state)) - early_locations: typing.Dict[int, typing.List[str]] = collections.defaultdict(list) - non_early_locations: typing.Dict[int, typing.List[str]] = collections.defaultdict(list) - for loc in multiworld.get_unfilled_locations(): - if loc in reachable: - early_locations[loc.player].append(loc.name) - else: # not reachable with swept state - non_early_locations[loc.player].append(loc.name) - 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) + plando_blocks: dict[int, list[PlandoItemBlock]] = dict() + player_ids: set[int] = 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'] - + plando_blocks[player] = [] + for block in multiworld.worlds[player].options.plando_items: + new_block: PlandoItemBlock = PlandoItemBlock(player, block.from_pool, block.force) + target_world = block.world if target_world is False or multiworld.players == 1: # target own world - worlds: typing.Set[int] = {player} + worlds: set[int] = {player} elif target_world is True: # target any worlds besides own worlds = set(multiworld.player_ids) - {player} elif target_world is None: # target all worlds @@ -922,172 +902,197 @@ def distribute_planned(multiworld: MultiWorld) -> 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 + new_block.worlds = 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 + items: list[str] | dict[str, typing.Any] = block.items if isinstance(items, dict): - item_list: typing.List[str] = [] + item_list: list[str] = [] for key, value in items.items(): if value is True: value = multiworld.itempool.count(multiworld.worlds[player].create_item(key)) item_list += [key] * value 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: 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 + locations_from_groups: list[str] = [] + resolved_locations: list[Location] = [] + for target_player in worlds: + world_locations = multiworld.get_unfilled_locations(target_player) + 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]) + resolved_locations.extend(location for location in world_locations + if location.name in [*locations, *locations_from_groups]) + new_block.locations = sorted(dict.fromkeys(locations)) + new_block.resolved_locations = sorted(set(resolved_locations)) + count = block.count + if not count: + count = len(new_block.items) + if isinstance(count, int): + count = {"min": count, "max": count} + if "min" not in count: + count["min"] = 0 + if "max" not in count: + count["max"] = len(new_block.items) + + new_block.count = count + plando_blocks[player].append(new_block) + + return plando_blocks + + +def resolve_early_locations_for_planned(multiworld: MultiWorld): + def warn(warning: str, force: bool | str) -> None: + if isinstance(force, bool): + logging.warning(f"{warning}") + else: + logging.debug(f"{warning}") + + def failed(warning: str, force: bool | str) -> None: + if force is True: + raise Exception(warning) + else: + warn(warning, force) + + swept_state = multiworld.state.copy() + swept_state.sweep_for_advancements() + reachable = frozenset(multiworld.get_reachable_locations(swept_state)) + early_locations: dict[int, list[Location]] = collections.defaultdict(list) + non_early_locations: dict[int, list[Location]] = collections.defaultdict(list) + for loc in multiworld.get_unfilled_locations(): + if loc in reachable: + early_locations[loc.player].append(loc) + else: # not reachable with swept state + non_early_locations[loc.player].append(loc) + + for player in multiworld.plando_item_blocks: + removed = [] + for block in multiworld.plando_item_blocks[player]: + locations = block.locations + resolved_locations = block.resolved_locations + worlds = block.worlds if "early_locations" in locations: - locations.remove("early_locations") for target_player in worlds: - locations += early_locations[target_player] + resolved_locations += early_locations[target_player] if "non_early_locations" in locations: - locations.remove("non_early_locations") for target_player in worlds: - locations += non_early_locations[target_player] + resolved_locations += non_early_locations[target_player] - block['locations'] = list(dict.fromkeys(locations)) + if block.count["max"] > len(block.items): + count = block.count["max"] + failed(f"Plando count {count} greater than items specified", block.force) + block.count["max"] = len(block.items) + if block.count["min"] > len(block.items): + block.count["min"] = len(block.items) + if block.count["max"] > len(block.resolved_locations) > 0: + count = block.count["max"] + failed(f"Plando count {count} greater than locations specified", block.force) + block.count["max"] = len(block.resolved_locations) + if block.count["min"] > len(block.resolved_locations): + block.count["min"] = len(block.resolved_locations) + block.count["target"] = multiworld.random.randint(block.count["min"], + block.count["max"]) - 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 not block.count["target"]: + removed.append(block) - if block['count']['target'] > 0: - plando_blocks.append(block) + for block in removed: + multiworld.plando_item_blocks[player].remove(block) + + +def distribute_planned_blocks(multiworld: MultiWorld, plando_blocks: list[PlandoItemBlock]): + def warn(warning: str, force: bool | str) -> None: + if isinstance(force, bool): + logging.warning(f"{warning}") + else: + logging.debug(f"{warning}") + + def failed(warning: str, force: bool | str) -> None: + if force is True: + raise Exception(warning) + else: + warn(warning, force) # 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.resolved_locations) - block.count["target"] + if len(block.resolved_locations) > 0 + else len(multiworld.get_unfilled_locations(block.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'] + worlds = placement.worlds + locations = placement.resolved_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}.") + 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 locations if candidate.item is None + and 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 867a5b6c..e72887c2 100644 --- a/Generate.py +++ b/Generate.py @@ -334,12 +334,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 = {} @@ -405,7 +399,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(): @@ -438,7 +432,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: @@ -542,10 +536,6 @@ def roll_settings(weights: dict, plando_options: PlandoOptions = PlandoOptions.b handle_option(ret, game_weights, option_key, option, plando_options) valid_keys.add(option_key) - # TODO remove plando_items after moving it to the options system - valid_keys.add("plando_items") - if PlandoOptions.items in plando_options: - ret.plando_items = copy.deepcopy(game_weights.get("plando_items", [])) if ret.game == "A Link to the Past": # TODO there are still more LTTP options not on the options system valid_keys |= {"sprite_pool", "sprite", "random_sprite_on_event"} diff --git a/Main.py b/Main.py index 5d9e1bc2..147fa382 100644 --- a/Main.py +++ b/Main.py @@ -11,8 +11,8 @@ from typing import Dict, List, Optional, Set, Tuple, Union import worlds from BaseClasses import CollectionState, Item, Location, LocationProgressType, MultiWorld, Region -from Fill import FillError, balance_multiworld_progression, distribute_items_restrictive, distribute_planned, \ - flood_items +from Fill import FillError, balance_multiworld_progression, distribute_items_restrictive, flood_items, \ + parse_planned_blocks, distribute_planned_blocks, resolve_early_locations_for_planned from Options import StartInventoryPool from Utils import __version__, output_path, version_tuple, get_settings from settings import get_settings @@ -37,9 +37,6 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No logger = logging.getLogger() multiworld.set_seed(seed, args.race, str(args.outputname) if args.outputname else None) multiworld.plando_options = args.plando_options - multiworld.plando_items = args.plando_items.copy() - multiworld.plando_texts = args.plando_texts.copy() - multiworld.plando_connections = args.plando_connections.copy() multiworld.game = args.game.copy() multiworld.player_name = args.name.copy() multiworld.sprite = args.sprite.copy() @@ -135,6 +132,8 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No multiworld.worlds[1].options.non_local_items.value = set() multiworld.worlds[1].options.local_items.value = set() + multiworld.plando_item_blocks = parse_planned_blocks(multiworld) + AutoWorld.call_all(multiworld, "connect_entrances") AutoWorld.call_all(multiworld, "generate_basic") @@ -179,8 +178,9 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No multiworld._all_state = None logger.info("Running Item Plando.") - - distribute_planned(multiworld) + resolve_early_locations_for_planned(multiworld) + distribute_planned_blocks(multiworld, [x for player in multiworld.plando_item_blocks + for x in multiworld.plando_item_blocks[player]]) logger.info('Running Pre Main Fill.') diff --git a/Options.py b/Options.py index 41c2a77d..86e58ca6 100644 --- a/Options.py +++ b/Options.py @@ -24,6 +24,12 @@ if typing.TYPE_CHECKING: 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 @@ -1019,7 +1025,7 @@ class PlandoTexts(Option[typing.List[PlandoText]], VerifyKeys): 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): @@ -1045,7 +1051,7 @@ class PlandoTexts(Option[typing.List[PlandoText]], VerifyKeys): 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)}") @@ -1169,7 +1175,7 @@ class PlandoConnections(Option[typing.List[PlandoConnection]], metaclass=Connect 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)) @@ -1187,7 +1193,7 @@ class PlandoConnections(Option[typing.List[PlandoConnection]], metaclass=Connect 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)}.") @@ -1469,6 +1475,131 @@ class ItemLinks(OptionList): 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 OptionError(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 OptionError(f"Plando `percentage` has to be int, not {type(percentage)}.") + if not (0 <= percentage <= 100): + raise OptionError(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 OptionError("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, list)): + raise OptionError(f"Plando 'items' has to be string, list, or " + f"dictionary, not {type(items)}") + locations = item.get("locations", []) + if not locations: + locations = item.get("location", ["Everywhere"]) + if locations: + count = 1 + if isinstance(locations, str): + locations = [locations] + if not isinstance(locations, list): + raise OptionError(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") + if not isinstance(from_pool, bool): + raise OptionError(f"Plando 'from_pool' has to be true or false, not {from_pool!r}.") + if not (isinstance(force, bool) or force == "silent"): + raise OptionError(f"Plando `force` has to be true or false or `silent`, not {force!r}.") + 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 OptionError(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: + # confirm a valid count + if isinstance(plando.count, dict): + if "min" in plando.count and "max" in plando.count: + if plando.count["min"] > plando.count["max"]: + raise OptionError("Plando cannot have count `min` greater than `max`.") + 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 = world.item_name_groups[item] + filtered_items = sorted(group.difference(list(plando.items.keys()))) + if not filtered_items: + raise OptionError(f"Plando `items` contains the group \"{item}\" " + f"and every item in it. This is not allowed.") + if value is True: + for key in filtered_items: + plando.items[key] = True + else: + for key in random.choices(filtered_items, k=value): + plando.items[key] = plando.items.get(key, 0) + 1 + 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: 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 @@ -1491,6 +1622,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 1082a029..b74f82b7 100644 --- a/test/general/test_implemented.py +++ b/test/general/test_implemented.py @@ -53,6 +53,22 @@ class TestImplemented(unittest.TestCase): 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", "connect_entrances", "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}. Seed: {multiworld.seed}") + def test_explicit_indirect_conditions_spheres(self): """Tests that worlds using explicit indirect conditions produce identical spheres as when using implicit indirect conditions""" diff --git a/test/general/test_state.py b/test/general/test_state.py index 460fc3d6..06c4046a 100644 --- a/test/general/test_state.py +++ b/test/general/test_state.py @@ -26,4 +26,4 @@ class TestBase(unittest.TestCase): for step in self.test_steps: with self.subTest("Step", step=step): call_all(multiworld, step) - self.assertTrue(multiworld.get_all_state(False, True)) + self.assertTrue(multiworld.get_all_state(False, allow_partial_entrances=True)) diff --git a/worlds/alttp/EntranceRandomizer.py b/worlds/alttp/EntranceRandomizer.py index e62088c1..569e6a5d 100644 --- a/worlds/alttp/EntranceRandomizer.py +++ b/worlds/alttp/EntranceRandomizer.py @@ -54,16 +54,13 @@ def parse_arguments(argv, no_defaults=False): ret = parser.parse_args(argv) # cannot be set through CLI currently - ret.plando_items = [] - ret.plando_texts = {} - ret.plando_connections = [] if multiargs.multi: defaults = copy.deepcopy(ret) for player in range(1, multiargs.multi + 1): playerargs = parse_arguments(shlex.split(getattr(ret, f"p{player}")), True) - for name in ["plando_items", "plando_texts", "plando_connections", "game", "sprite", "sprite_pool"]: + for name in ["game", "sprite", "sprite_pool"]: value = getattr(defaults, name) if getattr(playerargs, name) is None else getattr(playerargs, name) if player == 1: setattr(ret, name, {1: value}) diff --git a/worlds/alttp/__init__.py b/worlds/alttp/__init__.py index 1934138a..7f8d6ddf 100644 --- a/worlds/alttp/__init__.py +++ b/worlds/alttp/__init__.py @@ -505,20 +505,20 @@ class ALTTPWorld(World): 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=False) 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.get_location('Turtle Rock - Prize'), + self.get_location('Eastern Palace - Prize'), + self.get_location('Desert Palace - Prize'), + self.get_location('Tower of Hera - Prize'), + self.get_location('Palace of Darkness - Prize'), + self.get_location('Thieves\' Town - Prize'), + self.get_location('Skull Woods - Prize'), + self.get_location('Swamp Palace - Prize'), + self.get_location('Ice Palace - Prize'), + self.get_location('Misery Mire - Prize')] 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] @@ -526,8 +526,8 @@ class ALTTPWorld(World): 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, @@ -541,7 +541,7 @@ class ALTTPWorld(World): if self.options.mode == 'standard' and self.options.small_key_shuffle \ and self.options.small_key_shuffle != small_key_shuffle.option_universal and \ self.options.small_key_shuffle != small_key_shuffle.option_own_dungeons: - world.local_early_items[player]["Small Key (Hyrule Castle)"] = 1 + self.multiworld.local_early_items[self.player]["Small Key (Hyrule Castle)"] = 1 @classmethod def stage_pre_fill(cls, world): @@ -811,12 +811,15 @@ class ALTTPWorld(World): 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 a643e91c..9dffc6c6 100644 --- a/worlds/blasphemous/__init__.py +++ b/worlds/blasphemous/__init__.py @@ -207,7 +207,6 @@ class BlasphemousWorld(World): if not self.options.skill_randomizer: self.place_items_from_dict(skill_dict) - def place_items_from_set(self, location_set: Set[str], name: str): for loc in location_set: self.get_location(loc).place_locked_item(self.create_item(name)) diff --git a/worlds/hylics2/__init__.py b/worlds/hylics2/__init__.py index 18bcb0ed..f94d9c22 100644 --- a/worlds/hylics2/__init__.py +++ b/worlds/hylics2/__init__.py @@ -127,6 +127,10 @@ class Hylics2World(World): 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 edc4305a..defb285d 100644 --- a/worlds/kh2/__init__.py +++ b/worlds/kh2/__init__.py @@ -436,6 +436,10 @@ class KH2World(World): for location in keyblade_locations: location.locked = 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 91d504d5..a28ba39b 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 Fill import parse_planned_blocks, distribute_planned_blocks, resolve_early_locations_for_planned +from Options import PlandoItems from test.general import setup_solo_multiworld from worlds.AutoWorld import call_all from . import LADXTestBase @@ -19,14 +20,17 @@ 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"] - distribute_planned(self.multiworld) + self.multiworld.worlds[1].options.plando_items = PlandoItems.from_any(self.options["plando_items"]) + self.multiworld.plando_item_blocks = parse_planned_blocks(self.multiworld) + resolve_early_locations_for_planned(self.multiworld) + distribute_planned_blocks(self.multiworld, [x for player in self.multiworld.plando_item_blocks + for x in self.multiworld.plando_item_blocks[player]]) call_all(self.multiworld, "pre_fill") def test_planned(self): diff --git a/worlds/oot/__init__.py b/worlds/oot/__init__.py index 136439ee..401c387d 100644 --- a/worlds/oot/__init__.py +++ b/worlds/oot/__init__.py @@ -32,7 +32,7 @@ from .Cosmetics import patch_cosmetics 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 @@ -220,6 +220,8 @@ class OOTWorld(World): 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_rb/__init__.py b/worlds/pokemon_rb/__init__.py index 6bf66a11..a455e38f 100644 --- a/worlds/pokemon_rb/__init__.py +++ b/worlds/pokemon_rb/__init__.py @@ -321,7 +321,7 @@ class PokemonRedBlueWorld(World): "Fuchsia Gym - Koga Prize", "Saffron Gym - Sabrina Prize", "Cinnabar Gym - Blaine Prize", "Viridian Gym - Giovanni Prize" ] if self.multiworld.get_location(loc, self.player).item is None] - state = self.multiworld.get_all_state(False) + state = self.multiworld.get_all_state(False, True, False) # Give it two tries to place badges with wild Pokemon and learnsets as-is. # If it can't, then try with all Pokemon collected, and we'll try to fix HM move availability after. if attempt > 1: @@ -395,7 +395,7 @@ class PokemonRedBlueWorld(World): # 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, True, 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): @@ -448,7 +448,7 @@ class PokemonRedBlueWorld(World): self.local_locs = locs - all_state = self.multiworld.get_all_state(False) + all_state = self.multiworld.get_all_state(False, True, False) reachable_mons = set() for mon in poke_data.pokemon_data: @@ -516,6 +516,11 @@ class PokemonRedBlueWorld(World): loc.item = None loc.place_locked_item(self.pc_item) + def get_pre_fill_items(self) -> typing.List["Item"]: + pool = [self.create_item(mon) for mon in poke_data.pokemon_data] + pool.append(self.pc_item) + 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/pokemon_rb/pokemon.py b/worlds/pokemon_rb/pokemon.py index e5d161a4..f1c171b8 100644 --- a/worlds/pokemon_rb/pokemon.py +++ b/worlds/pokemon_rb/pokemon.py @@ -400,7 +400,7 @@ def verify_hm_moves(multiworld, world, player): last_intervene = None while True: intervene_move = None - test_state = multiworld.get_all_state(False) + test_state = multiworld.get_all_state(False, True, False) if not logic.can_learn_hm(test_state, world, "Surf", player): intervene_move = "Surf" elif not logic.can_learn_hm(test_state, world, "Strength", player): diff --git a/worlds/sc2/Locations.py b/worlds/sc2/Locations.py index b9c30bb7..42b1dd4d 100644 --- a/worlds/sc2/Locations.py +++ b/worlds/sc2/Locations.py @@ -66,11 +66,8 @@ def get_plando_locations(world: World) -> List[str]: if world is None: return [] plando_locations = [] - for plando_setting in world.multiworld.plando_items[world.player]: - plando_locations += plando_setting.get("locations", []) - plando_setting_location = plando_setting.get("location", None) - if plando_setting_location is not None: - plando_locations.append(plando_setting_location) + for plando_setting in world.options.plando_items: + plando_locations += plando_setting.locations return plando_locations diff --git a/worlds/shivers/__init__.py b/worlds/shivers/__init__.py index 85f2cf18..3430a5a0 100644 --- a/worlds/shivers/__init__.py +++ b/worlds/shivers/__init__.py @@ -245,7 +245,7 @@ class ShiversWorld(World): storage_items += [self.create_item("Empty") for _ in range(3)] - state = self.multiworld.get_all_state(False) + state = self.multiworld.get_all_state(False, True, False) self.random.shuffle(storage_locs) self.random.shuffle(storage_items) @@ -255,6 +255,27 @@ class ShiversWorld(World): self.storage_placements = {location.name.replace("Storage: ", ""): location.item.name.replace(" DUPE", "") for location in storage_locs} + def get_pre_fill_items(self) -> List[Item]: + if self.options.full_pots == "pieces": + return [self.create_item(name) for name, data in item_table.items() if + data.type == ItemType.POT_DUPLICATE] + elif self.options.full_pots == "complete": + return [self.create_item(name) for name, data in item_table.items() if + data.type == ItemType.POT_COMPELTE_DUPLICATE] + else: + pool = [] + pieces = [self.create_item(name) for name, data in item_table.items() if + data.type == ItemType.POT_DUPLICATE] + complete = [self.create_item(name) for name, data in item_table.items() if + data.type == ItemType.POT_COMPELTE_DUPLICATE] + for i in range(10): + if self.pot_completed_list[i] == 0: + pool.append(pieces[i]) + pool.append(pieces[i + 10]) + else: + pool.append(complete[i]) + return pool + def fill_slot_data(self) -> dict: return { "StoragePlacements": self.storage_placements, diff --git a/worlds/stardew_valley/test/stability/TestUniversalTracker.py b/worlds/stardew_valley/test/stability/TestUniversalTracker.py index 0268d9e5..7590635a 100644 --- a/worlds/stardew_valley/test/stability/TestUniversalTracker.py +++ b/worlds/stardew_valley/test/stability/TestUniversalTracker.py @@ -35,9 +35,6 @@ class TestUniversalTrackerGenerationIsStable(SVTestBase): args.multi = 1 args.race = None args.plando_options = self.multiworld.plando_options - args.plando_items = self.multiworld.plando_items - args.plando_texts = self.multiworld.plando_texts - args.plando_connections = self.multiworld.plando_connections args.game = self.multiworld.game args.name = self.multiworld.player_name args.sprite = {} diff --git a/worlds/witness/player_items.py b/worlds/witness/player_items.py index 7b71e3c1..d13ebcaf 100644 --- a/worlds/witness/player_items.py +++ b/worlds/witness/player_items.py @@ -214,20 +214,17 @@ class WitnessPlayerItems: # Remove items that are mentioned in any plando options. (Hopefully, in the future, plando will get resolved # before create_items so that we'll be able to check placed items instead of just removing all items mentioned # regardless of whether or not they actually wind up being manually placed. - for plando_setting in self._multiworld.plando_items[self._player_id]: - if plando_setting.get("from_pool", True): - for item_setting_key in [key for key in ["item", "items"] if key in plando_setting]: - if isinstance(plando_setting[item_setting_key], str): - output -= {plando_setting[item_setting_key]} - elif isinstance(plando_setting[item_setting_key], dict): - output -= {item for item, weight in plando_setting[item_setting_key].items() if weight} - else: - # Assume this is some other kind of iterable. - for inner_item in plando_setting[item_setting_key]: - if isinstance(inner_item, str): - output -= {inner_item} - elif isinstance(inner_item, dict): - output -= {item for item, weight in inner_item.items() if weight} + for plando_setting in self._world.options.plando_items: + if plando_setting.from_pool: + if isinstance(plando_setting.items, dict): + output -= {item for item, weight in plando_setting.items.items() if weight} + else: + # Assume this is some other kind of iterable. + for inner_item in plando_setting.items: + if isinstance(inner_item, str): + output -= {inner_item} + elif isinstance(inner_item, dict): + output -= {item for item, weight in inner_item.items() if weight} # Sort the output for consistency across versions if the implementation changes but the logic does not. return sorted(output)