commit fa3846973526122c11efbf812ef17d6bec8adb7e Author: Luminiferous Date: Sat Oct 10 17:05:45 2026 +0200 Initial Commit diff --git a/Data.py b/Data.py new file mode 100644 index 0000000..bc010d6 --- /dev/null +++ b/Data.py @@ -0,0 +1,109 @@ +import logging +from typing import Any + +from .DataValidation import DataValidation, ValidationError +from .Helpers import load_data_file as helpers_load_data_file + +from .hooks.Data import \ + after_load_game_file, \ + after_load_item_file, after_load_location_file, \ + after_load_event_file, \ + after_load_region_file, after_load_category_file, \ + after_load_option_file, after_load_meta_file + +# blatantly copied from the minecraft ap world because why not +def load_data_file(*args) -> dict: + logging.warning("Deprecated usage of importing load_data_file from Data.py uses the one from Helper.py instead") + return helpers_load_data_file(*args) + +def convert_to_list(data, property_name: str) -> list: + if isinstance(data, dict): + data = data.get(property_name, []) + return data + +class ManualFile: + filename: str + data_type: dict|list + + def __init__(self, filename, data_type): + self.filename = filename + self.data_type = data_type + + def load(self): + contents = helpers_load_data_file(self.filename) + + if not contents and type(contents) != self.data_type: + return self.data_type() + + return contents + + +game_table: dict[str, Any] = ManualFile('game.json', dict).load() #dict +item_table: list[dict[str, Any]] = convert_to_list(ManualFile('items.json', list).load(), 'data') #list +location_table: list[dict[str, Any]] = convert_to_list(ManualFile('locations.json', list).load(), 'data') #list +event_table: list[dict[str, Any]] = convert_to_list(ManualFile('events.json', list).load(), 'data') #list +region_table: dict[str, Any] = ManualFile('regions.json', dict).load() #dict +category_table: dict[str, Any] = ManualFile('categories.json', dict).load() #dict +option_table: dict[str, Any] = ManualFile('options.json', dict).load() #dict +meta_table: dict[str, Any] = ManualFile('meta.json', dict).load() #dict + +# Removal of schemas in root of tables +region_table.pop('$schema', '') +category_table.pop('$schema', '') + +# hooks +game_table = after_load_game_file(game_table) +item_table = after_load_item_file(item_table) +location_table = after_load_location_file(location_table) +event_table = after_load_event_file(event_table) +region_table = after_load_region_file(region_table) +category_table = after_load_category_file(category_table) +option_table = after_load_option_file(option_table) +meta_table = after_load_meta_file(meta_table) + +# seed all of the tables for validation +DataValidation.game_table = game_table +DataValidation.item_table = item_table +DataValidation.location_table = location_table +DataValidation.event_table = event_table +DataValidation.region_table = region_table + +# Add UT's glitch item if required +if game_table.get("glitches_item_name"): + DataValidation.event_table = DataValidation.event_table + [{"name": game_table["glitches_item_name"]}] + +# might as well save this for other uses in tests +DataValidation.location_name_to_location = {l.get("name", f"unknown location {key}"): l for key, l in enumerate(DataValidation.location_table)} +# since "copy_location" just changes data its handled here to simplify things +for key, event in enumerate(event_table): + if "copy_location" in event: + if event["copy_location"] not in DataValidation.location_name_to_location.keys(): + raise KeyError(f"Event {event.get('name', f'unnamed event #{key}')} tried to copy a location named {event['copy_location']} but its misspelled or does not exist.") + + event_table[key] = DataValidation.location_name_to_location[event["copy_location"]] | event + DataValidation.event_table[key] = event_table[key] + +DataValidation.item_table_with_events = DataValidation.item_table + DataValidation.event_table +DataValidation.location_table_with_events = DataValidation.location_table + DataValidation.event_table + +validation_errors = [] + +# check that json files are not just invalid json +try: DataValidation.checkForGameBeingInvalidJSON() +except ValidationError as e: validation_errors.append(e) + +try: DataValidation.checkForItemsBeingInvalidJSON() +except ValidationError as e: validation_errors.append(e) + +try: DataValidation.checkForLocationsBeingInvalidJSON() +except ValidationError as e: validation_errors.append(e) + + +############ +# If there are any validation errors, display all of them at once +############ + +if len(validation_errors) > 0: + logging.error("\nValidationError(s): \n\n%s\n\n" % ("\n".join([' - ' + str(validation_error) for validation_error in validation_errors]))) + print("\n\nYou can close this window.\n") + keeping_terminal_open = input("If you are running from a terminal, press Ctrl-C followed by ENTER to break execution.") diff --git a/DataValidation.py b/DataValidation.py new file mode 100644 index 0000000..93d810e --- /dev/null +++ b/DataValidation.py @@ -0,0 +1,573 @@ +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]))) diff --git a/Game.py b/Game.py new file mode 100644 index 0000000..d3d633a --- /dev/null +++ b/Game.py @@ -0,0 +1,16 @@ +from .Data import game_table + +game_name: str = "Manual_%s_%s" % (game_table["game"], game_table["creator"]) +filler_item_name: str | list[str] = game_table["filler_item_name"] if "filler_item_name" in game_table else "Filler" +glitches_item_name: str | None = game_table["glitches_item_name"] if "glitches_item_name" in game_table else None +starting_items: list[dict] | None = game_table["starting_items"] if "starting_items" in game_table else None + +if "starting_index" in game_table: + try: + starting_index = int(game_table["starting_index"]) + except ValueError: + raise Exception("The value of data/game.json:'starting_index' should be an int") +else: + starting_index = 1 + +unused_goals_are_locations = game_table.get("unused_goals_are_locations", False) diff --git a/Helpers.py b/Helpers.py new file mode 100644 index 0000000..1081677 --- /dev/null +++ b/Helpers.py @@ -0,0 +1,370 @@ +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") diff --git a/Items.py b/Items.py new file mode 100644 index 0000000..16c40ee --- /dev/null +++ b/Items.py @@ -0,0 +1,91 @@ +from BaseClasses import Item +from .Data import item_table +from .Game import filler_item_name, starting_index, game_name + + +###################### +# Generate item lookups +###################### + +item_id_to_name: dict[int, str] = {} +item_name_to_item: dict[str, dict] = {} +item_name_groups: dict[str, set[str]] = {} +advancement_item_names: set[str] = set() +lastItemId = -1 + +count = starting_index + +filler_found: set[str] = set() + +# add sequential generated ids to the lists +for key, val in enumerate(item_table): + if "id" in item_table[key]: + item_id = item_table[key]["id"] + if item_id >= count: + count = item_id + else: + raise ValueError(f"{item_table[key]['name']} has an invalid ID. ID must be at least {count + 1}") + + item_table[key]["id"] = count + item_table[key]["progression"] = val["progression"] if "progression" in val else False + if isinstance(val.get("category", []), str): + item_table[key]["category"] = [val["category"]] + if isinstance(filler_item_name, list): + if item_table[key].get("name") in filler_item_name: + filler_found.add(item_table[key].get("name")) + elif isinstance(filler_item_name, str): + if item_table[key].get("name") == filler_item_name: + filler_found.add(item_table[key].get("name")) + + count += 1 + +# add the filler item to the list of items for lookup +if isinstance(filler_item_name, list): + for generic_filler in filler_item_name: + if generic_filler not in filler_found: + item_table.append({ + "name": generic_filler, + "id": count, + }) + count += 1 +elif isinstance(filler_item_name, str) and not filler_found: + item_table.append({ + "name": filler_item_name, + "id": count, + }) + count += 1 + +for item in item_table: + item_name = item.get("name", f"Unnamed Item {item['id']}") + item_id_to_name[item["id"]] = item_name + item_name_to_item[item_name] = item + + if item["id"] is not None: + lastItemId = max(lastItemId, item["id"]) + + for c in item.get("category", []): + if c not in item_name_groups: + item_name_groups[c] = set() + item_name_groups[c].add(item_name) + + #Just lowercase the values here to remove all the .lower.strip down the line + item['value'] = {k.lower().strip(): v + for k, v in item.get('value', {}).items()} + + for v in item.get("value", {}).keys(): + group_name = f"has_{v}_value" + if group_name not in item_name_groups: + item_name_groups[group_name] = set() + item_name_groups[group_name].add(item_name) + +item_id_to_name[None] = "__Victory__" +item_name_to_id = {name: id for id, name in item_id_to_name.items()} + + +###################### +# Item classes +###################### + + +class ManualItem(Item): + game = game_name diff --git a/Locations.py b/Locations.py new file mode 100644 index 0000000..d09bd6a --- /dev/null +++ b/Locations.py @@ -0,0 +1,100 @@ +from BaseClasses import Location +from .Data import location_table, event_table +from .Game import starting_index, game_name +from typing import Any + + +###################### +# Generate location lookups +###################### + +count = starting_index +victory_names: list[str] = [] + +# add sequential generated ids to the lists +for key, _ in enumerate(location_table): + if "victory" in location_table[key] and location_table[key]["victory"]: + victory_names.append(location_table[key]["name"]) + + if "id" in location_table[key]: + item_id = location_table[key]["id"] + if item_id >= count: + count = item_id + else: + raise ValueError(f"{location_table[key]['name']} has an invalid ID. ID must be at least {count + 1}") + + location_table[key]["id"] = count + + if "region" not in location_table[key]: + location_table[key]["region"] = "Manual" # all locations are in the same region for Manual + + if isinstance(location_table[key].get("category", []), str): + location_table[key]["category"] = [location_table[key]["category"]] + + count += 1 + +if not victory_names: + # Add the game completion location, which will have the Victory item assigned to it automatically + location_table.append({ + "id": count + 1, + "name": "__Manual Game Complete__", + "region": "Manual", + "requires": [] + # "category": custom_victory_location["category"] if "category" in custom_victory_location else [] + }) + victory_names.append("__Manual Game Complete__") + +location_id_to_name: dict[int, str] = {} +location_name_to_location: dict[str, dict[str, Any]] = {} +location_name_groups: dict[str, set[str]] = {} +event_name_to_event: dict[str, dict[str, Any]] = {} +event_name_groups: dict[str, set[str]] = {} + +for loc in location_table: + loc_name = loc.get("name", f"Unnamed Location {loc['id']}") + location_id_to_name[loc["id"]] = loc_name + location_name_to_location[loc_name] = loc + + for c in loc.get("category", []): + if c not in location_name_groups: + location_name_groups[c] = set() + location_name_groups[c].add(loc_name) + + +# location_id_to_name[None] = "__Manual Game Complete__" +location_name_to_id = {name: id for id, name in location_id_to_name.items()} + +id = 0 +for key, event in enumerate(event_table): + event_name = f"{id}_{event['name']}".upper().replace(" ", "_") + while event_name in location_name_to_location: + id += 1 + event_name = f"{id}_{event['name']}".upper().replace(" ", "_") + if "location_name" in event: + if event["location_name"] in location_name_to_location: + raise Exception(f"Cannot define event {event['location_name']} with the same name as a location.") + event_name_to_event[event["location_name"]] = event + else: + event_name_to_event[event_name] = event + event_name_to_event[event_name]["location_name"] = event_name + event_table[key]["location_name"] = event_name + if 'visible' not in event: + event_name_to_event[event_name]['visible'] = False + event_table[key]['visible'] = False + if 'region' not in event: + event_name_to_event[event_name]['region'] = "Manual" + event_table[key]['region'] = "Manual" + for c in event.get("category", []): + if c not in event_name_groups: + event_name_groups[c] = set() + event_name_groups[c].add(event['name']) + + id += 1 + +###################### +# Location classes +###################### + + +class ManualLocation(Location): + game = game_name diff --git a/ManualClient.py b/ManualClient.py new file mode 100644 index 0000000..7c41d26 --- /dev/null +++ b/ManualClient.py @@ -0,0 +1,1547 @@ +from __future__ import annotations +import asyncio +from functools import cache +import os +import re +import sys +import time +import typing +from typing import Any, Dict, List, Optional +from enum import IntEnum + +import requests +from worlds import AutoWorldRegister, network_data_package +from worlds.LauncherComponents import icon_paths +import json +import traceback + +import ModuleUpdate +ModuleUpdate.update() + +import Utils + +from NetUtils import ClientStatus +from CommonClient import gui_enabled, logger, get_base_parser, ClientCommandProcessor, server_loop +from MultiServer import mark_raw + +tracker_loaded = False +try: + from worlds.tracker.TrackerClient import TrackerGameContext as SuperContext, TrackerCommandProcessor + ClientCommandProcessor = TrackerCommandProcessor + tracker_loaded = True +except ModuleNotFoundError: + from CommonClient import CommonContext as SuperContext + +if typing.TYPE_CHECKING: + import kvui + +class SortingOrderLoc(IntEnum): + alphabetical = 1 + inverted_alphabetical = -1 + natural = 2 + inverted_natural = -2 + default = 2 + +# Docs must be done after because otherwise __doc__ return none +SortingOrderLoc.alphabetical.__doc__ = "Sort alphabetically using the name of item defined in locations.json." +SortingOrderLoc.natural.__doc__ = "Sort like alphabetically but makes sure that any number are read as integer and thus sorted naturally. EG. key2 < key12" + +class SortingOrderItem(IntEnum): + alphabetical = 1 + inverted_alphabetical = -1 + natural = 2 + inverted_natural = -2 + received = 3 + inverted_received = -3 + default = 3 + +SortingOrderItem.alphabetical.__doc__ = "Sort alphabetically using the name of item defined in items.json." +SortingOrderItem.natural.__doc__ = "Sort like alphabetically but makes sure that any number are read as integer and thus sorted naturally. EG. key2 < key12" +SortingOrderItem.received.__doc__ = "Sort the item in the order they are received from the server" + +class SortingOrderCategories(IntEnum): + alphabetical = 1 + inverted_alphabetical = -1 + natural = 2 + inverted_natural = -2 + default = 2 + +SortingOrderCategories.alphabetical.__doc__ = "Sort alphabetically using the name of the category." +SortingOrderCategories.natural.__doc__ = "Sort like alphabetically but makes sure that any number are read as integer and thus sorted naturally. EG. key2 < key12" + + +@cache +def strip_articles(title: str) -> str: + lower = title.lower() + if lower.startswith("the "): + title = title[4:] + elif lower.startswith("a "): + title = title[2:] + elif lower.startswith("an "): + title = title[3:] + return title + +def natural_sort_key(key: str): + # Modified from https://stackoverflow.com/a/11150413 + def convert(text): + return int(text) if text.isdigit() else text.lower() + key = strip_articles(key) + + return [convert(c) for c in re.split('([0-9]+)', key)] + +class ManualClientCommandProcessor(ClientCommandProcessor): + ctx: ManualContext + def _cmd_resync(self) -> bool: + """Manually trigger a resync.""" + self.output("Syncing items.") + self.ctx.syncing = True + return True + + @mark_raw + def _cmd_send(self, location_name: str) -> bool: + """Send a check""" + names = self.ctx.location_names_to_id.keys() + location_name, usable, response = Utils.get_intended_text( + location_name, + names + ) + if usable: + location_id = self.ctx.location_names_to_id[location_name] + self.ctx.locations_checked.append(location_id) + self.ctx.syncing = True + return True + else: + self.output(response) + return False + + @mark_raw + def _cmd_open_settings(self) -> bool: + """Open the settings panel.""" + if gui_enabled: + self.ctx.ui.open_settings() + return True + else: + self.output("GUI is not enabled.") + return False + +class ManualContext(SuperContext): + command_processor = ManualClientCommandProcessor + game = None # this is changed in server_auth below based on user input + items_handling = 0b111 # full remote + tags = {"AP"} + + location_table = {} + item_table = {} + region_table = {} + category_table = {} + + tracker_reachable_locations: list[str] = [] + tracker_glitched_locations: list[str] = [] + tracker_reachable_events: list[str] = [] + + set_deathlink = False + last_death_link = 0 + deathlink_out = False + goaled = False + + visible_events: dict[str, dict[str, Any]] = {} + + search_term = "" + items_sorting = SortingOrderItem.default.name + items_sorting_uses_sortkeys = True + item_categories_sorting = SortingOrderCategories.default.name + item_categories_sorting_uses_sortkeys = True + locations_sorting = SortingOrderLoc.default.name + locations_sorting_uses_sortkeys = True + location_categories_sorting = SortingOrderCategories.default.name + location_categories_sorting_uses_sortkeys = True + block_unreachable_location_press = True + display_glitched_locations = True + allow_glitched_location_press = True + + colors = { + 'location_default': [219/255, 218/255, 213/255, 1], + 'location_in_logic': [2/255, 242/255, 42/255, 1], + 'location_in_glitched_logic': [247/255, 255/255, 119/255, 1], + 'category_even_default': [0.5, 0.5, 0.5, 0.1], + 'category_odd_default': [1.0, 1.0, 1.0, 0.0], + 'category_in_logic': [2/255, 82/255, 2/255, 1], + 'category_in_glitched_logic': [172/255, 179/255, 81/255, 1], + 'deathlink_received': [1, 0, 0, 1], + 'deathlink_primed': [1, 1, 1, 1], + 'deathlink_sent': [0, 1, 0, 1], + 'game_select_button': [200/255, 200/255, 200/255, 1], + 'header_background': [15/255, 80/255, 112/255, 1] + } + + def __init__(self, server_address, password, game, player_name) -> None: + super(ManualContext, self).__init__(server_address, password) + + if tracker_loaded: + super().set_callback(self.on_tracker_updated) # Universal Tracker takes this func and calls it when updateTracker is called + if hasattr(self, "set_events_callback"): + super().set_events_callback(self.on_tracker_events) # Universal Tracker takes this func and calls it when events are calculated + if hasattr(self, "set_glitches_callback"): + super().set_glitches_callback(self.on_glitches_call) # Universal Tracker takes this func and calls it when OOL glitched location are calculated + + self.send_index: int = 0 + self.syncing = False + self.game = game + self.username = player_name + self.locations_checked: list[int] = [] + self.locations_scouted: list[int] = [] + + async def server_auth(self, password_requested: bool = False): + if password_requested and not self.password: + if tracker_loaded: + await super(SuperContext, self).server_auth(password_requested) # type: ignore + else: + await super(ManualContext, self).server_auth(password_requested) + + if "Manual_" not in self.ui.game_bar_text.text: + raise Exception("The Manual client can only be used for Manual games.") + + self.game = self.ui.game_bar_text.text + self.goaled = False + + world = AutoWorldRegister.world_types.get(self.game) + if not self.location_table and not self.item_table and world is None: + raise Exception(f"Cannot load {self.game}, please add the apworld to custom_worlds/") + + data_package = network_data_package["games"].get(self.game, {}) + + self.update_ids(data_package) + + if world is not None and hasattr(world, "victory_names"): + self.victory_names = world.victory_names + self.goal_location = self.get_location_by_name(world.victory_names[0]) + else: + self.victory_names = ["__Manual Game Complete__"] + self.goal_location = self.get_location_by_name("__Manual Game Complete__") + + await self.get_username() + await self.send_connect() + + async def connection_closed(self): + await super(ManualContext, self).connection_closed() + + @property + def suggested_game(self) -> str: + if self.game: + return self.game + from .Game import game_name # This will at least give us the name of a manual they've installed + return Utils.persistent_load().get("client", {}).get("last_manual_game", None) or game_name + + def get_location_by_name(self, name) -> dict[str, Any]: + location = self.location_table.get(name) + if not location: + # It is absolutely possible to pull categories from the data_package via self.update_game. I have not done this yet. + location = AutoWorldRegister.world_types[self.game].location_name_to_location.get(name, {"name": name}) # type: ignore + return location + + def get_location_by_id(self, id) -> dict[str, Any]: + name = self.location_names.lookup_in_game(id) + return self.get_location_by_name(name) + + def get_item_by_name(self, name): + item = self.item_table.get(name) + if not item: + item = AutoWorldRegister.world_types[self.game].item_name_to_item.get(name, {"name": name}) # type: ignore + return item + + def get_item_by_id(self, id): + name = self.item_names.lookup_in_game(id) + return self.get_item_by_name(name) + + def get_category_by_name( self, name:str) -> dict[str, Any]: + category = self.category_table.get(name) + if not category: + category = AutoWorldRegister.world_types[self.game].category_table.get(name, {"name": name}) # type: ignore + return category + + def get_sorting_key_by_id(self, key, is_item=True) -> str: + Manual_dict: Dict[str, str] = self.get_item_by_id(key) if is_item else self.get_location_by_id(key) + name: str | None = None + if (is_item and self.items_sorting_uses_sortkeys) or \ + (not is_item and self.locations_sorting_uses_sortkeys): + name = Manual_dict.get("sort-key") + name = Manual_dict["name"] if name is None else name + name = strip_articles(name.lower()) + return name + + def get_category_sorting_key_by_name(self, name, is_item = True) -> str: + Manual_dict: Dict[str, Any] = self.get_category_by_name(name) + key: str | None = None + if (is_item and self.item_categories_sorting_uses_sortkeys) or\ + (not is_item and self.location_categories_sorting_uses_sortkeys): + key = Manual_dict.get("sort-key") + key = name if key is None else key + key = strip_articles(key.lower()) + return key + + def update_ids(self, data_package) -> None: + self.location_names_to_id = data_package['location_name_to_id'] + self.item_names_to_id = data_package['item_name_to_id'] + + def update_data_package(self, data_package: dict): + super().update_data_package(data_package) + for game, game_data in data_package["games"].items(): + if game == self.game: + self.update_ids(game_data) + + def set_search(self, search_term: str): + self.search_term = search_term + + def clear_search(self): + self.search_term = "" + + @property + def endpoints(self): + if self.server: + return [self.server] + else: + return [] + + async def shutdown(self): + await super(ManualContext, self).shutdown() + + def on_package(self, cmd: str, args: dict): + super().on_package(cmd, args) + + if cmd in {"Connected", "DataPackage"}: + if cmd == "Connected": + Utils.persistent_store("client", "last_manual_game", self.game) + if args.get("slot_data"): + goal = args["slot_data"].get("goal") + if goal and goal < len(self.victory_names): + self.goal_location = self.get_location_by_name(self.victory_names[goal]) + if args['slot_data'].get('death_link'): + self.ui.enable_death_link() + self.set_deathlink = True + self.last_death_link = 0 + self.visible_events = args['slot_data'].get('visible_events', {}) + logger.info(f"Slot data: {args['slot_data']}") + + self.ui.build_tracker_and_locations_table() + self.ui.request_update_tracker_and_locations_table(update_highlights=True) + elif cmd in {"ReceivedItems"}: + self.ui.request_update_tracker_and_locations_table(update_highlights=True) + elif cmd in {"RoomUpdate"}: + self.ui.request_update_tracker_and_locations_table(update_highlights=False) + + def on_deathlink(self, data: typing.Dict[str, typing.Any]) -> None: + super().on_deathlink(data) + self.ui.death_link_button.text = f"Death Link: {data['source']}" + self.ui.death_link_button.background_color = self.colors['deathlink_received'] + + def on_tracker_updated(self, reachable_locations: list[str]): + self.tracker_reachable_locations = reachable_locations + self.ui.request_update_tracker_and_locations_table(update_highlights=True) + + def on_tracker_events(self, events: list[str]): + self.tracker_reachable_events = events + if events: + self.ui.request_update_tracker_and_locations_table(update_highlights=True) + + def on_glitches_call(self, glitched_locations: list[str]): + self.tracker_glitched_locations = glitched_locations + if glitched_locations: + self.ui.request_update_tracker_and_locations_table(update_highlights=True) + + def is_event_visible(self, event_name, category_name): + if event_name not in self.visible_events: + return False + if category_name == "(No Category)" and len(self.visible_events[event_name]) == 0: + return True + return category_name in self.visible_events[event_name] + + def handle_connection_loss(self, msg: str) -> None: + """Helper for logging and displaying a loss of connection. Must be called from an except block.""" + exc_info = sys.exc_info() + logger.exception(msg, exc_info=exc_info, extra={'compact_gui': True}) + tracker_error = False + e = exc_info[2] + formatted_tb = ''.join(traceback.format_tb(e)) + while e: + if '/tracker/' in e.tb_frame.f_code.co_filename: + tracker_error = True + break + e = e.tb_next + + if tracker_error: + self._messagebox_connection_loss = self.gui_error( + "A Universal Tracker error has occurred. Please ensure that your version of UT matches your version of Archipelago.", + formatted_tb) + else: + self._messagebox_connection_loss = self.gui_error(msg, formatted_tb) + + def run_gui(self): + """Import kivy UI system from make_gui() and start running it as self.ui_task.""" + if hasattr(SuperContext, "make_gui"): + # Call the real one if it exists + return super().run_gui() + + # This is a copy of 0.5.1's run_gui, because backporting is easier than the alternative. + # This entire function can be removed once 0.5.1 is the old enough. + ui_class = self.make_gui() + self.ui = ui_class(self) + self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI") + + def make_gui(self) -> typing.Type["kvui.GameManager"]: + if hasattr(SuperContext, "make_gui"): + ui = super().make_gui() # before the kivy imports so kvui gets loaded first + else: + from kvui import GameManager + ui = GameManager + + from kivy.core.window import Window + from kivy.lang import Builder + from kivy.metrics import dp + from kivy.properties import ColorProperty + from kivy.uix.boxlayout import BoxLayout + from kivy.uix.button import Button + from kivy.uix.dropdown import DropDown + from kivy.uix.gridlayout import GridLayout + from kivy.uix.label import Label + from kivy.uix.layout import Layout + from kivy.uix.scrollview import ScrollView + from kivy.uix.settings import Settings + from kivy.uix.spinner import Spinner, SpinnerOption + from kivy.uix.textinput import TextInput + from kivy.uix.treeview import TreeView, TreeViewLabel, TreeViewNode + from kivy.config import ConfigParser + + class ManualTabLayout(BoxLayout): + pass + + class ManualControlsLayout(BoxLayout): + pass + + class TrackerAndLocationsLayout(GridLayout): + pass + + class TrackerLayoutScrollable(ScrollView): + pass + + class LocationsLayoutScrollable(ScrollView): + pass + + class TreeViewButton(Button, TreeViewNode): + victory: bool = False + id: int|None = None + location_name: str = "" + + class ItemLabel(Label): + item_id: int|None = None + item_count: int = 1 + item_name: str = "" + + class TreeViewScrollView(ScrollView, TreeViewNode): + pass + + class GameSelectOption(SpinnerOption): + background_color = self.colors['game_select_button'] + + class GameSelectDropDown(DropDown): + # If someone can figure out how to give this a solid background, I'd be very happy. + pass + + Builder.load_string( + """ + +: + canvas: + Color: + rgba: root.background_color + Rectangle: + pos: self.pos + size: self.size + + """) + + class ManualControlsStyledLayout(BoxLayout): + background_color = ColorProperty() + + class ManualManager(ui): + base_title = "Archipelago Manual Client" + listed_items = {"(No Category)": []} + item_categories = ["(No Category)"] + listed_locations = {"(No Category)": []} + location_categories = ["(No Category)"] + + active_item_accordion = 0 + active_location_accordion = 0 + + update_requested_time: Optional[float] = None + update_requested_highlights: bool = False + + mouse_pos: tuple + + ctx: ManualContext + + def __init__(self, ctx): + super().__init__(ctx) + + def build(self) -> Layout: + super().build() + + self.ctx.items_sorting = self.config.get('manual', 'items_sorting_order') + self.ctx.items_sorting_uses_sortkeys = self.config.get('manual', 'items_sorting_uses_sortkeys') + self.ctx.locations_sorting = self.config.get('manual', 'locations_sorting_order') + self.ctx.locations_sorting_uses_sortkeys = self.config.get('manual', 'locations_sorting_uses_sortkeys') + self.ctx.item_categories_sorting = self.config.get('manual', 'item_categories_sorting_order') + self.ctx.item_categories_sorting_uses_sortkeys = self.config.get('manual', 'item_categories_sorting_uses_sortkeys') + self.ctx.location_categories_sorting = self.config.get('manual', 'location_categories_sorting_order') + self.ctx.location_categories_sorting_uses_sortkeys = self.config.get('manual', 'location_categories_sorting_uses_sortkeys') + self.ctx.block_unreachable_location_press = True if self.config.get('universal-tracker', 'block_unreachable_location_press') == "Yes" else False + self.ctx.display_glitched_locations = True if self.config.get('universal-tracker', 'display_glitched_locations') == "Yes" else False + self.ctx.allow_glitched_location_press = True if self.config.get('universal-tracker', 'allow_glitched_location_press') == "Yes" else False + + self.manual_game_layout = BoxLayout(orientation="horizontal", size_hint_y=None, height=dp(30)) + + game_bar_label = Label(text="Manual Game ID", size=(dp(150), dp(30)), size_hint_y=None, size_hint_x=None) + manuals = [w for w in AutoWorldRegister.world_types.keys() if "Manual_" in w] + manuals.sort() # Sort by alphabetical order, not load order + self.manual_game_layout.add_widget(game_bar_label) + self.game_bar_text = Spinner(text=self.ctx.suggested_game, size_hint_y=None, height=dp(30), sync_height=True, + values=manuals, option_cls=GameSelectOption, dropdown_cls=GameSelectDropDown) + self.manual_game_layout.add_widget(self.game_bar_text) + + self.grid.add_widget(self.manual_game_layout, 3) + + panel = self.add_client_tab("Manual", ManualTabLayout(orientation="vertical")) + + self.controls_panel = ManualControlsLayout(orientation="horizontal", size_hint_y=None, height=dp(40)) + self.tracker_and_locations_panel = TrackerAndLocationsLayout(cols = 2) + + panel.content.add_widget(self.controls_panel) + panel.content.add_widget(self.tracker_and_locations_panel) + + self.build_tracker_and_locations_table() + + return self.container + + def get_application_config(self, defaultpath: str = "") -> str: + return Utils.user_path("manual_client.ini") + + + def build_config(self, config: ConfigParser): + super().build_config(config) + config.setdefaults("manual", { + "items_sorting_order": SortingOrderItem.default.name, + "items_sorting_uses_sortkeys": "Yes", + "locations_sorting_order": SortingOrderLoc.default.name, + "locations_sorting_uses_sortkeys": "Yes", + "item_categories_sorting_order": SortingOrderCategories.default.name, + "item_categories_sorting_uses_sortkeys": "Yes", + "location_categories_sorting_order": SortingOrderCategories.default.name, + "location_categories_sorting_uses_sortkeys": "Yes" + }) + config.setdefaults("universal-tracker", { + "block_unreachable_location_press": "Yes", + "display_glitched_locations": "Yes", + "allow_glitched_location_press": "Yes" + }) + + def build_settings(self, settings: Settings): + super().build_settings(settings) + json_data = [ + { + "type": "title", + "title": "Manual Client" + }, + + { + "type": "options", + "title": "Items Sorting Order", + "section": "manual", + "key": "items_sorting_order", + "options": list(SortingOrderItem._member_names_), + "desc": '\n'.join([f'[b]{i.name}/inverted_{i.name}[/b]: {i.__doc__}' for i in SortingOrderItem if i.__doc__ is not None]) + }, + { + "type": "bool", + "title": "Items Sorting uses sortkeys", + "desc": "Should Item sorting respect sort-keys", + "section": "manual", + "key": "items_sorting_uses_sortkeys", + "values": ["No", "Yes"] + }, + { + "type": "options", + "title": "Locations Sorting Order", + "section": "manual", + "key": "locations_sorting_order", + "options": list(SortingOrderLoc._member_names_), + "desc": "\n".join([f'[b]{i.name}/inverted_{i.name}[/b]: {i.__doc__}' for i in SortingOrderLoc if i.__doc__ is not None]) + }, + { + "type": "bool", + "title": "Locations Sorting uses sortkeys", + "desc": "Should Location sorting respect sort-keys", + "section": "manual", + "key": "locations_sorting_uses_sortkeys", + "values": ["No", "Yes"] + }, + { + "type": "options", + "title": "Item Categories Sorting Order", + "section": "manual", + "key": "item_categories_sorting_order", + "options": list(SortingOrderCategories._member_names_), + "desc": '\n'.join([f'[b]{i.name}/inverted_{i.name}[/b]: {i.__doc__}' for i in SortingOrderCategories if i.__doc__ is not None]) + }, + { + "type": "bool", + "title": "Item Categories Sorting uses sortkeys", + "desc": "Should Item Category sorting respect sort-keys", + "section": "manual", + "key": "item_categories_sorting_uses_sortkeys", + "values": ["No", "Yes"] + }, + { + "type": "options", + "title": "Location Categories Sorting Order", + "section": "manual", + "key": "location_categories_sorting_order", + "options": list(SortingOrderCategories._member_names_), + "desc": "Same Options as Items Category Sorting Order." + }, + { + "type": "bool", + "title": "Location Categories Sorting uses sortkeys", + "desc": "Should Location sorting respect sort-keys", + "section": "manual", + "key": "location_categories_sorting_uses_sortkeys", + "values": ["No", "Yes"] + }, + ] + if tracker_loaded: + json_data.extend([ + { + "type": "title", + "title": "Universal Tracker Compatibility" + }, + { + "type": "bool", + "title": "Stop accidental button press", + "section": "universal-tracker", + "key": "block_unreachable_location_press", + "desc": "Should only reachable location be able to be pressed", + "values": ["No", "Yes"] + }, + { + "type": "bool", + "title": "See Glitched Logic", + "section": "universal-tracker", + "key": "display_glitched_locations", + "desc": "Should locations marked as accessible OOL be shown in Yellow", + "values": ["No", "Yes"] + }, + { + "type": "bool", + "title": "Count glitched location as reachable for the press protection", + "section": "universal-tracker", + "key": "allow_glitched_location_press", + "desc": "Should locations marked as accessible OOL not be protected from being pressed", + "values": ["No", "Yes"] + }, + ]) + + settings.add_json_panel("Manual Client Settings", self.config, data=json.dumps(json_data)) + def on_config_change(self, config, section, key, value): + super().on_config_change(config, section, key, value) + if section == "manual": + if key == "items_sorting_order": + if value in SortingOrderItem._member_names_: + self.ctx.items_sorting = value + self.request_update_tracker_and_locations_table() + elif key == "items_sorting_uses_sortkeys": + self.ctx.items_sorting_uses_sortkeys = True if value == "Yes" else False + self.request_update_tracker_and_locations_table() + + elif key == "locations_sorting_order": + if value in SortingOrderLoc._member_names_: + self.ctx.locations_sorting = value + self.build_tracker_and_locations_table() + self.request_update_tracker_and_locations_table() + elif key == "locations_sorting_uses_sortkeys": + self.ctx.locations_sorting_uses_sortkeys = True if value == "Yes" else False + self.build_tracker_and_locations_table() + self.request_update_tracker_and_locations_table() + + elif key == "location_categories_sorting_order": + if value in SortingOrderCategories._member_names_: + self.ctx.location_categories_sorting = value + self.build_tracker_and_locations_table() + self.request_update_tracker_and_locations_table() + elif key == "location_categories_sorting_uses_sortkeys": + self.ctx.location_categories_sorting_uses_sortkeys = True if value == "Yes" else False + self.build_tracker_and_locations_table() + self.request_update_tracker_and_locations_table() + + elif key == "item_categories_sorting_order": + if value in SortingOrderCategories._member_names_: + self.ctx.item_categories_sorting = value + self.build_tracker_and_locations_table() + self.request_update_tracker_and_locations_table() + elif key == "item_categories_sorting_uses_sortkeys": + self.ctx.item_categories_sorting_uses_sortkeys = True if value == "Yes" else False + self.build_tracker_and_locations_table() + self.request_update_tracker_and_locations_table() + + elif section == "universal-tracker": + if key == "block_unreachable_location_press": + self.ctx.block_unreachable_location_press = True if value == "Yes" else False + elif key == "display_glitched_locations": + self.ctx.display_glitched_locations = True if value == "Yes" else False + self.build_tracker_and_locations_table() + self.request_update_tracker_and_locations_table() + elif key == "allow_glitched_location_press": + self.ctx.allow_glitched_location_press = True if value == "Yes" else False + + def clear_lists(self): + self.listed_items = {"(No Category)": []} + self.item_categories = ["(No Category)"] + self.listed_locations: Dict[str, List[int]] = {"(No Category)": [], "(Hinted)": []} + self.location_categories = ["(No Category)", "(Hinted)"] + + def set_active_item_accordion(self, instance): + index = 0 + + for widget in self.children: + if widget == instance: + self.active_item_accordion = index + return + + index += 1 + + def set_active_location_accordion(self, instance): + index = 0 + + for widget in self.children: + if widget == instance: + self.active_item_accordion = index + return + + index += 1 + + def enable_death_link(self): + if not hasattr(self, "death_link_button"): + self.death_link_button = Button(text="Death Link: Primed", + size_hint_x=None, width=150) + self.connect_layout.add_widget(self.death_link_button) + self.death_link_button.bind(on_release=self.send_death_link) + + def send_death_link(self, *args): + if self.ctx.last_death_link: + self.ctx.last_death_link = 0 + self.death_link_button.text = "Death Link: Primed" + self.death_link_button.background_color = self.ctx.colors['deathlink_primed'] + else: + self.ctx.deathlink_out = True + self.death_link_button.text = "Death Link: Sent" + self.death_link_button.background_color = self.ctx.colors['deathlink_sent'] + + def update_hints(self): + super().update_hints() + rebuild = False + for hint in self.ctx.stored_data.get(f"_read_hints_{self.ctx.team}_{self.ctx.slot}", []): + if hint["finding_player"] == self.ctx.slot: + if hint["location"] in self.ctx.missing_locations: + location = self.ctx.get_location_by_id(hint["location"]) + location["category"] = location.get("category", []) + if "(Hinted)" not in location["category"]: + location["category"].append("(Hinted)") + rebuild = True + json_str = [ + {"type": "player_id", "text": hint["receiving_player"]}, + {"type": "text", "text": "'s "}, + {"type": "item_id", + "text": hint["item"], + "flags": hint["item_flags"], + "player": hint["receiving_player"], + }, + ] + location['hint_text'] = self.json_to_kivy_parser(json_str) + + if rebuild: + self.build_tracker_and_locations_table() + self.request_update_tracker_and_locations_table() + + def update_search_from_input(self, instance, text: str): + self.ctx.set_search(text) + self.request_update_tracker_and_locations_table() # if we want search to be "snappier", we can just make this update + + def clear_search_input(self): + self.search_textbox.text = "" + self.ctx.clear_search() + self.request_update_tracker_and_locations_table() # if we want search to be "snappier", we can just make this update + + def window_mouseover(self, window, pos): + self.set_mouse_pos(window, pos) + + def set_mouse_pos(self, window, pos): + self.mouse_pos = pos + + def are_top_controls_at_mouse_pos(self) -> bool: + # check server connect section and controls + if self.connect_layout.collide_point(*self.mouse_pos): + return True + + # check game id section and dropdown + if self.manual_game_layout.collide_point(*self.mouse_pos): + return True + + # check tabbed navigation + if self.tabs.collide_point(*self.mouse_pos): + return True + + return False + + def get_top_obj_at_mouse_pos(self) -> Any: + for child in self.connect_layout.children: + if child.collide_point(*self.mouse_pos): + return child + + for child in self.manual_game_layout.children: + if child.collide_point(*self.mouse_pos): + return child + + for child in self.tabs.children: + if child.collide_point(*self.mouse_pos): + return child + + return None + + def build_tracker_and_locations_table(self): + Window.bind(mouse_pos=self.window_mouseover) + + self.controls_panel.clear_widgets() + self.tracker_and_locations_panel.clear_widgets() + + if not self.ctx.server or not self.ctx.auth: + self.tracker_and_locations_panel.add_widget( + Label(text="Waiting for connection...", size_hint_y=None, height=50, outline_width=1)) + return + + self.clear_lists() + + # build tab-specific controls above the two tracker columns + controls_styled_layout = ManualControlsStyledLayout(orientation="horizontal", size_hint_y=None, height=dp(40), padding=dp(5), background_color=self.ctx.colors["header_background"]) + search_layout = BoxLayout(orientation="horizontal", size_hint=(None, None), width=dp(320), height=dp(30), spacing=dp(2)) + search_label = Label(text="Search:", size_hint=(None, None), width=dp(55), height=dp(30), bold=True) + self.search_textbox = TextInput(size_hint=(None, None), width=dp(200), height=dp(30), multiline=False, write_tab=False) + self.search_textbox.bind(text = self.update_search_from_input) + search_button = Button(size_hint=(None, None), width=dp(50), height=dp(30), text="Clear") + search_button.bind(on_release=lambda *args: self.clear_search_input()) + + controls_styled_layout.add_widget(search_layout) + search_layout.add_widget(search_label) + search_layout.add_widget(self.search_textbox) + search_layout.add_widget(search_button) + self.controls_panel.add_widget(controls_styled_layout) + + # seed all category names to start + for item in self.ctx.item_table.values() or AutoWorldRegister.world_types[self.ctx.game].item_name_to_item.values(): + if "category" in item and len(item["category"]) > 0: + for category in item["category"]: + category_settings = self.ctx.category_table.get(category) or getattr(AutoWorldRegister.world_types[self.ctx.game], "category_table", {}).get(category, {}) + if "hidden" in category_settings and category_settings["hidden"]: + continue + if category not in self.item_categories: + self.item_categories.append(category) + + if category not in self.listed_items: + self.listed_items[category] = [] + + for event, categories in self.ctx.visible_events.items(): + for category in categories: + category_settings = self.ctx.category_table.get(category) or getattr(AutoWorldRegister.world_types[self.ctx.game], "category_table", {}).get(category, {}) + if "hidden" in category_settings and category_settings["hidden"]: + continue + if category not in self.item_categories: + self.item_categories.append(category) + if category not in self.listed_items: + self.listed_items[category] = [] + + + # Items are not received on connect, so don't bother attempting to work with received items here + + if not self.ctx.location_table and not hasattr(AutoWorldRegister.world_types[self.ctx.game], 'location_name_to_location'): + raise Exception("The apworld for %s is too outdated for this client. Please update it." % (self.ctx.game)) + + scoutable_locations = set() + hinted_locations = {} + + for location_id in self.ctx.missing_locations: + # holy nesting, wow + location_name = self.ctx.location_names.lookup_in_game(location_id) + location = self.ctx.get_location_by_name(location_name) + + if not location: + continue + + if location.get("scoutable", False): + scoutable_locations.add(location_id) + if location.get("hint_text", False): + hinted_locations[location_id] = location.get("hint_text", "") + + if "category" in location and len(location["category"]) > 0: + for category in location["category"]: + category_settings = self.ctx.category_table.get(category) or getattr(AutoWorldRegister.world_types[self.ctx.game], "category_table", {}).get(category, {}) + if "hidden" in category_settings and category_settings["hidden"]: + continue + if category not in self.location_categories: + self.location_categories.append(category) + + if category not in self.listed_locations: + self.listed_locations[category] = [] + + self.listed_locations[category].append(location_id) + else: # leave it in the generic category + self.listed_locations["(No Category)"].append(location_id) + + victory_location = self.ctx.goal_location + victory_categories = set(victory_location.get("category", [])) + + for category in victory_categories: + if category not in self.location_categories: + self.location_categories.append(category) + + if category not in self.listed_locations: + self.listed_locations[category] = [] + + if not victory_categories: + victory_categories.add("(No Category)") + + loc_sorting = SortingOrderLoc[self.ctx.locations_sorting] + + def get_location_key(key: int) -> str: + return self.ctx.get_sorting_key_by_id(key, is_item=False) + + if abs(loc_sorting) == SortingOrderLoc.alphabetical: + for category in self.listed_locations: + self.listed_locations[category].sort(key=get_location_key, reverse=loc_sorting < 0) + + elif abs(loc_sorting) == SortingOrderLoc.natural: + def alphanum_key(key: int) -> list[str|int]: + return natural_sort_key(get_location_key(key)) + for category in self.listed_locations: + self.listed_locations[category].sort(key=alphanum_key, reverse=loc_sorting < 0) + + + items_length = len(self.ctx.items_received) + tracker_panel_scrollable = TrackerLayoutScrollable(do_scroll=(False, True), bar_width=10) + tracker_panel = TreeView(root_options=dict(text="Items Received (%d)" % (items_length)), size_hint_y=None) + tracker_panel.bind(minimum_height=tracker_panel.setter('height')) + + def get_item_category_key(key: str) -> str: + return self.ctx.get_category_sorting_key_by_name(key) + def item_category_normal_sort_key(key: str): + result = [get_item_category_key(key)] + return [0 if key.lstrip().startswith("(") else 1] + result + def item_category_nat_sort_key(key: str): + result = natural_sort_key(get_item_category_key(key)) + return [0 if key.lstrip().startswith("(") else 1] + result + + # Sorting items categories + item_cat_sorting = SortingOrderCategories[self.ctx.item_categories_sorting] + if abs(item_cat_sorting) == SortingOrderCategories.natural: + self.listed_items = {key: self.listed_items[key] for key in + sorted(self.listed_items.keys(), + key=item_category_nat_sort_key, + reverse=item_cat_sorting < 0)} + else: + self.listed_items = {key: self.listed_items[key] for key in + sorted(self.listed_items.keys(), + key=item_category_normal_sort_key, + reverse=item_cat_sorting < 0)} + + # Since items_received is not available on connect, don't bother building item labels here + for item_category in self.listed_items.keys(): + category_tree = tracker_panel.add_node( + TreeViewLabel(text = "%s (%s)" % (item_category, len(self.listed_items[item_category]))) + ) + + category_scroll = tracker_panel.add_node(TreeViewScrollView(size_hint=(1, None), size=(Window.width / 2, 250)), category_tree) + category_layout = GridLayout(cols=1, size_hint_y=None) + category_layout.bind(minimum_height = category_layout.setter('height')) + category_scroll.add_widget(category_layout) + + locations_length = len(self.ctx.missing_locations) + locations_panel_scrollable = LocationsLayoutScrollable(do_scroll=(False, True), bar_width=10) + locations_panel = TreeView(root_options=dict(text="Remaining Locations (%d)" % (locations_length + 1)), size_hint_y=None) + locations_panel.bind(minimum_height=locations_panel.setter('height')) + + # This seems like a redundant copy of the same check above? + if not self.ctx.location_table and not hasattr(AutoWorldRegister.world_types[self.ctx.game], 'location_name_to_location'): + raise Exception("The apworld for %s is too outdated for this client. Please update it." % (self.ctx.game)) + + # Sorting location categories + loc_cat_sorting = SortingOrderCategories[self.ctx.location_categories_sorting] + + def get_location_category_key(key: str) -> str: + return self.ctx.get_category_sorting_key_by_name(key, is_item=False) + def loc_category_normal_sort_key(key: str): + result = [get_location_category_key(key)] + return [0 if key.lstrip().startswith("(") else 1] + result + def loc_category_nat_sort_key(key: str): + result = natural_sort_key(get_location_category_key(key)) + return [0 if key.lstrip().startswith("(") else 1] + result + if abs(loc_cat_sorting) == SortingOrderCategories.natural: + self.listed_locations = {key: self.listed_locations[key] for key in + sorted(self.listed_locations.keys(), + key=loc_category_nat_sort_key, + reverse=loc_cat_sorting < 0)} + else: + self.listed_locations = {key: self.listed_locations[key] for key in + sorted(self.listed_locations.keys(), + key=loc_category_normal_sort_key, + reverse=loc_cat_sorting < 0)} + + for location_category in self.listed_locations.keys(): + locations_in_category = len(self.listed_locations[location_category]) + + if (location_category in victory_categories) or \ + (not victory_categories and location_category == "(No Category)"): + locations_in_category += 1 + + category_tree = locations_panel.add_node( + TreeViewLabel(text = "%s (%s)" % (location_category, locations_in_category)) + ) + + category_scroll = locations_panel.add_node(TreeViewScrollView(size_hint=(1, None), size=(Window.width / 2, 250)), category_tree) + category_layout = GridLayout(cols=2, size_hint_y=None) + category_layout.bind(minimum_height = category_layout.setter('height')) + category_scroll.add_widget(category_layout) + + for location_id in self.listed_locations[location_category]: + has_hint = location_id in hinted_locations or location_id in scoutable_locations + + text = f"{self.ctx.location_names.lookup_in_game(location_id)}" + location_button = TreeViewButton(text=text, size_hint=(.75 if has_hint else 1, None), height=30) + location_button.bind(on_release=lambda *args, loc_id=location_id: self.location_button_callback(loc_id, *args)) + location_button.id = location_id + location_button.location_name = self.ctx.location_names.lookup_in_game(location_id) + category_layout.add_widget(location_button) + + if location_id in hinted_locations: + category_layout.add_widget(Label(text=hinted_locations[location_id], size_hint=(.25, None), height=30, font_size=10, markup=True)) + elif location_id in scoutable_locations: + location_scout = TreeViewButton(text="Scout", size_hint=(.25, None), height=30, font_size=10) + location_scout.bind(on_release=lambda *args, loc_id=location_id: self.location_scout_callback(loc_id, *args)) + location_scout.id = location_id + category_layout.add_widget(location_scout) + else: + # Add invisible spacer to maintain 2-column grid structure + category_layout.add_widget(Label(text="", size_hint=(None, None), height=0, width=0, opacity=0)) + + # if this is the category that Victory is in, display the Victory button + # if ("category" in victory_location_data and location_category in victory_location_data["category"]) or \ + # ("category" not in victory_location_data and location_category == "(No Category)"): + if location_category in victory_categories: + # Add the Victory location to be marked at any point, which is why locations length has 1 added to it above + victory_text: str = "VICTORY! (seed finished)" if victory_location["name"] == "__Manual Game Complete__" else "GOAL: " + victory_location["name"] + location_button = TreeViewButton(text=victory_text, size_hint=(1, None), height=dp(30), width=dp(400)) + location_button.victory = True + location_button.location_name = victory_location["name"] + location_button.bind(on_release=self.victory_button_callback) + category_layout.add_widget(location_button) + + tracker_panel_scrollable.add_widget(tracker_panel) + locations_panel_scrollable.add_widget(locations_panel) + self.tracker_and_locations_panel.add_widget(tracker_panel_scrollable) + self.tracker_and_locations_panel.add_widget(locations_panel_scrollable) + + def check_for_requested_update(self): + current_time = time.time() + + # wait 0.25 seconds before executing update, in case there are multiple update requests coming in + if self.update_requested_time and current_time - self.update_requested_time >= 0.25: + self.update_requested_time = None + self.update_tracker_and_locations_table(self.update_requested_highlights) + self.update_requested_highlights = False + + def request_update_tracker_and_locations_table(self, update_highlights=False): + self.update_requested_time = time.time() + self.update_requested_highlights = update_highlights or self.update_requested_highlights # if any of the requests wanted highlights, do highlight + + def update_tracker_and_locations_table(self, update_highlights=False): + items_length = len(self.ctx.items_received) + locations_length = len(self.ctx.missing_locations) + + if self.ctx.search_term: + items_length = len([ + i for i in self.ctx.items_received + if self.ctx.search_term.lower() in self.ctx.item_names.lookup_in_game(i.item).lower() + ]) + + locations_length = len([ + l for l in self.ctx.missing_locations + if self.ctx.search_term.lower() in self.ctx.location_names.lookup_in_game(l).lower() + ]) + + for _, child in enumerate(self.tracker_and_locations_panel.children): + # + # Structure of items: + # TrackerLayoutScrollable -> TreeView -> TreeViewLabel, TreeViewScrollView -> GridLayout -> Label + # item tracker -> category -> category label, category scroll -> label col -> item + # + if type(child) is TrackerLayoutScrollable: + treeview = child.children[0] # TreeView + treeview_nodes = treeview.iterate_all_nodes() + + items_received_label = next(treeview_nodes) # always the first node + items_received_label.text = "Items Received (%s)" % (items_length) + + # loop for each category in listed items and get the label + scrollview + for x in range(0, len(self.item_categories)): + category_label = next(treeview_nodes) # TreeViewLabel for category + category_scrollview = next(treeview_nodes) # TreeViewScrollView for housing category's grid layout + + old_category_text = category_label.text + + if type(category_label) is TreeViewLabel and type(category_scrollview) is TreeViewScrollView: + category_grid = category_scrollview.children[0] # GridLayout + + category_name = re.sub(r"\s\(\d+\)$", "", category_label.text) + category_count = 0 + category_unique_name_count = 0 + + existing_item_labels = [] + bold_item_labels = [] + + # for items that were already listed, determine if the qty changed. if it did, add them to the list to be bolded + for item in category_grid.children: + if type(item) is ItemLabel: + # Get the item name from the item Label, minus quantity, then do a lookup for count + old_count = item.item_count + item_name = item.item_name + item_id = item.item_id + if item_id: + item_count = len(list(i for i in self.ctx.items_received if i.item == item_id)) + else: + item_count = len(list(i for i in self.ctx.tracker_reachable_events if i == item_name)) + + # if the player is searching for text and the item name doesn't contain it, skip it + if self.ctx.search_term and not self.ctx.search_term.lower() in item_name.lower(): + item.width = 0 + item.height = 0 + item.opacity = 0 + else: + item.width = dp(400) + item.height = dp(30) + item.opacity = 1 + + if item_count > 0: + category_count += item_count + category_unique_name_count += 1 + + # Update the label quantity + if update_highlights and (old_count != item_count): + bold_item_labels.append(item_name) + item.item_count = item_count + + item.text="%s (%s)" % (item_name, item_count) + + existing_item_labels.append(item_name) + + # instead of reusing existing item listings, clear it all out and re-draw with the sorted list + category_grid.clear_widgets() + self.listed_items[category_name].clear() + category_count = 0 + category_unique_name_count = 0 + + # Label (for all item listings) + item_sorting = SortingOrderItem[self.ctx.items_sorting] + sorted_items_received = [i.item for i in self.ctx.items_received] + def get_item_key(key: int) -> str: + return self.ctx.get_sorting_key_by_id(key, is_item=True) + + if abs(item_sorting) == SortingOrderItem.alphabetical: + sorted_items_received = sorted(sorted_items_received, + key=get_item_key, + reverse=item_sorting < 0) + + elif abs(item_sorting) == SortingOrderItem.natural: + def alphanum_key(key: int) -> list[str|int]: + return natural_sort_key(get_item_key(key)) + + sorted_items_received = sorted(sorted_items_received, + key=alphanum_key, + reverse=item_sorting < 0) + + elif abs(item_sorting) == SortingOrderItem.received: + if item_sorting < 0: + sorted_items_received.reverse() + + for network_item in sorted_items_received: + item_name = self.ctx.item_names.lookup_in_game(network_item) + item_data = self.ctx.get_item_by_name(item_name) + + # if the player is searching for text and the item name doesn't contain it, skip it + if self.ctx.search_term and not self.ctx.search_term.lower() in item_name.lower(): + continue + + if "category" not in item_data or not item_data["category"]: + item_data["category"] = ["(No Category)"] + + if category_name in item_data["category"] and network_item not in self.listed_items[category_name]: + item_count = len(list(i for i in self.ctx.items_received if i.item == network_item)) + item_text = ItemLabel(text="%s (%s)" % (item_name, item_count), + size_hint=(None, None), height=dp(30), width=dp(400), bold=True) + item_text.item_name = item_name + item_text.item_count = item_count + item_text.item_id = network_item + + # if the item was previously listed and was bold, or if it wasn't previously listed at all, make it bold + item_text.bold = (update_highlights and (item_name in bold_item_labels or item_name not in existing_item_labels)) + + category_grid.add_widget(item_text) + self.listed_items[category_name].append(network_item) + + category_count += item_count + category_unique_name_count += 1 + + for event in sorted(self.ctx.tracker_reachable_events): + if self.ctx.is_event_visible(event, category_name) and event not in self.listed_items[category_name]: + item_count = len(list(i for i in self.ctx.tracker_reachable_events if i == event)) + item_text = ItemLabel(text="%s (%s)" % (event, item_count), + size_hint=(None, None), height=dp(30), width=dp(400), bold=True) + item_text.item_name = event + item_text.item_count = item_count + category_grid.add_widget(item_text) + self.listed_items[category_name].append(event) + category_count += item_count + category_unique_name_count += 1 + + scrollview_height = 30 * category_unique_name_count + + if scrollview_height > 250: + scrollview_height = 250 + + if scrollview_height < 10: + scrollview_height = 50 + + category_name = re.sub(r"\s\(\d+\)$", "", category_label.text) + category_label.text = "%s (%s)" % (category_name, category_count) + + if update_highlights: + category_label.bold = True if old_category_text != category_label.text else False + + category_scrollview.size=(Window.width / 2, scrollview_height) + + # + # Structure of locations: + # LocationsLayoutScrollable -> TreeView -> TreeViewLabel, TreeViewScrollView -> GridLayout -> Button + # location tracker -> category -> category label, category scroll -> label col -> location + # + if type(child) is LocationsLayoutScrollable: + treeview = child.children[0] # TreeView + treeview_nodes = treeview.iterate_all_nodes() + + locations_remaining_label = next(treeview_nodes) # always the first node + locations_remaining_label.text = "Remaining Locations (%d)" % (locations_length) + + # loop for each category in listed items and get the label + scrollview + for x in range(0, len(self.location_categories)): + category_label = next(treeview_nodes) # TreeViewLabel for category + category_scrollview = next(treeview_nodes) # TreeViewScrollView for housing category's grid layout + + if type(category_label) is TreeViewLabel and type(category_scrollview) is TreeViewScrollView: + category_grid = category_scrollview.children[0] # GridLayout + + category_name = re.sub(r"\s\(\d+\/?(\d+)?\)$", "", category_label.text) + category_count = 0 + reachable_count = 0 + reachable_glitch_count = 0 + + buttons_to_remove = [] + + # since victory is handled more briefly below, need to pull show/hide into functions here to reuse + def show_button_during_search(btn: TreeViewButton): + btn.width = dp(400) + btn.height = dp(30) + btn.opacity = 1 + btn.disabled = False + + def hide_button_during_search(btn: TreeViewButton): + btn.width = 0 + btn.height = 0 + btn.opacity = 0 + btn.disabled = True + + # Label (for existing item listings) + for location_button in category_grid.children: + if type(location_button) is TreeViewButton: + # Skip scout buttons + if location_button.text == "Scout": + continue + # should only be true for the victory location button, which has different text + if location_button.victory: + # if the player is searching for text and the location name doesn't contain it, hide and disable it + if self.ctx.search_term and not self.ctx.search_term.lower() in location_button.text.lower(): + hide_button_during_search(location_button) + else: + show_button_during_search(location_button) + category_count += 1 + + if location_button.victory and "__Victory__" in self.ctx.tracker_reachable_events: + location_button.background_color = self.ctx.colors['location_in_logic'] + reachable_count += 1 + + elif self.ctx.display_glitched_locations and location_button.victory and "__Victory__" in self.ctx.tracker_glitched_locations: + location_button.background_color = self.ctx.colors['location_in_glitched_logic'] + reachable_glitch_count += 1 + + continue + + if location_button.id and location_button.id not in self.ctx.missing_locations: + logger.info("location button being removed: " + location_button.text) + buttons_to_remove.append(location_button) + continue + + was_reachable = False + was_reachable_glitched = False + + if location_button.location_name in self.ctx.tracker_reachable_locations: + location_button.background_color = self.ctx.colors['location_in_logic'] + was_reachable = True + elif self.ctx.display_glitched_locations and location_button.text in self.ctx.tracker_glitched_locations: + location_button.background_color = self.ctx.colors['location_in_glitched_logic'] + was_reachable_glitched = True + else: + location_button.background_color = self.ctx.colors['location_default'] + + # if the player is searching for text and the location name doesn't contain it, hide and disable it + if self.ctx.search_term and not self.ctx.search_term.lower() in location_button.text.lower(): + hide_button_during_search(location_button) + else: + show_button_during_search(location_button) + + if was_reachable: + reachable_count += 1 + elif was_reachable_glitched: + reachable_glitch_count += 1 + category_count += 1 + + for location_button in buttons_to_remove: + parent = location_button.parent + button_index = parent.children.index(location_button) + # Grid cols=2: pairs are at adjacent indices (even with odd, odd with even) + pair_index = button_index - 1 if button_index % 2 == 1 else button_index + 1 + if 0 <= pair_index < len(parent.children): + parent.remove_widget(parent.children[pair_index]) + parent.remove_widget(location_button) + + scrollview_height = 30 * category_count + + if scrollview_height > 250: + scrollview_height = 250 + + if scrollview_height < 10: + scrollview_height = 50 + + count_text: str = str(category_count) + + if tracker_loaded: + if self.ctx.display_glitched_locations and reachable_glitch_count: + count_text = f"{reachable_count}/{category_count} ({reachable_glitch_count})" + else: + count_text = f"{reachable_count}/{category_count}" + + category_name = re.sub(r"\s\(\d+\/?([\d\s]+)?(\(\d+\))?\)$", "", category_label.text) + category_label.text = "%s (%s)" % (category_name, count_text) + + if reachable_count > 0: + # treeviewlabels don't have background color. because #justkivythings. + category_label.even_color = self.ctx.colors['category_in_logic'] + category_label.odd_color = self.ctx.colors['category_in_logic'] + category_label.odd_color[3] = 0.9 + elif self.ctx.display_glitched_locations and reachable_glitch_count > 0: + category_label.even_color = self.ctx.colors['category_in_glitched_logic'] + category_label.odd_color = self.ctx.colors['category_in_glitched_logic'] + category_label.odd_color[3] = 0.9 + else: + category_label.even_color = self.ctx.colors['category_even_default'] + category_label.odd_color = self.ctx.colors['category_odd_default'] + + category_scrollview.size=(Window.width / 2, scrollview_height) + + def location_button_callback(self, location_id: int, button: TreeViewButton): + if button.location_name not in self.ctx.location_names_to_id: + raise Exception("Locations were not loaded correctly. Please reconnect your client.") + + # if the mouse is currently hovering over any of the controls/tabs at the top of the client, ignore clicks for location buttons underneath + if self.are_top_controls_at_mouse_pos(): + # if there's an obj in the top controls/tab at the current mouse position, click it instead + if hovered_obj := self.get_top_obj_at_mouse_pos(): + if hasattr(hovered_obj, 'trigger_action'): # buttons, tabs, etc. + hovered_obj.trigger_action(duration=0) + elif hasattr(hovered_obj, 'focus'): # text inputs + hovered_obj.focus = True + + return + + if location_id: + if tracker_loaded and self.ctx.block_unreachable_location_press: + if button.location_name in self.ctx.tracker_reachable_locations: + pass + elif self.ctx.display_glitched_locations and self.ctx.allow_glitched_location_press \ + and button.location_name in self.ctx.tracker_glitched_locations: + pass + else: + logger.debug(f"button for location '{button.location_name}' was pressed while unreachable") + return + + self.ctx.locations_checked.append(location_id) + self.ctx.syncing = True + # Remove both the location button and its adjacent scout button/spacer from 2-column grid + parent = button.parent + button_index = parent.children.index(button) + # Grid cols=2: pairs are at adjacent indices (even with odd, odd with even) + pair_index = button_index - 1 if button_index % 2 == 1 else button_index + 1 + if 0 <= pair_index < len(parent.children): + parent.remove_widget(parent.children[pair_index]) + parent.remove_widget(button) + + # message = [{"cmd": 'LocationChecks', "locations": [location_id]}] + # self.ctx.send_msgs(message) + + def location_scout_callback(self, location_id: int, button: TreeViewButton) -> None: + # if the mouse is currently hovering over any of the controls/tabs at the top of the client, ignore clicks for location buttons underneath + if self.are_top_controls_at_mouse_pos(): + # if there's an obj in the top controls/tab at the current mouse position, click it instead + if hovered_obj := self.get_top_obj_at_mouse_pos(): + if hasattr(hovered_obj, 'trigger_action'): # buttons, tabs, etc. + hovered_obj.trigger_action(duration=0) + elif hasattr(hovered_obj, 'focus'): # text inputs + hovered_obj.focus = True + + return + + if location_id: + self.ctx.locations_scouted.append(location_id) + self.ctx.syncing = True + + def victory_button_callback(self, button: TreeViewButton): + # if the mouse is currently hovering over any of the controls/tabs at the top of the client, ignore clicks for location buttons underneath + if self.are_top_controls_at_mouse_pos(): + # if there's an obj in the top controls/tab at the current mouse position, click it instead + if hovered_obj := self.get_top_obj_at_mouse_pos(): + if hasattr(hovered_obj, 'trigger_action'): # buttons, tabs, etc. + hovered_obj.trigger_action(duration=0) + elif hasattr(hovered_obj, 'focus'): # text inputs + hovered_obj.focus = True + + return + + if tracker_loaded and self.ctx.block_unreachable_location_press and "__Victory__" not in self.ctx.tracker_reachable_events: + logger.debug(f"button for location '{button.text}' was pressed while unreachable") + else: + self.ctx.goaled = True + self.ctx.syncing = True + + return ManualManager + +async def game_watcher_manual(ctx: ManualContext): + while not ctx.exit_event.is_set(): + if ctx.ui: + ctx.ui.check_for_requested_update() + + if ctx.syncing == True: + sync_msg = [] + if ctx.locations_checked: + await ctx.check_locations(ctx.locations_checked) + if ctx.locations_scouted: + sync_msg.append({"cmd": "LocationScouts", "locations": list(ctx.locations_scouted), "create_as_hint": 2}) + if sync_msg: + await ctx.send_msgs(sync_msg) + ctx.syncing = False + + if ctx.set_deathlink: + ctx.set_deathlink = False + await ctx.update_death_link(True) + + if ctx.deathlink_out: + ctx.deathlink_out = False + await ctx.send_death() + + victory = ctx.goaled + ctx.locations_checked = [] + ctx.locations_scouted = [] + if not ctx.finished_game and victory: + await ctx.send_msgs([{"cmd": "StatusUpdate", "status": ClientStatus.CLIENT_GOAL}]) + ctx.finished_game = True + await asyncio.sleep(0.1) + + +def read_apmanual_file(apmanual_file) -> dict[str, Any]: + import zipfile + from base64 import b64decode + + from .container import APManualFile + + if zipfile.is_zipfile(apmanual_file): + try: + container = APManualFile(apmanual_file) + container.read() + return container.get_manifest() + except Exception as e: + logger.exception("Error reading APManual file:", e) + + with open(apmanual_file, "r") as f: + return json.loads(b64decode(f.read())) + + +async def main(args): + config_file = {} + if args.apmanual_file and os.path.exists(args.apmanual_file): + config_file = read_apmanual_file(args.apmanual_file) + if config_file.get("server") and not args.connect: + args.connect = config_file["server"] + + ctx = ManualContext(args.connect, args.password, config_file.get("game"), config_file.get("player_name")) + ctx.server_task = asyncio.create_task(server_loop(ctx), name="server loop") + + ctx.item_table = config_file.get("items", {}) + ctx.location_table = config_file.get("locations", {}) + ctx.region_table = config_file.get("regions", {}) + ctx.category_table = config_file.get("categories", {}) + + if tracker_loaded: + ctx.run_generator() + if gui_enabled: + ctx.run_gui() + ctx.run_cli() + progression_watcher = asyncio.create_task( + game_watcher_manual(ctx), name="ManualProgressionWatcher") + + await ctx.exit_event.wait() + ctx.server_address = None + + await progression_watcher + + await ctx.shutdown() + +def launch(*launch_args) -> None: + import colorama + + parser = get_base_parser(description="Manual Client, for operating a Manual game in Archipelago.") + parser.add_argument('apmanual_file', default="", type=str, nargs="?", + help='Path to an APMANUAL file') + + args, rest = parser.parse_known_args(launch_args) + colorama.init() + asyncio.run(main(args)) + colorama.deinit() + + if not os.path.exists(icon_paths["manual"]): + # Download the icon for next time + icon_url = "https://manualforarchipelago.github.io/ManualBuilder/images/ap-manual-discord-logo-square-96x96.png" + with open(icon_paths["manual"], 'wb') as f: + f.write(requests.get(icon_url).content) + +if __name__ == '__main__': + launch() diff --git a/Meta.py b/Meta.py new file mode 100644 index 0000000..0056aff --- /dev/null +++ b/Meta.py @@ -0,0 +1,70 @@ + +from BaseClasses import Tutorial +from enum import Enum +from worlds.AutoWorld import World, WebWorld +from .Data import game_table, meta_table +from .Helpers import convert_to_long_string + +############## +# Meta Classes +############## +class ManualWeb(WebWorld): + tutorials = [Tutorial( + "Multiworld Setup Guide", + "A guide to setting up manual game integration for Archipelago multiworld games.", + "English", + "setup_en.md", + "setup/en", + ["Fuzzy"] + )] + +###################################### +# Convert meta.json data to properties +###################################### +def set_world_description(base_doc: str) -> str: + if meta_table.get("docs", {}).get("apworld_description"): + return convert_to_long_string(meta_table["docs"]["apworld_description"]) + + return base_doc + + +def set_world_webworld(web: WebWorld) -> WebWorld: + from .Options import make_options_group + if meta_table.get("docs", {}).get("web", {}): + Web_Config = meta_table["docs"]["web"] + + web.theme = Web_Config.get("theme", web.theme) + web.game_info_languages = Web_Config.get("game_info_languages", web.game_info_languages) + web.options_presets = Web_Config.get("options_presets", web.options_presets) + web.options_page = Web_Config.get("options_page", web.options_page) + web.option_groups = make_options_group() + if hasattr(web, 'bug_report_page'): + web.bug_report_page = Web_Config.get("bug_report_page", web.bug_report_page) + else: + web.bug_report_page = Web_Config.get("bug_report_page", None) + + if Web_Config.get("tutorials", []): + tutorials = [] + for tutorial in Web_Config.get("tutorials", []): + # Converting json to Tutorials + tutorials.append(Tutorial( + tutorial.get("name", "Multiworld Setup Guide"), + tutorial.get("description", "A guide to setting up manual game integration for Archipelago multiworld games."), + tutorial.get("language", "English"), + tutorial.get("file_name", "setup_en.md"), + tutorial.get("link", "setup/en"), + tutorial.get("authors", [game_table.get("creator")]) + )) + web.tutorials = tutorials + return web + +################# +# Meta Properties +################# +world_description: str = set_world_description(""" + Manual games allow you to set custom check locations and custom item names that will be rolled into a multiworld. + This allows any variety of game -- PC, console, board games, Microsoft Word memes... really anything -- to be part of a multiworld randomizer. + The key component to including these games is some level of manual restriction. Since the items are not actually withheld from the player, + the player must manually refrain from using these gathered items until the tracker shows that they have been acquired or sent. + """) +world_webworld: ManualWeb = set_world_webworld(ManualWeb()) diff --git a/Options.py b/Options.py new file mode 100644 index 0000000..9d6fc72 --- /dev/null +++ b/Options.py @@ -0,0 +1,267 @@ +import sys +from Options import PerGameCommonOptions, FreeText, Toggle, DefaultOnToggle, Choice, TextChoice, Range, NamedRange, DeathLink, \ + OptionGroup, StartInventoryPool, Visibility, item_and_loc_options, Option +from .hooks.Options import before_options_defined, after_options_defined, before_option_groups_created, after_option_groups_created +from .Data import category_table, game_table, option_table +from .Helpers import convert_to_long_string, format_to_valid_identifier +from .Locations import victory_names +from .Items import item_table +from .Game import starting_items + +from dataclasses import make_dataclass +from typing import List, Any, Type +import logging + + +class FillerTrapPercent(Range): + """How many fillers will be replaced with traps. 0 means no additional traps, 100 means all fillers are traps.""" + range_end = 100 + +class GenerateRegionDiagram(Toggle): + """Generate a region diagram.""" + visibility = Visibility.none # Hidden option + +class ManualDeathLink(DeathLink): + """When you die, everyone who enabled death link dies. Of course, the reverse is true too.""" + +def createChoiceOptions(values: dict, aliases: dict) -> dict: + values = {'option_' + i: v for i, v in values.items()} + aliases = {'alias_' + i: v for i, v in aliases.items()} + return {**values, **aliases} + +def convertOptionVisibility(input) -> Visibility: + visibility = Visibility.all + if isinstance(input, list): + visibility = Visibility.none + for type in input: + visibility |= Visibility[type.lower()] + + elif isinstance(input,str): + if input.startswith('0b'): + visibility = Visibility(int(input, base=0)) + else: + visibility = Visibility[input.lower()] + + elif isinstance(input, int): + visibility = Visibility(input) + return visibility + +def getOriginalOptionArguments(option: Type[Option[Any]]) -> dict: + args = {} + args['default'] = option.default + if hasattr(option, 'display_name'): args['display'] = option.display_name + args['rich_text_doc'] = option.rich_text_doc + args['default'] = option.default + args['visibility'] = option.visibility + return args + +manual_option_groups: dict[str, List[Type[Option[Any]]]] = {} +def addOptionToGroup(option_name: str, group: str): + if group not in manual_option_groups.keys(): + manual_option_groups[group] = [] + if manual_options.get(option_name) and manual_options[option_name] not in manual_option_groups[group]: + manual_option_groups[group].append(manual_options[option_name]) + +###################### +# Manual's default options +###################### + +manual_options: dict[str, Type[Option[Any]]] = before_options_defined({}) +manual_options["start_inventory_from_pool"] = StartInventoryPool +manual_options["generate_region_diagram"] = GenerateRegionDiagram + +if len(victory_names) > 1: + if manual_options.get('goal'): + logging.warning("Existing Goal option found created via Hooks, it will be overwritten by Manual's generated Goal option.\nIf you want to support old yaml you will need to add alias in after_options_defined") + + goal: dict[str, Any] = {'option_' + v: i for i, v in enumerate(victory_names)} + goal['__module__'] = __name__ + + manual_options['goal'] = type('goal', (Choice,), dict(goal)) + manual_options['goal'].__doc__ = "Choose your victory condition." + + +if any(item.get('trap') for item in item_table): + manual_options["filler_traps"] = FillerTrapPercent + +if game_table.get("death_link"): + manual_options["death_link"] = ManualDeathLink + + +###################### +# Option.json options +###################### + +for option_name, option in option_table.get('core', {}).items(): + if option_name.startswith('_'): #To allow commenting out options + continue + option_display_name = option_name + option_name = format_to_valid_identifier(option_name) + + if manual_options.get(option_name): + original_option: Type[Option] = manual_options[option_name] + original_doc = str(original_option.__doc__) + + if issubclass(original_option, Toggle): + if option.get('default', None) is not None: + option_type = DefaultOnToggle if option['default'] else Toggle + + if original_option.__base__ != option_type: #only recreate if needed + args = getOriginalOptionArguments(original_option) + args['__module__'] = __name__ + manual_options[option_name] = type(option_name, (option_type,), dict(args)) # Type checker doesn't like having a variable as a base for the type # type: ignore + logging.debug(f"Manual: Option.json converted option '{option_display_name}' into a {option_type}") + + elif issubclass(original_option, Choice): + if option.get("values"): + raise Exception(f"You cannot modify the values of the '{option_display_name}' option since they cannot have their value changed by Option.json") + + if option.get('aliases'): + for alias, value in option['aliases'].items(): + original_option.aliases[alias] = value + original_option.options.update(original_option.aliases) #for an alias to be valid it must also be in options + + logging.debug(f"Manual: Option.json modified option '{option_display_name}''s aliases") + + elif issubclass(original_option, Range): + if option.get('values'): #let user add named values + args = getOriginalOptionArguments(original_option) + args['special_range_names'] = {} + if issubclass(original_option, NamedRange): + args['special_range_names'] = dict(original_option.special_range_names) + args['special_range_names']['default'] = option.get('default', args['special_range_names'].get('default', args['default'])) + args['range_start'] = original_option.range_start + args['range_end'] = original_option.range_end + args['special_range_names'] = {**args['special_range_names'], **{l.lower(): v for l, v in option['values'].items()}} + + args['__module__'] = __name__ + manual_options[option_name] = type(option_name, (NamedRange,), dict(args)) + logging.debug(f"Manual: Option.json converted option '{option_display_name}' into a {NamedRange}") + + manual_options[option_name].display_name = option.get('display_name', option_display_name) # type: ignore + manual_options[option_name].__doc__ = convert_to_long_string(option.get('description', original_doc)) + if option.get('rich_text_doc'): + manual_options[option_name].rich_text_doc = option["rich_text_doc"] + + if option.get('default'): + manual_options[option_name].default = option['default'] + + if option.get('hidden'): + manual_options[option_name].visibility = Visibility.none + elif option.get('visibility'): + manual_options[option_name].visibility = convertOptionVisibility(option['visibility']) + else: + logging.debug(f"Manual: Option.json just tried to modify the option '{option_display_name}' but it doesn't currently exists") + + +supported_option_types = ["Toggle", "Choice", "Range"] +for option_name, option in option_table.get('user', {}).items(): + if option_name.startswith('_'): #To allow commenting out options + continue + option_display_name = option_name + option_name = format_to_valid_identifier(option_name) + if manual_options.get(option_name): + logging.warning(f"Manual: An option with the name '{option_display_name}' cannot be added since it already exists in Manual Core Options. \nTo modify an existing option move it to the 'core' section of Option.json") + + else: + option_type = option.get('type', "").title() + + if option_type not in supported_option_types: + raise Exception(f'Option {option_display_name} in options.json has an invalid type of "{option["type"]}".\nIt must be one of the folowing: {supported_option_types}') + + args = {'display_name': option.get('display_name', option_display_name)} + + # Default to Toggle to prevent hypothetical Unbound option_class + option_class: Type[Option] = DefaultOnToggle if option.get('default', False) else Toggle + + if option_type == "Choice": + args = {**args, **createChoiceOptions(option.get('values'), option.get('aliases', {}))} + option_class = TextChoice if option.get("allow_custom_value", False) else Choice + + elif option_type == "Range": + args['range_start'] = option.get('range_start', 0) + args['range_end'] = option.get('range_end', 1) + if option.get('values'): + args['special_range_names'] = {l.lower(): v for l, v in option['values'].items()} + args['special_range_names']['default'] = option.get('default', args['range_start']) + option_class = NamedRange if option.get('values') else Range + + if option.get('default'): + args['default'] = option['default'] + + if option.get('rich_text_doc',None) is not None: + args["rich_text_doc"] = option["rich_text_doc"] + + if option.get('hidden'): + args['visibility'] = Visibility.none + elif option.get('visibility'): + args['visibility'] = convertOptionVisibility(option['visibility']) + + args['__module__'] = __name__ + + manual_options[option_name] = type(option_name, (option_class,), args ) # Same as the first ignore above # type: ignore + manual_options[option_name].__doc__ = convert_to_long_string(option.get('description', "an Option")) + + if option.get('group'): + addOptionToGroup(option_name, option['group']) + +###################### +# category and starting_items options +###################### + +for category in category_table: + for option_name in category_table[category].get("yaml_option", []): + if option_name[0] == "!": + option_name = option_name[1:] + option_name = format_to_valid_identifier(option_name) + if option_name not in manual_options: + manual_options[option_name] = type(option_name, (DefaultOnToggle,), {"default": True, "__module__": __name__}) + manual_options[option_name].__doc__ = "Should items/locations linked to this option be enabled?" + +if starting_items: + for starting_items in starting_items: + if starting_items.get("yaml_option"): + for option_name in starting_items["yaml_option"]: + if option_name[0] == "!": + option_name = option_name[1:] + option_name = format_to_valid_identifier(option_name) + if option_name not in manual_options: + manual_options[option_name] = type(option_name, (DefaultOnToggle,), {"default": True, "__module__": __name__}) + manual_options[option_name].__doc__ = "Should items/locations linked to this option be enabled?" + +###################### +# OptionGroups Creation +###################### + +def make_options_group() -> list[OptionGroup]: + global manual_option_groups + manual_option_groups = before_option_groups_created(manual_option_groups) + option_groups: List[OptionGroup] = [] + + # For some reason, unless they are added manually, the base item and loc option don't get grouped as they should + base_item_loc_group = item_and_loc_options + + if manual_option_groups: + if 'Item & Location Options' in manual_option_groups.keys(): + base_item_loc_group = manual_option_groups.pop('Item & Location Options') #Put the custom options before the base AP options + base_item_loc_group.extend(item_and_loc_options) + + if 'Game Options' in manual_option_groups.keys(): + # Archipelago automatically assign ungrouped options to this group unless its defined so by deleting it here we let AP recreate it later + manual_option_groups.pop('Game Options') + + for group, options in manual_option_groups.items(): + option_groups.append(OptionGroup(group, options)) + + option_groups.append(OptionGroup('Item & Location Options', base_item_loc_group, True)) + + return after_option_groups_created(option_groups) + +manual_options_data = make_dataclass('ManualOptionsClass', manual_options.items(), bases=(PerGameCommonOptions,)) +after_options_defined(manual_options_data) + +# Make the options available in this module for import, needed for WebWorld compatibility +this = sys.modules[__name__] +for name, obj in manual_options.items(): + setattr(this, name, obj) +del this diff --git a/Regions.py b/Regions.py new file mode 100644 index 0000000..6b099f9 --- /dev/null +++ b/Regions.py @@ -0,0 +1,78 @@ +from BaseClasses import Entrance, MultiWorld, Region, ItemClassification +from .Helpers import is_category_enabled, is_location_enabled, is_event_enabled +from .Data import region_table +from .Locations import ManualLocation, location_name_to_location +from .Items import ManualItem +from worlds.AutoWorld import World + + +if not region_table: + region_table = {} + +regionMap = { **region_table } +starting_regions = [ name for name in regionMap if "starting" in regionMap[name].keys() and regionMap[name]["starting"] ] + +if len(starting_regions) == 0: + starting_regions = region_table.keys() # the Manual region connects to all user-defined regions automatically if you specify no starting regions + +regionMap["Manual"] = { + "requires": [], + "connects_to": starting_regions +} + + +def create_regions(world: World, multiworld: MultiWorld, player: int): + # Create regions and assign locations to each region + for region in regionMap: + if "connects_to" not in regionMap[region]: + exit_array = None + else: + exit_array = regionMap[region]["connects_to"] or None + + # safeguard for bad value at the end + if not exit_array: + exit_array = None + + locations = [] + for location in world.location_table: + if "region" in location and location["region"] == region: + if is_location_enabled(multiworld, player, location): + locations.append(location["name"]) + + new_region = create_region(world, multiworld, player, region, locations, exit_array) + multiworld.regions += [new_region] + + # Link regions together + for region in regionMap: + if "connects_to" in regionMap[region] and regionMap[region]["connects_to"]: + for linkedRegion in regionMap[region]["connects_to"]: + connection = multiworld.get_entrance(getConnectionName(region, linkedRegion), player) + connection.connect(multiworld.get_region(linkedRegion, player)) + +def create_region(world: World, multiworld: MultiWorld, player: int, name: str, locations=None, exits=None): + ret = Region(name, player, multiworld) + + if locations: + for location in locations: + loc_id = world.location_name_to_id.get(location, 0) + locationObj = ManualLocation(player, location, loc_id, ret) + if location_name_to_location[location].get('prehint'): + world.options.start_location_hints.value.add(location) + ret.locations.append(locationObj) + if exits: + for exit in exits: + ret.exits.append(Entrance(player, getConnectionName(name, exit), ret)) + return ret + +def getConnectionName(entranceName: str, exitName: str): + return entranceName + "To" + exitName + +def create_events(world: World, multiworld: MultiWorld, player: int): + for name, event in world.event_name_to_event.items(): + if not is_event_enabled(multiworld, player, event): + continue + region = multiworld.get_region(event.get("region", "Manual"), player) + item = ManualItem(event["name"], ItemClassification.progression, None, player=player) + location = ManualLocation(player, name, None, region) + region.locations.append(location) + location.place_locked_item(item) diff --git a/Rules.py b/Rules.py new file mode 100644 index 0000000..0f572b5 --- /dev/null +++ b/Rules.py @@ -0,0 +1,845 @@ +import dataclasses +import inspect +from typing import TYPE_CHECKING, Any, Callable, Optional, Any +from enum import IntEnum +from operator import eq, ge, le + +from .Regions import regionMap +from .hooks import Rules +from .Helpers import clamp, is_item_enabled, is_option_enabled, get_option_value, convert_string_to_type,\ + format_to_valid_identifier, format_state_prog_items_key, ProgItemsCat +from .Game import game_name + +from BaseClasses import MultiWorld, CollectionState, Entrance +from worlds.AutoWorld import World +from worlds.generic.Rules import set_rule, add_rule +from Options import Choice, Toggle, Range, NamedRange, NumericOption +from Utils import version_tuple + +import re +import math +import logging + +if TYPE_CHECKING: + from . import ManualWorld + +# At some point in the future, we should depreciate the non-RB codepath. But that's not until at least 0.7.X +use_rulebuilder = version_tuple >= (0, 6, 7) + +if TYPE_CHECKING and use_rulebuilder: + import rule_builder.rules + +FUNCTION_REGEX = re.compile(r'\{(\w+)\((.*?)\)\}') +ITEM_REGEX = re.compile(r'\|(@?)([^|]+?)(\:[^:|]+)?\|') +AND_REGEX = re.compile(r'\s?\bAND\b\s?', re.IGNORECASE) +OR_REGEX = re.compile(r'\s?\bOR\b\s?', flags=re.IGNORECASE) + +class LogicErrorSource(IntEnum): + INFIX_TO_POSTFIX = 1 + """includes more closing parentheses than opening (but not the opposite)""" + EVALUATE_POSTFIX = 2 + """ includes missing pipes and missing value on either side of AND/OR""" + EVALUATE_STACK_SIZE = 3 + """includes missing curly brackets""" + +def construct_logic_error(location_or_region: dict, source: LogicErrorSource) -> KeyError: + object_type = "location/region" + object_name = location_or_region.get("name", "Unknown") + + if location_or_region.get("is_region", False) or "starting" in location_or_region or "connects_to" in location_or_region: + object_type = "region" + elif "region" in location_or_region or "category" in location_or_region: + object_type = "location" + + if source == LogicErrorSource.INFIX_TO_POSTFIX: + source_text = "There may be mismatched parentheses, or other invalid syntax for the requires." + elif source == LogicErrorSource.EVALUATE_POSTFIX: + source_text = "There may be missing || around item names, or an AND/OR that is missing a value on one side, or other invalid syntax for the requires." + elif source == LogicErrorSource.EVALUATE_STACK_SIZE: + source_text = "There may be missing {} around requirement functions like YamlEnabled() / YamlDisabled(), or other invalid syntax for the requires." + else: + source_text = "This requires includes invalid syntax." + + return KeyError(f"Invalid 'requires' for {object_type} '{object_name}': {source_text} (ERROR {source})") + +def infix_to_postfix(expr: str, location: dict) -> str: + prec: dict[str, int] = {"&": 2, "|": 2, "!": 3} + stack: list[str] = [] + postfix: str = "" + + try: + for c in expr: + if c.isnumeric(): + postfix += c + elif c in prec: + while stack and stack[-1] != "(" and prec[c] <= prec[stack[-1]]: + postfix += stack.pop() + stack.append(c) + elif c == "(": + stack.append(c) + elif c == ")": + while stack and stack[-1] != "(": + postfix += stack.pop() + stack.pop() + else: + # added this here since '|Chun-Li| or {YamlCompare(Example_Choice == 1)' is valid because of the 1 at the end + raise ValueError(f"Invalid Character '{c}' in expression '{expr}', it should be either a number or a parentheses") + + while stack: + postfix += stack.pop() + except ValueError as ex: + text = str(ex) + if "'{'" in text or ")}" in text: + raise construct_logic_error(location, LogicErrorSource.EVALUATE_STACK_SIZE) + + raise construct_logic_error(location, LogicErrorSource.EVALUATE_POSTFIX) + + except Exception: + raise construct_logic_error(location, LogicErrorSource.INFIX_TO_POSTFIX) + + return postfix + + +def evaluate_postfix(expr: str, location: dict) -> bool: + stack: list[bool] = [] + + try: + for c in expr: + if c == "0": + stack.append(False) + elif c == "1": + stack.append(True) + elif c == "&": + op2 = stack.pop() + op1 = stack.pop() + stack.append(op1 and op2) + elif c == "|": + op2 = stack.pop() + op1 = stack.pop() + stack.append(op1 or op2) + elif c == "!": + op = stack.pop() + stack.append(not op) + except Exception: + raise construct_logic_error(location, LogicErrorSource.EVALUATE_POSTFIX) + + if len(stack) != 1: + raise construct_logic_error(location, LogicErrorSource.EVALUATE_STACK_SIZE) + + return stack.pop() + +def set_rules(world: "ManualWorld", multiworld: MultiWorld, player: int): + def evaluate_nonnumeric_count(item_base: str, item_name: str, item_count: str, is_category: bool, area: dict) -> tuple[str, int]: + item_count = item_count.strip() + if item_count.isnumeric(): + return item_name, int(item_count) + + items_counts = world.get_item_counts(player, only_progression=True) + if is_category: + total_count = sum([items_counts.get(item, 0) for item in world.item_and_event_name_groups.get(item_name, set())]) + else: + total_count = items_counts.get(item_name, 0) + if item_count.lower() == 'all': + count = total_count + elif item_count.lower() == 'half': + count = int(total_count / 2) + elif item_count.endswith('%') and len(item_count) > 1: + percent = clamp(float(item_count[:-1]) / 100, 0, 1) + count = math.ceil(total_count * percent) + # If invalid count assume its actually part of the item name + else: + item_name = item_base.strip("|") + count = 1 + return item_name, count + + def construct_rule_from_string(area: dict) -> "rule_builder.rules.Rule | None": + if not use_rulebuilder: + return None + + import rule_builder.rules + + requires_list = area.get('requires', '') + if requires_list == "": + return rule_builder.rules.True_() + + def recursively_tokenize_manual_rule(partial: str) -> "rule_builder.rules.Rule | None": + if not partial: + return rule_builder.rules.True_() + rule: Rule | None = None + remaining = '' + partial = partial.strip() + if match := ITEM_REGEX.match(partial): + is_category = bool(match.group(1)) + item_name = match.group(2) + item_count = (str(match.group(3) or "1")).lstrip(':') + + if item_count.isnumeric(): + count = int(item_count) + else: + item_name, count = evaluate_nonnumeric_count(match.group(0), item_name, item_count, is_category, area) + + if is_category: + rule = rule_builder.rules.HasFromList(*world.item_and_event_name_groups.get(item_name, set()), count=count) + else: + rule = rule_builder.rules.Has(item_name, count) + remaining = partial[len(match.group(0)):] + elif match := FUNCTION_REGEX.match(partial): + func_name = match.group(1) + func_args = match.group(2).split(",") + if func_args == ['']: + func_args.pop() + + rule_class = None + search = [ + (rule_builder.rules.DEFAULT_RULES, func_name), + (globals(), func_name + "Rule"), + (globals(), func_name), + (Rules, func_name + "Rule"), + (Rules, func_name), + ] + for ns, name in search: + if isinstance(ns, dict): + func = ns.get(name) + else: + func = getattr(ns, name, None) + + if func and inspect.isclass(func) and issubclass(func, rule_builder.rules.Rule): + convert_req_function_args(None, func, func_args, area['name'], world) + rule_class = func + break + + if func and inspect.signature(func).return_annotation is str: + # I'm assuming that functions that return strings don't need states. + convert_req_function_args(None, func, func_args, area['name'], world) + rule = recursively_tokenize_manual_rule(func(*func_args)) + break + + if rule is None: + if not rule_class: + logging.warning(f'Warning: Could not find Rule implementation of {func_name}.') + # By returning None, we're saying "This entire requires string can't be done with a Rule. Fall back to the pre-rb lambdas" + return None + + rule = rule_class(*func_args) + remaining = partial[len(match.group(0)):] + elif partial[0] == "(": + func_founds: list[str] = [] + for match in FUNCTION_REGEX.finditer(partial): + if match.group(0) not in partial: + # already done all of them + continue + + func_founds.append(match.group(0)) + # looks like : {{Function#0}} + id = len(func_founds) - 1 + partial = partial.replace(match.group(0), f"{{{{Function#{id}}}}}") + + inner = '' + queue = list(partial[1:]) + stack = 1 + while stack > 0: + if not queue: + raise construct_logic_error(area, LogicErrorSource.INFIX_TO_POSTFIX) + + c = queue.pop(0) + if c == "(": + stack += 1 + elif c == ")": + stack -= 1 + else: + inner += c + remaining = "".join(queue) + + for id, func in enumerate(func_founds): + remaining = remaining.replace(f"{{{{Function#{id}}}}}", func) + inner = inner.replace(f"{{{{Function#{id}}}}}", func) + + rule = recursively_tokenize_manual_rule(inner) + else: + logging.warning(f'Warning: Could not convert {partial} into a Rule') + return None + + if rule is None: + return None + + if match := OR_REGEX.match(remaining): + remaining = remaining[len(match.group(0)):] + right = recursively_tokenize_manual_rule(remaining) + if right is None: + return None + rule = rule | right + remaining = '' + elif match := AND_REGEX.match(remaining): + remaining = remaining[len(match.group(0)):] + right = recursively_tokenize_manual_rule(remaining) + if right is None: + return None + rule = rule & right + remaining = '' + + if remaining: + raise ValueError(f'Unexpected token `{remaining}` in {area["name"]} rule') + return rule + + return recursively_tokenize_manual_rule(requires_list) + + # this is only called when the area (think, location or region) has a "requires" field that is a string + def checkRequireStringForArea(state: CollectionState, area: dict): + requires_list = area["requires"] + + # Preparing some variables for exception messages + area_type = "region" if area.get("is_region",False) else "location" + area_name = area.get("name", f"unknown with these parameters: {area}") + + if requires_list == "": + return True + + def findAndRecursivelyExecuteFunctions(requires_list: str, recursionDepth: int = 0) -> str: + found_functions = FUNCTION_REGEX.findall(requires_list) + if found_functions: + if recursionDepth > world.rules_functions_maximum_recursion: + raise RecursionError(f'One or more functions in {area_type} "{area_name}"\'s requires looped too many time (maximum recursion is {world.rules_functions_maximum_recursion}) \ + \n As of this Exception the following function(s) are waiting to run: {[f[0] for f in found_functions]} \ + \n And the currently processed requires look like this: "{requires_list}"') + else: + for item in found_functions: + func_name = item[0] + func_args = item[1].split(",") + if func_args == ['']: + func_args.pop() + + func = globals().get(func_name) + + if func is None: + func = getattr(Rules, func_name, None) + + if not callable(func): + raise ValueError(f'Invalid function "{func_name}" in {area_type} "{area_name}".') + + convert_req_function_args(state, func, func_args, area_name, world) + try: + result = func(*func_args) + except Exception as ex: + raise RuntimeError(f'A call to the function "{func_name}" in {area_type} "{area_name}"\'s requires raised an Exception. \ + \nUnless it was called by another function, it should look something like "{{{func_name}({item[1]})}}" in {area_type}s.json. \ + \nFull error message: \ + \n\n{type(ex).__name__}: {ex}') + if isinstance(result, bool): + requires_list = requires_list.replace("{" + func_name + "(" + item[1] + ")}", "1" if result else "0") + else: + requires_list = requires_list.replace("{" + func_name + "(" + item[1] + ")}", str(result)) + + requires_list = findAndRecursivelyExecuteFunctions(requires_list, recursionDepth + 1) + return requires_list + + requires_list = findAndRecursivelyExecuteFunctions(requires_list) + + # parse user written statement into list of each item + for match in ITEM_REGEX.finditer(requires_list): + item_base = match.group(0) + is_category = bool(match.group(1)) + item_name = match.group(2) + item_count = match.group(3) + + if item_base not in requires_list: + # previous instance of this item was already processed + continue + + if not item_count: + item_count = "1" + item_count = item_count.lstrip(':') + + item_name, numeric_count = evaluate_nonnumeric_count(item_base, item_name, item_count, is_category, area) + + if is_category: + found = state.has_from_list(world.item_and_event_name_groups.get(item_name, set()), player, numeric_count) + else: + found = state.has(item_name, player, numeric_count) + + if found: + requires_list = requires_list.replace(item_base, "1") + else: + requires_list = requires_list.replace(item_base, "0") + + requires_list = AND_REGEX.sub('&', requires_list, count=0) + requires_list = OR_REGEX.sub('|', requires_list, count=0) + + requires_string = infix_to_postfix("".join(requires_list), area) + return (evaluate_postfix(requires_string, area)) + + # this is only called when the area (think, location or region) has a "requires" field that is a dict + def checkRequireDictForArea(state: CollectionState, area: dict): + canAccess = True + + for item in area["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)): + canAccessOr = True + 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 + or_item_count = 1 + + if len(or_item_parts) > 1: + or_item_name = or_item_parts[0] + or_item_count = int(or_item_parts[1]) + + if not state.has(or_item_name, player, or_item_count): + canAccessOr = False + + if canAccessOr: + canAccess = True + break + else: + item_parts = item.split(":") + item_name = item + item_count = 1 + + if len(item_parts) > 1: + item_name = item_parts[0] + item_count = int(item_parts[1]) + + if not state.has(item_name, player, item_count): + canAccess = False + + return canAccess + + # handle any type of checking needed, then ferry the check off to a dedicated method for that check + def fullLocationOrRegionCheck(state: CollectionState, area: dict): + # if it's not a usable object of some sort, default to true + if not area: + return True + + # don't require the "requires" key for locations and regions if they don't need to use it + if "requires" not in area.keys(): + return True + + if isinstance(area["requires"], str): + return checkRequireStringForArea(state, area) + else: # item access is in dict form + return checkRequireDictForArea(state, area) + + used_location_names = [] + # Region access rules + extra_entrance_rules = {} + for region in regionMap.keys(): + entrance_rules = regionMap[region].get("entrance_requires", {}) + for e in entrance_rules: + entrance = world.get_entrance(f'{e}To{region}') + area = {"requires": entrance_rules[e]} + extra_entrance_rules[entrance.name] = area + + exit_rules = regionMap[region].get("exit_requires", {}) + for e in exit_rules: + exit = world.get_entrance(f'{region}To{e}') + area = {"requires": exit_rules[e]} + extra_entrance_rules[exit.name] = area + + for region in regionMap.keys(): + used_location_names.extend([l.name for l in multiworld.get_region(region, player).locations]) + for exitRegion in multiworld.get_region(region, player).entrances: + extra = extra_entrance_rules.get(exitRegion.name, {}) + area = regionMap[region] + area["name"] = exitRegion.name + area['is_region'] = True + rb_rule = construct_rule_from_string(area) + if rb_rule is not None: + if extra: + rb_extra_rule = construct_rule_from_string(extra) + if rb_extra_rule is None: + raise ValueError(f'Unable to combine Rule and functions for {exitRegion.name}.') + rb_rule = rb_rule & rb_extra_rule + world.set_rule(world.get_entrance(exitRegion.name), rb_rule) + else: + def fullRegionCheck(state: CollectionState, region=area): + return fullLocationOrRegionCheck(state, region) + + add_rule(world.get_entrance(exitRegion.name), fullRegionCheck) + if extra: + add_rule(world.get_entrance(exitRegion.name), lambda state, extra=extra: fullLocationOrRegionCheck(state, extra)) + + # Location access rules + for location in (world.location_table + world.event_table): + if "location_name" in location: + name = location["location_name"] + else: + name = location["name"] + if name not in used_location_names: + continue + + locFromWorld = multiworld.get_location(name, player) + + if "requires" in location: + rb_rule = construct_rule_from_string(location) + if rb_rule is not None: + world.set_rule(locFromWorld, rb_rule) + else: + def checkLocation(state: CollectionState, location=location): + locationCheck = fullLocationOrRegionCheck(state, location) + return locationCheck + + set_rule(locFromWorld, checkLocation) + elif use_rulebuilder: + import rule_builder.rules + world.set_rule(locFromWorld, rule_builder.rules.True_()) + else: # No location requires? It's accessible. + def allRegionsAccessible(state): + return True + + set_rule(locFromWorld, allRegionsAccessible) + + # Victory requirement + multiworld.completion_condition[player] = lambda state: state.has("__Victory__", player) + +def convert_req_function_args(state: CollectionState | None, func, args: list[str | Any], areaName: str, world: World) -> None: + parameters = inspect.signature(func).parameters + knownParameters = [World, 'ManualWorld', MultiWorld, CollectionState] + index = -1 + for parameter in parameters.values(): + target_type = parameter.annotation + if str(target_type) == "collections.abc.Iterable[rule_builder.options.OptionFilter]": + # parameters >= this one are added by rule builder and do not need to be converted + break + + index += 1 + if target_type in knownParameters: + if target_type in [World, 'ManualWorld']: + args.insert(index, world) + elif target_type == MultiWorld: + args.insert(index, world.multiworld) + elif target_type == CollectionState and state is None: + raise ValueError('Function needs a state but none was available') + elif target_type == CollectionState: + args.insert(index, state) + continue + if parameter.name.lower() == "player": + args.insert(index, world.player) + continue + + if index < len(args) and args[index] != "": + value = args[index].strip() + else: + if parameter.default is not inspect.Parameter.empty: + if index < len(args): + args[index] = parameter.default + else: + args.insert(index, parameter.default) + continue + else: + if parameter.annotation is inspect.Parameter.empty: + raise Exception(f"A call of the \"{func.__name__}\" function in \"{areaName}\"'s requirement, asks for a value for its argument \"{parameter.name}\" but it's missing.") + else: + raise Exception(f"A call of the \"{func.__name__}\" function in \"{areaName}\"'s requirement, asks for a value of type {target_type} for its argument \"{parameter.name}\" but it's missing.") + + if target_type == str or parameter.annotation is inspect.Parameter.empty: #Don't convert since its already a string or if we don't know the type to convert to + args[index] = value + continue + + try: + value = convert_string_to_type(value, target_type) + + except Exception as e: + raise Exception(f"A call of the \"{func.__name__}\" function in \"{areaName}\"'s requirement, asks for a value of type {target_type}\nfor its argument \"{parameter.name}\" but its value \"{value}\" cannot be converted to {target_type} \nOriginal Error:'{e}'") + + args[index] = value + + +def ItemValue(state: CollectionState, player: int, valueCount: str): + """When passed a string with this format: 'valueName:int', + this function will check if the player has collect at least 'int' valueName worth of items\n + eg. {ItemValue(Coins:12)} will check if the player has collect at least 12 coins worth of items + """ + + args: list[str] = valueCount.split(":") + if not len(args) == 2 or not args[1].isnumeric(): + raise Exception(f"ItemValue needs a number after : so it looks something like 'ItemValue({args[0]}:12)'") + value_name = format_state_prog_items_key(ProgItemsCat.VALUE, args[0]) + requested_count = int(args[1].strip()) + return state.has(value_name, player, requested_count) + + +# Two useful functions to make require work if an item is disabled instead of making it inaccessible +def OptOne(world: "ManualWorld", item: str) -> str: + """Check if the passed item (with or without ||) is enabled, then this returns |item:count| + where count is clamped to the maximum number of said item in the itempool.\n + Eg. requires: "{OptOne(|DisabledItem|)} and |other items|" become "|DisabledItem:0| and |other items|" if the item is disabled. + """ + if item == "": + return "" #Skip this function if item is left blank + + items_counts = world.get_item_counts(only_progression=True) + + require_category = False + + if '@' in item[:2]: + require_category = True + + item = item.lstrip('|@$').rstrip('|') + + item_parts = item.split(":") + item_name = item + item_count = '1' + + if len(item_parts) > 1: + item_name = item_parts[0] + item_count = item_parts[1] + + if require_category: + if item_count.isnumeric(): + #Only loop if we can use the result to clamp + category_items = [item for item in world.item_name_to_item.values() if "category" in item and item_name in item["category"]] + category_items_counts = sum([items_counts.get(category_item["name"], 0) for category_item in category_items]) + item_count = clamp(int(item_count), 0, category_items_counts) + return f"|@{item_name}:{item_count}|" + else: + if item_count.isnumeric(): + item_current_count = items_counts.get(item_name, 0) + item_count = clamp(int(item_count), 0, item_current_count) + return f"|{item_name}:{item_count}|" + +# OptAll check the passed require string and loop every item to check if they're enabled, +def OptAll(world: "ManualWorld", requires: str) -> bool|str: + """Check the passed require string and loop every item to check if they're enabled, + then returns the require string with items counts adjusted using OptOne\n + eg. requires: "{OptAll(|DisabledItem| and |@CategoryWithModifedCount:10|)} and |other items|" + become "|DisabledItem:0| and |@CategoryWithModifedCount:2| and |other items|" """ + requires_list = requires + + if requires_list == "": + return True + + # parse user written statement into list of each item + for item in re.findall(r'\|[^|]+\|', requires): + itemScanned = OptOne(world, item) + requires_list = requires_list.replace(item, itemScanned) + + return requires_list + +# going to be deprecated to name consistently to other req functions, in pascal case +def canReachLocation(state: CollectionState, player: int, location: str): + logging.warning("The 'canReachLocation' requirement function is being renamed to 'CanReachLocation'. Use that instead, as the lowercase version will be deprecated.") + return CanReachLocation(state, player, location) + +# Rule to expose the can_reach_location core function +def CanReachLocation(state: CollectionState, player: int, location: str) -> bool: + """Can the player reach the given location?""" + if state.can_reach_location(location, player): + return True + return False + +def OptionCount(world: "ManualWorld", item: str, option_name: str) -> str: + """Set the required count of 'item' to be the value set in the player's yaml of the Numerical option 'option_name'.""" + return _optionCountLogic(world, item, option_name ) + +def OptionCountPercent(world: "ManualWorld", item: str, option_name: str) -> str: + """Set the required count of 'item' to be a percentage of it total count based on the player's yaml value for Numerical option 'option_name'.""" + return _optionCountLogic(world, item, option_name, is_percent=True) + +def _optionCountLogic(world: "ManualWorld", item: str, option_name: str, is_percent: bool = False) -> str: + option_name = option_name.strip() + option: NumericOption | None = getattr(world.options, option_name, None) + if option is None: + raise ValueError(f"Could not find an option named: {option_name}") + + # Verification that the value is compatible + if not isinstance(option.value, int): + raise ValueError(f"Cannot use a value that is not a number. Got value of '{option.value}' from option {option_name}") + + item = item.strip('|').strip() + return f"|{item}:{option.value}{'%' if is_percent else ''}|" + +def YamlEnabled(multiworld: MultiWorld, player: int, param: str) -> bool: + """Is a yaml option enabled?""" + return is_option_enabled(multiworld, player, param) + +def YamlDisabled(multiworld: MultiWorld, player: int, param: str) -> bool: + """Is a yaml option disabled?""" + return not is_option_enabled(multiworld, player, param) + +def YamlCompare(world: "ManualWorld", args: str, skipCache: bool = False) -> bool: + """Is a yaml option's value compared using {comparator} to the requested value + \nFormat it like {YamlCompare(OptionName==value)} + \nWhere == can be any of the following: ==, !=, >=, <=, <, > + \nExample: {YamlCompare(Example_Range > 5)}""" + comp_symbols = { #Maybe find a better name for this + '==' : eq, + '!=' : eq, #complement of == + '>=' : ge, + '<=' : le, + '=': eq, #Alternate to be like yaml_option + '<' : ge, #complement of >= + '>' : le, #complement of <= + } + + reverse_result = False + + #Find the comparator symbol to split the string with and for logs + if '==' in args: + comparator = '==' + elif '!=' in args: + comparator = '!=' + reverse_result = True #complement of == thus reverse by default + elif '>=' in args: + comparator = '>=' + elif '<=' in args: + comparator = '<=' + elif '=' in args: + comparator = '=' + elif '<' in args: + comparator = '<' + reverse_result = True #complement of >= + elif '>' in args: + comparator = '>' + reverse_result = True #complement of <= + else: + raise ValueError(f"Could not find a valid comparator in given string '{args}', it must be one of {comp_symbols.keys()}") + + option_name, value = args.split(comparator) + + initial_option_name = str(option_name).strip() #For exception messages + option_name = format_to_valid_identifier(option_name) + + # Detect !reversing of result like yaml_option + if option_name.startswith('!'): + reverse_result = not reverse_result + option_name = option_name.lstrip('!') + initial_option_name = initial_option_name.lstrip('!') + + value = value.strip() + + option = getattr(world.options, option_name, None) + if option is None: + raise ValueError(f"YamlCompare could not find an option called '{initial_option_name}' to compare against, its either missing on misspelt") + + if not value: #empty string '' + raise ValueError(f"Could not find a valid value to compare against in given string '{args}'. \nThere must be a value to compare against after the comparator (in this case '{comparator}').") + + if not skipCache: #Cache made for optimization purposes + cacheindex = option_name + '_' + comp_symbols[comparator].__name__ + '_' + format_to_valid_identifier(value.lower()) + + if not hasattr(world, 'yaml_compare_rule_cache'): + world.yaml_compare_rule_cache = dict[str,bool]() + + if skipCache or world.yaml_compare_rule_cache.get(cacheindex, None) is None: + try: + if issubclass(type(option), Choice): + value = convert_string_to_type(value, str|int) + if isinstance(value, str): + value = option.from_text(value).value + + elif issubclass(type(option), Range): + if type(option).__base__ == NamedRange: + value = convert_string_to_type(value, str|int) + if isinstance(value, str): + value = option.from_text(value).value + + else: + value = convert_string_to_type(value, int) + + elif issubclass(type(option), Toggle): + value = int(convert_string_to_type(value, bool)) + + else: + raise ValueError(f"YamlCompare does not currently support Option of type {type(option)} \nAsk about it in #Manual-dev and it might be added.") + + except KeyError as ex: + raise ValueError(f"YamlCompare failed to find the requested value in what the \"{initial_option_name}\" option supports.\ + \nRaw error:\ + \n\n{type(ex).__name__}:{ex}") + + except Exception as ex: + raise TypeError(f"YamlCompare failed to convert the requested value to what a {type(option).__base__.__name__} option supports.\ + \nCaused By:\ + \n\n{type(ex).__name__}:{ex}") + + if isinstance(value, str) and comp_symbols[comparator].__name__ != 'eq': + #At this point if its still a string don't try and compare with strings using > < >= <= + raise ValueError(f'YamlCompare can only compare strings with one of the following: {[s for s, v in comp_symbols.items() if v.__name__ == "eq"]} and you tried to do: "{option.value} {comparator} {value}"') + + result = comp_symbols[comparator](option.value, value) + + if not skipCache: + world.yaml_compare_rule_cache[cacheindex] = result + + else: #if exists and not skipCache + result = world.yaml_compare_rule_cache[cacheindex] + + return not result if reverse_result else result + +if use_rulebuilder: + from rule_builder.rules import Rule, Has, True_, False_ + + @dataclasses.dataclass() + class ItemValueRule(Rule["ManualWorld"], game=game_name): + valueCount: str + def _instantiate(self, world: "ManualWorld") -> Rule.Resolved: + args: list[str] = self.valueCount.split(":") + if not len(args) == 2 or not args[1].isnumeric(): + raise Exception(f"ItemValue needs a number after : so it looks something like 'ItemValue({args[0]}:12)'") + value_name = format_state_prog_items_key(ProgItemsCat.VALUE, args[0]) + requested_count = int(args[1].strip()) + return Has(value_name, requested_count).resolve(world) + + @dataclasses.dataclass() + class YamlEnabledRule(Rule["ManualWorld"], game=game_name): + yaml_option: str + def _instantiate(self, world: "ManualWorld") -> Rule.Resolved: + if getattr(world.options, self.yaml_option).value: + return True_().resolve(world) + else: + return False_().resolve(world) + + @dataclasses.dataclass() + class YamlDisabledRule(Rule["ManualWorld"], game=game_name): + yaml_option: str + def _instantiate(self, world: "ManualWorld") -> Rule.Resolved: + if getattr(world.options, self.yaml_option).value: + return False_().resolve(world) + else: + return True_().resolve(world) + + @dataclasses.dataclass() + class YamlCompareRule(Rule["ManualWorld"], game=game_name): + yaml_comparison: str + def _instantiate(self, world: "ManualWorld") -> Rule.Resolved: + if YamlCompare(world, self.yaml_comparison): + return True_().resolve(world) + else: + return False_().resolve(world) + + + + # # This was an attempt to make a universal lambda caller rule. It's messy, complicated, and probably not worth it. + # # As you can see above, you are much better off implementing optimized Rules rather than trying to execute check-time access rules this way. + # # This code is left here both as a warning and also a starting point. + # # But as things currently stand, you're better off either letting the whole rule fall back to lambdas or writing a proper custom Rule. + # # This middleground is not a good idea. + # @dataclasses.dataclass() + # class FallbackLambdaRule(Rule["ManualWorld"], game=game_name): + # access_rule: Callable + # args: list[str | Any] + + # def _instantiate(self, world: "ManualWorld") -> Rule.Resolved: + # return self.Resolved(self.access_rule, self.args, world) + + # class Resolved(Rule.Resolved): + # access_rule: Callable + # args: list[str | Any] + # world: World + + # @override + # def _evaluate(self, state: CollectionState) -> bool: + # args = self.args.copy() + # convert_req_function_args(state, self.access_rule, args, "", self.world) + # value = self.access_rule(*args) + # if isinstance(value, bool): + # return value + # name = self.access_rule.__name__ + # raise ValueError(f'Unexpected return value {value} from {name}') + + diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..0ade92a --- /dev/null +++ b/__init__.py @@ -0,0 +1,636 @@ +import logging +import os +from typing import Callable, Optional, ClassVar, Counter, Any +import webbrowser + +import Utils +from worlds.generic.Rules import forbid_items_for_player +from worlds.LauncherComponents import Component, SuffixIdentifier, components, Type, launch, icon_paths + +from .Data import item_table, location_table, event_table, category_table +from .Game import game_name, filler_item_name, starting_items, unused_goals_are_locations, glitches_item_name +from .Meta import world_description, world_webworld +from .Locations import location_id_to_name, location_name_to_id, location_name_to_location, location_name_groups, victory_names, event_name_to_event, event_name_groups +from .Items import item_id_to_name, item_name_to_id, item_name_to_item, item_name_groups +from .DataValidation import runGenerationDataValidation, runPreFillDataValidation + +from .Regions import create_regions, create_events +from .Items import ManualItem +from .Rules import set_rules +from .Options import manual_options_data +from .Helpers import is_item_enabled, get_option_value, remove_specific_item, resolve_yaml_option, format_state_prog_items_key, convert_string_to_itemclassification, ProgItemsCat +from .container import APManualFile + +from BaseClasses import CollectionState, ItemClassification, Item, Location +from Options import PerGameCommonOptions +from worlds.AutoWorld import World + +from .hooks.World import \ + hook_get_filler_item_name, before_create_regions, after_create_regions, \ + before_create_items_all, before_create_items_place_items, before_create_items_starting, before_create_items_filler, after_create_items, \ + before_create_item, after_create_item, \ + before_set_rules, after_set_rules, \ + before_generate_basic, \ + before_fill_slot_data, after_fill_slot_data, before_write_spoiler, \ + before_extend_hint_information, after_extend_hint_information, \ + after_collect_item, after_remove_item, before_generate_early, hook_interpret_slot_data + +class ManualWorld(World): + __doc__ = world_description + game: ClassVar[str] = game_name + web = world_webworld + + options_dataclass = manual_options_data + data_version = 2 + required_client_version = (0, 3, 4) + + # These properties are set from the imports of the same name above. + item_table = item_table + location_table = location_table # this is likely imported from Data instead of Locations because the Game Complete location should not be in here, but is used for lookups + event_table = event_table + category_table = category_table + + item_id_to_name = item_id_to_name + item_name_to_id = item_name_to_id + item_name_to_item = item_name_to_item + item_name_groups = item_name_groups + + filler_item_name = filler_item_name + + item_counts: dict[int, Counter[str]] = {} + item_counts_progression: dict[int, Counter[str]] = {} + start_inventory = {} + + location_id_to_name = location_id_to_name + location_name_to_id = location_name_to_id + location_name_to_location = location_name_to_location + location_name_groups = location_name_groups + victory_names = victory_names + + event_name_to_event = event_name_to_event + event_name_groups = event_name_groups + item_and_event_name_groups: dict[str, set[str]] = dict(item_name_groups) + for name, category in event_name_groups.items(): + if name in item_and_event_name_groups.keys(): + item_and_event_name_groups[name] = item_name_groups[name].union(category) + else: + item_and_event_name_groups[name] = category + + # UT (the universal-est of trackers) can now generate without a YAML + ut_can_gen_without_yaml = True + glitches_item_name: str | None = glitches_item_name + + origin_region_name = "Manual" + + def get_filler_item_name(self) -> str: + hook_result = hook_get_filler_item_name(self, self.multiworld, self.player) + if hook_result and isinstance(hook_result, str): + return hook_result + elif hook_result and isinstance(hook_result, list): + return self.random.choice(hook_result) + elif isinstance(filler_item_name, str): + return self.filler_item_name + else: + random_filler_name = self.random.choice(filler_item_name) + return random_filler_name + + def interpret_slot_data(self, slot_data: dict[str, Any]) -> dict[str, Any]: + #this is called by tools like UT + if not slot_data: + return {} + + regen = hook_interpret_slot_data(self, self.player, slot_data) or slot_data + return regen + + @classmethod + def stage_assert_generate(cls, multiworld) -> None: + runGenerationDataValidation(cls) + + def generate_early(self) -> None: + before_generate_early(self, self.multiworld, self.player) + for item in item_table: + if item.get("local"): + if item.get("name") not in self.options.local_items.value: + self.options.local_items.value.add(item["name"]) + + if hasattr(self.multiworld, "re_gen_passthrough"): + slot_data = self.multiworld.re_gen_passthrough.get(self.game, {}) + if slot_data: + for key, value in slot_data.items(): + if hasattr(self.options, key): + getattr(self.options, key).value = value + + def create_regions(self): + before_create_regions(self, self.multiworld, self.player) + + create_regions(self, self.multiworld, self.player) + + create_events(self, self.multiworld, self.player) + + location_game_complete = self.multiworld.get_location(victory_names[get_option_value(self.multiworld, self.player, 'goal')], self.player) + location_game_complete.address = None + + if not unused_goals_are_locations: + for unused_goal in [self.multiworld.get_location(name, self.player) for name in victory_names if name != location_game_complete.name]: + unused_goal.parent_region.locations.remove(unused_goal) + + location_game_complete.place_locked_item( + ManualItem("__Victory__", ItemClassification.progression, None, player=self.player)) + + after_create_regions(self, self.multiworld, self.player) + + def create_items(self): + # Generate item pool + pool: list[Item] = [] + traps = [] + configured_item_names = self.item_id_to_name.copy() + is_UT = bool(getattr(self.multiworld, "generation_is_fake", False)) + + items_config: dict[str, int|dict[ItemClassification | str | int, int]] = {} + for name in configured_item_names.values(): + if name == "__Victory__": continue + # intentionally using the Game.py filler_item_name here because it can be a non-Items item + if isinstance(filler_item_name, str): + if name == filler_item_name: continue + elif isinstance(filler_item_name, list): + if name in filler_item_name: continue + + item = self.item_name_to_item[name] + item_count = int(item.get("count", 1)) + + if item.get("trap"): + traps.append(name) + + if not is_item_enabled(self.multiworld, self.player, item): + items_config[name] = 0 + + else: + if item.get("classification_count"): + items_config[name] = item["classification_count"] + + else: + items_config[name] = item_count + + items_config = before_create_items_all(items_config, self, self.multiworld, self.player) + + for name, configs in items_config.items(): + total_created = 0 + if type(configs) is int: + total_created = configs + for _ in range(configs): + new_item = self.create_item(name) + pool.append(new_item) + elif type(configs) is dict: + for override, count in configs.items(): + total_created += count + if isinstance(override, ItemClassification): + true_class = override + else: + try: + if isinstance(override, int): + true_class = ItemClassification(override) + else: + true_class = convert_string_to_itemclassification(override) + except Exception as ex: + raise Exception(f"Item override '{override}' for {name} improperly defined\n\n{type(ex).__name__}:{ex}") + + for _ in range(count): + new_item = self.create_item(name, true_class) + pool.append(new_item) + else: + raise Exception(f"Item override for {name} improperly defined") + + if total_created == 0 or is_UT: continue + + item = self.item_name_to_item[name] + if item.get("early"): # Some or all early + if isinstance(item["early"],int) or (isinstance(item["early"],str) and item["early"].isnumeric()): + self.multiworld.early_items[self.player][name] = int(item["early"]) + + elif isinstance(item["early"],bool): #No need to deal with true vs false since false wont get here + self.multiworld.early_items[self.player][name] = total_created + + else: + raise Exception(f"Item {name}'s 'early' has an invalid value of '{item['early']}'. \nA boolean or an integer was expected.") + + if item.get("local_early"): # Some or all local and early + if isinstance(item["local_early"],int) or (isinstance(item["local_early"],str) and item["local_early"].isnumeric()): + self.multiworld.local_early_items[self.player][name] = int(item["local_early"]) + + elif isinstance(item["local_early"],bool): + self.multiworld.local_early_items[self.player][name] = total_created + + else: + raise Exception(f"Item {name}'s 'local_early' has an invalid value of '{item['local_early']}'. \nA boolean or an integer was expected.") + + # Handle item forbidding/placement + pool = before_create_items_place_items(pool, self, self.multiworld, self.player) + locations_with_forbid: list[Location] = [] + locations_with_placements: list[Location] = [] + if not is_UT: + for location in self.multiworld.get_unfilled_locations(player=self.player): + manual_location = self.location_name_to_location.get(location.name, {}) + if manual_location.get("place_item") or manual_location.get("place_item_category"): + locations_with_placements.append(location) + elif manual_location.get("dont_place_item") or manual_location.get("dont_place_item_category"): + locations_with_forbid.append(location) + + # Handle specific item forbidding using forbid_items_for_player + for location in locations_with_forbid: + manual_location = self.location_name_to_location.get(location.name, {}) + forbidden_item_names: set[str] = set() + + if manual_location.get("dont_place_item"): + forbidden_item_names |= set(manual_location["dont_place_item"]) + + if manual_location.get("dont_place_item_category"): + for cat in manual_location["dont_place_item_category"]: + forbidden_item_names |= self.item_name_groups.get(cat, set()) + + if forbidden_item_names: + forbid_items_for_player(location, set(forbidden_item_names), self.player) + + # Handle specific item placements using place_locked_item + for location in locations_with_placements: + manual_location = self.location_name_to_location.get(location.name, {}) + eligible_items = [] + eligible_item_names: set[str] = set() + forbidden_item_names = set() + place_messages = [] + forbid_messages = [] + + #First we get possible items names + if manual_location.get("place_item"): + eligible_item_names |= set(manual_location["place_item"]) + place_messages.append('", "'.join(manual_location["place_item"])) + + if manual_location.get("place_item_category"): + for cat in manual_location["place_item_category"]: + eligible_item_names |= self.item_name_groups.get(cat, set()) + place_messages.append('", "'.join(manual_location["place_item_category"]) + " category(ies)") + + # Second we check for forbidden items names + if manual_location.get("dont_place_item"): + forbidden_item_names |= set(manual_location["dont_place_item"]) + forbid_messages.append('", "'.join(manual_location["dont_place_item"]) + ' items') + + if manual_location.get("dont_place_item_category"): + for cat in manual_location["dont_place_item_category"]: + forbidden_item_names |= self.item_name_groups.get(cat, set()) + forbid_messages.append('", "'.join(manual_location["dont_place_item_category"]) + ' category(ies)') + + # If we forbid some names, check for those in the possible names and remove them + if forbidden_item_names: + eligible_item_names = {name for name in eligible_item_names if name not in forbidden_item_names} + + if eligible_item_names: + eligible_items = [item for item in pool if item.name in eligible_item_names] + + if len(eligible_items) == 0: + nl = "\n" + if forbidden_item_names: + raise Exception(f'Could not find a suitable item to place at "{manual_location["name"]}".\n No items that match "{f"{nl} or ".join(place_messages)}"\n Maybe because of forbidden "{f"{nl} or ".join(forbid_messages)}"') + raise Exception(f'Could not find a suitable item to place at "{manual_location["name"]}". \n No items that match "{f"{nl} or ".join(place_messages)}"') + + item_to_place = self.random.choice(eligible_items) + location.place_locked_item(item_to_place) + + # remove the item we're about to place from the pool so it isn't placed twice + remove_specific_item(pool, item_to_place) + + # Handle game.json's starting_items + pool = before_create_items_starting(pool, self, self.multiworld, self.player) + + items_started: list[Item] = [] + + if starting_items: + for starting_item_block in starting_items: + if not resolve_yaml_option(self.multiworld, self.player, starting_item_block): + continue + # if there's a condition on having a previous item, check for any of them + # if not found in items started, this starting item rule shouldn't execute, and check the next one + if "if_previous_item" in starting_item_block: + matching_items = [item for item in items_started if item.name in starting_item_block["if_previous_item"]] + + if len(matching_items) == 0: + continue + + # start with the full pool of items + items = pool + + # if the setting lists specific item names, limit the items to just those + if "items" in starting_item_block: + items = [item for item in pool if item.name in starting_item_block["items"]] + + # if the setting lists specific item categories, limit the items to ones that have any of those categories + if "item_categories" in starting_item_block: + items_in_categories = [item["name"] for item in self.item_name_to_item.values() if "category" in item and len(set(starting_item_block["item_categories"]).intersection(item["category"])) > 0] + items = [item for item in pool if item.name in items_in_categories] + + self.random.shuffle(items) + + # if the setting lists a specific number of random items that should be pulled, only use a subset equal to that number + if "random" in starting_item_block: + items = items[0:starting_item_block["random"]] + + for starting_item in items: + items_started.append(starting_item) + self.multiworld.push_precollected(starting_item) + remove_specific_item(pool, starting_item) + + self.start_inventory = {i.name: items_started.count(i) for i in items_started} + + pool = before_create_items_filler(pool, self, self.multiworld, self.player) + pool = self.adjust_filler_items(pool, traps) + pool = after_create_items(pool, self, self.multiworld, self.player) + + # need to put all of the items in the pool so we can have a full state for placement + self.multiworld.itempool += pool + + # Preparing to count the items: + precollected_items = list(self.multiworld.precollected_items[self.player]) + + # UT doesn't precollect the exceptions so this can be skipped + if not is_UT: + # Filter Precollected items for those not in logic aka created by start_inventory(_from_pool) + precollected_exceptions = self.options.start_inventory.value + self.options.start_inventory_from_pool.value # type: ignore + for item, count in precollected_exceptions.items(): + items_iter = iter([i for i in precollected_items if i.name == item]) + for _ in range(count): + precollected_items.remove(next(items_iter)) + # Placed items: + placed_pool: list[Item] = [] + for location in self.multiworld.get_filled_locations(self.player): + if location.item is not None: # For type checkers + placed_pool.append(location.item) + + real_pool = pool + precollected_items + placed_pool + self.item_counts[self.player] = self.get_item_counts(pool=real_pool) + self.item_counts_progression[self.player] = self.get_item_counts(pool=real_pool, only_progression=True) + + def create_item(self, name: str, class_override: Optional['ItemClassification']=None) -> Item: + name = before_create_item(name, self, self.multiworld, self.player) + + if name == self.glitches_item_name: + return ManualItem(name, class_override or ItemClassification.progression, None, self.player) + + item = self.item_name_to_item[name] + classification: ItemClassification = ItemClassification.filler + if class_override is not None: + classification = class_override + + elif item.get("classification_count"): + # This should only be run if create_item is called outside of create_items + not_prog_classes: list[ItemClassification] = [] + progression_classes: list[ItemClassification] = [] + for cat, count in item["classification_count"].items(): + if count: + true_class = convert_string_to_itemclassification(cat) + if ItemClassification.progression in true_class: + progression_classes.append(true_class) + else: + not_prog_classes.append(true_class) + if progression_classes: + classification |= self.random.choice(progression_classes) + elif not_prog_classes: + classification |= not_prog_classes[0] + + else: + if item.get("trap"): + classification |= ItemClassification.trap + + if item.get("useful"): + classification |= ItemClassification.useful + + if item.get("progression_skip_balancing"): + classification |= ItemClassification.progression_skip_balancing + elif item.get("progression"): + classification |= ItemClassification.progression + + item_object = ManualItem(name, classification, + self.item_name_to_id[name], player=self.player) + + item_object = after_create_item(item_object, self, self.multiworld, self.player) + + return item_object + + # Item Value need a tweaked collect and remove: + def collect(self, state: CollectionState, item: Item) -> bool: + change = super().collect(state, item) + manual_item = self.item_name_to_item.get(item.name, {}) + if change and manual_item.get("value"): + for key, value in manual_item["value"].items(): + state.prog_items[item.player][format_state_prog_items_key(ProgItemsCat.VALUE, key)] += int(value) + after_collect_item(self, state, change, item) + return change + + def remove(self, state: CollectionState, item: Item) -> bool: + change = super().remove(state, item) + manual_item = self.item_name_to_item.get(item.name, {}) + if change and manual_item.get("value"): + for key, value in manual_item["value"].items(): + state.prog_items[item.player][format_state_prog_items_key(ProgItemsCat.VALUE, key)] -= int(value) + after_remove_item(self, state, change, item) + return change + + def set_rules(self): + before_set_rules(self, self.multiworld, self.player) + + set_rules(self, self.multiworld, self.player) + + after_set_rules(self, self.multiworld, self.player) + + def generate_basic(self): + before_generate_basic(self, self.multiworld, self.player) + + # Enable this in Meta.json to generate a diagram of your manual. Only works on 0.4.4+ + if get_option_value(self.multiworld, self.player, "generate_region_diagram"): + from Utils import visualize_regions + visualize_regions(self.multiworld.get_region("Manual", self.player), f"{self.game}_{self.player}.puml") + + def pre_fill(self): + # DataValidation after all the hooks are done but before fill + runPreFillDataValidation(self, self.multiworld) + + def fill_slot_data(self): + slot_data = before_fill_slot_data({}, self, self.multiworld, self.player) + + # slot_data["DeathLink"] = bool(self.multiworld.death_link[self.player].value) + common_options = set(PerGameCommonOptions.type_hints.keys()) + common_options |= set(["generate_region_diagram", "start_inventory_from_pool"]) + for option_key, _ in self.options_dataclass.type_hints.items(): + if option_key in common_options: + continue + slot_data[option_key] = get_option_value(self.multiworld, self.player, option_key) + + slot_data["visible_events"] = {} + for _, event in self.event_name_to_event.items(): + event_name = event["name"] + if event["visible"] and event_name not in slot_data["visible_events"]: + slot_data["visible_events"][event_name] = event.get("category", []) + elif event_name in slot_data["visible_events"]: + temp_list = event.get("category", []) + slot_data["visible_events"][event_name] + slot_data["visible_events"][event_name] = list(set(temp_list)) + + slot_data = after_fill_slot_data(slot_data, self, self.multiworld, self.player) + + return slot_data + + def generate_output(self, output_directory: str): + filename = f"{self.multiworld.get_out_file_name_base(self.player)}.apmanual" + zf_path = os.path.join(output_directory, filename) + + apmanual = APManualFile(zf_path, player=self.player, player_name=self.player_name) + apmanual.write() + + if get_option_value(self.multiworld, self.player, "generate_region_diagram"): + from Utils import visualize_regions + visualize_regions(self.multiworld.get_region("Manual", self.player), f"{self.game}_{self.player}_spoiler.puml") + + + def write_spoiler(self, spoiler_handle): + before_write_spoiler(self, self.multiworld, spoiler_handle) + + def extend_hint_information(self, hint_data: dict[int, dict[int, str]]) -> None: + before_extend_hint_information(hint_data, self, self.multiworld, self.player) + + for location in self.multiworld.get_locations(self.player): + if not location.address: + continue + if "hint_entrance" in self.location_name_to_location[location.name]: + if self.player not in hint_data: + hint_data.update({self.player: {}}) + hint_data[self.player][location.address] = self.location_name_to_location[location.name]["hint_entrance"] + + after_extend_hint_information(hint_data, self, self.multiworld, self.player) + + ### + # Non-standard AP world methods + ### + + rules_functions_maximum_recursion: int = 5 + """Default: 5\n + The maximum time a location/region's requirement can loop to check for functions\n + One thing to remember is the more you loop the longer generation will take. So probably leave it as is unless you really needs it.""" + + def add_filler_items(self, item_pool, traps): + Utils.deprecate("You're calling the deprecated add_filler_items() function. Use the adjust_filler_items() function instead.") + return self.adjust_filler_items(item_pool, traps) + + def adjust_filler_items(self, item_pool, traps): + extras = len(self.multiworld.get_unfilled_locations(player=self.player)) - len(item_pool) + + if extras > 0: + trap_percent = get_option_value(self.multiworld, self.player, "filler_traps") + if not traps: + trap_percent = 0 + + trap_count = extras * trap_percent // 100 + filler_count = extras - trap_count + + for _ in range(0, trap_count): + extra_item = self.create_item(self.random.choice(traps)) + item_pool.append(extra_item) + + for _ in range(0, filler_count): + extra_item = self.create_item(self.get_filler_item_name()) + item_pool.append(extra_item) + elif extras < 0: + logging.warning(f"{self.game} has more items than locations. {abs(extras)} non-progression items will be removed at random.") + # Filler is only assigned if the item doesn't have any other tags, so it only has to be covered by itself. + # Skip Balancing is also not covered due to how it's only supported when paired with Progression. + # As a result, these cover every possible combination can be removed. + fillers = [item for item in item_pool if item.classification == ItemClassification.filler] + traps = [item for item in item_pool if item.classification == ItemClassification.trap] + useful = [item for item in item_pool if item.classification == ItemClassification.useful] + # Useful + Trap is classified separately so that it can have a unique priority ranking. + useful_traps = [item for item in item_pool if + ItemClassification.progression not in item.classification + and ItemClassification.useful in item.classification + and ItemClassification.trap in item.classification] + self.random.shuffle(fillers) + self.random.shuffle(traps) + self.random.shuffle(useful) + self.random.shuffle(useful_traps) + for _ in range(0, abs(extras)): + popped = None + if fillers: + popped = fillers.pop() + elif traps: + popped = traps.pop() + elif useful: + popped = useful.pop() + elif useful_traps: + popped = useful_traps.pop() + else: + logging.warning("Could not remove enough non-progression items from the pool.") + break + remove_specific_item(item_pool, popped) + + return item_pool + + def get_item_counts(self, player: Optional[int] = None, pool: list[Item] | None | bool = None, only_progression: bool = False) -> Counter[str]: + """Returns the player real item counts.\n + If you provide an item pool using the pool argument, then it's item counts will be returned. + Otherwise, this function will only work after create_items, before then an empty Counter is returned.\n + The only_progression argument let you filter the items to only get the count of progression items.""" + if player is None: + player = self.player + + if isinstance(pool, bool): + Utils.deprecate("the 'reset' argument of get_item_counts has been deprecated to increase the stability of item counts.\ + \nIt should be removed. If you require a new up to date count you can get it using the 'pool' argument.\ + \nThat result wont be saved to world unless you override the values of world.item_counts_progression or world.item_counts depending on if you counted only the items with progresion or not.") + pool = None + + if pool is not None: + return Counter([i.name for i in pool if not only_progression or i.advancement]) + + if only_progression: + return self.item_counts_progression.get(player, Counter()) + else: + return self.item_counts.get(player, Counter()) + + +### +# Non-world client methods +### + +def launch_client(*args): + import CommonClient + from .ManualClient import launch as Main + + if CommonClient.gui_enabled: + launch(Main, name="Manual client", args=args) + else: + Main(*args) + +class VersionedComponent(Component): + def __init__(self, display_name: str, script_name: Optional[str] = None, func: Optional[Callable] = None, version: int = 0, file_identifier: Optional[Callable[[str], bool]] = None, icon: Optional[str] = None): + super().__init__(display_name=display_name, script_name=script_name, func=func, component_type=Type.CLIENT, file_identifier=file_identifier, icon=icon) + self.version = version + +def add_client_to_launcher() -> None: + version = 2026_09_18 # YYYYMMDD + found = False + + if "manual" not in icon_paths: + icon_paths["manual"] = Utils.user_path('data', 'manual.png') + + discord_component = None + for c in components: + if c.display_name == "Manual Client": + found = True + if getattr(c, "version", 0) < version: # We have a newer version of the Manual Client than the one the last apworld added + c.version = version + c.func = launch_client + c.icon = "manual" + elif c.display_name == "Manual Discord Server": + discord_component = c + + if not found: + components.append(VersionedComponent("Manual Client", "ManualClient", func=launch_client, version=version, file_identifier=SuffixIdentifier('.apmanual'), icon="manual")) + if not discord_component: + components.append(Component("Manual Discord Server", "ManualDiscord", func=lambda: webbrowser.open("https://discord.gg/hm4rQnTzQ5"), icon="discord", component_type=Type.ADJUSTER)) + +add_client_to_launcher() diff --git a/container.py b/container.py new file mode 100644 index 0000000..a6acd17 --- /dev/null +++ b/container.py @@ -0,0 +1,45 @@ +import json +import zipfile +from typing import Any + +from worlds import Files + +from .Data import region_table, category_table +from .Game import game_name +from .Items import item_name_to_item +from .Locations import location_name_to_location + + +class APManualFile(Files.APPlayerContainer): + game = game_name + patch_file_ending = ".apmanual" + + items: dict[str, Any] = None + locations: dict[str, Any] = None + regions: dict[str, Any] = None + categories: dict[str, Any] = None + + def __init__(self, *args: Any, **kwargs: Any): + super().__init__(*args, **kwargs) + + + def write_contents(self, opened_zipfile: zipfile.ZipFile): + super().write_contents(opened_zipfile) + opened_zipfile.writestr("items.json", json.dumps(item_name_to_item, indent=2)) + opened_zipfile.writestr("locations.json", json.dumps(location_name_to_location, indent=2)) + opened_zipfile.writestr("regions.json", json.dumps(region_table, indent=2)) + opened_zipfile.writestr("categories.json", json.dumps(category_table, indent=2)) + + def read_contents(self, opened_zipfile: zipfile.ZipFile) -> dict[str, Any]: + manifest = super().read_contents(opened_zipfile) + self.items = json.loads(opened_zipfile.read("items.json")) + self.locations = json.loads(opened_zipfile.read("locations.json")) + self.regions = json.loads(opened_zipfile.read("regions.json")) + return manifest + + def get_manifest(self) -> dict[str, Any]: + data = super().get_manifest() + data["items"] = self.items + data["locations"] = self.locations + data["regions"] = self.regions + return data diff --git a/data/categories.json b/data/categories.json new file mode 100644 index 0000000..d3b0849 --- /dev/null +++ b/data/categories.json @@ -0,0 +1,10 @@ +{ + "$schema": "https://github.com/ManualForArchipelago/Manual/raw/main/schemas/Manual.categories.schema.json", + "Wizard Mode Available": { + "hidden": true, + "yaml_option": ["wizard_mode_available"] + }, + "Starting": { + "hidden": true + } +} diff --git a/data/events.json b/data/events.json new file mode 100644 index 0000000..2ff390f --- /dev/null +++ b/data/events.json @@ -0,0 +1,28 @@ +{ + "$schema": "https://github.com/ManualForArchipelago/Manual/raw/main/schemas/Manual.events.schema.json", + "data": [ + { + "name": "Marvel event", + "requires": "|@Right Side:all|", + "visible": true + }, + { + "name": "Capcom event", + "requires": "|@Left Side:all|" + }, + { + "name": "Stars_event", + "copy_location": "Stars%" + }, + { + "name": "X-Men Assemble", + "category": ["Right Side"], + "requires": "((|Wolverine|) OR |X-23|) AND |Storm| and |Magneto:1|" + }, + { + "name": "Thunderbolts and Lightning", + "requires": "{CanReachLocation(Beat the Game - Storm)} and {CanReachLocation(Beat the Game - Thor)}", + "region": "Third Region" + } + ] +} diff --git a/data/game.json b/data/game.json new file mode 100644 index 0000000..43e246e --- /dev/null +++ b/data/game.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://github.com/ManualForArchipelago/Manual/raw/main/schemas/Manual.game.schema.json", + "game": "Cogmind", + "creator": "Luminiferous & Xintrosi", + "filler_item_name": "Filler", + "starting_items": [ + { + "item_categories": ["Starting"] + } + ], + "death_link": false, + "starting_index": 1, + "unused_goals_are_locations": false +} diff --git a/data/items.json b/data/items.json new file mode 100644 index 0000000..595301d --- /dev/null +++ b/data/items.json @@ -0,0 +1,565 @@ +{ + "$schema": "https://github.com/ManualForArchipelago/Manual/raw/main/schemas/Manual.items.schema.json", + "_mainComplexFloors-nvimCodeFolding": "{{{", + { + "count": 1, + "name": "-10 Materials", + "category": [ + "Main Complex Floors", + "Starting" + ], + "progression": true + }, + { + "count": 1, + "name": "-9 Materials", + "category": [ + "Main Complex Floors" + ], + "progression": true + }, + { + "count": 1, + "name": "-8 Materials", + "category": [ + "Main Complex Floors" + ], + "progression": true + }, + { + "count": 1, + "name": "-7 Factory", + "category": [ + "Main Complex Floors" + ], + "progression": true + }, + { + "count": 1, + "name": "-6 Factory", + "category": [ + "Main Complex Floors" + ], + "progression": true + }, + { + "count": 1, + "name": "-5 Factory", + "category": [ + "Main Complex Floors" + ], + "progression": true + }, + { + "count": 1, + "name": "-4 Factory", + "category": [ + "Main Complex Floors" + ], + "progression": true + }, + { + "count": 1, + "name": "-3 Research", + "category": [ + "Main Complex Floors" + ], + "progression": true + }, + { + "count": 1, + "name": "-2 Research", + "category": [ + "Main Complex Floors" + ], + "progression": true + }, + { + "count": 1, + "name": "-1 Access", + "category": [ + "Main Complex Floors" + ], + "progression": true + }, + "_mainComplexFloors-end": "}}}", + "_branchAreas-nvimCodeFolding": "{{{", + { + "count": 1, + "name": "Archives", + "category": [ + "Branch Areas" + ], + "progression": true + }, + { + "count": 1, + "name": "Armory", + "category": [ + "Branch Areas" + ], + "progression": true + }, + { + "count": "2", + "name": "Lower Caves", + "category": [ + "Branch Areas" + ], + "progression": true + }, + { + "count": "2", + "name": "Upper Caves", + "category": [ + "Branch Areas" + ], + "progression": true + }, + { + "count": 1, + "name": "Cetus", + "category": [ + "Branch Areas" + ], + "progression": true + }, + { + "count": 1, + "name": "Command", + "category": [ + "Branch Areas" + ], + "filler": true + }, + { + "count": 1, + "name": "Acces_0", + "category": [ + "Branch Areas" + ], + "filler": true + }, + { + "count": 1, + "name": "DSF", + "category": [ + "Branch Areas" + ], + "filler": true + }, + { + "count": 1, + "name": "Data Miner (Location)", + "category": [ + "Branch Areas" + ], + "progression": true + }, + { + "count": 1, + "name": "Exiles (Location)", + "category": [ + "Branch Areas" + ], + "progression": true + }, + { + "count": 1, + "name": "Extension", + "category": [ + "Branch Areas" + ], + "progression": true + }, + { + "count": 1, + "name": "Garrison", + "category": [ + "Branch Areas" + ], + "progression": true + }, + { + "count": 1, + "name": "Hub_04(d)", + "category": [ + "Branch Areas" + ], + "progression": true + }, + { + "count": 1, + "name": "Lab", + "category": [ + "Branch Areas" + ], + "progression": true + }, + { + "count": 1, + "name": "-10 Mines", + "category": [ + "Branch Areas" + ], + "progression": true + }, + { + "count": 1, + "name": "-9 Mines", + "category": [ + "Branch Areas" + ], + "progression": true + }, + { + "count": 1, + "name": "Quarantine", + "category": [ + "Branch Areas" + ], + "progression": true + }, + { + "count": 1, + "name": "Recycling", + "category": [ + "Branch Areas" + ], + "progression": true + }, + { + "count": 1, + "name": "Scraptown", + "category": [ + "Branch Areas" + ], + "progression": true + }, + { + "count": 1, + "name": "Section 7", + "category": [ + "Branch Areas" + ], + "progression": true + }, + { + "count": 1, + "name": "Storage", + "category": [ + "Branch Areas" + ], + "progression": true + }, + { + "count": 1, + "name": "Subcaves", + "category": [ + "Branch Areas" + ], + "progression": true + }, + { + "count": 1, + "name": "Testing", + "category": [ + "Branch Areas" + ], + "progression": true + }, + { + "count": 1, + "name": "Warlord (Location)", + "category": [ + "Branch Areas" + ], + "progression": true + }, + { + "count": 1, + "name": "Wastes Escape", + "category": [ + "Branch Areas" + ], + "useful": true + }, + { + "count": 1, + "name": "Zhirov (Location)", + "category": [ + "Branch Areas" + ], + "useful": true + }, + { + "count": 1, + "name": "Zion", + "category": [ + "Branch Areas" + ], + "progression": true + }, + { + "count": 1, + "name": "Zion Deep Caves", + "category": [ + "Branch Areas" + ], + "progression": true + }, + "_branchAreas-end": "}}}", + "_parts-nvimCodeFolding": "{{{", + { + "count": 1, + "name": "Rating 1 Parts", + "category": [ + "Parts", + "Starting" + ], + "progression": true + }, + { + "count": 1, + "name": "Rating 2 Parts", + "category": [ + "Parts" + ], + "progression": true + }, + { + "count": 1, + "name": "Rating 3 Parts", + "category": [ + "Parts" + ], + "progression": true + }, + { + "count": 1, + "name": "Rating 4 Parts", + "category": [ + "Parts" + ], + "progression": true + }, + { + "count": 1, + "name": "Rating 5 Parts", + "category": [ + "Parts" + ], + "progression": true + }, + { + "count": 1, + "name": "Rating 6 Parts", + "category": [ + "Parts" + ], + "progression": true + }, + { + "count": 1, + "name": "Rating 7 Parts", + "category": [ + "Parts" + ], + "progression": true + }, + { + "count": 1, + "name": "Rating 8 Parts", + "category": [ + "Parts" + ], + "progression": true + }, + { + "count": 1, + "name": "Rating 9 Parts", + "category": [ + "Parts" + ], + "progression": true + }, + { + "count": 1, + "name": "Rating 10 Parts", + "category": [ + "Parts" + ], + "useful": true + }, + { + "count": 1, + "name": "Exiles Prototypes", + "category": [ + "Parts" + ], + "useful": true + }, + "_parts-end": "}}}", + "_evolution-nvimCodeFolding": "{{{", + { + "count": "8", + "name": "Evolution Slot", + "category": [ + "Evolution", + "Starting" + ], + "progression_skip_balancing": true + }, + { + "count": "18", + "name": "Evolution Slot", + "category": [ + "Evolution" + ], + "progression_skip_balancing": true + }, + "_evolution-end": "}}}", + "_alignment-nvimCodeFolding": "{{{", + { + "count": 1, + "name": "Core Reset Matrix", + "category": [ + "Alignment" + ], + "useful": true + }, + { + "count": 1, + "name": "FarCom", + "category": [ + "Alignment" + ], + "useful": true + }, + { + "count": 1, + "name": "Imprinted", + "category": [ + "Alignment" + ], + "useful": true + }, + { + "count": 1, + "name": "RIF", + "category": [ + "Alignment" + ], + "useful": true + }, + { + "count": 1, + "name": "Revision 17", + "category": [ + "Alignment" + ], + "useful": true + }, + { + "count": 1, + "name": "UFD", + "category": [ + "Alignment" + ], + "useful": true + }, + { + "count": 1, + "name": "Warlord", + "category": [ + "Alignment" + ], + "useful": true + }, + { + "count": 1, + "name": "Zhirov", + "category": [ + "Alignment" + ], + "useful": true + }, + "_alignment-end": "}}}", + "_filler-nvimCodeFolding": "{{{", + { + "count": "4", + "name": "Free Item from unlocked Ratings", + "category": [ + "Filler" + ], + "filler": true + }, + { + "count": 1, + "name": "GOLEM Unit", + "category": [ + "Filler" + ], + "filler": true + }, + { + "count": 1, + "name": "Scrap Engine", + "category": [ + "Filler" + ], + "filler": true + }, + { + "count": 1, + "name": "SIGIX Exoskeleton", + "category": [ + "Filler" + ], + "filler": true + }, + { + "count": "3", + "name": "CORE Restore", + "category": [ + "Filler" + ], + "filler": true + }, + { + "count": "1", + "name": "Integrity Restore (All Equipped Parts)", + "category": [ + "Filler" + ], + "filler": true + }, + { + "count": 1, + "name": "Companion Sword", + "category": [ + "Filler" + ], + "filler": true + }, + { + "count": 1, + "name": "Microwarp Drive", + "category": [ + "Filler" + ], + "filler": true + }, + "_filler-end": "}}}", + "_mcGuffin-nvimCodeFolding": "{{{", + { + "count": "27", + "name": "Triangle", + "category": [ + "McGuffin" + ], + "progression": true + }, + "_mcGuffin-end": "}}}" +] + diff --git a/data/locations.json b/data/locations.json new file mode 100644 index 0000000..0f421df --- /dev/null +++ b/data/locations.json @@ -0,0 +1,962 @@ +{ + "$schema": "https://github.com/ManualForArchipelago/Manual/raw/main/schemas/Manual.locations.schema.json", + { + "name": "Exit to -9 Materials", + "region": "-10", + "category": [ + "Main Complex" + ], + "requires": [] + }, + { + "name": "Earned Evolution Slots (1 of 18)", + "region": "-10", + "category": [ + "Evolution" + ], + "requires": [] + }, + { + "name": "Earned Evolution Slots (2 of 18)", + "region": "-10", + "category": [ + "Evolution" + ], + "requires": [] + }, + { + "name": "Exit to -10 Mines", + "region": "-10", + "category": [ + "Branch" + ], + "requires": [] + }, + { + "name": "Exit to Subcaves", + "region": "-10", + "category": [ + "Branch" + ], + "requires": [] + }, + { + "name": "Exit to Exiles", + "region": "-10", + "category": [ + "Branch" + ], + "requires": "|-10 Mines|" + }, + { + "name": "Acquire Subcaves Prototype", + "region": "-10", + "category": [ + "Feat of Strength" + ], + "requires": "|Subcaves|", + "place_item_category": [ + "Filler" + ] + }, + { + "name": "Use FARCOM", + "category": [ + "Alignment" + ], + "requires": "|-10 Mines| AND |Exiles (Location)|", + "region": "-10" + }, + { + "name": "Exit to -8 Materials", + "region": "-9", + "category": [ + "Main Complex" + ], + "requires": [] + }, + { + "name": "Pickup Rating 2 Normal Item", + "region": "-9", + "category": [ + "Items" + ], + "requires": [] + }, + { + "name": "Exit to Storage", + "region": "-9", + "category": [ + "Branch" + ], + "requires": [] + }, + { + "name": "Earned Evolution Slots (3 of 18)", + "region": "-9", + "category": [ + "Evolution" + ], + "requires": [] + }, + { + "name": "Earned Evolution Slots (4 of 18)", + "region": "-9", + "category": [ + "Evolution" + ], + "requires": [] + }, + { + "name": "Exit to -9 Mines", + "region": "-9", + "category": [ + "Branch" + ], + "requires": "|-9 Materials|" + }, + { + "name": "Use a Recycling Unit to Recycle", + "region": "-9", + "category": [ + "Feat of Strength" + ], + "requires": [] + }, + { + "name": "Hack 3 Schematics", + "region": "-9", + "category": [ + "Feat of Strength" + ], + "requires": [] + }, + { + "name": "Analyze 3 Bots", + "region": "-9", + "category": [ + "Feat of Strength" + ], + "requires": [] + }, + { + "name": "Exit to Recycling", + "category": [ + "Branch" + ], + "requires": "|Storage| or |Subcaves|", + "region": "-9" + }, + { + "name": "Exit to Scraptown", + "category": [ + "Branch" + ], + "requires": "|Recycling| AND (|Storage| or |Subcaves|)", + "region": "-9" + }, + { + "name": "Join UFD", + "category": [ + "Alignment" + ], + "requires": "|Scraptown| AND |Recycling| AND (|Storage| OR |Subcaves|)", + "region": "-9" + }, + { + "name": "Install 3 Botnets on the Same Floor", + "category": [ + "Feat of Strength" + ], + "requires": "|Rating 2 Parts|", + "region": "-9" + }, + { + "name": "Destroy two swarmers in a single salvo", + "category": [ + "Feat of Strength" + ], + "requires": "|Rating 5 Parts|", + "region": "-9" + }, + { + "name": "Exit to -7 Factory", + "region": "-8", + "category": [ + "Main Complex" + ], + "requires": [] + }, + { + "name": "Pickup Rating 3 Normal Item", + "region": "-8", + "category": [ + "Items" + ], + "requires": [] + }, + { + "name": "Seal a Garrison", + "region": "-8", + "category": [ + "Feat of Strength" + ], + "requires": [] + }, + { + "name": "Enter a Garrison", + "region": "-8", + "category": [ + "Branch" + ], + "requires": [] + }, + { + "name": "Install RIF 1", + "region": "-8", + "category": [ + "Alignment" + ], + "requires": "|Garrison|" + }, + { + "name": "Earned Evolution Slots (5 of 18)", + "region": "-8", + "category": [ + "Evolution" + ], + "requires": [] + }, + { + "name": "Earned Evolution Slots (6 of 18)", + "region": "-8", + "category": [ + "Evolution" + ], + "requires": [] + }, + { + "name": "Unlock DSF", + "region": "-7", + "category": [ + "Branch" + ], + "requires": [] + }, + { + "name": "Pick up Rating 2* Prototype", + "region": "-8", + "category": [ + "Items" + ], + "requires": [] + }, + { + "name": "Scanalyze a rating 2+ part", + "region": "-8", + "category": [ + "Feat of Strength" + ], + "requires": [] + }, + { + "name": "Exit to -6 Factory", + "region": "-7", + "category": [ + "Main Complex" + ], + "requires": [] + }, + { + "name": "Pickup Rating 4 Normal Item", + "region": "-7", + "category": [ + "Items" + ], + "requires": [] + }, + { + "name": "Exit to -7 Caves", + "region": "-7", + "category": [ + "Branch" + ], + "requires": [] + }, + { + "name": "Exit to Data Miner", + "region": "-7", + "category": [ + "Branch" + ], + "requires": "|Lower Caves|" + }, + { + "name": "Exit to Zion", + "region": "-7", + "category": [ + "Branch" + ], + "requires": "|Lower Caves|" + }, + { + "name": "Exit to Zion Deep Caves", + "region": "-7", + "category": [ + "Branch" + ], + "requires": "|Zion| AND |Lower Caves|" + }, + { + "name": "Install RIF 2", + "region": "-7", + "category": [ + "Alignment" + ], + "requires": "|Garrison|" + }, + { + "name": "Meet Data Miner", + "region": "-7", + "category": [ + "Alignment" + ], + "requires": "|Lower Caves| AND |Data Miner (Location)|" + }, + { + "name": "Become Imprinted", + "region": "-7", + "category": [ + "Alignment" + ], + "requires": "|Lower Caves| AND |Zion|" + }, + { + "name": "Kill Z-Imprinter", + "category": [ + "Feat of Strength" + ], + "requires": "|Lower Caves| AND |Zion| AND |Zion Deep Caves|", + "region": "-7" + }, + { + "name": "Fall down a Chute Trap", + "region": "-7", + "category": [ + "Feat of Strength" + ], + "requires": [] + }, + { + "name": "Escape the Wastes", + "region": "-7", + "category": [ + "Feat of Strength" + ], + "requires": [] + }, + { + "name": "Earned Evolution Slots (7 of 18)", + "region": "-7", + "category": [ + "Evolution" + ], + "requires": [] + }, + { + "name": "Earned Evolution Slots (8 of 18)", + "region": "-7", + "category": [ + "Evolution" + ], + "requires": [] + }, + { + "name": "Fall down a Chute Trap ON PURPOSE", + "region": "-7", + "category": [ + "Feat of Strength" + ], + "requires": [] + }, + { + "name": "Assimilate an Operator", + "region": "-7", + "category": [ + "Feat of Strength" + ], + "requires": [] + }, + { + "name": "Pick up Rating 3* Prototype", + "region": "-7", + "category": [ + "Items" + ], + "requires": [] + }, + { + "name": "Kill a GOLEM", + "region": "-7", + "category": [ + "Feat of Strength" + ], + "requires": "|Zion Deep Caves| AND |Zion| AND |Lower Caves|" + }, + { + "name": "Hack 5 Schematics", + "region": "-7", + "category": [ + "Feat of Strength" + ], + "requires": [] + }, + { + "name": "Acquire a GOLEM Unit", + "category": [ + "Feat of Strength" + ], + "requires": "|Lower Caves| AND |Zion| AND |Zion Deep Caves|", + "region": "-7" + }, + { + "name": "Acquire an Exiles Prototype", + "category": [ + "Feat of Strength" + ], + "requires": "(|-10 Mines| AND |Exiles (Location)|) OR (|Lower Caves| AND |Zion| AND |Zion Deep Caves| AND |-7 Factory| AND ((|Recycling| and |Subcaves|) or (|-8 Materials| and |-9 Materials|))", + "region": "-10" + }, + { + "name": "Exit to -5 Factory", + "region": "-6", + "category": [ + "Main Complex" + ], + "requires": [] + }, + { + "name": "Pickup Rating 5 Normal Item", + "region": "-6", + "category": [ + "Items" + ], + "requires": [] + }, + { + "name": "Exit to -6 Caves", + "region": "-6", + "category": [ + "Branch" + ], + "requires": [] + }, + { + "name": "Exit to Archives", + "region": "-6", + "category": [ + "Branch" + ], + "requires": "|Cetus| AND |Extension|" + }, + { + "name": "Exit to Cetus", + "region": "-6", + "category": [ + "Branch" + ], + "requires": "|Extension|" + }, + { + "name": "Exit to Hub_04(d)", + "region": "-6", + "category": [ + "Branch" + ], + "requires": "|Archives| AND |Cetus| AND |Extension|" + }, + { + "name": "Exit to Extension", + "region": "-6", + "category": [ + "Branch" + ], + "requires": [] + }, + { + "name": "Install RIF 3", + "region": "-6", + "category": [ + "Alignment" + ], + "requires": "|Garrison|" + }, + { + "name": "Meet Revision 17", + "region": "-6", + "category": [ + "Alignment" + ], + "requires": "|Cetus| AND |Extension|" + }, + { + "name": "Earned Evolution Slots (9 of 18)", + "region": "-6", + "category": [ + "Evolution" + ], + "requires": [] + }, + { + "name": "Earned Evolution Slots (10 of 18)", + "region": "-6", + "category": [ + "Evolution" + ], + "requires": [] + }, + { + "name": "Pick up Rating 4* Prototype", + "region": "-6", + "category": [ + "Items" + ], + "requires": [] + }, + { + "name": "Kill a Heavy", + "region": "-6", + "category": [ + "Feat of Strength" + ], + "requires": [] + }, + { + "name": "Kill a Cetus Guard", + "region": "-6", + "category": [ + "Feat of Strength" + ], + "requires": "|Cetus| AND |Extension|" + }, + { + "name": "Force(Fedlink)", + "region": "-6", + "category": [ + "Feat of Strength" + ], + "requires": "|Scraptown| AND |UFD| AND |Recycling| AND (|Storage| OR |Subcaves|)" + }, + { + "name": "Exit to -4 Factory", + "region": "-5", + "category": [ + "Main Complex" + ], + "requires": [] + }, + { + "name": "Pickup Rating 6 Normal Item", + "region": "-5", + "category": [ + "Items" + ], + "requires": [] + }, + { + "name": "Exit to -5 Caves", + "region": "-5", + "category": [ + "Branch" + ], + "requires": [] + }, + { + "name": "Install RIF 4", + "region": "-5", + "category": [ + "Alignment" + ], + "requires": "|Garrison|" + }, + { + "name": "Exit to Zhirov", + "region": "-5", + "category": [ + "Branch" + ], + "requires": "|Upper Caves|" + }, + { + "name": "Earned Evolution Slots (11 of 18)", + "region": "-5", + "category": [ + "Evolution" + ], + "requires": [] + }, + { + "name": "Earned Evolution Slots (12 of 18)", + "region": "-5", + "category": [ + "Evolution" + ], + "requires": [] + }, + { + "name": "Pick up Rating 5* Prototype", + "region": "-5", + "category": [ + "Items" + ], + "requires": [] + }, + { + "name": "Scanalyze a rating 5+ part", + "region": "-5", + "category": [ + "Feat of Strength" + ], + "requires": [] + }, + { + "name": "Analyze 5 Bots", + "region": "-5", + "category": [ + "Feat of Strength" + ], + "requires": [] + }, + { + "name": "Bothack an army of 5+ Combat Bots", + "region": "-5", + "category": [ + "Feat of Strength" + ], + "requires": "|RIF:1| AND |Garrison|" + }, + { + "name": "Exit to Warlord (Location)", + "category": [ + "Branch" + ], + "requires": "|Upper Caves|", + "region": "-5" + }, + { + "name": "Talk to Warlord", + "category": [ + "Alignment" + ], + "requires": "|Upper Caves| AND |Warlord (Location)|", + "region": "-5" + }, + { + "name": "Exit to -3 Research", + "region": "-4", + "category": [ + "Main Complex" + ], + "requires": [] + }, + { + "name": "Pickup Rating 7 Normal Item", + "region": "-4", + "category": [ + "Items" + ], + "requires": [] + }, + { + "name": "Exit to -4 Caves", + "region": "-4", + "category": [ + "Branch" + ], + "requires": [] + }, + { + "name": "Exit to Armory", + "region": "-4", + "category": [ + "Branch" + ], + "requires": [] + }, + { + "name": "Exit to Lab", + "region": "-4", + "category": [ + "Branch" + ], + "requires": "|Armory|" + }, + { + "name": "Install RIF 5", + "region": "-4", + "category": [ + "Alignment" + ], + "requires": "|Garrison|" + }, + { + "name": "Earned Evolution Slots (13 of 18)", + "region": "-4", + "category": [ + "Evolution" + ], + "requires": [] + }, + { + "name": "Earned Evolution Slots (14 of 18)", + "region": "-4", + "category": [ + "Evolution" + ], + "requires": [] + }, + { + "name": "5% or Less core Exposure", + "region": "-4", + "category": [ + "Feat of Strength" + ], + "requires": [] + }, + { + "name": "Corrupt a Behemoth to Death", + "region": "-4", + "category": [ + "Feat of Strength" + ], + "requires": [] + }, + { + "name": "Blow up 4 explosive machines indirectly", + "region": "-4", + "category": [ + "Feat of Strength" + ], + "requires": [] + }, + { + "name": "Fabricate 3 things on one floor", + "region": "-4", + "category": [ + "Feat of Strength" + ], + "requires": [] + }, + { + "name": "Repair 3 items on one floor", + "region": "-4", + "category": [ + "Feat of Strength" + ], + "requires": [] + }, + { + "name": "Use a Launcher Loader in combat", + "region": "-4", + "category": [ + "Feat of Strength" + ], + "requires": [] + }, + { + "name": "Pick up Rating 6* Prototype", + "region": "-4", + "category": [ + "Items" + ], + "requires": [] + }, + { + "name": "Index(Machines) Successfully", + "category": [ + "Feat of Strength" + ], + "requires": "|Rating 6 Parts|", + "region": "-4" + }, + { + "name": "Purge alert three times in a single map", + "category": [ + "Feat of Strength" + ], + "requires": "|Rating 6 Parts|", + "region": "-4" + }, + { + "name": "Exit to -2 Research", + "region": "-3", + "category": [ + "Main Complex" + ], + "requires": [] + }, + { + "name": "Pickup Rating 8 Normal Item", + "region": "-3", + "category": [ + "Items" + ], + "requires": [] + }, + { + "name": "Exit to Quarantine", + "region": "-3", + "category": [ + "Branch" + ], + "requires": [] + }, + { + "name": "Exit to Testing", + "region": "-3", + "category": [ + "Branch" + ], + "requires": [] + }, + { + "name": "Earned Evolution Slots (15 of 18)", + "region": "-3", + "category": [ + "Evolution" + ], + "requires": [] + }, + { + "name": "Earned Evolution Slots (16 of 18)", + "region": "-3", + "category": [ + "Evolution" + ], + "requires": [] + }, + { + "name": "Pick up Rating 7* Prototype", + "region": "-3", + "category": [ + "Items" + ], + "requires": [] + }, + { + "name": "Exit to -1 Access", + "region": "-2", + "category": [ + "Main Complex" + ], + "requires": [] + }, + { + "name": "Pickup Rating 9 Normal Item", + "region": "-2", + "category": [ + "Items" + ], + "requires": [] + }, + { + "name": "Exit to Section 7", + "region": "-2", + "category": [ + "Branch" + ], + "requires": "|Quarantine| AND |Testing|" + }, + { + "name": "Earned Evolution Slots (17 of 18)", + "region": "-2", + "category": [ + "Evolution" + ], + "requires": [] + }, + { + "name": "Earned Evolution Slots (18 of 18)", + "region": "-2", + "category": [ + "Evolution" + ], + "requires": [] + }, + { + "name": "Pick up Rating 8* Prototype", + "region": "-2", + "category": [ + "Items" + ], + "requires": [] + }, + { + "name": "Scanalyze a rating 8+ part", + "region": "-2", + "category": [ + "Feat of Strength" + ], + "requires": [] + }, + { + "name": "Pickup Rating 10 Normal Item", + "region": "-1", + "category": [ + "Items" + ], + "requires": [], + "place_item_category": [ + "Filler" + ] + }, + { + "name": "Exit to Command", + "region": "-1", + "category": [ + "Branch" + ], + "requires": [], + "place_item_category": [ + "Filler" + ] + }, + { + "name": "Exit to Access_0", + "region": "-1", + "category": [ + "Branch" + ], + "requires": [], + "place_item_category": [ + "Filler" + ] + }, + { + "name": "Pick up Rating 9* Prototype", + "region": "-1", + "category": [ + "Items" + ], + "requires": [] + }, + { + "name": "Pick up Rating 10* Prototype", + "region": "-1", + "category": [ + "Items" + ], + "requires": [], + "place_item_category": [ + "Filler" + ] + }, + { + "category": [], + "requires": "|Triangle:27|", + "name": "Victory!", + "victory": true, + "region": "-1" + } +] + diff --git a/data/meta.json b/data/meta.json new file mode 100644 index 0000000..dddc3fe --- /dev/null +++ b/data/meta.json @@ -0,0 +1,39 @@ +{ + "$schema": "https://raw.githubusercontent.com/ManualForArchipelago/Manual/main/schemas/Manual.meta.schema.json", + "_comment": [ "remove the _ before a non-comment/info property to enable it", + "For more info use json schemas to get a description and allowed value for every properties", + "https://github.com/ManualForArchipelago/Manual/blob/main/docs/resources/json-schemas-for-world-devs.md"], + "docs": { + "_comment": [ "For more info on the possible documentation properties check schema and/or the github", + "https://github.com/ArchipelagoMW/Archipelago/blob/main/docs/world%20api.md#webworld-class" + ], + "apworld_description": [ + "Manual games allow you to set custom check locations and custom item names that will be rolled into a multiworld.", + "This allows any variety of game -- PC, console, board games, Microsoft Word memes... really anything -- to be part of a multiworld randomizer.", + "The key component to including these games is some level of manual restriction. Since the items are not actually withheld from the player,", + "the player must manually refrain from using these gathered items until the tracker shows that they have been acquired or sent." + ], + "web": { + "_comment": "for more info check github https://github.com/ArchipelagoMW/Archipelago/blob/main/docs/world%20api.md#webworld-class", + "theme": "grass", + "bug_report_page": "https://discord.gg/T5bcsVHByx", + "tutorials": [ + { + "name": "Multiworld Setup Guide", + "description": "A guide to setting up manual game integration for Archipelago multiworld games.", + "language": "English", + "file_name": "setup_en.md", + "link": "setup/en", + "_authors": ["Fuzzy"], + "_comment": "if you don't include the authors it will use creator from game.json" + } + ], + "_options_presets": { + "Easy":{ + "Option1": true, + "Option2": "potato" + } + } + } + } +} diff --git a/data/options.json b/data/options.json new file mode 100644 index 0000000..18e0ea3 --- /dev/null +++ b/data/options.json @@ -0,0 +1,82 @@ +{ + "$schema": "https://raw.githubusercontent.com/ManualForArchipelago/Manual/main/schemas/Manual.options.schema.json", + "_comment": "Add a _ before an option name to comment it out and it wont be added to the apworld", + "core": { + "goal":{ + "_comment": [ + "Using 'goal' as the name of an option let you override some of the generated goal parameters" + ], + "display_name": "The End Objective", + "description": "How to End your Randomized playthrough.", + "aliases": {"easiest": 0}, + "hidden": false + }, + "death_link": { + "default": false + }, + "filler_traps": { + "_comment": "Using values here let you set a 'recommended' filler_trap percentage or even a default percentage", + "values": { + "recommended": 20 + } + } + }, + "user": { + "_commented_out_option":{ + "type": "Toggle", + "default": true + }, + "Example_Toggle":{ + "description": [ + "This is a Toggle", + "A simple boolean, aka True or False set the initial value with \"default\"" + ], + "type": "Toggle", + "default": false, + "group": "Example Options" + }, + "Example_Choice":{ + "type": "Choice", + "description": [ + "This is a Choice", + "Let the user pick from a list of values", + "allow_custom_value let a player define their own text value", + "Currently the only use for Choice in Manual outside of hooks is the goal" + ], + "values": { + "start":0, + "test":1 + }, + "default": 0, + "allow_custom_value": false, + "group": "Example Options" + }, + "Example_Range":{ + "type": "Range", + "description": [ + "This is a Range", + "Allow the player to specify a value between 'start' and 'end'", + "if you include a \"values\" you can define the label for certain values", + "Currently the only use for Range in Manual outside of hooks is 'filler_trap'" + ], + "range_start": 0, + "default": 1, + "range_end": 5, + "values": {"example": 2}, + "group": "Example Options" + }, + + "DLC_enabled":{ + "display_name": "DLC Enabled", + "description": "Is the Dlc enabled?", + "type": "Toggle", + "visibility": [ + "simple_ui", + "complex_ui", + "template" + ], + "default": true + } + } + +} diff --git a/data/regions.json b/data/regions.json new file mode 100644 index 0000000..73625a6 --- /dev/null +++ b/data/regions.json @@ -0,0 +1,69 @@ +{ + "$schema": "https://github.com/ManualForArchipelago/Manual/raw/main/schemas/Manual.regions.schema.json", + "-11": { + "connects_to": [ + "-10" + ], + "requires": [], + "starting": true + }, + "-10": { + "connects_to": [ + "-9" + ], + "requires": [] + }, + "-9": { + "connects_to": [ + "-8" + ], + "requires": "|-9 Materials| AND |Evolution Slot:2| AND |Rating 2 Parts|" + }, + "-8": { + "connects_to": [ + "-7" + ], + "requires": "(|-8 Materials| AND |Evolution Slot:4| AND |Rating 3 Parts|" + }, + "-7": { + "connects_to": [ + "-6" + ], + "requires": "|-7 Factory| AND |Evolution Slot:6| AND |Rating 4 Parts|" + }, + "-6": { + "connects_to": [ + "-5" + ], + "requires": "|-6 Factory| AND |Evolution Slot:8| AND |Rating 5 Parts|" + }, + "-5": { + "connects_to": [ + "-4" + ], + "requires": "|-5 Factory| AND |Evolution Slot:10| AND |Rating 6 Parts|" + }, + "-4": { + "connects_to": [ + "-3" + ], + "requires": "|-4 Factory| AND |Evolution Slot:12| AND |Rating 7 Parts|" + }, + "-3": { + "connects_to": [ + "-2" + ], + "requires": "|-3 Research| AND |Evolution Slot:14| AND |Rating 8 Parts|" + }, + "-2": { + "connects_to": [ + "-1" + ], + "requires": "|-2 Research| AND |Evolution Slot:16| AND |Rating 9 Parts|" + }, + "-1": { + "connects_to": [], + "requires": "|-1 Access| AND |Evolution Slot:18|" + } +} + diff --git a/docs/setup_en.md b/docs/setup_en.md new file mode 100644 index 0000000..78e6382 --- /dev/null +++ b/docs/setup_en.md @@ -0,0 +1,21 @@ +# Manual Randomizer Setup Guide + +## Required Software + +- None + +## Installation Procedures + +Needs content. + +## Joining a MultiWorld Game + +Needs content. + +## Multiplayer Manual + +Needs content. + +## Game Troubleshooting + +Needs content. \ No newline at end of file diff --git a/hooks/Data.py b/hooks/Data.py new file mode 100644 index 0000000..515dfdd --- /dev/null +++ b/hooks/Data.py @@ -0,0 +1,42 @@ +# called after the game.json file has been loaded +def after_load_game_file(game_table: dict) -> dict: + return game_table +# called after the items.json file has been loaded, before any item loading or processing has occurred +# if you need access to the items after processing to add ids, etc., you should use the hooks in World.py +def after_load_item_file(item_table: list) -> list: + return item_table + +# NOTE: Progressive items are not currently supported in Manual. Once they are, +# this hook will provide the ability to meaningfully change those. +def after_load_progressive_item_file(progressive_item_table: list) -> list: + return progressive_item_table + +# called after the locations.json file has been loaded, before any location loading or processing has occurred +# if you need access to the locations after processing to add ids, etc., you should use the hooks in World.py +def after_load_location_file(location_table: list) -> list: + return location_table + +# called after the events.json file has been loaded, before any processing has occurred +# If you need access to the events after processing, you should use the hooks in World.py +def after_load_event_file(event_table: list) -> list: + return event_table + +# called after the regions.json file has been loaded, before any location loading or processing has occurred +# if you need access to the locations after processing to add ids, etc., you should use the hooks in World.py +def after_load_region_file(region_table: dict) -> dict: + return region_table + +# called after the categories.json file has been loaded +def after_load_category_file(category_table: dict) -> dict: + return category_table + +# called after the categories.json file has been loaded +def after_load_option_file(option_table: dict) -> dict: + # option_table["core"] is the dictionary of modification of existing options + # option_table["user"] is the dictionary of custom options + return option_table + +# called after the meta.json file has been loaded and just before the properties of the apworld are defined. You can use this hook to change what is displayed on the webhost +# for more info check https://github.com/ArchipelagoMW/Archipelago/blob/main/docs/world%20api.md#webworld-class +def after_load_meta_file(meta_table: dict) -> dict: + return meta_table diff --git a/hooks/Helpers.py b/hooks/Helpers.py new file mode 100644 index 0000000..a689966 --- /dev/null +++ b/hooks/Helpers.py @@ -0,0 +1,23 @@ +from typing import Optional, Any +from BaseClasses import MultiWorld + + +# Use this if you want to override the default behavior of is_option_enabled +# Return True to enable the category, False to disable it, or None to use the default behavior +def before_is_category_enabled(multiworld: MultiWorld, player: int, category_name: str) -> Optional[bool]: + return None + +# Use this if you want to override the default behavior of is_option_enabled +# Return True to enable the item, False to disable it, or None to use the default behavior +def before_is_item_enabled(multiworld: MultiWorld, player: int, item: dict[str, Any]) -> Optional[bool]: + return None + +# Use this if you want to override the default behavior of is_option_enabled +# Return True to enable the location, False to disable it, or None to use the default behavior +def before_is_location_enabled(multiworld: MultiWorld, player: int, location: dict[str, Any]) -> Optional[bool]: + return None + +# Use this if you want to override the default behavior of is_option_enabled +# Return True to enable the event, False to disable it, or None to use the default behavior +def before_is_event_enabled(multiworld: MultiWorld, player: int, event: dict[str, Any]) -> Optional[bool]: + return None diff --git a/hooks/Options.py b/hooks/Options.py new file mode 100644 index 0000000..e514ba5 --- /dev/null +++ b/hooks/Options.py @@ -0,0 +1,57 @@ +# Object classes from AP that represent different types of options that you can create +from Options import Option, FreeText, NumericOption, Toggle, DefaultOnToggle, Choice, TextChoice, Range, NamedRange, OptionGroup, PerGameCommonOptions +# These helper methods allow you to determine if an option has been set, or what its value is, for any player in the multiworld +from ..Helpers import is_option_enabled, get_option_value +from typing import Type, Any + + +#################################################################### +# NOTE: At the time that options are created, Manual has no concept of the multiworld or its own world. +# Options are defined before the world is even created. +# +# Example of creating your own option: +# +# class MakeThePlayerOP(Toggle): +# """Should the player be overpowered? Probably not, but you can choose for this to do... something!""" +# display_name = "Make me OP" +# +# options["make_op"] = MakeThePlayerOP +# +# +# Then, to see if the option is set, you can call is_option_enabled or get_option_value. +##################################################################### + + +# To add an option, use the before_options_defined hook below and something like this: +# options["total_characters_to_win_with"] = TotalCharactersToWinWith +# +class TotalCharactersToWinWith(Range): + """Instead of having to beat the game with all characters, you can limit locations to a subset of character victory locations.""" + display_name = "Number of characters to beat the game with before victory" + range_start = 10 + range_end = 50 + default = 50 + +# This is called before any manual options are defined, in case you want to define your own with a clean slate or let Manual define over them +def before_options_defined(options: dict[str, Type[Option[Any]]]) -> dict[str, Type[Option[Any]]]: + return options + +# This is called after any manual options are defined, in case you want to see what options are defined or want to modify the defined options +def after_options_defined(options: Type[PerGameCommonOptions]): + # To access a modifiable version of options check the dict in options.type_hints + # For example if you want to change DLC_enabled's display name you would do: + # options.type_hints["DLC_enabled"].display_name = "New Display Name" + + # Here's an example on how to add your aliases to the generated goal + # options.type_hints['goal'].aliases.update({"example": 0, "second_alias": 1}) + # options.type_hints['goal'].options.update({"example": 0, "second_alias": 1}) #for an alias to be valid it must also be in options + + pass + +# Use this Hook if you want to add your Option to an Option group (existing or not) +def before_option_groups_created(groups: dict[str, list[Type[Option[Any]]]]) -> dict[str, list[Type[Option[Any]]]]: + # Uses the format groups['GroupName'] = [TotalCharactersToWinWith] + return groups + +def after_option_groups_created(groups: list[OptionGroup]) -> list[OptionGroup]: + return groups diff --git a/hooks/Rules.py b/hooks/Rules.py new file mode 100644 index 0000000..52ff872 --- /dev/null +++ b/hooks/Rules.py @@ -0,0 +1,29 @@ +from typing import Optional +from worlds.AutoWorld import World +from ..Helpers import clamp, get_items_with_value +from BaseClasses import MultiWorld, CollectionState + +import re + +# Sometimes you have a requirement that is just too messy or repetitive to write out with boolean logic. +# Define a function here, and you can use it in a requires string with {function_name()}. +def overfishedAnywhere(world: World, state: CollectionState, player: int): + """Has the player collected all fish from any fishing log?""" + for cat, items in world.item_name_groups: + if cat.endswith("Fishing Log") and state.has_all(items, player): + return True + return False + +# You can also pass an argument to your function, like {function_name(15)} +# Note that all arguments are strings, so you'll need to convert them to ints if you want to do math. +def anyClassLevel(state: CollectionState, player: int, level: str): + """Has the player reached the given level in any class?""" + for item in ["Figher Level", "Black Belt Level", "Thief Level", "Red Mage Level", "White Mage Level", "Black Mage Level"]: + if state.count(item, player) >= int(level): + return True + return False + +# You can also return a string from your function, and it will be evaluated as a requires string. +def requiresMelee(): + """Returns a requires string that checks if the player has unlocked the tank.""" + return "|Figher Level:15| or |Black Belt Level:15| or |Thief Level:15|" diff --git a/hooks/World.py b/hooks/World.py new file mode 100644 index 0000000..5aa5eff --- /dev/null +++ b/hooks/World.py @@ -0,0 +1,207 @@ +# Object classes from AP core, to represent an entire MultiWorld and this individual World that's part of it +from typing import Any +from worlds.AutoWorld import World +from BaseClasses import MultiWorld, CollectionState, Item + +# Object classes from Manual -- extending AP core -- representing items and locations that are used in generation +from ..Items import ManualItem +from ..Locations import ManualLocation + +# Raw JSON data from the Manual apworld, respectively: +# data/game.json, data/items.json, data/locations.json, data/regions.json +# +from ..Data import game_table, item_table, location_table, region_table + +# These helper methods allow you to determine if an option has been set, or what its value is, for any player in the multiworld +from ..Helpers import is_option_enabled, get_option_value, format_state_prog_items_key, ProgItemsCat, remove_specific_item + +# calling logging.info("message") anywhere below in this file will output the message to both console and log file +import logging + +######################################################################################## +## Order of method calls when the world generates: +## 1. create_regions - Creates regions and locations +## 2. create_items - Creates the item pool +## 3. set_rules - Creates rules for accessing regions and locations +## 4. generate_basic - Runs any post item pool options, like place item/category +## 5. pre_fill - Creates the victory location +## +## The create_item method is used by plando and start_inventory settings to create an item from an item name. +## The fill_slot_data method will be used to send data to the Manual client for later use, like deathlink. +######################################################################################## + + + +# Use this function to change the valid filler items to be created to replace item links or starting items. +# Default value is the `filler_item_name` from game.json +def hook_get_filler_item_name(world: World, multiworld: MultiWorld, player: int) -> list[str] | str | bool: + return False + +def before_generate_early(world: World, multiworld: MultiWorld, player: int) -> None: + """ + This is the earliest hook called during generation, before anything else is done. + Use it to check or modify incompatible options, or to set up variables for later use. + """ + pass + +def before_create_regions(world: World, multiworld: MultiWorld, player: int): + """ + Called before regions and locations are created. Not clear why you'd want this, but it's here. + Victory location is included, but Victory event is not placed yet. + """ + pass + +def after_create_regions(world: World, multiworld: MultiWorld, player: int): + """ + Called after regions and locations are created, in case you want to see or modify that information. + Victory location is included. + + Use this hook to modify locations from the world + If you wish to remove them entirely, use Helpers.before_is_location_enabled instead. + """ + + # Add your code here to calculate which locations to exclude + for region in multiworld.regions: + if region.player == player: + for location in region.locations: + # If you have other things you want to modify, you can do it here as well. + # The fields you might care about are: item_rule, always_allow, progress_type, and show_in_spoiler. + pass + +# This hook allows you to access the item names & counts before the items are created. Use this to increase/decrease the amount of a specific item in the pool +# Valid item_config key/values: +# {"Item Name": 5} <- This will create qty 5 items using all the default settings +# {"Item Name": {"useful": 7}} <- This will create qty 7 items and force them to be classified as useful +# {"Item Name": {"progression": 2, "useful": 1}} <- This will create 3 items, with 2 classified as progression and 1 as useful +# {"Item Name": {0b0110: 5}} <- If you know the special flag for the item classes, you can also define non-standard options. This setup +# will create 5 items that are the "useful trap" class +# {"Item Name": {ItemClassification.useful: 5}} <- You can also use the classification directly +def before_create_items_all(item_config: dict[str, int|dict], world: World, multiworld: MultiWorld, player: int) -> dict[str, int|dict]: + return item_config + +# The item pool before place_item(_category) are processed, in case you want to see the raw item pool at that stage +def before_create_items_place_items(item_pool: list, world: World, multiworld: MultiWorld, player: int) -> list: + return item_pool + +# The item pool before starting items are processed, in case you want to see the raw item pool at that stage +def before_create_items_starting(item_pool: list, world: World, multiworld: MultiWorld, player: int) -> list: + return item_pool + +# The item pool after starting items are processed but before filler is added, in case you want to see the raw item pool at that stage +def before_create_items_filler(item_pool: list, world: World, multiworld: MultiWorld, player: int) -> list: + # Use this hook to remove items from the item pool + itemNamesToRemove: list[str] = [] # List of item names + + # Add your code here to calculate which items to remove. + # + # Because multiple copies of an item can exist, you need to add an item name + # to the list multiple times if you want to remove multiple copies of it. + + for itemName in itemNamesToRemove: + item = next(i for i in item_pool if i.name == itemName) + remove_specific_item(item_pool, item) + + return item_pool + + # Some other useful hook options: + + ## Place an item at a specific location + # location = next(l for l in multiworld.get_unfilled_locations(player=player) if l.name == "Location Name") + # item_to_place = next(i for i in item_pool if i.name == "Item Name") + # location.place_locked_item(item_to_place) + # remove_specific_item(item_pool, item_to_place) + +# The complete item pool prior to being set for generation is provided here, in case you want to make changes to it +def after_create_items(item_pool: list, world: World, multiworld: MultiWorld, player: int) -> list: + return item_pool + +# Called before rules for accessing regions and locations are created. Not clear why you'd want this, but it's here. +def before_set_rules(world: World, multiworld: MultiWorld, player: int): + pass + +# Called after rules for accessing regions and locations are created, in case you want to see or modify that information. +def after_set_rules(world: World, multiworld: MultiWorld, player: int): + # Use this hook to modify the access rules for a given location + + def Example_Rule(state: CollectionState) -> bool: + # Calculated rules take a CollectionState object and return a boolean + # True if the player can access the location + # CollectionState is defined in BaseClasses + return True + + ## Common functions: + # location = world.get_location(location_name, player) + # location.access_rule = Example_Rule + + ## Combine rules: + # old_rule = location.access_rule + # location.access_rule = lambda state: old_rule(state) and Example_Rule(state) + # OR + # location.access_rule = lambda state: old_rule(state) or Example_Rule(state) + +# The item name to create is provided before the item is created, in case you want to make changes to it +def before_create_item(item_name: str, world: World, multiworld: MultiWorld, player: int) -> str: + return item_name + +# The item that was created is provided after creation, in case you want to modify the item +def after_create_item(item: ManualItem, world: World, multiworld: MultiWorld, player: int) -> ManualItem: + return item + +# This method is run towards the end of pre-generation, before the place_item options have been handled and before AP generation occurs +def before_generate_basic(world: World, multiworld: MultiWorld, player: int): + pass + +# This method is run every time an item is added to the state, can be used to modify the value of an item. +# IMPORTANT! Any changes made in this hook must be cancelled/undone in after_remove_item +def after_collect_item(world: World, state: CollectionState, Changed: bool, item: Item): + # the following let you add to the Potato Item Value count + # if item.name == "Cooked Potato": + # state.prog_items[item.player][format_state_prog_items_key(ProgItemsCat.VALUE, "Potato")] += 1 + pass + +# This method is run every time an item is removed from the state, can be used to modify the value of an item. +# IMPORTANT! Any changes made in this hook must be first done in after_collect_item +def after_remove_item(world: World, state: CollectionState, Changed: bool, item: Item): + # the following let you undo the addition to the Potato Item Value count + # if item.name == "Cooked Potato": + # state.prog_items[item.player][format_state_prog_items_key(ProgItemsCat.VALUE, "Potato")] -= 1 + pass + + +# This is called before slot data is set and provides an empty dict ({}), in case you want to modify it before Manual does +def before_fill_slot_data(slot_data: dict, world: World, multiworld: MultiWorld, player: int) -> dict: + return slot_data + +# This is called after slot data is set and provides the slot data at the time, in case you want to check and modify it after Manual is done with it +def after_fill_slot_data(slot_data: dict, world: World, multiworld: MultiWorld, player: int) -> dict: + return slot_data + +# This is called right at the end, in case you want to write stuff to the spoiler log +def before_write_spoiler(world: World, multiworld: MultiWorld, spoiler_handle) -> None: + pass + +# This is called when you want to add information to the hint text +def before_extend_hint_information(hint_data: dict[int, dict[int, str]], world: World, multiworld: MultiWorld, player: int) -> None: + + ### Example way to use this hook: + # if player not in hint_data: + # hint_data.update({player: {}}) + # for location in multiworld.get_locations(player): + # if not location.address: + # continue + # + # use this section to calculate the hint string + # + # hint_data[player][location.address] = hint_string + + pass + +def after_extend_hint_information(hint_data: dict[int, dict[int, str]], world: World, multiworld: MultiWorld, player: int) -> None: + pass + +def hook_interpret_slot_data(world: World, player: int, slot_data: dict[str, Any]) -> dict[str, Any]: + """ + Called when Universal Tracker wants to perform a fake generation + Use this if you want to use or modify the slot_data for passed into re_gen_passthrough + """ + return slot_data diff --git a/hooks/__init__.py b/hooks/__init__.py new file mode 100644 index 0000000..d4262f8 --- /dev/null +++ b/hooks/__init__.py @@ -0,0 +1 @@ +# intentionally empty \ No newline at end of file diff --git a/test/__init__.py b/test/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/test/manual_test.py b/test/manual_test.py new file mode 100644 index 0000000..95e264b --- /dev/null +++ b/test/manual_test.py @@ -0,0 +1,6 @@ +from test.TestBase import WorldTestBase +from ..Game import game_name + + +class ManualTest(WorldTestBase): + game = game_name