diff --git a/Fill.py b/Fill.py index 0bebe4a5a..6bc717980 100644 --- a/Fill.py +++ b/Fill.py @@ -5,7 +5,7 @@ from Hints import HintArea from Item import Item, ItemFactory, ItemInfo -from ItemPool import remove_junk_items +from ItemPool import item_groups, remove_junk_barren_items, removable_major_barren_items, remove_junk_items from Location import Location, DisableType from LocationList import location_groups from Rules import set_shop_rules @@ -26,6 +26,168 @@ class FillError(ShuffleError): pass +def is_item_replaceable_barren(item: Item, settings) -> bool: + """ + Determines if an item can be replaced by Nothing in barren mode. + + Returns False for items that must ALWAYS remain in the pool. + + Args: + item: The item to check + settings: The world settings object + + Returns: + bool: True if the item can be replaced, False otherwise + """ + if item.type == 'Shop': + return False + + # Songs: depends on shuffle_song_items setting + if item.type == 'Song' and settings.shuffle_song_items != 'any': + return False + + # Keys/BK: keep them in the pool if it's vanilla or own dungeon to preserve key logic + if item.type == 'SmallKey' and (settings.shuffle_smallkeys == 'vanilla' or settings.shuffle_smallkeys == 'dungeon'): + return False + if item.type == 'BossKey' and (settings.shuffle_bosskeys == 'vanilla' or settings.shuffle_bosskeys == 'dungeon'): + return False + + # Ice Traps: NEVER replace (important gameplay role) + if item.name == 'Ice Trap': + return False + + # Winner piece of heart isn't removed for junk ice trap + if item.name == 'Piece of Heart (Treasure Chest Game)' and settings.ice_trap_appearance == 'junk_only' and settings.junk_ice_traps != 'off': + return False + + return True + +def reduce_placed_items_to_barren(worlds: list[World]) -> None: + """ + Replaces placed items with "Nothing" if they are not required to beat the game. + + This function is called AFTER items have been placed. It tests each major item + to see if the game is still beatable without it. If yes, the item is replaced + with "Nothing". + + Args: + worlds: List of World objects with items already placed + """ + logger.info('Reducing placed items to barren minimum...') + logger.info('Testing each placed item to see if it is required...') + + replaced_count = 0 + tested_count = 0 + + # Collect all placed items (items in locations, not in the pool) + all_locations: list[Location] = [location for world in worlds for location in world.get_locations() if location.item is not None] + + # Filter to testable items + testable_locations: list[Location] = [] + for location in all_locations: + item = location.item + # Skip if item is already Nothing + if item.name == 'Nothing': + continue + # Skip if item type is protected + if not is_item_replaceable_barren(item, item.world.settings): + continue + testable_locations.append(location) + + logger.info(f'Found {len(testable_locations)} testable items in placed locations') + + # Randomize test order for variability + random.shuffle(testable_locations) + + # Test each item + for location in testable_locations: + tested_count += 1 + original_item = location.item + is_replaced = False + + # Case 1: Replace junk directly (without testing) + if original_item.name in item_groups['Junk']: + is_replaced = replace_junk_item_with_nothing(location, original_item, worlds) + + # Case 2: Replace junk songs (Prelude and Serenade) (without testing) + elif original_item.name in item_groups['JunkSong']: + is_replaced = replace_junk_item_with_nothing(location, original_item, worlds) + + # Case 3: Replace junk dungeon items (maps/compasses) (without testing) + elif original_item.name in item_groups['Map'] or original_item.name in item_groups['Compass']: + is_replaced = replace_junk_item_with_nothing(location, original_item, worlds) + + # Case 4: Replace all health upgrades when the goal is not heart count (without testing) + elif original_item.name in item_groups['HealthUpgrade'] and item.world.settings.shuffle_ganon_bosskey != 'hearts' and item.world.settings.bridge != 'hearts': + is_replaced = replace_junk_item_with_nothing(location, original_item, worlds) + + # Case 5: Bottles can be removed in all locations reachable + elif original_item.name in item_groups['Bottle']: + is_replaced = replace_major_item_with_nothing(location, original_item, worlds) + + # Case 6: Major items can be removed because they unlock no location + elif original_item.name in remove_junk_barren_items: + is_replaced = replace_major_item_with_nothing(location, original_item, worlds) + + # Case 7: Major items can be replaced by other items to unlock locations + elif original_item.name in removable_major_barren_items: + is_replaced = replace_major_item_with_nothing(location, original_item, worlds) + + # Default: Replace major items only when reachable_locations is 'beatable' + elif item.world.settings.reachable_locations == 'beatable': + is_replaced = replace_major_item_with_nothing(location, original_item, worlds) + + if is_replaced: + replaced_count += 1 + + logger.info(f'Barren reduction complete: {replaced_count}/{tested_count} items replaced with Nothing') + + # Rebuild item_pool for each world based on the final placed items + # This ensures the spoiler log shows the correct item counts after barren reduction + for world in worlds: + placed_items = [] + for location in world.get_locations(): + if location.item is None: + continue + item = location.item + placed_items.append(item) + + # Rebuild the item_pool with the placed items + # This will filter out dungeon items, drops, events, and rewards automatically + world.distribution.set_complete_itempool(placed_items) + logger.info(f'Rebuilt item_pool for world {world.id}: {len(world.distribution.item_pool)} unique items') + +def replace_major_item_with_nothing(location: Location, original_item: Item | None, worlds: list[World]): + # Temporarily replace with Nothing + nothing_item = ItemFactory('Nothing', original_item.world) + nothing_item.location = location + location.item = nothing_item + + # Test if the game is still beatable + try: + test_search = Search([w.state for w in worlds]) + beatable = test_search.can_beat_game(scan_for_items=True) + except Exception as e: + logger.warning(f'Error testing {original_item.name} at {location.name}: {e}') + beatable = False + + if beatable: + # Keep the Nothing - item is not required + logger.debug(f'Replaced {original_item.name} at {location.name} with Nothing') + return True + else: + # Restore original item - it's required + logger.debug(f'Cannot replace {original_item.name} at {location.name} with Nothing') + location.item = original_item + return False + +def replace_junk_item_with_nothing(location: Location, original_item: Item | None, worlds: list[World]): + # Directly replace with Nothing + nothing_item = ItemFactory('Nothing', original_item.world) + nothing_item.location = location + location.item = nothing_item + return True + # Places all items into the world def distribute_items_restrictive(worlds: list[World], fill_locations: Optional[list[Location]] = None) -> None: if worlds[0].settings.shuffle_song_items == 'song': diff --git a/ItemPool.py b/ItemPool.py index 29c69b7e6..372bce1e6 100644 --- a/ItemPool.py +++ b/ItemPool.py @@ -245,6 +245,38 @@ 'Heart Container': 0, 'Piece of Heart': 0, }, + # Barren mode: starts with minimal pool + removes junk, then reduce_item_pool_barren() removes progression items + 'barren': { + 'Bombchus (5)': 1, + 'Bombchus (10)': 0, + 'Bombchus (20)': 0, + 'Magic Meter': 1, + 'Nayrus Love': 1, + 'Double Defense': 0, + 'Deku Stick Capacity': 0, + 'Deku Nut Capacity': 0, + 'Bow': 1, + 'Slingshot': 1, + 'Bomb Bag': 1, + 'Heart Container': 0, + 'Piece of Heart': 0, + # Additional junk removal for barren - remove all ammo/consumables + 'Bombs (5)': 0, + 'Bombs (10)': 0, + 'Bombs (20)': 0, + 'Arrows (5)': 0, + 'Arrows (10)': 0, + 'Arrows (30)': 0, + 'Deku Seeds (30)': 0, + 'Deku Stick (1)': 0, + 'Deku Nuts (5)': 0, + 'Deku Nuts (10)': 0, + 'Recovery Heart': 0, + 'Rupees (5)': 0, + 'Rupees (20)': 0, + 'Rupees (50)': 0, + 'Rupees (200)': 0, + }, } shopsanity_rupees: list[str] = ( @@ -333,6 +365,30 @@ 'Biggoron Sword' ] +remove_junk_barren_items: list[str] = [ + 'Ice Arrows', + 'Deku Nut Capacity', + 'Deku Stick Capacity', + 'Double Defense', + 'Biggoron Sword', + 'Farores Wind', + "Goron Mask", + "Zora Mask", + "Gerudo Mask", + "Mask of Truth", + "Rupee (1)", + "Rupee (Treasure Chest Game) (1)", + "Rupees (Treasure Chest Game) (20)", + "Rupees (Treasure Chest Game) (5)" +] + +removable_major_barren_items: list[str] = [ + 'Nayrus Love', + 'Stone of Agony', + 'Fire Arrows', + 'Blue Fire Arrows' +] + # a useless placeholder item placed at some skipped and inaccessible locations # (e.g. HC Malon Egg with Skip Child Zelda, or the carpenters with Open Gerudo Fortress) IGNORE_LOCATION: str = 'Nothing' diff --git a/Main.py b/Main.py index 4ff048ff8..2b11a1447 100644 --- a/Main.py +++ b/Main.py @@ -13,7 +13,7 @@ from Cosmetics import CosmeticsLog, patch_cosmetics from EntranceShuffle import set_entrances -from Fill import distribute_items_restrictive, ShuffleError +from Fill import distribute_items_restrictive, reduce_placed_items_to_barren, ShuffleError from Goals import update_goal_items, replace_goal_names from Hints import build_gossip_hints from HintList import clear_hint_exclusion_cache, misc_item_hint_table, misc_location_hint_table @@ -117,7 +117,13 @@ def resolve_settings(settings: Settings) -> Optional[Rom]: def generate(settings: Settings) -> Spoiler: worlds = build_world_graphs(settings) + place_items(worlds) + + # If barren mode is enabled, replace non-required items with Nothing AFTER placement + if settings.item_pool_value == 'barren': + reduce_placed_items_to_barren(worlds) + for world in worlds: world.distribution.configure_effective_starting_items(worlds, world) if worlds[0].enable_goal_hints: diff --git a/SettingsList.py b/SettingsList.py index 71ad23998..ba88c2e3f 100644 --- a/SettingsList.py +++ b/SettingsList.py @@ -4151,7 +4151,8 @@ class SettingInfos: 'plentiful': 'Plentiful', 'balanced': 'Balanced', 'scarce': 'Scarce', - 'minimal': 'Minimal' + 'minimal': 'Minimal', + 'barren': 'Barren' }, gui_tooltip = '''\ 'Ludicrous': Every item in the game is a major @@ -4172,10 +4173,18 @@ class SettingInfos: open location checks are removed. All health upgrades are removed. Only one Bombchu item is available. + + 'Barren': Replaces items with "Nothing" while + ensuring the seed remains beatable. Junk items + (rupees, ammo) are replaced automatically. Progression + items are tested one by one. Results in the absolute + minimum required items. Warning: Very challenging! + Ammo must be farmed from enemies/pots. ''', shared = True, disable = { - 'ludicrous': {'settings': ['one_item_per_dungeon']} + 'ludicrous': {'settings': ['one_item_per_dungeon']}, + 'barren': {'settings': ['one_item_per_dungeon']} } )