Files
Manual-Cogmind-Archipelago/DataValidation.py
T
2026-10-10 17:05:45 +02:00

574 lines
29 KiB
Python

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])))