diff --git a/linklink/Data.py b/linklink/Data.py index 81ca89c..37da70c 100644 --- a/linklink/Data.py +++ b/linklink/Data.py @@ -1,4 +1,5 @@ import logging +from typing import Any from .DataValidation import DataValidation, ValidationError from .Helpers import load_data_file as helpers_load_data_file @@ -6,6 +7,7 @@ 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 @@ -36,13 +38,14 @@ def load(self): return contents -game_table = ManualFile('game.json', dict).load() #dict -item_table = convert_to_list(ManualFile('items.json', list).load(), 'data') #list -location_table = convert_to_list(ManualFile('locations.json', list).load(), 'data') #list -region_table = ManualFile('regions.json', dict).load() #dict -category_table = ManualFile('categories.json', dict).load() #dict -option_table = ManualFile('options.json', dict).load() #dict -meta_table = ManualFile('meta.json', dict).load() #dict +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', '') @@ -52,6 +55,7 @@ def load(self): 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) @@ -61,8 +65,23 @@ def load(self): 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 +# 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 diff --git a/linklink/DataValidation.py b/linklink/DataValidation.py index 46bf852..bb06ee3 100644 --- a/linklink/DataValidation.py +++ b/linklink/DataValidation.py @@ -3,21 +3,26 @@ 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 = {} - item_table = [] - location_table = [] - region_table = {} - + 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(): - for location in DataValidation.location_table: + for location in DataValidation.location_table_with_events: if "requires" not in location: continue @@ -37,10 +42,10 @@ def checkItemNamesInLocationRequires(): item_name = item_parts[0] item_name = item_name[1:] - item_category_exists = len([item for item in DataValidation.item_table if item_name in item.get('category', [])]) > 0 + 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["name"])) + raise ValidationError("Item category %s is required by location %s but is misspelled or does not exist." % (item_name, location.get("name"))) continue @@ -52,10 +57,10 @@ def checkItemNamesInLocationRequires(): if len(item_parts) > 1: item_name = item_parts[0] - item_exists = len([item["name"] for item in DataValidation.item_table if item["name"] == item_name]) > 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"])) + 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"]: @@ -73,7 +78,7 @@ def checkItemNamesInLocationRequires(): if len(or_item_parts) > 1: or_item_name = or_item_parts[0] - item_exists = len([item["name"] for item in DataValidation.item_table if item["name"] == or_item_name]) > 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"])) @@ -84,7 +89,7 @@ def checkItemNamesInLocationRequires(): if len(item_parts) > 1: item_name = item_parts[0] - item_exists = len([item["name"] for item in DataValidation.item_table if item["name"] == item_name]) > 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"])) @@ -113,7 +118,7 @@ def checkItemNamesInRegionRequires(): item_name = item_parts[0] item_name = item_name[1:] - item_category_exists = len([item for item in DataValidation.item_table if item_name in item.get('category', [])]) > 0 + 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)) @@ -128,7 +133,7 @@ def checkItemNamesInRegionRequires(): if len(item_parts) > 1: item_name = item_parts[0] - item_exists = len([item["name"] for item in DataValidation.item_table if item["name"] == item_name]) > 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)) @@ -149,7 +154,7 @@ def checkItemNamesInRegionRequires(): if len(or_item_parts) > 1: or_item_name = or_item_parts[0] - item_exists = len([item["name"] for item in DataValidation.item_table if item["name"] == or_item_name]) > 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)) @@ -160,14 +165,14 @@ def checkItemNamesInRegionRequires(): if len(item_parts) > 1: item_name = item_parts[0] - item_exists = len([item["name"] for item in DataValidation.item_table if item["name"] == item_name]) > 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: + for location in DataValidation.location_table_with_events: if "region" not in location or location["region"] in ["Menu", "Manual"]: continue @@ -176,19 +181,54 @@ def checkRegionNamesInLocations(): 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 "progression" in item and item["progression"]: + if item.get("progression"): continue # progression_skip_balancing is also progression, so no check needed - if "progression_skip_balancing" in item and item["progression_skip_balancing"]: + 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: + for location in DataValidation.location_table_with_events: if "requires" not in location: continue @@ -197,11 +237,11 @@ def checkItemsThatShouldBeRequired(): # if boolean, else legacy if isinstance(location_requires, str): - if '|{}|'.format(item["name"]) in location_requires: - raise ValidationError("Item %s is required by location %s, but the item is not marked as progression." % (item["name"], location["name"])) + 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["name"] in location_requires: - raise ValidationError("Item %s is required by location %s, but the item is not marked as progression." % (item["name"], location["name"])) + 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: @@ -215,11 +255,11 @@ def checkItemsThatShouldBeRequired(): # if boolean, else legacy if isinstance(region_requires, str): - if '|{}|'.format(item["name"]) in region_requires: - raise ValidationError("Item %s is required by region %s, but the item is not marked as progression." % (item["name"], region_name)) + 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["name"] in region_requires: - raise ValidationError("Item %s is required by region %s, but the item is not marked as progression." % (item["name"], region_name)) + 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]: @@ -302,30 +342,62 @@ def checkRegionsConnectingToOtherRegions(): 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_count = len([i for i in DataValidation.item_table if i["name"] == item["name"]]) + 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)}\"") - if name_count > 1: - raise ValidationError("Item %s is defined more than once." % (item["name"])) + @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_count = len([l for l in DataValidation.location_table if l["name"] == location["name"]]) - - if name_count > 1: - raise ValidationError("Location %s is defined more than once." % (location["name"])) + 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 checkForDuplicateRegionNames(): - # this currently does nothing because the region name is a dict key, which will never be non-unique / limited to 1 - for region_name in DataValidation.region_table: - name_count = len([r for r in DataValidation.region_table if r == region_name]) - - if name_count > 1: - raise ValidationError("Region %s is defined more than once." % (region_name)) + 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(): @@ -340,7 +412,7 @@ def checkStartingItemsForValidItemsAndCategories(): if "items" in starting_block: for item_name in starting_block["items"]: - if not item_name in [item["name"] for item in DataValidation.item_table]: + 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: @@ -389,7 +461,7 @@ def checkPlacedItemsForValidItems(): continue for item_name in place_item: - if not item_name in [item["name"] for item in DataValidation.item_table]: + 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 @@ -438,7 +510,7 @@ def checkForNonStartingRegionsThatAreUnreachable(): def runPreFillDataValidation(world: World, multiworld: MultiWorld): - validation_errors = [] + validation_errors: list[ValidationError] = [] # check if there is enough items with values try: DataValidation.preFillCheckIfEnoughItemsForValue(world, multiworld) @@ -451,7 +523,13 @@ def runPreFillDataValidation(world: World, multiworld: MultiWorld): # Called during stage_assert_generate def runGenerationDataValidation(cls) -> None: - validation_errors = [] + 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() @@ -460,10 +538,18 @@ def runGenerationDataValidation(cls) -> None: 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) @@ -479,9 +565,6 @@ def runGenerationDataValidation(cls) -> None: try: DataValidation.checkForDuplicateLocationNames() except ValidationError as e: validation_errors.append(e) - try: DataValidation.checkForDuplicateRegionNames() - 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) diff --git a/linklink/Game.py b/linklink/Game.py index dc9e9a3..2a7f467 100644 --- a/linklink/Game.py +++ b/linklink/Game.py @@ -1,9 +1,6 @@ from .Data import game_table -if 'creator' in game_table: - game_table['player'] = game_table['creator'] - -game_name = "Manual_%s_%s" % (game_table["game"], game_table["player"]) +game_name = "Manual_%s_%s" % (game_table["game"], game_table["creator"]) filler_item_name = game_table["filler_item_name"] if "filler_item_name" in game_table else "Filler" starting_items = game_table["starting_items"] if "starting_items" in game_table else None diff --git a/linklink/Helpers.py b/linklink/Helpers.py index 40d379c..ec497d1 100644 --- a/linklink/Helpers.py +++ b/linklink/Helpers.py @@ -1,19 +1,16 @@ import ast import csv -import os import pkgutil import json +import re -from BaseClasses import MultiWorld, Item +from BaseClasses import MultiWorld, Item, ItemClassification from enum import IntEnum -from typing import Optional, List, TYPE_CHECKING, Union, get_args, get_origin, Any +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 +from .hooks.Helpers import before_is_category_enabled, before_is_item_enabled, before_is_location_enabled, before_is_event_enabled -if TYPE_CHECKING: - from .Items import ManualItem - from .Locations import ManualLocation # blatantly copied from the minecraft ap world because why not def load_data_file(*args) -> dict: @@ -87,7 +84,7 @@ def is_item_name_enabled(multiworld: MultiWorld, player: int, item_name: str) -> return is_item_enabled(multiworld, player, item) -def is_item_enabled(multiworld: MultiWorld, player: int, item: "ManualItem") -> bool: +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: @@ -103,7 +100,7 @@ def is_location_name_enabled(multiworld: MultiWorld, player: int, location_name: return is_location_enabled(multiworld, player, location) -def is_location_enabled(multiworld: MultiWorld, player: int, location: "ManualLocation") -> bool: +def is_location_enabled(multiworld: MultiWorld, player: int, location: dict[str, Any]) -> bool: """Check if a location has been disabled by a yaml option.""" hook_result = before_is_location_enabled(multiworld, player, location) if hook_result is not None: @@ -111,7 +108,15 @@ def is_location_enabled(multiworld: MultiWorld, player: int, location: "ManualLo return _is_manualobject_enabled(multiworld, player, location) -def _is_manualobject_enabled(multiworld: MultiWorld, player: int, object: Any) -> bool: +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. """ @@ -210,10 +215,34 @@ def convert_to_long_string(input: str | list[str]) -> str: 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[:1].isdigit(): + if input.isidentifier() and not iskeyword(input): + return input + + if iskeyword(input): input = "_" + input - return input.replace(" ", "_") + # 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 @@ -232,6 +261,25 @@ def format_state_prog_items_key(category: str|ProgItemsCat ,key: str) -> str: 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]) diff --git a/linklink/Items.py b/linklink/Items.py index d31c810..68c7677 100644 --- a/linklink/Items.py +++ b/linklink/Items.py @@ -1,6 +1,6 @@ from BaseClasses import Item from .Data import item_table -from .Game import filler_item_name, starting_index +from .Game import filler_item_name, starting_index, game_name ###################### @@ -34,11 +34,11 @@ item_table[key]["progression"] = val["progression"] if "progression" in val else False if isinstance(val.get("category", []), str): item_table[key]["category"] = [val["category"]] - + count += 1 for item in item_table: - item_name = item["name"] + 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 @@ -70,4 +70,4 @@ class ManualItem(Item): - game = "Manual" + game = game_name diff --git a/linklink/Locations.py b/linklink/Locations.py index d94094d..d2ee209 100644 --- a/linklink/Locations.py +++ b/linklink/Locations.py @@ -1,6 +1,7 @@ from BaseClasses import Location -from .Data import location_table -from .Game import starting_index +from .Data import location_table, event_table +from .Game import starting_index, game_name +from typing import Any ###################### @@ -44,26 +45,50 @@ victory_names.append("__Manual Game Complete__") location_id_to_name: dict[int, str] = {} -location_name_to_location: dict[str, dict] = {} +location_name_to_location: dict[str, dict[str, Any]] = {} location_name_groups: dict[str, list[str]] = {} +event_name_to_event: dict[str, dict[str, Any]] = {} -for item in location_table: - location_id_to_name[item["id"]] = item["name"] - location_name_to_location[item["name"]] = item +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 item.get("category", []): + for c in loc.get("category", []): if c not in location_name_groups: location_name_groups[c] = [] - location_name_groups[c].append(item["name"]) + location_name_groups[c].append(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" + id += 1 + ###################### # Location classes ###################### class ManualLocation(Location): - game = "Manual" + game = game_name diff --git a/linklink/MANUAL_VERSION.txt b/linklink/MANUAL_VERSION.txt index b8a9ad3..422a6d3 100644 --- a/linklink/MANUAL_VERSION.txt +++ b/linklink/MANUAL_VERSION.txt @@ -1 +1 @@ -manual_stable_20250813 +manual_stable_20260319 diff --git a/linklink/ManualClient.py b/linklink/ManualClient.py index e5f9b96..257676a 100644 --- a/linklink/ManualClient.py +++ b/linklink/ManualClient.py @@ -1,11 +1,13 @@ from __future__ import annotations import asyncio +from functools import cache import os import re import sys import time import typing -from typing import Any, Optional +from typing import Any, Dict, List, Optional +from enum import IntEnum import requests from worlds import AutoWorldRegister, network_data_package @@ -13,7 +15,6 @@ import json import traceback - import ModuleUpdate ModuleUpdate.update() @@ -34,6 +35,47 @@ if typing.TYPE_CHECKING: import kvui +class SortingOrderLoc(IntEnum): + custom = 1 + inverted_custom = -1 + alphabetical = 2 + inverted_alphabetical = -2 + natural = 3 + inverted_natural = -3 + default = 3 + +# Docs must be done after because otherwise __doc__ return none +SortingOrderLoc.custom.__doc__ = "Sort alphabetically using the custom sorting keys defined in locations.json if present, and the name otherwise." +SortingOrderLoc.alphabetical.__doc__ = "Sort alphabetically using the name of item defined in locations.json." +SortingOrderLoc.natural.__doc__ = "Sort like custom but makes sure that any number are read as integer and thus sorted naturally. EG. key2 < key12" + +class SortingOrderItem(IntEnum): + custom = 1 + inverted_custom = -1 + alphabetical = 2 + inverted_alphabetical = -2 + natural = 3 + inverted_natural = -3 + received = 4 + inverted_received = -4 + default = 4 + +SortingOrderItem.custom.__doc__ = "Sort alphabetically using the custom sorting keys defined in items.json if present, and the name otherwise." +SortingOrderItem.alphabetical.__doc__ = "Sort alphabetically using the name of item defined in items.json." +SortingOrderItem.natural.__doc__ = "Sort like custom 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" + +@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 + class ManualClientCommandProcessor(ClientCommandProcessor): def _cmd_resync(self) -> bool: """Manually trigger a resync.""" @@ -53,13 +95,20 @@ def _cmd_send(self, location_name: str) -> bool: 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 @@ -79,7 +128,12 @@ class ManualContext(SuperContext): last_death_link = 0 deathlink_out = False + visible_events = {} + search_term = "" + items_sorting = SortingOrderItem.default.name + locations_sorting = SortingOrderLoc.default.name + block_unreachable_location_press = True colors = { 'location_default': [219/255, 218/255, 213/255, 1], @@ -118,7 +172,7 @@ async def server_auth(self, password_requested: bool = 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 lib/worlds/") + raise Exception(f"Cannot load {self.game}, please add the apworld to custom_worlds/") data_package = network_data_package["games"].get(self.game, {}) @@ -205,6 +259,7 @@ def on_package(self, cmd: str, args: dict): 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() @@ -228,6 +283,13 @@ def on_tracker_events(self, events: list[str]): if events: 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() @@ -267,20 +329,22 @@ def make_gui(self) -> typing.Type["kvui.GameManager"]: from kvui import GameManager ui = GameManager + from kivy.core.window import Window + from kivy.lang import Builder from kivy.metrics import dp - from kivy.uix.button import Button + 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, TreeViewNode, TreeViewLabel - from kivy.core.window import Window - from kivy.lang import Builder - from kivy.properties import ColorProperty + from kivy.uix.treeview import TreeView, TreeViewLabel, TreeViewNode + from kivy.config import ConfigParser class ManualTabLayout(BoxLayout): pass @@ -340,6 +404,8 @@ class ManualManager(ui): update_requested_time: Optional[float] = None update_requested_highlights: bool = False + mouse_pos: tuple + ctx: ManualContext def __init__(self, ctx): @@ -348,6 +414,10 @@ def __init__(self, ctx): def build(self) -> Layout: super().build() + self.ctx.items_sorting = self.config.get('manual', 'items_sorting_order') + self.ctx.locations_sorting = self.config.get('manual', 'locations_sorting_order') + self.ctx.block_unreachable_location_press = True if self.config.get('universal-tracker', 'block_unreachable_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) @@ -372,10 +442,82 @@ def build(self) -> Layout: 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, + "locations_sorting_order": SortingOrderLoc.default.name + }) + config.setdefaults("universal-tracker", { + "block_unreachable_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": "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]) + }, + ] + 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 green location be able to be 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 == "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 section == "universal-tracker": + if key == "block_unreachable_location_press": + self.ctx.block_unreachable_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 = {"(No Category)": [], "(Hinted)": []} + self.listed_locations: Dict[str, List[int]] = {"(No Category)": [], "(Hinted)": []} self.location_categories = ["(No Category)", "(Hinted)"] def set_active_item_accordion(self, instance): @@ -440,7 +582,45 @@ def clear_search_input(self): 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() @@ -479,6 +659,16 @@ def build_tracker_and_locations_table(self): 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 @@ -521,8 +711,29 @@ def build_tracker_and_locations_table(self): if not victory_categories: victory_categories.add("(No Category)") - for category in self.listed_locations: - self.listed_locations[category].sort() + loc_sorting = SortingOrderLoc[self.ctx.locations_sorting] + + if abs(loc_sorting) == SortingOrderLoc.alphabetical: + for category in self.listed_locations: + self.listed_locations[category].sort(key=self.ctx.location_names.lookup_in_game, reverse=loc_sorting < 0) + elif abs(loc_sorting) == SortingOrderLoc.custom: + for category in self.listed_locations: + self.listed_locations[category].sort(key=lambda i: self.ctx.get_location_by_id(i).get("sort-key", self.ctx.get_location_by_id(i).get("name", "")), \ + reverse=loc_sorting < 0) + + elif abs(loc_sorting) == SortingOrderLoc.natural: + # Modified from https://stackoverflow.com/a/11150413 + def convert(text): + return int(text) if text.isdigit() else text.lower() + + def alphanum_key(i): + name = strip_articles(self.ctx.get_location_by_id(i).get("name", "")) + + return [convert(c) for c in re.split('([0-9]+)', self.ctx.get_location_by_id(i).get("sort-key", name))] + + 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) @@ -651,8 +862,11 @@ def update_tracker_and_locations_table(self, update_highlights=False): # Get the item name from the item Label, minus quantity, then do a lookup for count old_item_text = item.text item_name = re.sub(r"\s\(\d+\)$", "", item.text) - item_id = self.ctx.item_names_to_id[item_name] - item_count = len(list(i for i in self.ctx.items_received if i.item == item_id)) + item_id = self.ctx.item_names_to_id.get(item_name, False) + 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(): @@ -683,9 +897,32 @@ def update_tracker_and_locations_table(self, update_highlights=False): category_unique_name_count = 0 # Label (for all item listings) - sorted_items_received = sorted([ - i.item for i in self.ctx.items_received - ]) + item_sorting = SortingOrderItem[self.ctx.items_sorting] + sorted_items_received = [i.item for i in self.ctx.items_received] + + if abs(item_sorting) == SortingOrderItem.alphabetical: + sorted_items_received = sorted(sorted_items_received, + key=self.ctx.item_names.lookup_in_game, + reverse=item_sorting < 0) + elif abs(item_sorting) == SortingOrderItem.custom: + sorted_items_received = sorted(sorted_items_received, + key=lambda i: self.ctx.get_item_by_id(i).get("sort-key", self.ctx.get_item_by_id(i).get("name", "")), + reverse=item_sorting < 0) + + elif abs(item_sorting) == SortingOrderItem.natural: + def convert(text): + return int(text) if text.isdigit() else text.lower() + def alphanum_key(i): + name = self.ctx.get_item_by_id(i).get("name", "") + name = strip_articles(name) + + return [convert(c) for c in re.split('([0-9]+)',self.ctx.get_item_by_id(i).get("sort-key", name)) + ] + 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) @@ -712,6 +949,16 @@ def update_tracker_and_locations_table(self, update_highlights=False): 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 = Label(text="%s (%s)" % (event, item_count), + size_hint=(None, None), height=dp(30), width=dp(400), bold=True) + 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: @@ -844,15 +1091,40 @@ def location_button_callback(self, location_id, button): if button.text 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: - self.ctx.locations_checked.append(location_id) - self.ctx.syncing = True - button.parent.remove_widget(button) + if tracker_loaded and self.ctx.block_unreachable_location_press and button.text not in self.ctx.tracker_reachable_locations: + logger.debug(f"button for location '{button.text}' was pressed while unreachable") + else: + self.ctx.locations_checked.append(location_id) + self.ctx.syncing = True + button.parent.remove_widget(button) # message = [{"cmd": 'LocationChecks', "locations": [location_id]}] # self.ctx.send_msgs(message) def victory_button_callback(self, button): + # 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 + self.ctx.items_received.append("__Victory__") self.ctx.syncing = True @@ -878,19 +1150,26 @@ async def game_watcher_manual(ctx: ManualContext): ctx.deathlink_out = False await ctx.send_death() - sending = [] victory = ("__Victory__" in ctx.items_received) - ctx.locations_checked = sending - message = [{"cmd": 'LocationChecks', "locations": sending}] - await ctx.send_msgs(message) + ctx.locations_checked = [] 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): +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.as_dict() + except Exception as e: + print("Error reading APManual file:", e) with open(apmanual_file, 'r') as f: return json.loads(b64decode(f.read())) diff --git a/linklink/Meta.py b/linklink/Meta.py index 94bdd1a..0056aff 100644 --- a/linklink/Meta.py +++ b/linklink/Meta.py @@ -1,7 +1,8 @@ from BaseClasses import Tutorial +from enum import Enum from worlds.AutoWorld import World, WebWorld -from .Data import meta_table +from .Data import game_table, meta_table from .Helpers import convert_to_long_string ############## @@ -52,7 +53,7 @@ def set_world_webworld(web: WebWorld) -> WebWorld: tutorial.get("language", "English"), tutorial.get("file_name", "setup_en.md"), tutorial.get("link", "setup/en"), - tutorial.get("authors", [meta_table.get("creator", meta_table.get("player", "Unknown"))]) + tutorial.get("authors", [game_table.get("creator")]) )) web.tutorials = tutorials return web @@ -67,5 +68,3 @@ def set_world_webworld(web: WebWorld) -> WebWorld: 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()) - -enable_region_diagram = bool(meta_table.get("enable_region_diagram", False)) diff --git a/linklink/Options.py b/linklink/Options.py index fbed239..e46bd0a 100644 --- a/linklink/Options.py +++ b/linklink/Options.py @@ -1,3 +1,4 @@ +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 @@ -16,6 +17,10 @@ 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 + 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()} @@ -60,12 +65,14 @@ def addOptionToGroup(option_name: str, group: str): 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 = {'option_' + v: i for i, v in enumerate(victory_names)} + 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." @@ -98,6 +105,7 @@ def addOptionToGroup(option_name: str, group: str): 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}") @@ -123,6 +131,7 @@ def addOptionToGroup(option_name: str, group: str): 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}") @@ -185,6 +194,8 @@ def addOptionToGroup(option_name: str, group: str): 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")) @@ -201,7 +212,7 @@ def addOptionToGroup(option_name: str, group: str): 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}) + 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: @@ -212,7 +223,7 @@ def addOptionToGroup(option_name: str, group: str): 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}) + 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?" ###################### @@ -232,6 +243,10 @@ def make_options_group() -> list[OptionGroup]: 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)) @@ -241,3 +256,9 @@ def make_options_group() -> list[OptionGroup]: 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/linklink/Regions.py b/linklink/Regions.py index f7cb603..fbff537 100644 --- a/linklink/Regions.py +++ b/linklink/Regions.py @@ -1,7 +1,8 @@ -from BaseClasses import Entrance, MultiWorld, Region -from .Helpers import is_category_enabled, is_location_enabled +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 @@ -70,3 +71,13 @@ def create_region(world: World, multiworld: MultiWorld, player: int, name: str, 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/linklink/Rules.py b/linklink/Rules.py index e5766ce..29633df 100644 --- a/linklink/Rules.py +++ b/linklink/Rules.py @@ -10,7 +10,7 @@ from BaseClasses import MultiWorld, CollectionState from worlds.AutoWorld import World from worlds.generic.Rules import set_rule, add_rule -from Options import Choice, Toggle, Range, NamedRange +from Options import Choice, Toggle, Range, NamedRange, NumericOption import re import math @@ -179,6 +179,7 @@ def findAndRecursivelyExecuteFunctions(requires_list: str, recursionDepth: int = if require_type == 'category': category_items = [item for item in world.item_name_to_item.values() if "category" in item and item_name in item["category"]] + category_items += [event for event in world.event_name_to_event.values() if "category" in event and item_name in event["category"]] category_items_counts = sum([items_counts.get(category_item["name"], 0) for category_item in category_items]) if item_count.lower() == 'all': item_count = category_items_counts @@ -218,8 +219,8 @@ def findAndRecursivelyExecuteFunctions(requires_list: str, recursionDepth: int = if total <= item_count: requires_list = requires_list.replace(item_base, "0") - requires_list = re.sub(r'\s?\bAND\b\s?', '&', requires_list, 0, re.IGNORECASE) - requires_list = re.sub(r'\s?\bOR\b\s?', '|', requires_list, 0, re.IGNORECASE) + requires_list = re.sub(r'\s?\bAND\b\s?', '&', requires_list, count=0, flags=re.IGNORECASE) + requires_list = re.sub(r'\s?\bOR\b\s?', '|', requires_list, count=0, flags=re.IGNORECASE) requires_string = infix_to_postfix("".join(requires_list), area) return (evaluate_postfix(requires_string, area)) @@ -304,11 +305,15 @@ def fullRegionCheck(state: CollectionState, region=regionMap[region], region_nam add_rule(exit, lambda state, rule={"requires": exit_rules[e]}: fullLocationOrRegionCheck(state, rule)) # Location access rules - for location in world.location_table: - if location["name"] not in used_location_names: + 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(location["name"], player) + locFromWorld = multiworld.get_location(name, player) locationRegion = regionMap[location["region"]] if "region" in location else None @@ -403,20 +408,20 @@ def ItemValue(state: CollectionState, player: int, valueCount: str): # Two useful functions to make require work if an item is disabled instead of making it inaccessible -def OptOne(world: "ManualWorld", item: str, items_counts: Optional[dict] = None): +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 - if not items_counts: - items_counts = world.get_item_counts(only_progression=True) - require_type = 'item' + items_counts = world.get_item_counts(only_progression=True) + + require_category = False if '@' in item[:2]: - require_type = 'category' + require_category = True item = item.lstrip('|@$').rstrip('|') @@ -428,53 +433,70 @@ def OptOne(world: "ManualWorld", item: str, items_counts: Optional[dict] = None) item_name = item_parts[0] item_count = item_parts[1] - if require_type == 'category': + 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}|" - elif require_type == 'item': + 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): +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 - items_counts = world.get_item_counts(only_progression=True) - - functions = {} if requires_list == "": return True - for item in re.findall(r'\{(\w+)\(([^)]*)\)\}', requires_list): - #so this function doesn't try to get item from other functions, in theory. - func_name = item[0] - functions[func_name] = item[1] - requires_list = requires_list.replace("{" + func_name + "(" + item[1] + ")}", "{" + func_name + "(temp)}") + # parse user written statement into list of each item for item in re.findall(r'\|[^|]+\|', requires): - itemScanned = OptOne(world, item, items_counts) + itemScanned = OptOne(world, item) requires_list = requires_list.replace(item, itemScanned) - for function in functions: - requires_list = requires_list.replace("{" + function + "(temp)}", "{" + func_name + "(" + functions[func_name] + ")}") return requires_list -# Rule to expose the can_reach_location core function +# 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) diff --git a/linklink/__init__.py b/linklink/__init__.py index efef46c..0813346 100644 --- a/linklink/__init__.py +++ b/linklink/__init__.py @@ -1,26 +1,25 @@ -from base64 import b64encode import logging import os -import json -from typing import Callable, Optional, Counter +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_subprocess, icon_paths -from .Data import item_table, location_table, region_table, category_table +from .Data import item_table, location_table, event_table, region_table, category_table from .Game import game_name, filler_item_name, starting_items -from .Meta import world_description, world_webworld, enable_region_diagram -from .Locations import location_id_to_name, location_name_to_id, location_name_to_location, location_name_groups, victory_names +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 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 +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, get_items_for_player, resolve_yaml_option, format_state_prog_items_key, ProgItemsCat +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 from Options import PerGameCommonOptions @@ -34,12 +33,11 @@ before_generate_basic, after_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 -from .hooks.Data import hook_interpret_slot_data + after_collect_item, after_remove_item, before_generate_early, hook_interpret_slot_data class ManualWorld(World): __doc__ = world_description - game: str = game_name + game: ClassVar[str] = game_name web = world_webworld options_dataclass = manual_options_data @@ -49,6 +47,7 @@ class ManualWorld(World): # 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 @@ -68,36 +67,42 @@ class ManualWorld(World): location_name_groups = location_name_groups victory_names = victory_names + event_name_to_event = event_name_to_event + # UT (the universal-est of trackers) can now generate without a YAML - ut_can_gen_without_yaml = False # Temporary disable until we fix the bugs with it + ut_can_gen_without_yaml = True def get_filler_item_name(self) -> str: return hook_get_filler_item_name(self, self.multiworld, self.player) or self.filler_item_name - def interpret_slot_data(self, slot_data: dict[str, any]): + 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 False - - regen = False - for key, value in slot_data.items(): - if key in self.options_dataclass.type_hints: - getattr(self.options, key).value = value - regen = True + return {} - regen = hook_interpret_slot_data(self, self.player, slot_data) or regen + 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) + 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 @@ -126,11 +131,15 @@ def create_items(self): if item.get("trap"): traps.append(name) - if "category" in item: - if not is_item_enabled(self.multiworld, self.player, item): - item_count = 0 + if not is_item_enabled(self.multiworld, self.player, item): + items_config[name] = 0 - items_config[name] = item_count + 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) @@ -150,10 +159,8 @@ def create_items(self): try: if isinstance(cat, int): true_class = ItemClassification(cat) - elif cat.startswith('0b'): - true_class = ItemClassification(int(cat, base=0)) else: - true_class = ItemClassification[cat] + true_class = convert_string_to_itemclassification(cat) except Exception as ex: raise Exception(f"Item override '{cat}' for {name} improperly defined\n\n{type(ex).__name__}:{ex}") @@ -228,7 +235,7 @@ def create_items(self): for starting_item in items: items_started.append(starting_item) self.multiworld.push_precollected(starting_item) - pool.remove(starting_item) + remove_specific_item(pool, starting_item) self.start_inventory = {i.name: items_started.count(i) for i in items_started} @@ -240,7 +247,18 @@ def create_items(self): # then will remove specific item placements below from the overall pool self.multiworld.itempool += pool - real_pool = pool + items_started + # Filter Precollected items for those not in logic aka created by start_inventory(_from_pool) + precollected_items = list(self.multiworld.precollected_items[self.player]) + + # UT doesn't precollect the exceptions so this can be skipped + if not hasattr(self.multiworld, "generation_is_fake"): + 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)) + + real_pool = pool + precollected_items 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) @@ -248,20 +266,36 @@ def create_item(self, name: str, class_override: Optional['ItemClassification']= name = before_create_item(name, self, self.multiworld, self.player) item = self.item_name_to_item[name] + classification: ItemClassification = ItemClassification.filler if class_override is not None: classification = class_override - else: - classification = ItemClassification.filler - if "trap" in item and item["trap"]: + 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 "useful" in item and item["useful"]: + if item.get("useful"): classification |= ItemClassification.useful - if "progression_skip_balancing" in item and item["progression_skip_balancing"]: + if item.get("progression_skip_balancing"): classification |= ItemClassification.progression_skip_balancing - elif "progression" in item and item["progression"]: + elif item.get("progression"): classification |= ItemClassification.progression item_object = ManualItem(name, classification, @@ -362,13 +396,13 @@ def generate_basic(self): location.place_locked_item(item_to_place) # remove the item we're about to place from the pool so it isn't placed twice - self.multiworld.itempool.remove(item_to_place) + remove_specific_item(self.multiworld.itempool, item_to_place) after_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 enable_region_diagram: + if get_option_value(self.multiworld, self.player, "generate_region_diagram"): from Utils import visualize_regions visualize_regions(self.multiworld.get_region("Menu", self.player), f"{self.game}_{self.player}.puml") @@ -386,15 +420,26 @@ def fill_slot_data(self): 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): - data = self.client_data() filename = f"{self.multiworld.get_out_file_name_base(self.player)}.apmanual" - with open(os.path.join(output_directory, filename), 'wb') as f: - f.write(b64encode(bytes(json.dumps(data), 'utf-8'))) + zf_path = os.path.join(output_directory, filename) + + apmanual = APManualFile(zf_path, player=self.player, player_name=self.player_name) + apmanual.write() + def write_spoiler(self, spoiler_handle): before_write_spoiler(self, self.multiworld, spoiler_handle) @@ -422,7 +467,7 @@ def extend_hint_information(self, hint_data: dict[int, dict[int, str]]) -> None: 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("Use adjust_filler_items instead.") + 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): @@ -473,7 +518,7 @@ def adjust_filler_items(self, item_pool, traps): else: logging.warning("Could not remove enough non-progression items from the pool.") break - item_pool.remove(popped) + remove_specific_item(item_pool, popped) return item_pool @@ -500,18 +545,6 @@ def get_item_counts(self, player: Optional[int] = None, pool: list[Item] | None return self.item_counts.get(player, Counter()) - def client_data(self): - return { - "game": self.game, - 'player_name': self.multiworld.get_player_name(self.player), - 'player_id': self.player, - 'items': self.item_name_to_item, - 'locations': self.location_name_to_location, - # todo: extract connections out of multiworld.get_regions() instead, in case hooks have modified the regions. - 'regions': region_table, - 'categories': category_table - } - ### # Non-world client methods ### @@ -531,7 +564,7 @@ def __init__(self, display_name: str, script_name: Optional[str] = None, func: O self.version = version def add_client_to_launcher() -> None: - version = 2025_08_12 # YYYYMMDD + version = 2026_01_02 # YYYYMMDD found = False if "manual" not in icon_paths: diff --git a/linklink/container.py b/linklink/container.py new file mode 100644 index 0000000..f48a760 --- /dev/null +++ b/linklink/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 .Locations import location_name_to_location +from .Items import item_name_to_item + +if hasattr(Files, 'APPlayerContainer'): + APPlayerContainer = Files.APPlayerContainer +else: + # Prior to 0.6.2, all containers were player containers. + APPlayerContainer = Files.APContainer + +class APManualFile(APPlayerContainer): + game = game_name + patch_file_ending = ".apmanual" + + 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 as_dict(self) -> dict[str, Any]: + data = {} + data["items"] = self.items + data["locations"] = self.locations + data["regions"] = self.regions + return data diff --git a/linklink/hooks/Data.py b/linklink/hooks/Data.py index f3a440d..f6698e0 100644 --- a/linklink/hooks/Data.py +++ b/linklink/hooks/Data.py @@ -95,3 +95,7 @@ def hook_interpret_slot_data(world, player: int, slot_data: dict[str, Any]) -> b def after_load_option_file(option_table: dict) -> dict: return option_table + + +def after_load_event_file(event_table: list) -> list: + return event_table diff --git a/linklink/hooks/Helpers.py b/linklink/hooks/Helpers.py index 9f6df9d..840123b 100644 --- a/linklink/hooks/Helpers.py +++ b/linklink/hooks/Helpers.py @@ -16,3 +16,7 @@ def before_is_item_enabled(multiworld: MultiWorld, player: int, item: dict[str, # 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 + + +def before_is_event_enabled(multiworld: MultiWorld, player: int, event: dict[str, Any]) -> Optional[bool]: + return None diff --git a/linklink/hooks/Options.py b/linklink/hooks/Options.py index 9b8f8d9..55f150e 100644 --- a/linklink/hooks/Options.py +++ b/linklink/hooks/Options.py @@ -1,3 +1,5 @@ +from Options import FreeText, NumericOption, Toggle, DefaultOnToggle, Choice, TextChoice, Range, NamedRange, PerGameCommonOptions +from typing import Type # Object classes from AP that represent different types of options that you can create from typing import Any from Options import Option, OptionGroup, OptionSet diff --git a/linklink/hooks/World.py b/linklink/hooks/World.py index da086be..69811c1 100644 --- a/linklink/hooks/World.py +++ b/linklink/hooks/World.py @@ -1,3 +1,6 @@ +from typing import Any +from ..Helpers import format_state_prog_items_key, ProgItemsCat, remove_specific_item +from ..Data import game_table, item_table, location_table, region_table # Object classes from AP core, to represent an entire MultiWorld and this individual World that's part of it import logging import re @@ -317,3 +320,19 @@ def after_collect_item(world: World, state: CollectionState, Changed: bool, item def after_remove_item(world: World, state: CollectionState, Changed: bool, item: Item): pass + + +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 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