import logging import re import json from worlds.AutoWorld import World from BaseClasses import MultiWorld, ItemClassification from typing import Any, Counter from .Helpers import convert_string_to_itemclassification class ValidationError(Exception): pass class DataValidation(): game_table: dict[str, Any] = {} item_table: list[dict[str, Any]] = [] item_table_with_events: list[dict[str, Any]] = [] event_table: list[dict[str, Any]] = [] location_table: list[dict[str, Any]] = [] region_table: dict[str, Any] = {} location_table_with_events: list[dict[str, Any]] = [] location_name_to_location: dict[str, dict[str, Any]] = {} @staticmethod def checkItemNamesInLocationRequires(): from .Rules import ITEM_REGEX for location in DataValidation.location_table_with_events: if "requires" not in location: continue if isinstance(location["requires"], str): # parse user written statement into list of each item for match in ITEM_REGEX.finditer(location["requires"]): is_category = bool(match.group(1)) item_name = match.group(2) item_count = (str(match.group(3) or "1")).lstrip(':').strip() if not item_count.isnumeric() and item_count.lower() not in ["all", "half"] and not item_count.endswith('%'): item_name = match.group(0).strip("|") logging.debug(f'Invalid item_count "{item_count}" found, reverting to initial item_name "{item_name}"') # if it's a category, validate that the category exists if is_category: item_category_exists = len([item for item in DataValidation.item_table_with_events if item_name in item.get('category', [])]) > 0 if not item_category_exists: raise ValidationError("Item category %s is required by location %s but is misspelled or does not exist." % (item_name, location.get("name"))) continue else: item_exists = len([item.get("name") for item in DataValidation.item_table_with_events if item.get("name") == item_name]) > 0 if not item_exists: raise ValidationError("Item %s is required by location %s but is misspelled or does not exist." % (item_name, location.get("name"))) else: # item access is in dict form for item in location["requires"]: # if the require entry is an object with "or" or a list of items, treat it as a standalone require of its own if (isinstance(item, dict) and "or" in item and isinstance(item["or"], list)) or (isinstance(item, list)): or_items = item if isinstance(item, dict): or_items = item["or"] for or_item in or_items: or_item_parts = or_item.split(":") or_item_name = or_item if len(or_item_parts) > 1: or_item_name = or_item_parts[0] item_exists = len([item.get("name") for item in DataValidation.item_table_with_events if item.get("name") == or_item_name]) > 0 if not item_exists: raise ValidationError("Item %s is required by location %s but is misspelled or does not exist." % (or_item_name, location["name"])) else: item_parts = item.split(":") item_name = item if len(item_parts) > 1: item_name = item_parts[0] item_exists = len([item.get("name") for item in DataValidation.item_table_with_events if item.get("name") == item_name]) > 0 if not item_exists: raise ValidationError("Item %s is required by location %s but is misspelled or does not exist." % (item_name, location["name"])) @staticmethod def checkItemNamesInRegionRequires(): from .Rules import ITEM_REGEX for region_name in DataValidation.region_table: region = DataValidation.region_table[region_name] if "requires" not in region: continue if isinstance(region["requires"], str): # parse user written statement into list of each item for match in ITEM_REGEX.finditer(region["requires"]): is_category = bool(match.group(1)) item_name = match.group(2) item_count = (str(match.group(3) or "1")).lstrip(':').strip() if not item_count.isnumeric() and item_count.lower() not in ["all", "half"] and not item_count.endswith('%'): item_name = match.group(0).strip("|") logging.debug(f'Invalid item_count "{item_count}" found, reverting to initial item_name "{item_name}"') # if it's a category, validate that the category exists if is_category: item_category_exists = len([item for item in DataValidation.item_table_with_events if item_name in item.get('category', [])]) > 0 if not item_category_exists: raise ValidationError("Item category %s is required by region %s but is misspelled or does not exist." % (item_name, region_name)) else: item_exists = len([item.get("name") for item in DataValidation.item_table_with_events if item.get("name") == item_name]) > 0 if not item_exists: raise ValidationError("Item %s is required by region %s but is misspelled or does not exist." % (item_name, region_name)) else: # item access is in dict form for item in region["requires"]: # if the require entry is an object with "or" or a list of items, treat it as a standalone require of its own if (isinstance(item, dict) and "or" in item and isinstance(item["or"], list)) or (isinstance(item, list)): or_items = item if isinstance(item, dict): or_items = item["or"] for or_item in or_items: or_item_parts = or_item.split(":") or_item_name = or_item if len(or_item_parts) > 1: or_item_name = or_item_parts[0] item_exists = len([item.get("name") for item in DataValidation.item_table_with_events if item.get("name") == or_item_name]) > 0 if not item_exists: raise ValidationError("Item %s is required by region %s but is misspelled or does not exist." % (or_item_name, region_name)) else: item_parts = item.split(":") item_name = item if len(item_parts) > 1: item_name = item_parts[0] item_exists = len([item.get("name") for item in DataValidation.item_table_with_events if item.get("name") == item_name]) > 0 if not item_exists: raise ValidationError("Item %s is required by region %s but is misspelled or does not exist." % (item_name, region_name)) @staticmethod def checkRegionNamesInLocations(): for location in DataValidation.location_table_with_events: if "region" not in location or location["region"] in ["Menu", "Manual"]: continue region_exists = len([name for name in DataValidation.region_table if name == location["region"]]) > 0 if not region_exists: raise ValidationError("Region %s is set for location %s, but the region is misspelled or does not exist." % (location["region"], location["name"])) @staticmethod def checkItemsHasValidClassificationCount(): for item in DataValidation.item_table: if not item.get("classification_count"): continue for cat, count in item["classification_count"].items(): cat = str(cat) if count == 0: continue try: convert_string_to_itemclassification(cat) except KeyError as ex: raise ValidationError(f"Item '{item.get('name', '')}''s classification_count '{cat}' is misspelled or does not exist.\n Valid names are {', '.join(ItemClassification.__members__.keys())} \n\n{type(ex).__name__}:{ex}") except Exception as ex: raise ValidationError(f"Item '{item.get('name', '')}''s classification_count '{cat}' was improperly defined\n\n{type(ex).__name__}:{ex}") @staticmethod def checkItemsThatShouldBeRequired(): for item in DataValidation.item_table: # if the item is already progression, no need to check if item.get("progression"): continue # progression_skip_balancing is also progression, so no check needed if item.get("progression_skip_balancing"): continue # if any of the advanced type is already progression then no check needed if item.get("classification_count"): has_progression = False for cat, count in item["classification_count"].items(): cat = str(cat) if count == 0: continue try: true_class = convert_string_to_itemclassification(cat) except: # Skip since this validation error is dealt with in checkItemsHasValidClassificationCount true_class = ItemClassification.filler if ItemClassification.progression in true_class: has_progression = True break if has_progression: continue # check location requires for the presence of item name for location in DataValidation.location_table_with_events: if "requires" not in location: continue # convert to json so we don't have to guess the data type location_requires = json.dumps(location["requires"]) # if boolean, else legacy if isinstance(location_requires, str): if '|{}|'.format(item.get("name")) in location_requires: raise ValidationError("Item %s is required by location %s, but the item is not marked as progression." % (item.get("name"), location["name"])) else: if item.get("name") in location_requires: raise ValidationError("Item %s is required by location %s, but the item is not marked as progression." % (item.get("name"), location["name"])) # check region requires for the presence of item name for region_name in DataValidation.region_table: region = DataValidation.region_table[region_name] if "requires" not in region: continue # convert to json so we don't have to guess the data type region_requires = json.dumps(region["requires"]) # if boolean, else legacy if isinstance(region_requires, str): if '|{}|'.format(item.get("name")) in region_requires: raise ValidationError("Item %s is required by region %s, but the item is not marked as progression." % (item.get("name"), region_name)) else: if item.get("name") in region_requires: raise ValidationError("Item %s is required by region %s, but the item is not marked as progression." % (item.get("name"), region_name)) @staticmethod def _checkLocationRequiresForItemValueWithRegex(values_requested: dict[str, int], requires) -> dict[str, int]: if isinstance(requires, str) and 'ItemValue' in requires: for result in re.findall(r'\{ItemValue\(([^:]*)\:(.*?)\)\}', requires): value = result[0].lower().strip() count = int(result[1].split(",")[0]) if not values_requested.get(value): values_requested[value] = count else: values_requested[value] = max(values_requested[value], count) return values_requested @staticmethod def preFillCheckIfEnoughItemsForValue(world: World, multiworld: MultiWorld): from .Helpers import get_items_with_value, get_items_for_player, filter_used_regions player = world.player values_requested = {} player_regions = [] #Grab all the player's regions for region in multiworld.regions: if region.player != player: continue player_regions.append(region) used_regions = filter_used_regions(player_regions) used_regions_names = {r.name for r in set(used_regions)} #Check used regions (and their parent(s)) for ItemValue requirement for region in used_regions: manualregion = DataValidation.region_table.get(region.name, {}) if manualregion: if manualregion.get("requires"): DataValidation._checkLocationRequiresForItemValueWithRegex(values_requested, json.dumps(manualregion["requires"])) for region_entrance, require in manualregion.get('entrance_requires', {}).items(): if region_entrance in used_regions_names: DataValidation._checkLocationRequiresForItemValueWithRegex(values_requested, json.dumps(require)) for region_exit, require in manualregion.get('exit_requires', {}).items(): if region_exit in used_regions_names: DataValidation._checkLocationRequiresForItemValueWithRegex(values_requested, json.dumps(require)) for location in region.locations: manualLocation = world.location_name_to_location.get(location.name, {}) if "requires" in manualLocation and manualLocation["requires"]: DataValidation._checkLocationRequiresForItemValueWithRegex(values_requested, json.dumps(manualLocation["requires"])) # compare whats available vs requested but only if there's anything requested if values_requested: errors = [] existing_items = [item for item in get_items_for_player(multiworld, player, True) if item.code is not None and ItemClassification.progression in item.classification] for value, val_count in values_requested.items(): items_value = get_items_with_value(world, multiworld, value, player) found_count = 0 if items_value: for item in existing_items: if item.name in items_value: found_count += items_value[item.name] if found_count < val_count: errors.append(f" '{value}': {found_count} out of the {val_count} {value} worth of progression items required can be found.") if errors: raise ValidationError("There are not enough progression items for the following value(s): \n" + "\n".join(errors)) @staticmethod def checkRegionsConnectingToOtherRegions(): for region_name in DataValidation.region_table: region = DataValidation.region_table[region_name] if "connects_to" not in region: continue for connecting_region in region["connects_to"]: region_exists = len([name for name in DataValidation.region_table if name == connecting_region]) > 0 if not region_exists: raise ValidationError("Region %s connects to a region %s, which is misspelled or does not exist." % (region_name, connecting_region)) @staticmethod def checkForMissingItemNames(): missing_name_count = len([i for i in DataValidation.item_table if not i.get("name")]) if missing_name_count > 0: raise ValidationError("At least one of your items is missing the 'name' field.") @staticmethod def checkForDuplicateItemNames(): names: Counter[str] = Counter() problems: list[str] = [] for item in DataValidation.item_table: name = item.get("name") if name is not None: # Counter don't require checking that the key exists names[name] += 1 if names[name] > 1: problems.append(name) if problems: if len(problems) == 1: raise ValidationError(f"Item {problems[0]} is defined more than once.") else: separator = '", "' raise ValidationError(f"The following Items are defined more than once.\n \"{separator.join(problems)}\"") @staticmethod def checkForMissingLocationNames(): missing_name_count = len([l for l in DataValidation.location_table if not l.get("name")]) if missing_name_count > 0: raise ValidationError("At least one of your locations is missing the 'name' field.") @staticmethod def checkForDuplicateLocationNames(): names: Counter[str] = Counter() problems: list[str] = [] for location in DataValidation.location_table: name = location.get("name") if name is not None: # Counter don't require checking that the key exists names[name] += 1 if names[name] > 1: problems.append(name) if problems: if len(problems) == 1: raise ValidationError(f"Location {problems[0]} is defined more than once.") else: separator = '", "' raise ValidationError(f"The following Locations are defined more than once.\n \"{separator.join(problems)}\"") @staticmethod def checkForInvalidRegionNames(): # check for regions that Manual defines itself; currently limited to "Menu" and "Manual" for region_name in ["Menu", "Manual"]: if region_name in DataValidation.region_table: raise ValidationError(f"You cannot define a '{region_name}' region because Manual already defines a region with the same name.") @staticmethod def checkStartingItemsForValidItemsAndCategories(): if "starting_items" not in DataValidation.game_table: return starting_items = DataValidation.game_table["starting_items"] for starting_block in starting_items: if "items" in starting_block and "item_categories" in starting_block: raise ValidationError("One of your starting item definitions has both 'items' and 'item_categories' defined, but only one will be applied.") if "items" in starting_block: for item_name in starting_block["items"]: if not item_name in [item.get("name") for item in DataValidation.item_table]: raise ValidationError("Item %s is set as a starting item, but is misspelled or is not defined." % (item_name)) if "item_categories" in starting_block: for category_name in starting_block["item_categories"]: if len([item for item in DataValidation.item_table if "category" in item and category_name in item["category"]]) == 0: raise ValidationError("Item category %s is set as a starting item category, but is misspelled or is not defined on any items." % (category_name)) @staticmethod def checkStartingItemsForBadSyntax(): if not (starting_items := DataValidation.game_table.get("starting_items", False)): return for starting_block in starting_items: if type(starting_block) is not dict or len(starting_block.keys()) == 0: raise ValidationError("One of your starting item definitions is not a valid dictionary.\n Each definition must be inside {}, as demonstrated in the Manual documentation.") valid_keys = ["items", "item_categories", "random", "if_previous_item", "_comment", "yaml_option"] # _comment is provided by schema invalid_keys = [f'"{key}"' for key in starting_block.keys() if key not in valid_keys] if len(invalid_keys) > 0: raise ValidationError("One of your starting item definitions is invalid and may have unexpected results.\n The invalid starting item definition specifies the following incorrect keys: {}".format(", ".join(invalid_keys))) @staticmethod def checkPlacedItemsAndCategoriesForBadSyntax(): for location in DataValidation.location_table: place_item = location.get("place_item", False) place_item_category = location.get("place_item_category", False) if not place_item and not place_item_category: continue if place_item and type(place_item) is not list: raise ValidationError("One of your location has an incorrectly formatted place_item.\n The items, even just one, must be inside [].") if place_item_category and type(place_item_category) is not list: raise ValidationError("One of your location has an incorrectly formatted place_item_category.\n The categories, even just one, must be inside [].") @staticmethod def checkPlacedItemsForValidItems(): for location in DataValidation.location_table: if not (place_item := location.get("place_item", False)): continue # don't bother checking for valid items if the syntax is wrong if type(place_item) is not list: continue for item_name in place_item: if not item_name in [item.get("name") for item in DataValidation.item_table]: raise ValidationError("Item %s is placed (using place_item) on a location, but is misspelled or is not defined." % (item_name)) @staticmethod def checkPlacedItemCategoriesForValidItemCategories(): for location in DataValidation.location_table: if not (place_item_category := location.get("place_item_category", False)): continue # don't bother checking for valid item categories if the syntax is wrong if type(place_item_category) is not list: continue for category_name in place_item_category: if len([item for item in DataValidation.item_table if "category" in item and category_name in item["category"]]) == 0: raise ValidationError("Item category %s is placed (using place_item_category) on a location, but is misspelled or is not defined." % (category_name)) @staticmethod def checkForGameBeingInvalidJSON(): if len(DataValidation.game_table) == 0: raise ValidationError("No settings were found in your game.json. This likely indicates that your JSON is incorrectly formatted. Use https://jsonlint.com/ to validate your JSON files.") @staticmethod def checkForItemsBeingInvalidJSON(): if len(DataValidation.item_table) == 0: raise ValidationError("No items were found in your items.json. This likely indicates that your JSON is incorrectly formatted. Use https://jsonlint.com/ to validate your JSON files.") @staticmethod def checkForLocationsBeingInvalidJSON(): if len(DataValidation.location_table) == 0: raise ValidationError("No locations were found in your locations.json. This likely indicates that your JSON is incorrectly formatted. Use https://jsonlint.com/ to validate your JSON files.") @staticmethod def checkForNonStartingRegionsThatAreUnreachable(): using_starting_regions = len([region for region in DataValidation.region_table if DataValidation.region_table[region].get("starting")]) > 0 if not using_starting_regions: return nonstarting_regions = [region for region in DataValidation.region_table if not DataValidation.region_table[region].get("starting")] for nonstarter in nonstarting_regions: regions_that_connect_to = [region for region in DataValidation.region_table if "connects_to" in DataValidation.region_table[region] and nonstarter in DataValidation.region_table[region]["connects_to"]] if len(regions_that_connect_to) == 0: raise ValidationError("The region '%s' is set as a non-starting region, but has no regions that connect to it. It will be inaccessible." % nonstarter) def runPreFillDataValidation(world: World, multiworld: MultiWorld): validation_errors: list[ValidationError] = [] # check if there is enough items with values try: DataValidation.preFillCheckIfEnoughItemsForValue(world, multiworld) except ValidationError as e: validation_errors.append(e) if validation_errors: heading = f"ValidationError(s) for pre_fill of {world.game}:"; newline = "\n" raise Exception(f"\n\n{heading} \n\n{newline.join([' - ' + str(validation_error) for validation_error in validation_errors])}\n\n") # Called during stage_assert_generate def runGenerationDataValidation(cls) -> None: validation_errors: list[ValidationError] = [] try: DataValidation.checkForMissingItemNames() except ValidationError as e: validation_errors.append(e) try: DataValidation.checkForMissingLocationNames() except ValidationError as e: validation_errors.append(e) # check that requires have correct item names in locations and regions try: DataValidation.checkItemNamesInLocationRequires() except ValidationError as e: validation_errors.append(e) try: DataValidation.checkItemNamesInRegionRequires() except ValidationError as e: validation_errors.append(e) # check that region names are valid (i.e., not already defined, etc.) try: DataValidation.checkForInvalidRegionNames() except ValidationError as e: validation_errors.append(e) # check that region names are correct in locations try: DataValidation.checkRegionNamesInLocations() except ValidationError as e: validation_errors.append(e) # check that any classification_count used in items are valid try: DataValidation.checkItemsHasValidClassificationCount() except ValidationError as e: validation_errors.append(e) # check that items that are required by locations and regions are also marked required try: DataValidation.checkItemsThatShouldBeRequired() except ValidationError as e: validation_errors.append(e) # check that regions that are connected to are correct try: DataValidation.checkRegionsConnectingToOtherRegions() except ValidationError as e: validation_errors.append(e) # check for duplicate names in items, locations, and regions try: DataValidation.checkForDuplicateItemNames() except ValidationError as e: validation_errors.append(e) try: DataValidation.checkForDuplicateLocationNames() except ValidationError as e: validation_errors.append(e) # check that starting items are actually valid starting item definitions try: DataValidation.checkStartingItemsForBadSyntax() except ValidationError as e: validation_errors.append(e) # check that starting items and starting item categories actually exist in the items json try: DataValidation.checkStartingItemsForValidItemsAndCategories() except ValidationError as e: validation_errors.append(e) # check that placed items are actually valid place item definitions try: DataValidation.checkPlacedItemsAndCategoriesForBadSyntax() except ValidationError as e: validation_errors.append(e) # check placed item and item categories for valid options for each try: DataValidation.checkPlacedItemsForValidItems() except ValidationError as e: validation_errors.append(e) try: DataValidation.checkPlacedItemCategoriesForValidItemCategories() except ValidationError as e: validation_errors.append(e) # check for regions that are set as non-starting regions and have no connectors to them (so are unreachable) try: DataValidation.checkForNonStartingRegionsThatAreUnreachable() except ValidationError as e: validation_errors.append(e) if len(validation_errors) > 0: heading = f"ValidationError(s) in {cls.game}:"; raise Exception("\n\n%s \n\n%s\n\n" % (heading, "\n".join([' - ' + str(validation_error) for validation_error in validation_errors])))