From e22e434258c17fa0e7e5667acdc51e56589941ee Mon Sep 17 00:00:00 2001 From: Silvris <58583688+Silvris@users.noreply.github.com> Date: Wed, 18 Feb 2026 14:16:04 -0600 Subject: [PATCH] Options: support "random" and variations for OptionSet with defined valid_keys (#4418) * seemingly works? needs testing * attempt docs update * move to verify resolution (keep?) * account for no valid keys and "random" being passed * Update advanced_settings_en.md * Update Options.py Co-authored-by: qwint * Update Options.py Co-authored-by: Doug Hoskisson * unify random handling between range and set * Update Options.py * Update Options.py * Update Options.py Co-authored-by: Doug Hoskisson * super is weird * fix item/location * remove groups from options * unittest * pep8 * Update Options.py Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com> * Update Options.py --------- Co-authored-by: qwint Co-authored-by: Doug Hoskisson Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com> --- Options.py | 114 +++++++++++++------- test/general/test_options.py | 16 ++- worlds/generic/docs/advanced_settings_en.md | 3 + 3 files changed, 93 insertions(+), 40 deletions(-) diff --git a/Options.py b/Options.py index 8c1c8b15c3..57119ff66c 100644 --- a/Options.py +++ b/Options.py @@ -24,6 +24,39 @@ if typing.TYPE_CHECKING: import pathlib +_RANDOM_OPTS = [ + "random", "random-low", "random-middle", "random-high", + "random-range-low--", "random-range-middle--", + "random-range-high--", "random-range--", +] + + +def triangular(lower: int, end: int, tri: float = 0.5) -> int: + """ + Integer triangular distribution for `lower` inclusive to `end` inclusive. + + Expects `lower <= end` and `0.0 <= tri <= 1.0`. The result of other inputs is undefined. + """ + # Use the continuous range [lower, end + 1) to produce an integer result in [lower, end]. + # random.triangular is actually [a, b] and not [a, b), so there is a very small chance of getting exactly b even + # when a != b, so ensure the result is never more than `end`. + return min(end, math.floor(random.triangular(0.0, 1.0, tri) * (end - lower + 1) + lower)) + + +def random_weighted_range(text: str, range_start: int, range_end: int): + if text == "random-low": + return triangular(range_start, range_end, 0.0) + elif text == "random-high": + return triangular(range_start, range_end, 1.0) + elif text == "random-middle": + return triangular(range_start, range_end) + elif text == "random": + return random.randint(range_start, range_end) + else: + raise Exception(f"random text \"{text}\" did not resolve to a recognized pattern. " + f"Acceptable values are: {', '.join(_RANDOM_OPTS)}.") + + def roll_percentage(percentage: int | float) -> bool: """Roll a percentage chance. percentage is expected to be in range [0, 100]""" @@ -690,12 +723,6 @@ class Range(NumericOption): range_start = 0 range_end = 1 - _RANDOM_OPTS = [ - "random", "random-low", "random-middle", "random-high", - "random-range-low--", "random-range-middle--", - "random-range-high--", "random-range--", - ] - def __init__(self, value: int): if value < self.range_start: raise Exception(f"{value} is lower than minimum {self.range_start} for option {self.__class__.__name__}") @@ -744,25 +771,16 @@ class Range(NumericOption): @classmethod def weighted_range(cls, text) -> Range: - if text == "random-low": - return cls(cls.triangular(cls.range_start, cls.range_end, 0.0)) - elif text == "random-high": - return cls(cls.triangular(cls.range_start, cls.range_end, 1.0)) - elif text == "random-middle": - return cls(cls.triangular(cls.range_start, cls.range_end)) - elif text.startswith("random-range-"): + if text.startswith("random-range-"): return cls.custom_range(text) - elif text == "random": - return cls(random.randint(cls.range_start, cls.range_end)) else: - raise Exception(f"random text \"{text}\" did not resolve to a recognized pattern. " - f"Acceptable values are: {', '.join(cls._RANDOM_OPTS)}.") + return cls(random_weighted_range(text, cls.range_start, cls.range_end)) @classmethod def custom_range(cls, text) -> Range: textsplit = text.split("-") try: - random_range = [int(textsplit[len(textsplit) - 2]), int(textsplit[len(textsplit) - 1])] + random_range = [int(textsplit[-2]), int(textsplit[-1])] except ValueError: raise ValueError(f"Invalid random range {text} for option {cls.__name__}") random_range.sort() @@ -770,14 +788,9 @@ class Range(NumericOption): raise Exception( f"{random_range[0]}-{random_range[1]} is outside allowed range " f"{cls.range_start}-{cls.range_end} for option {cls.__name__}") - if text.startswith("random-range-low"): - return cls(cls.triangular(random_range[0], random_range[1], 0.0)) - elif text.startswith("random-range-middle"): - return cls(cls.triangular(random_range[0], random_range[1])) - elif text.startswith("random-range-high"): - return cls(cls.triangular(random_range[0], random_range[1], 1.0)) - else: - return cls(random.randint(random_range[0], random_range[1])) + if textsplit[2] in ("low", "middle", "high"): + return cls(random_weighted_range(f"{textsplit[0]}-{textsplit[2]}", *random_range)) + return cls(random_weighted_range("random", *random_range)) @classmethod def from_any(cls, data: typing.Any) -> Range: @@ -792,18 +805,6 @@ class Range(NumericOption): def __str__(self) -> str: return str(self.value) - @staticmethod - def triangular(lower: int, end: int, tri: float = 0.5) -> int: - """ - Integer triangular distribution for `lower` inclusive to `end` inclusive. - - Expects `lower <= end` and `0.0 <= tri <= 1.0`. The result of other inputs is undefined. - """ - # Use the continuous range [lower, end + 1) to produce an integer result in [lower, end]. - # random.triangular is actually [a, b] and not [a, b), so there is a very small chance of getting exactly b even - # when a != b, so ensure the result is never more than `end`. - return min(end, math.floor(random.triangular(0.0, 1.0, tri) * (end - lower + 1) + lower)) - class NamedRange(Range): special_range_names: typing.Dict[str, int] = {} @@ -1000,13 +1001,19 @@ class OptionList(Option[typing.List[typing.Any]], VerifyKeys): class OptionSet(Option[typing.Set[str]], VerifyKeys): default = frozenset() supports_weighting = False + random_str: str | None - def __init__(self, value: typing.Iterable[str]): + def __init__(self, value: typing.Iterable[str], random_str: str | None = None): self.value = set(deepcopy(value)) + self.random_str = random_str super(OptionSet, self).__init__() @classmethod def from_text(cls, text: str): + check_text = text.lower().split(",") + if ((cls.valid_keys or cls.verify_item_name or cls.verify_location_name) + and len(check_text) == 1 and check_text[0].startswith("random")): + return cls((), check_text[0]) return cls([option.strip() for option in text.split(",")]) @classmethod @@ -1015,6 +1022,35 @@ class OptionSet(Option[typing.Set[str]], VerifyKeys): return cls(data) return cls.from_text(str(data)) + def verify(self, world: typing.Type[World], player_name: str, plando_options: PlandoOptions) -> None: + if self.random_str and not self.value: + choice_list = sorted(self.valid_keys) + if self.verify_item_name: + choice_list.extend(sorted(world.item_names)) + if self.verify_location_name: + choice_list.extend(sorted(world.location_names)) + if self.random_str.startswith("random-range-"): + textsplit = self.random_str.split("-") + try: + random_range = [int(textsplit[-2]), int(textsplit[-1])] + except ValueError: + raise ValueError(f"Invalid random range {self.random_str} for option {self.__class__.__name__} " + f"for player {player_name}") + random_range.sort() + if random_range[0] < 0 or random_range[1] > len(choice_list): + raise Exception( + f"{random_range[0]}-{random_range[1]} is outside allowed range " + f"0-{len(choice_list)} for option {self.__class__.__name__} for player {player_name}") + if textsplit[2] in ("low", "middle", "high"): + choice_count = random_weighted_range(f"{textsplit[0]}-{textsplit[2]}", + random_range[0], random_range[1]) + else: + choice_count = random_weighted_range("random", random_range[0], random_range[1]) + else: + choice_count = random_weighted_range(self.random_str, 0, len(choice_list)) + self.value = set(random.sample(choice_list, k=choice_count)) + super(Option, self).verify(world, player_name, plando_options) + @classmethod def get_option_name(cls, value): return ", ".join(sorted(value)) diff --git a/test/general/test_options.py b/test/general/test_options.py index cbd8d7b533..e610e36794 100644 --- a/test/general/test_options.py +++ b/test/general/test_options.py @@ -1,8 +1,9 @@ import unittest from BaseClasses import PlandoOptions -from Options import Choice, ItemLinks, PlandoConnections, PlandoItems, PlandoTexts +from Options import Choice, ItemLinks, OptionSet, PlandoConnections, PlandoItems, PlandoTexts from Utils import restricted_dumps + from worlds.AutoWorld import AutoWorldRegister @@ -81,6 +82,19 @@ class TestOptions(unittest.TestCase): restricted_dumps(option.from_any(option.default)) if issubclass(option, Choice) and option.default in option.name_lookup: restricted_dumps(option.from_text(option.name_lookup[option.default])) + + def test_option_set_keys_random(self): + """Tests that option sets do not contain 'random' and its variants as valid keys""" + for game_name, world_type in AutoWorldRegister.world_types.items(): + if game_name not in ("Archipelago", "Sudoku", "Super Metroid"): + for option_key, option in world_type.options_dataclass.type_hints.items(): + if issubclass(option, OptionSet): + with self.subTest(game=game_name, option=option_key): + self.assertFalse(any(random_key in option.valid_keys for random_key in ("random", + "random-high", + "random-low"))) + for key in option.valid_keys: + self.assertFalse("random-range" in key) def test_pickle_dumps_plando(self): """Test that plando options using containers of a custom type can be pickled""" diff --git a/worlds/generic/docs/advanced_settings_en.md b/worlds/generic/docs/advanced_settings_en.md index bc8754b9c6..7dc0e6ba4c 100644 --- a/worlds/generic/docs/advanced_settings_en.md +++ b/worlds/generic/docs/advanced_settings_en.md @@ -156,6 +156,9 @@ Options taking a choice of a number can also use a variety of `random` options t * `random-range-low-#-#`, `random-range-middle-#-#`, and `random-range-high-#-#` will choose a number at random from the specified numbers, but with the specified weights +Options defining a unique set of options can also make use of the prior `random` options to select a subset of possible +options. The resulting number is used as the number of random items the set should contain. + ### Example ```yaml