Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
164 changes: 163 additions & 1 deletion Fill.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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':
Expand Down
56 changes: 56 additions & 0 deletions ItemPool.py
Original file line number Diff line number Diff line change
Expand Up @@ -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] = (
Expand Down Expand Up @@ -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'
Expand Down
8 changes: 7 additions & 1 deletion Main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
13 changes: 11 additions & 2 deletions SettingsList.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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']}
}
)

Expand Down