from __future__ import annotations import abc import logging from copy import deepcopy import math import numbers import typing import random from schema import Schema, And, Or, Optional from Utils import get_fuzzy_results if typing.TYPE_CHECKING: from BaseClasses import PlandoOptions from worlds.AutoWorld import World class AssembleOptions(abc.ABCMeta): def __new__(mcs, name, bases, attrs): options = attrs["options"] = {} name_lookup = attrs["name_lookup"] = {} # merge parent class options for base in bases: if getattr(base, "options", None): options.update(base.options) name_lookup.update(base.name_lookup) new_options = {name[7:].lower(): option_id for name, option_id in attrs.items() if name.startswith("option_")} assert "random" not in new_options, "Choice option 'random' cannot be manually assigned." assert len(new_options) == len(set(new_options.values())), "same ID cannot be used twice. Try alias?" attrs["name_lookup"].update({option_id: name for name, option_id in new_options.items()}) options.update(new_options) # apply aliases, without name_lookup aliases = {name[6:].lower(): option_id for name, option_id in attrs.items() if name.startswith("alias_")} assert "random" not in aliases, "Choice option 'random' cannot be manually assigned." # auto-alias Off and On being parsed as True and False if "off" in options: options["false"] = options["off"] if "on" in options: options["true"] = options["on"] options.update(aliases) if "verify" not in attrs: # not overridden by class -> look up bases verifiers = [f for f in (getattr(base, "verify", None) for base in bases) if f] if len(verifiers) > 1: # verify multiple bases/mixins def verify(self, *args, **kwargs) -> None: for f in verifiers: f(self, *args, **kwargs) attrs["verify"] = verify else: assert verifiers, "class Option is supposed to implement def verify" # auto-validate schema on __init__ if "schema" in attrs.keys(): if "__init__" in attrs: def validate_decorator(func): def validate(self, *args, **kwargs): ret = func(self, *args, **kwargs) self.value = self.schema.validate(self.value) return ret return validate attrs["__init__"] = validate_decorator(attrs["__init__"]) else: # construct an __init__ that calls parent __init__ cls = super(AssembleOptions, mcs).__new__(mcs, name, bases, attrs) def meta__init__(self, *args, **kwargs): super(cls, self).__init__(*args, **kwargs) self.value = self.schema.validate(self.value) cls.__init__ = meta__init__ return cls return super(AssembleOptions, mcs).__new__(mcs, name, bases, attrs) T = typing.TypeVar('T') class Option(typing.Generic[T], metaclass=AssembleOptions): value: T default = 0 # convert option_name_long into Name Long as display_name, otherwise name_long is the result. # Handled in get_option_name() auto_display_name = False # can be weighted between selections supports_weighting = True # filled by AssembleOptions: name_lookup: typing.Dict[T, str] options: typing.Dict[str, int] def __repr__(self) -> str: return f"{self.__class__.__name__}({self.current_option_name})" def __hash__(self) -> int: return hash(self.value) @property def current_key(self) -> str: return self.name_lookup[self.value] def get_current_option_name(self) -> str: """Deprecated. use current_option_name instead. TODO remove around 0.4""" logging.warning(DeprecationWarning(f"get_current_option_name for {self.__class__.__name__} is deprecated." f" use current_option_name instead. Worlds should use {self}.current_key")) return self.current_option_name @property def current_option_name(self) -> str: """For display purposes. Worlds should be using current_key.""" return self.get_option_name(self.value) @classmethod def get_option_name(cls, value: T) -> str: if cls.auto_display_name: return cls.name_lookup[value].replace("_", " ").title() else: return cls.name_lookup[value] def __int__(self) -> T: return self.value def __bool__(self) -> bool: return bool(self.value) @classmethod @abc.abstractmethod def from_any(cls, data: typing.Any) -> Option[T]: ... if typing.TYPE_CHECKING: def verify(self, world: typing.Type[World], player_name: str, plando_options: PlandoOptions) -> None: pass else: def verify(self, *args, **kwargs) -> None: pass class FreeText(Option[str]): """Text option that allows users to enter strings. Needs to be validated by the world or option definition.""" def __init__(self, value: str): assert isinstance(value, str), "value of FreeText must be a string" self.value = value @property def current_key(self) -> str: return self.value @classmethod def from_text(cls, text: str) -> FreeText: return cls(text) @classmethod def from_any(cls, data: typing.Any) -> FreeText: return cls.from_text(str(data)) @classmethod def get_option_name(cls, value: str) -> str: return value class NumericOption(Option[int], numbers.Integral, abc.ABC): default = 0 # note: some of the `typing.Any`` here is a result of unresolved issue in python standards # `int` is not a `numbers.Integral` according to the official typestubs # (even though isinstance(5, numbers.Integral) == True) # https://github.com/python/typing/issues/272 # https://github.com/python/mypy/issues/3186 # https://github.com/microsoft/pyright/issues/1575 def __eq__(self, other: typing.Any) -> bool: if isinstance(other, NumericOption): return self.value == other.value else: return typing.cast(bool, self.value == other) def __lt__(self, other: typing.Union[int, NumericOption]) -> bool: if isinstance(other, NumericOption): return self.value < other.value else: return self.value < other def __le__(self, other: typing.Union[int, NumericOption]) -> bool: if isinstance(other, NumericOption): return self.value <= other.value else: return self.value <= other def __gt__(self, other: typing.Union[int, NumericOption]) -> bool: if isinstance(other, NumericOption): return self.value > other.value else: return self.value > other def __bool__(self) -> bool: return bool(self.value) def __int__(self) -> int: return self.value def __mul__(self, other: typing.Any) -> typing.Any: if isinstance(other, NumericOption): return self.value * other.value else: return self.value * other def __rmul__(self, other: typing.Any) -> typing.Any: if isinstance(other, NumericOption): return other.value * self.value else: return other * self.value def __sub__(self, other: typing.Any) -> typing.Any: if isinstance(other, NumericOption): return self.value - other.value else: return self.value - other def __rsub__(self, left: typing.Any) -> typing.Any: if isinstance(left, NumericOption): return left.value - self.value else: return left - self.value def __add__(self, other: typing.Any) -> typing.Any: if isinstance(other, NumericOption): return self.value + other.value else: return self.value + other def __radd__(self, left: typing.Any) -> typing.Any: if isinstance(left, NumericOption): return left.value + self.value else: return left + self.value def __truediv__(self, other: typing.Any) -> typing.Any: if isinstance(other, NumericOption): return self.value / other.value else: return self.value / other def __rtruediv__(self, left: typing.Any) -> typing.Any: if isinstance(left, NumericOption): return left.value / self.value else: return left / self.value def __abs__(self) -> typing.Any: return abs(self.value) def __and__(self, other: typing.Any) -> int: return self.value & int(other) def __ceil__(self) -> int: return math.ceil(self.value) def __floor__(self) -> int: return math.floor(self.value) def __floordiv__(self, other: typing.Any) -> int: return self.value // int(other) def __invert__(self) -> int: return ~(self.value) def __lshift__(self, other: typing.Any) -> int: return self.value << int(other) def __mod__(self, other: typing.Any) -> int: return self.value % int(other) def __neg__(self) -> int: return -(self.value) def __or__(self, other: typing.Any) -> int: return self.value | int(other) def __pos__(self) -> int: return +(self.value) def __pow__(self, exponent: numbers.Complex, modulus: typing.Optional[numbers.Integral] = None) -> int: if not (modulus is None): assert isinstance(exponent, numbers.Integral) return pow(self.value, exponent, modulus) # type: ignore return self.value ** exponent # type: ignore def __rand__(self, other: typing.Any) -> int: return int(other) & self.value def __rfloordiv__(self, other: typing.Any) -> int: return int(other) // self.value def __rlshift__(self, other: typing.Any) -> int: return int(other) << self.value def __rmod__(self, other: typing.Any) -> int: return int(other) % self.value def __ror__(self, other: typing.Any) -> int: return int(other) | self.value def __round__(self, ndigits: typing.Optional[int] = None) -> int: return round(self.value, ndigits) def __rpow__(self, base: typing.Any) -> typing.Any: return base ** self.value def __rrshift__(self, other: typing.Any) -> int: return int(other) >> self.value def __rshift__(self, other: typing.Any) -> int: return self.value >> int(other) def __rxor__(self, other: typing.Any) -> int: return int(other) ^ self.value def __trunc__(self) -> int: return math.trunc(self.value) def __xor__(self, other: typing.Any) -> int: return self.value ^ int(other) class Toggle(NumericOption): option_false = 0 option_true = 1 default = 0 def __init__(self, value: int): assert value == 0 or value == 1, "value of Toggle can only be 0 or 1" self.value = value @classmethod def from_text(cls, text: str) -> Toggle: if text == "random": return cls(random.choice(list(cls.name_lookup))) elif text.lower() in {"off", "0", "false", "none", "null", "no"}: return cls(0) else: return cls(1) @classmethod def from_any(cls, data: typing.Any): if type(data) == str: return cls.from_text(data) else: return cls(int(data)) @classmethod def get_option_name(cls, value): return ["No", "Yes"][int(value)] __hash__ = Option.__hash__ # see https://docs.python.org/3/reference/datamodel.html#object.__hash__ class DefaultOnToggle(Toggle): default = 1 class Choice(NumericOption): auto_display_name = True def __init__(self, value: int): self.value: int = value @classmethod def from_text(cls, text: str) -> Choice: text = text.lower() if text == "random": return cls(random.choice(list(cls.name_lookup))) for option_name, value in cls.options.items(): if option_name == text: return cls(value) raise KeyError( f'Could not find option "{text}" for "{cls.__name__}", ' f'known options are {", ".join(f"{option}" for option in cls.name_lookup.values())}') @classmethod def from_any(cls, data: typing.Any) -> Choice: if type(data) == int and data in cls.options.values(): return cls(data) return cls.from_text(str(data)) def __eq__(self, other): if isinstance(other, self.__class__): return other.value == self.value elif isinstance(other, str): assert other in self.options, f"compared against a str that could never be equal. {self} == {other}" return other == self.current_key elif isinstance(other, int): assert other in self.name_lookup, f"compared against an int that could never be equal. {self} == {other}" return other == self.value elif isinstance(other, bool): return other == bool(self.value) else: raise TypeError(f"Can't compare {self.__class__.__name__} with {other.__class__.__name__}") def __ne__(self, other): if isinstance(other, self.__class__): return other.value != self.value elif isinstance(other, str): assert other in self.options, f"compared against a str that could never be equal. {self} != {other}" return other != self.current_key elif isinstance(other, int): assert other in self.name_lookup, f"compared against am int that could never be equal. {self} != {other}" return other != self.value elif isinstance(other, bool): return other != bool(self.value) elif other is None: return False else: raise TypeError(f"Can't compare {self.__class__.__name__} with {other.__class__.__name__}") __hash__ = Option.__hash__ # see https://docs.python.org/3/reference/datamodel.html#object.__hash__ class TextChoice(Choice): """Allows custom string input and offers choices. Choices will resolve to int and text will resolve to string""" value: typing.Union[str, int] def __init__(self, value: typing.Union[str, int]): assert isinstance(value, str) or isinstance(value, int), \ f"{value} is not a valid option for {self.__class__.__name__}" self.value = value @property def current_key(self) -> str: if isinstance(self.value, str): return self.value return super().current_key @classmethod def from_text(cls, text: str) -> TextChoice: if text.lower() == "random": # chooses a random defined option but won't use any free text options return cls(random.choice(list(cls.name_lookup))) for option_name, value in cls.options.items(): if option_name.lower() == text.lower(): return cls(value) return cls(text) @classmethod def get_option_name(cls, value: T) -> str: if isinstance(value, str): return value return super().get_option_name(value) def __eq__(self, other: typing.Any): if isinstance(other, self.__class__): return other.value == self.value elif isinstance(other, str): if other in self.options: return other == self.current_key return other == self.value elif isinstance(other, int): assert other in self.name_lookup, f"compared against an int that could never be equal. {self} == {other}" return other == self.value elif isinstance(other, bool): return other == bool(self.value) else: raise TypeError(f"Can't compare {self.__class__.__name__} with {other.__class__.__name__}") class BossMeta(AssembleOptions): def __new__(mcs, name, bases, attrs): if name != "PlandoBosses": assert "bosses" in attrs, f"Please define valid bosses for {name}" attrs["bosses"] = frozenset((boss.lower() for boss in attrs["bosses"])) assert "locations" in attrs, f"Please define valid locations for {name}" attrs["locations"] = frozenset((location.lower() for location in attrs["locations"])) cls = super().__new__(mcs, name, bases, attrs) assert not cls.duplicate_bosses or "singularity" in cls.options, f"Please define option_singularity for {name}" return cls class PlandoBosses(TextChoice, metaclass=BossMeta): """Generic boss shuffle option that supports plando. Format expected is 'location1-boss1;location2-boss2;shuffle_mode'. If shuffle_mode is not provided in the string, this will be the default shuffle mode. Must override can_place_boss, which passes a plando boss and location. Check if the placement is valid for your game here.""" bosses: typing.ClassVar[typing.Union[typing.Set[str], typing.FrozenSet[str]]] locations: typing.ClassVar[typing.Union[typing.Set[str], typing.FrozenSet[str]]] duplicate_bosses: bool = False @classmethod def from_text(cls, text: str): # set all of our text to lower case for name checking text = text.lower() if text == "random": return cls(random.choice(list(cls.options.values()))) for option_name, value in cls.options.items(): if option_name == text: return cls(value) options = text.split(";") # since plando exists in the option verify the plando values given are valid cls.validate_plando_bosses(options) return cls.get_shuffle_mode(options) @classmethod def get_shuffle_mode(cls, option_list: typing.List[str]): # find out what mode of boss shuffle we should use for placing bosses after plando # and add as a string to look nice in the spoiler if "random" in option_list: shuffle = random.choice(list(cls.options)) option_list.remove("random") options = ";".join(option_list) + f";{shuffle}" boss_class = cls(options) else: for option in option_list: if option in cls.options: options = ";".join(option_list) break else: if cls.duplicate_bosses and len(option_list) == 1: if cls.valid_boss_name(option_list[0]): # this doesn't exist in this class but it's a forced option for classes where this is called options = option_list[0] + ";singularity" else: options = option_list[0] + f";{cls.name_lookup[cls.default]}" else: options = ";".join(option_list) + f";{cls.name_lookup[cls.default]}" boss_class = cls(options) return boss_class @classmethod def validate_plando_bosses(cls, options: typing.List[str]) -> None: used_locations = [] used_bosses = [] for option in options: # check if a shuffle mode was provided in the incorrect location if option == "random" or option in cls.options: if option != options[-1]: raise ValueError(f"{option} option must be at the end of the boss_shuffle options!") elif "-" in option: location, boss = option.split("-") if location in used_locations: raise ValueError(f"Duplicate Boss Location {location} not allowed.") if not cls.duplicate_bosses and boss in used_bosses: raise ValueError(f"Duplicate Boss {boss} not allowed.") used_locations.append(location) used_bosses.append(boss) if not cls.valid_boss_name(boss): raise ValueError(f"{boss.title()} is not a valid boss name.") if not cls.valid_location_name(location): raise ValueError(f"{location.title()} is not a valid boss location name.") if not cls.can_place_boss(boss, location): raise ValueError(f"{location.title()} is not a valid location for {boss.title()} to be placed.") else: if cls.duplicate_bosses: if not cls.valid_boss_name(option): raise ValueError(f"{option} is not a valid boss name.") else: raise ValueError(f"{option.title()} is not formatted correctly.") @classmethod def can_place_boss(cls, boss: str, location: str) -> bool: raise NotImplementedError @classmethod def valid_boss_name(cls, value: str) -> bool: return value in cls.bosses @classmethod def valid_location_name(cls, value: str) -> bool: return value in cls.locations def verify(self, world: typing.Type[World], player_name: str, plando_options: "PlandoOptions") -> None: if isinstance(self.value, int): return from BaseClasses import PlandoOptions if not(PlandoOptions.bosses & plando_options): # plando is disabled but plando options were given so pull the option and change it to an int option = self.value.split(";")[-1] self.value = self.options[option] logging.warning(f"The plando bosses module is turned off, so {self.name_lookup[self.value].title()} " f"boss shuffle will be used for player {player_name}.") class Range(NumericOption): range_start = 0 range_end = 1 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__}") elif value > self.range_end: raise Exception(f"{value} is higher than maximum {self.range_end} for option {self.__class__.__name__}") self.value = value @classmethod def from_text(cls, text: str) -> Range: text = text.lower() if text.startswith("random"): return cls.weighted_range(text) elif text == "default" and hasattr(cls, "default"): return cls.from_any(cls.default) elif text == "high": return cls(cls.range_end) elif text == "low": return cls(cls.range_start) elif cls.range_start == 0 \ and hasattr(cls, "default") \ and cls.default != 0 \ and text in ("true", "false"): # these are the conditions where "true" and "false" make sense if text == "true": return cls.from_any(cls.default) else: # "false" return cls(0) return cls(int(text)) @classmethod def weighted_range(cls, text) -> Range: if text == "random-low": return cls(cls.triangular(cls.range_start, cls.range_end, cls.range_start)) elif text == "random-high": return cls(cls.triangular(cls.range_start, cls.range_end, cls.range_end)) elif text == "random-middle": return cls(cls.triangular(cls.range_start, cls.range_end)) elif 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: random, random-high, random-middle, random-low, " f"random-range-low--, random-range-middle--, " f"random-range-high--, or random-range--.") @classmethod def custom_range(cls, text) -> Range: textsplit = text.split("-") try: random_range = [int(textsplit[len(textsplit) - 2]), int(textsplit[len(textsplit) - 1])] except ValueError: raise ValueError(f"Invalid random range {text} for option {cls.__name__}") random_range.sort() if random_range[0] < cls.range_start or random_range[1] > cls.range_end: 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], random_range[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], random_range[1])) else: return cls(random.randint(random_range[0], random_range[1])) @classmethod def from_any(cls, data: typing.Any) -> Range: if type(data) == int: return cls(data) return cls.from_text(str(data)) @classmethod def get_option_name(cls, value: int) -> str: return str(value) def __str__(self) -> str: return str(self.value) @staticmethod def triangular(lower: int, end: int, tri: typing.Optional[int] = None) -> int: return int(round(random.triangular(lower, end, tri), 0)) class SpecialRange(Range): special_range_cutoff = 0 special_range_names: typing.Dict[str, int] = {} """Special Range names have to be all lowercase as matching is done with text.lower()""" @classmethod def from_text(cls, text: str) -> Range: text = text.lower() if text in cls.special_range_names: return cls(cls.special_range_names[text]) return super().from_text(text) @classmethod def weighted_range(cls, text) -> Range: if text == "random-low": return cls(cls.triangular(cls.special_range_cutoff, cls.range_end, cls.special_range_cutoff)) elif text == "random-high": return cls(cls.triangular(cls.special_range_cutoff, cls.range_end, cls.range_end)) elif text == "random-middle": return cls(cls.triangular(cls.special_range_cutoff, cls.range_end)) elif text.startswith("random-range-"): return cls.custom_range(text) elif text == "random": return cls(random.randint(cls.special_range_cutoff, cls.range_end)) else: raise Exception(f"random text \"{text}\" did not resolve to a recognized pattern. " f"Acceptable values are: random, random-high, random-middle, random-low, " f"random-range-low--, random-range-middle--, " f"random-range-high--, or random-range--.") class VerifyKeys: valid_keys = frozenset() valid_keys_casefold: bool = False convert_name_groups: bool = False verify_item_name: bool = False verify_location_name: bool = False value: typing.Any @classmethod def verify_keys(cls, data: typing.List[str]): if cls.valid_keys: data = set(data) dataset = set(word.casefold() for word in data) if cls.valid_keys_casefold else set(data) extra = dataset - cls.valid_keys if extra: raise Exception(f"Found unexpected key {', '.join(extra)} in {cls}. " f"Allowed keys: {cls.valid_keys}.") def verify(self, world: typing.Type[World], player_name: str, plando_options: "PlandoOptions") -> None: if self.convert_name_groups and self.verify_item_name: new_value = type(self.value)() # empty container of whatever value is for item_name in self.value: new_value |= world.item_name_groups.get(item_name, {item_name}) self.value = new_value elif self.convert_name_groups and self.verify_location_name: new_value = type(self.value)() for loc_name in self.value: new_value |= world.location_name_groups.get(loc_name, {loc_name}) self.value = new_value if self.verify_item_name: for item_name in self.value: if item_name not in world.item_names: picks = get_fuzzy_results(item_name, world.item_names, limit=1) raise Exception(f"Item {item_name} from option {self} " f"is not a valid item name from {world.game}. " f"Did you mean '{picks[0][0]}' ({picks[0][1]}% sure)") elif self.verify_location_name: for location_name in self.value: if location_name not in world.location_names: picks = get_fuzzy_results(location_name, world.location_names, limit=1) raise Exception(f"Location {location_name} from option {self} " f"is not a valid location name from {world.game}. " f"Did you mean '{picks[0][0]}' ({picks[0][1]}% sure)") class OptionDict(Option[typing.Dict[str, typing.Any]], VerifyKeys): default: typing.Dict[str, typing.Any] = {} supports_weighting = False def __init__(self, value: typing.Dict[str, typing.Any]): self.value = deepcopy(value) @classmethod def from_any(cls, data: typing.Dict[str, typing.Any]) -> OptionDict: if type(data) == dict: cls.verify_keys(data) return cls(data) else: raise NotImplementedError(f"Cannot Convert from non-dictionary, got {type(data)}") def get_option_name(self, value): return ", ".join(f"{key}: {v}" for key, v in value.items()) def __contains__(self, item): return item in self.value class ItemDict(OptionDict): verify_item_name = True def __init__(self, value: typing.Dict[str, int]): if any(item_count < 1 for item_count in value.values()): raise Exception("Cannot have non-positive item counts.") super(ItemDict, self).__init__(value) class OptionList(Option[typing.List[typing.Any]], VerifyKeys): default: typing.List[typing.Any] = [] supports_weighting = False def __init__(self, value: typing.List[typing.Any]): self.value = deepcopy(value) super(OptionList, self).__init__() @classmethod def from_text(cls, text: str): return cls([option.strip() for option in text.split(",")]) @classmethod def from_any(cls, data: typing.Any): if type(data) == list: cls.verify_keys(data) return cls(data) return cls.from_text(str(data)) def get_option_name(self, value): return ", ".join(map(str, value)) def __contains__(self, item): return item in self.value class OptionSet(Option[typing.Set[str]], VerifyKeys): default: typing.Union[typing.Set[str], typing.FrozenSet[str]] = frozenset() supports_weighting = False def __init__(self, value: typing.Iterable[str]): self.value = set(deepcopy(value)) super(OptionSet, self).__init__() @classmethod def from_text(cls, text: str): return cls([option.strip() for option in text.split(",")]) @classmethod def from_any(cls, data: typing.Any): if isinstance(data, (list, set, frozenset)): cls.verify_keys(data) return cls(data) return cls.from_text(str(data)) def get_option_name(self, value): return ", ".join(sorted(value)) def __contains__(self, item): return item in self.value class ItemSet(OptionSet): verify_item_name = True convert_name_groups = True class Accessibility(Choice): """Set rules for reachability of your items/locations. Locations: ensure everything can be reached and acquired. Items: ensure all logically relevant items can be acquired. Minimal: ensure what is needed to reach your goal can be acquired.""" display_name = "Accessibility" option_locations = 0 option_items = 1 option_minimal = 2 alias_none = 2 default = 1 class ProgressionBalancing(SpecialRange): """A system that can move progression earlier, to try and prevent the player from getting stuck and bored early. A lower setting means more getting stuck. A higher setting means less getting stuck.""" default = 50 range_start = 0 range_end = 99 display_name = "Progression Balancing" special_range_names = { "disabled": 0, "normal": 50, "extreme": 99, } common_options = { "progression_balancing": ProgressionBalancing, "accessibility": Accessibility } class LocalItems(ItemSet): """Forces these items to be in their native world.""" display_name = "Local Items" class NonLocalItems(ItemSet): """Forces these items to be outside their native world.""" display_name = "Not Local Items" class StartInventory(ItemDict): """Start with these items.""" verify_item_name = True display_name = "Start Inventory" class StartInventoryPool(StartInventory): """Start with these items and don't place them in the world. The game decides what the replacement items will be.""" verify_item_name = True display_name = "Start Inventory from Pool" class StartHints(ItemSet): """Start with these item's locations prefilled into the !hint command.""" display_name = "Start Hints" class LocationSet(OptionSet): verify_location_name = True convert_name_groups = True class StartLocationHints(LocationSet): """Start with these locations and their item prefilled into the !hint command""" display_name = "Start Location Hints" class ExcludeLocations(LocationSet): """Prevent these locations from having an important item""" display_name = "Excluded Locations" class PriorityLocations(LocationSet): """Prevent these locations from having an unimportant item""" display_name = "Priority Locations" class DeathLink(Toggle): """When you die, everyone dies. Of course the reverse is true too.""" display_name = "Death Link" class ItemLinks(OptionList): """Share part of your item pool with other players.""" default = [] schema = Schema([ { "name": And(str, len), "item_pool": [And(str, len)], Optional("exclude"): [And(str, len)], "replacement_item": Or(And(str, len), None), Optional("local_items"): [And(str, len)], Optional("non_local_items"): [And(str, len)], Optional("link_replacement"): Or(None, bool), } ]) @staticmethod def verify_items(items: typing.List[str], item_link: str, pool_name: str, world, allow_item_groups: bool = True) -> typing.Set: pool = set() for item_name in items: if item_name not in world.item_names and (not allow_item_groups or item_name not in world.item_name_groups): picks = get_fuzzy_results(item_name, world.item_names, limit=1) picks_group = get_fuzzy_results(item_name, world.item_name_groups.keys(), limit=1) picks_group = f" or '{picks_group[0][0]}' ({picks_group[0][1]}% sure)" if allow_item_groups else "" raise Exception(f"Item {item_name} from item link {item_link} " f"is not a valid item from {world.game} for {pool_name}. " f"Did you mean '{picks[0][0]}' ({picks[0][1]}% sure){picks_group}") if allow_item_groups: pool |= world.item_name_groups.get(item_name, {item_name}) else: pool |= {item_name} return pool def verify(self, world: typing.Type[World], player_name: str, plando_options: "PlandoOptions") -> None: link: dict super(ItemLinks, self).verify(world, player_name, plando_options) existing_links = set() for link in self.value: if link["name"] in existing_links: raise Exception(f"You cannot have more than one link named {link['name']}.") existing_links.add(link["name"]) pool = self.verify_items(link["item_pool"], link["name"], "item_pool", world) local_items = set() non_local_items = set() if "exclude" in link: pool -= self.verify_items(link["exclude"], link["name"], "exclude", world) if link["replacement_item"]: self.verify_items([link["replacement_item"]], link["name"], "replacement_item", world, False) if "local_items" in link: local_items = self.verify_items(link["local_items"], link["name"], "local_items", world) local_items &= pool if "non_local_items" in link: non_local_items = self.verify_items(link["non_local_items"], link["name"], "non_local_items", world) non_local_items &= pool intersection = local_items.intersection(non_local_items) if intersection: raise Exception(f"item_link {link['name']} has {intersection} " f"items in both its local_items and non_local_items pool.") link.setdefault("link_replacement", None) per_game_common_options = { **common_options, # can be overwritten per-game "local_items": LocalItems, "non_local_items": NonLocalItems, "start_inventory": StartInventory, "start_hints": StartHints, "start_location_hints": StartLocationHints, "exclude_locations": ExcludeLocations, "priority_locations": PriorityLocations, "item_links": ItemLinks } if __name__ == "__main__": from worlds.alttp.Options import Logic import argparse map_shuffle = Toggle compass_shuffle = Toggle key_shuffle = Toggle big_key_shuffle = Toggle hints = Toggle test = argparse.Namespace() test.logic = Logic.from_text("no_logic") test.map_shuffle = map_shuffle.from_text("ON") test.hints = hints.from_text('OFF') try: test.logic = Logic.from_text("overworld_glitches_typo") except KeyError as e: print(e) try: test.logic_owg = Logic.from_text("owg") except KeyError as e: print(e) if test.map_shuffle: print("map_shuffle is on") print(f"Hints are {bool(test.hints)}") print(test)