Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
dfc21d0
Allow user to select specific locations to exclude from progression
tanjo3 Jun 5, 2024
9619fbe
Use `zlib` to compress permalinks further
tanjo3 Jun 5, 2024
419a414
Fix encode/decode mismatch with `progression_locations` option
tanjo3 Jun 6, 2024
d0855f8
Ensure logic state is correct before randomizing items
tanjo3 Jun 9, 2024
159e6c2
Add test cases for location exclusion
tanjo3 Jun 9, 2024
1dbf7c0
Fix incorrect counting of excluded charts
tanjo3 Jan 1, 2026
b791fef
Properly set and count items as nonprogress for excluded locations
tanjo3 Jan 12, 2026
091e37f
Change UI object names to be more descriptive
tanjo3 Jan 24, 2026
03075ed
Add UI elements to tabstops
tanjo3 Jan 24, 2026
3d10593
Fix excluded sunken treasures not being added to `nonprogress_locations`
tanjo3 Feb 25, 2026
bca67f4
Check that excluded sunken treasures are properly non-progress locations
tanjo3 Feb 25, 2026
3195a47
Avoid setting `progression_locations` unnecessarily
tanjo3 Mar 19, 2026
034c764
Remove redundant assignment
tanjo3 Mar 19, 2026
636d400
Add search filters for the locations
tanjo3 Mar 19, 2026
65e98b7
Prevent locations from potentially being added to `locations_to_filte…
tanjo3 Mar 19, 2026
87228ef
Convert list to set for efficient lookup
tanjo3 Mar 19, 2026
c1665f2
Ensure that excluded locations are always sorted internally
tanjo3 Mar 19, 2026
0db2354
Show version mismatch dialog when pasting an older permalink
tanjo3 Mar 23, 2026
361e5da
Properly hide `progression_locations` from permalink
tanjo3 Mar 23, 2026
be7cbdb
Move excluded locations to `validate`
tanjo3 Apr 23, 2026
ada9daf
Add parameter to return cached item locations dict without deepcopy
tanjo3 Apr 23, 2026
0c2626d
Fix circular import
tanjo3 Apr 23, 2026
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
98 changes: 73 additions & 25 deletions logic/logic.py
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,28 @@ def set_prerandomization_item_location(self, location_name, item_name):
assert location_name in self.item_locations
self.prerandomization_item_locations[location_name] = item_name

def get_num_charts_excluded(self):
return Logic.get_num_charts_excluded_static(self.item_locations, self.options)

@staticmethod
def get_num_charts_excluded_static(item_locations: dict[str, dict], options: Options):
if options.randomize_charts:
num_charts_excluded = sum(location_name.endswith(" - Sunken Treasure") for location_name in options.excluded_locations)
else:
# Only count excluded sunken treasures that would actually be a progress location.
num_charts_excluded = 0
for location_name in options.excluded_locations:
if location_name not in item_locations or not location_name.endswith(" - Sunken Treasure"):
continue
original_item = item_locations[location_name]["Original item"]
is_triforce_shard = original_item.startswith("Triforce Shard ")
if is_triforce_shard and options.progression_triforce_charts:
num_charts_excluded += 1
elif not is_triforce_shard and options.progression_treasure_charts:
num_charts_excluded += 1

return num_charts_excluded

def get_num_progression_locations(self):
return Logic.get_num_progression_locations_static(self.item_locations, self.options)

Expand All @@ -213,10 +235,18 @@ def get_num_progression_locations_static(item_locations: dict[str, dict], option
filter_sunken_treasure=True
)
num_progress_locations = len(progress_locations)

max_sunken_treasure_locations = 0
if options.progression_triforce_charts:
num_progress_locations += 8
max_sunken_treasure_locations += 8
if options.progression_treasure_charts:
num_progress_locations += 41
max_sunken_treasure_locations += 41

num_charts_excluded = Logic.get_num_charts_excluded_static(item_locations, options)
if options.randomize_charts:
num_progress_locations += min(max_sunken_treasure_locations, 49 - num_charts_excluded)
else:
num_progress_locations += max_sunken_treasure_locations - num_charts_excluded

return num_progress_locations

Expand Down Expand Up @@ -261,13 +291,17 @@ def get_max_required_bosses_banned_locations(self):
def get_progress_and_non_progress_locations(self):
all_locations = list(self.item_locations.keys())
progress_locations = self.filter_locations_for_progression(all_locations, filter_sunken_treasure=True)
excluded_locations_set = set(self.options.excluded_locations)
nonprogress_locations = []
for location_name in all_locations:
if location_name in progress_locations:
continue

types = self.item_locations[location_name]["Types"]
if "Sunken Treasure" in types:
if location_name in excluded_locations_set:
nonprogress_locations.append(location_name)
continue
chart_name = self.chart_name_for_location(location_name)
if "Triforce Chart" in chart_name:
if self.options.progression_triforce_charts:
Expand Down Expand Up @@ -481,19 +515,28 @@ def check_item_is_useful(self, item_name, inaccessible_undone_item_locations):
self.cached_items_are_useful[item_name] = False
return False

def filter_locations_for_progression(self, locations_to_filter: list[str], filter_sunken_treasure=False):
def filter_locations_for_progression(self, locations_to_filter: list[str], filter_sunken_treasure=False, filter_excluded_locations=True, filter_excluded_sunken_treasure=True):
return Logic.filter_locations_for_progression_static(
locations_to_filter,
self.item_locations,
self.options,
filter_sunken_treasure=filter_sunken_treasure
filter_sunken_treasure=filter_sunken_treasure,
filter_excluded_locations=filter_excluded_locations,
filter_excluded_sunken_treasure=filter_excluded_sunken_treasure,
)

@staticmethod
def filter_locations_for_progression_static(locations_to_filter: list[str], item_locations: dict[str, dict], options: Options, filter_sunken_treasure=False):
def filter_locations_for_progression_static(locations_to_filter: list[str], item_locations: dict[str, dict], options: Options, filter_sunken_treasure=False, filter_excluded_locations=True, filter_excluded_sunken_treasure=True):
excluded_locations_set = set(options.excluded_locations)
filtered_locations = []
for location_name in locations_to_filter:
types = item_locations[location_name]["Types"]

# If the location is excluded, filter it out of progression locations.
# However, have special handling for sunken treasure locations as they may not yet be finalized as being progression.
if filter_excluded_locations and location_name in excluded_locations_set and ("Sunken Treasure" not in types or filter_excluded_sunken_treasure):
continue

if "No progression" in types:
continue
if "Consumables only" in types:
Expand Down Expand Up @@ -624,27 +667,28 @@ def filter_items_valid_for_location(self, items, location_name):
return valid_items

@staticmethod
def load_and_parse_item_locations() -> dict[str, dict]:
if Logic.initial_item_locations is not None:
return copy.deepcopy(Logic.initial_item_locations)

with open(os.path.join(LOGIC_PATH, "item_locations.txt")) as f:
item_locations = yaml.load(f)

for location_name in item_locations:
req_string = item_locations[location_name]["Need"]
if req_string is None:
raise Exception("Requirements are blank for location \"%s\"" % location_name)
item_locations[location_name]["Need"] = Logic.parse_logic_expression(req_string)
def load_and_parse_item_locations(deepcopy: bool = True) -> dict[str, dict]:
if Logic.initial_item_locations is None:
with open(os.path.join(LOGIC_PATH, "item_locations.txt")) as f:
item_locations = yaml.load(f)

types_string = item_locations[location_name]["Types"]
types = types_string.split(",")
types = [type.strip() for type in types]
item_locations[location_name]["Types"] = types

Logic.initial_item_locations = copy.deepcopy(item_locations)
return item_locations
for location_name in item_locations:
req_string = item_locations[location_name]["Need"]
if req_string is None:
raise Exception("Requirements are blank for location \"%s\"" % location_name)
item_locations[location_name]["Need"] = Logic.parse_logic_expression(req_string)

types_string = item_locations[location_name]["Types"]
types = types_string.split(",")
types = [type.strip() for type in types]
item_locations[location_name]["Types"] = types

Logic.initial_item_locations = copy.deepcopy(item_locations)

if deepcopy:
return copy.deepcopy(Logic.initial_item_locations)
return Logic.initial_item_locations

def load_and_parse_macros(self):
if Logic.initial_macros is not None:
self.macros = copy.deepcopy(Logic.initial_macros)
Expand Down Expand Up @@ -764,11 +808,15 @@ def make_useless_progress_items_nonprogress(self):
filter_sunken_treasure = True
if self.options.progression_triforce_charts or self.options.progression_treasure_charts:
filter_sunken_treasure = False
filter_excluded_sunken_treasure = True
if self.rando.charts.is_enabled() and not self.rando.fully_initialized:
filter_excluded_sunken_treasure = False
progress_locations = Logic.filter_locations_for_progression_static(
list(self.item_locations.keys()),
self.item_locations,
self.options,
filter_sunken_treasure=filter_sunken_treasure
filter_sunken_treasure=filter_sunken_treasure,
filter_excluded_sunken_treasure=filter_excluded_sunken_treasure,
)

items_needed = {}
Expand Down
19 changes: 19 additions & 0 deletions options/wwrando_options.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,15 @@ class TrickDifficulty(StrEnum):

@dataclass
class Options(BaseOptions):
def validate(self):
super().validate()

if self.excluded_locations:
from logic.logic import Logic

valid_locations = set(Logic.load_and_parse_item_locations(deepcopy=False).keys())
self.excluded_locations = sorted({loc for loc in self.excluded_locations if loc in valid_locations})

#region Progress locations
progression_dungeons: bool = option(
default=True,
Expand Down Expand Up @@ -138,6 +147,16 @@ class Options(BaseOptions):
description="Miscellaneous locations that don't fit into any of the above categories (outdoors chests, wind shrine, Cyclos, etc).<br>"
"<u>If this is not checked, they will still be randomized</u>, but will only contain optional items you don't need to beat the game.",
)

progression_locations: list[str] = option(
default_factory=lambda: [],
permalink=False,
description="Randomized locations that can have progress items.",
)
excluded_locations: list[str] = option(
default_factory=lambda: [],
description="Randomized locations that cannot have progress items.",
)
#endregion

#region Modes
Expand Down
42 changes: 37 additions & 5 deletions randomizer.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from random import Random
import hashlib
import sys
import zlib
from typing import TypeVar, Callable
from io import BytesIO
import string
Expand Down Expand Up @@ -238,9 +239,22 @@ def __init__(self, seed, clean_iso_path, randomized_output_folder, options: Opti
num_progress_locations = self.logic.get_num_progression_locations()
max_required_bosses_banned_locations = self.logic.get_max_required_bosses_banned_locations()
self.all_randomized_progress_items = self.logic.unplaced_progress_items.copy()
if num_progress_locations - max_required_bosses_banned_locations < len(self.all_randomized_progress_items):
num_progress_items = len(self.all_randomized_progress_items)

# If sunken treasure locations are progression, we need to take into account locations that are excluded and adjust the progress item count.
if options.progression_triforce_charts or options.progression_treasure_charts:
num_charts_excluded = self.logic.get_num_charts_excluded()
if options.randomize_charts:
max_sunken_treasure_locations = 0
if options.progression_triforce_charts:
max_sunken_treasure_locations += 8
if options.progression_treasure_charts:
max_sunken_treasure_locations += 41
num_progress_items -= max(0, max_sunken_treasure_locations + num_charts_excluded - 49)

if num_progress_locations - max_required_bosses_banned_locations < num_progress_items:
error_message = "Not enough progress locations to place all progress items.\n\n"
error_message += "Total progress items: %d\n" % len(self.all_randomized_progress_items)
error_message += "Total progress items: %d\n" % num_progress_items
error_message += "Progress locations with current options: %d\n" % num_progress_locations
if max_required_bosses_banned_locations > 0:
error_message += "Maximum Required Bosses Mode banned locations: %d\n" % max_required_bosses_banned_locations
Expand Down Expand Up @@ -529,14 +543,20 @@ def encode_permalink(cls, seed: str, options: Options):
elif option.name == "randomized_gear":
# Handled above.
continue
elif option.name == "excluded_locations":
assert issubclass(typing.get_origin(option.type) or option.type, list)
value_set = set(value)
for location_name in Logic.load_and_parse_item_locations(deepcopy=False):
bit = location_name in value_set
bitswriter.write(bit, 1)
else:
raise Exception(f"Option {option.name} of type {option.type} is not currently supported by the permalink system.")

bitswriter.flush()

for byte in bitswriter.bytes:
permalink += struct.pack(">B", byte)
base64_encoded_permalink = base64.b64encode(permalink).decode("ascii")
base64_encoded_permalink = base64.b64encode(zlib.compress(permalink)).decode("ascii")
return base64_encoded_permalink

@classmethod
Expand All @@ -545,7 +565,11 @@ def decode_permalink(cls, base64_encoded_permalink: str, options: Options = None
if not base64_encoded_permalink:
raise Exception(f"Permalink is blank.")

permalink = base64.b64decode(base64_encoded_permalink)
raw_permalink = base64.b64decode(base64_encoded_permalink)
try:
permalink = zlib.decompress(raw_permalink)
except zlib.error:
permalink = raw_permalink
given_version_num, seed, options_bytes = permalink.split(b"\0", 2)
given_version_num = given_version_num.decode("ascii")
seed = seed.decode("ascii")
Expand Down Expand Up @@ -616,6 +640,14 @@ def decode_permalink(cls, base64_encoded_permalink: str, options: Options = None
elif option.name == "randomized_gear":
# Handled above.
continue
elif option.name == "excluded_locations":
assert issubclass(typing.get_origin(option.type) or option.type, list)
excluded_list = []
for location_name in Logic.load_and_parse_item_locations(deepcopy=False):
excluded = bitsreader.read(1)
if excluded == 1:
excluded_list.append(location_name)
options.excluded_locations = sorted(excluded_list)
else:
raise Exception(f"Option {option.name} of type {option.type} is not currently supported by the permalink system.")

Expand Down Expand Up @@ -1017,7 +1049,7 @@ def get_log_header(self):
non_disabled_options = [
option.name for option in Options.all()
if self.options[option.name] not in [False, [], {}]
and option.name != "randomized_gear" # Just takes up space
and option.name not in ["randomized_gear", "progression_locations"] # Just takes up space
]
option_strings = []
for option_name in non_disabled_options:
Expand Down
2 changes: 2 additions & 0 deletions randomizers/items.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ def progress_save_text(self) -> str:
return "Saving items..."

def _randomize(self):
self.logic.initialize_from_randomizer_state()

if not self.options.keylunacy:
self.randomize_dungeon_items()

Expand Down
Loading
Loading