import ast import csv import pkgutil import json import re from BaseClasses import MultiWorld, Item, ItemClassification from enum import IntEnum from typing import Optional, List, Union, get_args, get_origin, Any from types import GenericAlias from worlds.AutoWorld import World from .hooks.Helpers import before_is_category_enabled, before_is_item_enabled, before_is_location_enabled, before_is_event_enabled # blatantly copied from the minecraft ap world because why not def load_data_file(*args) -> dict: fname = "/".join(["data", *args]) try: filedata = json.loads(pkgutil.get_data(__name__, fname).decode()) except: filedata = [] return filedata def load_data_csv(*args) -> list[dict]: fname = "/".join(["data", *args]) try: lines = pkgutil.get_data(__name__, fname).decode().splitlines() except: lines = [] filedata = list(csv.DictReader(lines)) return filedata def is_option_enabled(multiworld: MultiWorld, player: int, name: str) -> bool: return get_option_value(multiworld, player, name) > 0 def get_option_value(multiworld: MultiWorld, player: int, name: str) -> Union[int, dict]: option = getattr(multiworld.worlds[player].options, name, None) if option is None: return 0 return option.value def clamp(value, min, max): """Returns value clamped to the inclusive range of min and max""" if value < min: return min elif value > max: return max else: return value def is_category_enabled(multiworld: MultiWorld, player: int, category_name: str) -> bool: from .Data import category_table """Check if a category has been disabled by a yaml option.""" hook_result = before_is_category_enabled(multiworld, player, category_name) if hook_result is not None: return hook_result category_data = category_table.get(category_name, {}) return resolve_yaml_option(multiworld, player, category_data) def resolve_yaml_option(multiworld: MultiWorld, player: int, data: dict) -> bool: if "yaml_option" in data: for option_name in data["yaml_option"]: required = True if option_name.startswith("!"): option_name = option_name[1:] required = False option_name = format_to_valid_identifier(option_name) if is_option_enabled(multiworld, player, option_name) != required: return False return True def is_item_name_enabled(multiworld: MultiWorld, player: int, item_name: str) -> bool: """Check if an item named 'item_name' has been disabled by a yaml option.""" item = multiworld.worlds[player].item_name_to_item.get(item_name, {}) if not item: return False return is_item_enabled(multiworld, player, item) def is_item_enabled(multiworld: MultiWorld, player: int, item: dict[str, Any]) -> bool: """Check if an item has been disabled by a yaml option.""" hook_result = before_is_item_enabled(multiworld, player, item) if hook_result is not None: return hook_result return _is_manualobject_enabled(multiworld, player, item) def is_location_name_enabled(multiworld: MultiWorld, player: int, location_name: str) -> bool: """Check if a location named 'location_name' has been disabled by a yaml option.""" location = multiworld.worlds[player].location_name_to_location.get(location_name, {}) if not location: return False return is_location_enabled(multiworld, player, location) def is_location_enabled(multiworld: MultiWorld, player: int, location: dict[str, Any]) -> bool: """Check if a location has been disabled by a yaml option.""" if getattr(multiworld, "generation_is_fake", False): # When UT is doing a fake gen, give it everything return True hook_result = before_is_location_enabled(multiworld, player, location) if hook_result is not None: return hook_result return _is_manualobject_enabled(multiworld, player, location) def is_event_enabled(multiworld: MultiWorld, player: int, event: dict[str, Any]) -> bool: """Check if an event has been disabled by a yaml option.""" hook_result = before_is_event_enabled(multiworld, player, event) if hook_result is not None: return hook_result return _is_manualobject_enabled(multiworld, player, event) def _is_manualobject_enabled(multiworld: MultiWorld, player: int, object: dict[str, Any]) -> bool: """Internal method: Check if a Manual Object has any category disabled by a yaml option. \nPlease use the proper is_'item/location'_enabled or is_'item/location'_name_enabled methods instead. """ enabled = True for category in object.get("category", []): if not is_category_enabled(multiworld, player, category): enabled = False break return enabled def get_items_for_player(multiworld: MultiWorld, player: int, includePrecollected: bool = False) -> List[Item]: """Return list of items of a player including placed items""" items = [i for i in multiworld.get_items() if i.player == player] if includePrecollected: items.extend(multiworld.precollected_items.get(player, [])) return items def reset_specific_item_value_cache_for_player(world: World, value: str, player: Optional[int] = None) -> dict[str, int]: if player is None: player = world.player return world.item_values[player].pop(value, {}) def reset_item_value_cache_for_player(world: World, player: Optional[int] = None): if player is None: player = world.player world.item_values[player] = {} def get_items_with_value(world: World, multiworld: MultiWorld, value: str, player: Optional[int] = None, skipCache: bool = False) -> dict[str, int]: """Return a dict of every items with a specific value type present in their respective 'value' dict\n Output in the format 'Item Name': 'value count'\n Keep a cache of the result, it can be skipped with 'skipCache == True'\n To force a Reset of the player's cache of a value use either reset_specific_item_value_cache_for_player or reset_item_value_cache_for_player """ if player is None: player = world.player player_items = get_items_for_player(multiworld, player, True) # Just a small check to prevent caching {} if items don't exist yet if not player_items: return {value: -1} value = value.lower().strip() if not skipCache: if not hasattr(world, 'item_values'): #Cache of just the item values world.item_values = {} if not world.item_values.get(player): world.item_values[player] = {} if value not in world.item_values.get(player, {}).keys() or skipCache: item_with_values = {i.name: world.item_name_to_item[i.name]['value'].get(value, 0) for i in player_items if i.code is not None and i.name in world.item_name_groups.get(f'has_{value}_value', [])} if skipCache: return item_with_values world.item_values[player][value] = item_with_values return world.item_values[player].get(value) def filter_used_regions(player_regions: dict|list) -> set: """Return a set of regions that are actually used in Generation. It includes region that have no locations but are required by other regions\n The dict version of the player_regions must be in the format: dict(region name str: region) """ used_regions = set() if isinstance(player_regions, list): player_regions = {r.name: r for r in player_regions} #Grab all the player's regions and take note of those with locations for region in player_regions.values(): if region.locations: used_regions.add(region) #Check every known region with location for parent regions checked_parent = [] for region in set(used_regions): def checkParent(parent_region): if parent_region.name in checked_parent: #dont check a region twice return checked_parent.append(parent_region.name) used_regions.add(parent_region) for entrance in parent_region.entrances: if player_regions.get(entrance.parent_region.name): checkParent(entrance.parent_region) return checkParent(region) return used_regions def convert_to_long_string(input: str | list[str]) -> str: """Verify that the input is a str. If it's a list[str] then it combine them into a str in a way that works with yaml template/website options descriptions""" if not isinstance(input, str): return str.join("\n ", input) return input def format_to_valid_identifier(input: str) -> str: """Make sure the input is a valid python identifier""" from keyword import iskeyword input = input.strip() if input.isidentifier() and not iskeyword(input): return input if iskeyword(input): input = "_" + input # if its already a valid keyword no need to check all its characters return input if input[:1].isdecimal(): input = "_" + input input = "".join([c if f"_{c}".isidentifier() else "_" for c in input]) return input def remove_specific_item(source: list[Item], item: Item) -> Item: """Remove and return an item from a list in a more precise way, base AP only check for name and player id before removing. \nThis checks that the item IS the exact same in the list. \nRaise ValueError if the item is not in the list.""" # Inspired by https://stackoverflow.com/a/58761459 for i in range(len(source)): # check all elements of the list like a normal remove does if item is source[i]: return source.pop(i) # if we reach here we didn't get any item raise ValueError(f"Item '{item.name}' could not be found in source list") class ProgItemsCat(IntEnum): VALUE = 1 CATEGORY = 2 def format_state_prog_items_key(category: str|ProgItemsCat ,key: str) -> str: """Convert the inputted key to the format used in state.has(key) to check/set the count of an item_value. Using either one of the predefined categories or a custom string. Example: Coin -> MANUAL_VALUE_coin """ if isinstance(category, str): cat_key = format_to_valid_identifier(category.upper()) else: cat_key = category.name return f"MANUAL_{cat_key}_{format_to_valid_identifier(key.lower())}" def convert_string_to_itemclassification(string: str) -> ItemClassification: def stringCheck(string): if string.isdigit(): true_class = ItemClassification(int(string)) elif string.startswith('0b'): true_class = ItemClassification(int(string, base=0)) else: true_class = ItemClassification[string] return true_class if "+" in string or "," in string: true_class = ItemClassification.filler for substring in re.split(r'[+,]', string): true_class |= stringCheck(substring.strip()) else: true_class = stringCheck(string) return true_class def convert_string_to_type(input: str, target_type: type) -> Any: """Take a string and attempt to convert it to {target_type} \ntarget_type can be a single type(ex. str), an union (int|str), an Optional type (Optional[str]) or a combo of any of those (Optional[int|str]) \nSpecial logic: - When target_type is Optional or contains None: it will check if input.lower() is "none" - When target_type contains bool: it will check if input.lower() is "true", "1", "false" or "0" - If bool is the last type in target_type it also run the input directly through bool(input) if previous fails \nif you want this to possibly fail without Exceptions include str in target_type, your input should get returned if all the other conversions fails """ def checktype(target_type, found_types: list): if issubclass(type(target_type), type): #is it a single type (str, list, etc) if target_type not in found_types: found_types.append(target_type) elif issubclass(type(target_type), GenericAlias): #is it something like list[str] and dict{str:int} if target_type not in found_types and get_origin(target_type) not in found_types: #dont add 'dict[str]' if we already search for 'dict' found_types.append(target_type) elif issubclass(type(target_type), type(str|int)) \ or issubclass(type(target_type), type(Union[str|int])): #Support both version of Union, and Optional and other alike for arg in get_args(target_type): checktype(arg, found_types) else: raise Exception(f"'{value}' cannot be converted to {target_type} since its not a supported type \nAsk about it in #Manual-support and it might be added.") found_types = [] checktype(target_type, found_types) if str in found_types: #do it last found_types.remove(str) found_types.append(str) value = input.strip() i = 0 errors = [] for value_type in found_types: i += 1 if issubclass(value_type, type(None)): if value.lower() == 'none': return None errors.append(str(value_type) + ": value was not 'none'") elif issubclass(value_type, bool): if value.lower() in ['true', '1', 'on']: return True elif value.lower() in ['false', '0', 'off']: return False else: if i == len(found_types): return value_type(value) #if its the last type might as well try and convert to bool errors.append(str(value_type) + ": value was not in either ['true', '1', 'on'] or ['false', '0', 'off']") elif issubclass(value_type, list) or issubclass(value_type, dict) \ or issubclass(value_type, set) or issubclass(type(value_type), GenericAlias): try: try: converted_value = ast.literal_eval(value) except ValueError as e: # The ValueError from ast when the string cannot be evaluated as a literal is usually something like # "malformed node or string on line 1: ", which is not # helpful, so re-raise with a better exception message. raise ValueError(f"'{value}' could not be evaluated as a literal") from e compareto = get_origin(value_type) if issubclass(type(value_type), GenericAlias) else value_type if issubclass(compareto, type(converted_value)): return converted_value else: errors.append(str(value_type) + f": value '{value}' was not a valid {str(compareto)}") except Exception as e: errors.append(str(value_type) + ": " + str(e)) continue else: try: return value_type(value) except Exception as e: errors.append(str(value_type) + ": " + str(e)) continue newline = "\n" raise Exception(f"'{value}' could not be converted to {target_type}, here's the conversion failure message(s):\n\n{newline.join([' - ' + str(validation_error) for validation_error in errors])}\n\n")