Initial Commit

This commit is contained in:
Luminiferous committed 2026-10-10 17:05:45 +02:00
commit fa38469735
30 files changed
+6902

No files matched your search

+109
View File
@@ -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.")
+573
View File
@@ -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])))
+16
View File
@@ -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)
+370
View File
@@ -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: <ast.Name object at 0x000001AEBBCC7590>", 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")
+91
View File
@@ -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
+100
View File
@@ -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
+1547
View File
File diff suppressed because it is too large. Load diff
+70
View File
@@ -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())
+267
View File
@@ -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
+78
View File
@@ -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)
+845
View File
@@ -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}')
+636
View File
@@ -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()
+45
View File
@@ -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
+10
View File
@@ -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
}
}
+28
View File
@@ -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"
}
]
}
+14
View File
@@ -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
}
+565
View File
@@ -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": "}}}"
]
+962
View File
@@ -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"
}
]
+39
View File
@@ -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"
}
}
}
}
}
+82
View File
@@ -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
}
}
}
+69
View File
@@ -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|"
}
}
+21
View File
@@ -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.
+42
View File
@@ -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
+23
View File
@@ -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
+57
View File
@@ -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
+29
View File
@@ -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|"
+207
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
# intentionally empty
View File
Whitespace-only changes.
+6
View File
@@ -0,0 +1,6 @@
from test.TestBase import WorldTestBase
from ..Game import game_name
class ManualTest(WorldTestBase):
game = game_name